├── .sassrc ├── server ├── .dockerignore ├── index.ts ├── jest.config.js ├── tsconfig.json ├── Dockerfile ├── package.json ├── protocol.ts ├── server.ts ├── game.test.ts └── game.ts ├── img ├── table.jpg ├── Segment7Standard.otf ├── about │ ├── dealer.png │ ├── game.mp4 │ ├── pay.png │ ├── round.png │ └── winds.png ├── models.blend ├── hand.svg ├── icon.svg ├── center.svg └── winds.svg ├── sound ├── stick.wav └── discard.wav ├── .gitignore ├── docker-compose.dev.yml ├── .gitattributes ├── docker-compose.prod.yml ├── docker-compose.yml ├── .dockerignore ├── tsconfig.json ├── src ├── index.ts ├── thing.ts ├── sound-player.ts ├── types.ts ├── utils.ts ├── mouse-tracker.ts ├── base-client.ts ├── client-ui.ts ├── asset-loader.ts ├── game.ts ├── selection-box.ts ├── setup-deal.ts ├── movement.ts ├── object-view.ts ├── client.ts ├── center.ts ├── slot.ts ├── mouse-ui.ts ├── setup.ts ├── thing-group.ts ├── style.sass ├── setup-slots.ts └── spectator-overlay.ts ├── export.py ├── run-parcel.js ├── package.json ├── Jenkinsfile ├── Dockerfile ├── .eslintrc.json ├── nginx.conf ├── COPYING ├── Makefile ├── README.md └── about.html /.sassrc: -------------------------------------------------------------------------------- 1 | { 2 | "includePaths": ["node_modules"] 3 | } -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .dockerignore -------------------------------------------------------------------------------- /img/table.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riichinomics/autotable/HEAD/img/table.jpg -------------------------------------------------------------------------------- /sound/stick.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riichinomics/autotable/HEAD/sound/stick.wav -------------------------------------------------------------------------------- /sound/discard.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riichinomics/autotable/HEAD/sound/discard.wav -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | .cache/ 5 | *.auto.* 6 | *.blend1 7 | .vscode/ 8 | -------------------------------------------------------------------------------- /img/Segment7Standard.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riichinomics/autotable/HEAD/img/Segment7Standard.otf -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | nginx: 5 | ports: 6 | - "80:80" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.blend filter=lfs diff=lfs merge=lfs -text 2 | img/about/* filter=lfs diff=lfs merge=lfs -text 3 | -------------------------------------------------------------------------------- /img/about/dealer.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:37d2ceaf6ab5af0b921873efa1f277007630c8b7549e178988b7aac2269702c5 3 | size 45162 4 | -------------------------------------------------------------------------------- /img/about/game.mp4: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:7053227949eede4242033275a44be2e4476f38ade800b707a2a9f499067923f7 3 | size 951257 4 | -------------------------------------------------------------------------------- /img/about/pay.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:de80acfdafdc5bc93800cd7da6122302037078f22a493caea21902f4a5b66760 3 | size 115796 4 | -------------------------------------------------------------------------------- /img/about/round.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0f951443825d8a0f12642fde36ee86824a18f6724097367668a43896f5310cf5 3 | size 3576 4 | -------------------------------------------------------------------------------- /img/about/winds.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:55911f4ceb2dd50b1868be940c9d14018edd11f594be135c5c5011c9864ca9c1 3 | size 25031 4 | -------------------------------------------------------------------------------- /img/models.blend: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:cb9bb2ce68a5cdb2352de996df3d2d7aa399256733ed72883751b3cd89f5b2a9 3 | size 2089044 4 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { Server } from './server'; 2 | 3 | if (!process.env['DEBUG']) { 4 | console.debug = function() {}; 5 | } 6 | 7 | new Server(1235).run(); 8 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | nginx: 5 | networks: 6 | - ingress_link 7 | - default 8 | 9 | networks: 10 | ingress_link: 11 | external: true 12 | -------------------------------------------------------------------------------- /server/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: {'^.+\\.ts?$': 'ts-jest'}, 3 | testEnvironment: 'node', 4 | testRegex: '.*\\.(test|spec)?\\.(ts|tsx)$', 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] 6 | }; 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | nginx: 5 | build: 6 | context: . 7 | image: autotable-nginx 8 | 9 | server: 10 | build: 11 | context: ./server 12 | image: autotable-server 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | .cache/ 5 | *.auto.* 6 | *.blend1 7 | .vscode/ 8 | 9 | img/*.png 10 | img/*.glb 11 | 12 | server/ 13 | .eslintrc.json 14 | .gitignore 15 | .gitattributes 16 | COPYING 17 | docker-compose.yml 18 | Dockerfile 19 | README.md 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es6", 5 | "lib": ["dom", "es6", "es2015.promise"], 6 | "esModuleInterop": true, 7 | "moduleResolution": "node" 8 | }, 9 | "references": [ 10 | { "path": "server" } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "target": "es6", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "composite": true 11 | }, 12 | "lib": ["es2015"], 13 | } 14 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.18 AS base 2 | 3 | FROM base AS build 4 | WORKDIR /build/ 5 | COPY ./ /build 6 | RUN yarn 7 | RUN yarn build 8 | 9 | FROM base 10 | ENV NODE_ENV production 11 | COPY --from=build /build/dist /dist 12 | WORKDIR /dist 13 | COPY --from=build /build/package.json /build/yarn.lock ./ 14 | RUN yarn install --production 15 | ENTRYPOINT node index.js 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | //import 'normalize.css'; 2 | import 'bootstrap/dist/js/bootstrap'; 3 | import { AssetLoader } from './asset-loader'; 4 | import { Game } from './game'; 5 | 6 | const assetLoader = new AssetLoader(); 7 | 8 | 9 | assetLoader.loadAll().then(() => { 10 | const game = new Game(assetLoader); 11 | // for debugging 12 | Object.assign(window, {game}); 13 | game.start(); 14 | }); 15 | -------------------------------------------------------------------------------- /export.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import sys 3 | 4 | 5 | def print_usage(): 6 | print("usage: export.py target_file") 7 | 8 | 9 | argv = sys.argv[1:] 10 | 11 | if '--' not in argv: 12 | print_usage() 13 | exit(1) 14 | 15 | argv = argv[argv.index("--") + 1:] 16 | 17 | if len(argv) != 1: 18 | print_usage() 19 | exit(1) 20 | 21 | target_file = argv[0] 22 | bpy.ops.export_scene.gltf(filepath=target_file, export_apply=True, export_colors=False) 23 | -------------------------------------------------------------------------------- /run-parcel.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | // run-parcel.js 4 | const Bundler = require('parcel'); 5 | const express = require('express'); 6 | 7 | const bundler = new Bundler(['index.html', 'about.html']); 8 | const app = express(); 9 | 10 | app.get('/', (req, res, next) => { 11 | req.url = '/index.html'; 12 | app._router.handle(req, res, next); 13 | }); 14 | 15 | app.use(bundler.middleware()); 16 | 17 | const port = Number(process.env.PORT || 1234); 18 | app.listen(port); 19 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "@types/jest": "^25.2.2", 8 | "jest": "^26.0.1", 9 | "ts-jest": "^25.5.1", 10 | "typescript": "^4.0" 11 | }, 12 | "scripts": { 13 | "build": "tsc", 14 | "start": "tsc && node dist/index.js", 15 | "test": "jest" 16 | }, 17 | "dependencies": { 18 | "@types/ws": "^7.2.4", 19 | "ws": "^7.2.5" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "autotable", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Paweł Marczewski ", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@types/events": "^3.0.0", 9 | "@types/jquery": "^3.3.38", 10 | "@types/qs": "^6.9.2", 11 | "@typescript-eslint/eslint-plugin": "^2.30.0", 12 | "@typescript-eslint/parser": "^2.30.0", 13 | "eslint": "^6.8.0", 14 | "express": "^4.17.1", 15 | "node-sass": "^4.14.1", 16 | "parcel": "^1.12.4", 17 | "typescript": "^4.0" 18 | }, 19 | "dependencies": { 20 | "bootstrap": "^4.4.1", 21 | "events": "^3.1.0", 22 | "jquery": "^3.5.1", 23 | "normalize.css": "^8.0.1", 24 | "popper.js": "^1.16.1", 25 | "qs": "^6.9.4", 26 | "three": "^0.115.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/protocol.ts: -------------------------------------------------------------------------------- 1 | interface NewMessage { 2 | type: 'NEW'; 3 | } 4 | 5 | interface JoinMessage { 6 | type: 'JOIN'; 7 | gameId: string; 8 | } 9 | 10 | interface JoinedMessage { 11 | type: 'JOINED'; 12 | gameId: string; 13 | playerId: string; 14 | isFirst: boolean; 15 | password?: string; 16 | } 17 | 18 | interface UpdateMessage { 19 | type: 'UPDATE'; 20 | // kind, key, value 21 | entries: Array; 22 | full: boolean; 23 | } 24 | 25 | interface AuthMessage { 26 | type: 'AUTH'; 27 | password: string; 28 | } 29 | 30 | interface AuthedMessage { 31 | type: 'AUTHED'; 32 | isAuthed: boolean; 33 | } 34 | 35 | 36 | export type Entry = [string, string | number, any | null]; 37 | 38 | export type Message = NewMessage 39 | | JoinMessage 40 | | JoinedMessage 41 | | UpdateMessage 42 | | AuthMessage 43 | | AuthedMessage; 44 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | stages { 4 | stage('docker compose') { 5 | steps { 6 | sh 'docker compose build' 7 | } 8 | } 9 | 10 | stage('docker down') { 11 | steps { 12 | sh 'docker stack down autotable' 13 | sh ''' 14 | until [ -z "$(docker service ls --filter label=com.docker.stack.namespace=autotable -q)" ] || [ "$limit" -lt 0 ]; do 15 | sleep 1; 16 | done 17 | 18 | until [ -z "$(docker network ls --filter label=com.docker.stack.namespace=autotable -q)" ] || [ "$limit" -lt 0 ]; do 19 | sleep 1; 20 | done 21 | ''' 22 | } 23 | } 24 | 25 | stage('docker stack') { 26 | steps { 27 | sh 'docker stack up -c docker-compose.prod.yml -c docker-compose.yml autotable' 28 | } 29 | } 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye-slim AS inkscape 2 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y --no-install-recommends install \ 3 | inkscape \ 4 | make 5 | 6 | WORKDIR /build/ 7 | COPY Makefile ./ 8 | COPY img/*.svg ./img/ 9 | RUN make svgs 10 | 11 | FROM debian:bullseye-slim AS blender 12 | RUN apt-get update && \ 13 | DEBIAN_FRONTEND=noninteractive apt-get install -y \ 14 | blender \ 15 | python3-pip \ 16 | make 17 | 18 | RUN pip3 install -U numpy 19 | 20 | WORKDIR /build/ 21 | COPY Makefile ./ 22 | COPY --from=inkscape /build/img/* ./img/ 23 | COPY export.py ./ 24 | COPY img/* ./img/ 25 | RUN make files 26 | 27 | FROM node:12.18 AS parcel 28 | RUN echo "deb http://archive.debian.org/debian stretch main" > /etc/apt/sources.list 29 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y --no-install-recommends install \ 30 | make 31 | 32 | COPY ./ /build/ 33 | WORKDIR /build/ 34 | COPY --from=blender /build/img/* ./img/ 35 | 36 | RUN yarn 37 | RUN make -o files build 38 | 39 | FROM nginx 40 | COPY --from=parcel /build/build /dist 41 | COPY ./nginx.conf /etc/nginx/nginx.conf 42 | 43 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@typescript-eslint/recommended" 4 | ], 5 | "env": { 6 | "browser": true, 7 | "es6": true 8 | }, 9 | "parserOptions": { 10 | "ecmaVersion": 6, 11 | "sourceType": "module", 12 | "ecmaFeatures": { 13 | } 14 | }, 15 | "rules": { 16 | "semi": "error", 17 | "eqeqeq": "error", 18 | "prefer-const": "warn", 19 | "no-console": "warn", 20 | "@typescript-eslint/explicit-function-return-type": ["warn", { "allowExpressions": true }], 21 | "@typescript-eslint/explicit-member-accessibility": "off", 22 | "@typescript-eslint/indent": "off", 23 | "@typescript-eslint/no-use-before-define": "off", 24 | "@typescript-eslint/no-unused-vars": ["warn", { "args": "none" }], 25 | "@typescript-eslint/no-non-null-assertion": "off", 26 | "@typescript-eslint/no-namespace": "off", 27 | "@typescript-eslint/no-explicit-any": "off", 28 | "@typescript-eslint/no-empty-function": "off", 29 | "@typescript-eslint/ban-ts-ignore": "off", 30 | "@typescript-eslint/no-inferrable-types": "off" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | '$status $body_bytes_sent "$http_referer" ' 19 | '"$http_user_agent" "$http_x_forwarded_for"'; 20 | 21 | server { 22 | listen 80; 23 | location / { 24 | expires 0d; 25 | alias /dist/; 26 | } 27 | 28 | location /ws { 29 | proxy_pass http://autotable_server:1235/; 30 | proxy_http_version 1.1; 31 | proxy_set_header Upgrade $http_upgrade; 32 | proxy_set_header Connection "upgrade"; 33 | 34 | # Prevent dropping idle connections 35 | proxy_read_timeout 7d; 36 | } 37 | } 38 | 39 | access_log /var/log/nginx/access.log main; 40 | 41 | sendfile on; 42 | 43 | keepalive_timeout 65; 44 | gzip on; 45 | } 46 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | The following license (MIT) applies to my code. Note that that doesn't include 2 | the images and sounds. See README.md for details. 3 | 4 | Copyright (c) 2020 Paweł Marczewski 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: files 3 | 4 | TEXTURES = img/sticks.auto.png img/tiles.auto.png img/tiles.washizu.auto.png img/center.auto.png img/winds.auto.png 5 | 6 | ICONS = img/icon-16.auto.png img/icon-32.auto.png img/icon-96.auto.png 7 | 8 | .PHONY: parcel 9 | parcel: files 10 | node run-parcel.js 11 | 12 | .PHONY: server 13 | server: 14 | cd server && yarn start 15 | 16 | .PHONY: files 17 | files: img/models.auto.glb $(ICONS) 18 | 19 | .PHONY: svgs 20 | svgs: $(ICONS) $(TEXTURES) 21 | 22 | img/tiles.auto.png: img/tiles.svg 23 | inkscape $< --export-filename=$@ --export-width=1024 --export-background=#ffffff --export-background-opacity=1 24 | 25 | img/tiles.washizu.auto.png: img/tiles.washizu.svg 26 | inkscape $< --export-filename=$@ --export-width=1024 --export-background=#eeeeee --export-background-opacity=0.1 27 | 28 | img/sticks.auto.png: img/sticks.svg 29 | inkscape $< --export-filename=$@ --export-width=256 --export-height=512 30 | 31 | img/center.auto.png: img/center.svg 32 | inkscape $< --export-filename=$@ --export-width=512 --export-height=512 33 | 34 | img/winds.auto.png: img/winds.svg 35 | inkscape $< --export-filename=$@ --export-width=128 --export-height=64 36 | 37 | img/icon-%.auto.png: img/icon.svg 38 | inkscape $< --export-filename=$@ --export-width=$* 39 | 40 | img/models.auto.glb: img/models.blend $(TEXTURES) 41 | blender $< --background -noaudio --python export.py -- $@ 42 | 43 | .PHONY: build 44 | build: files 45 | rm -rf build 46 | yarn run parcel build *.html --public-url . --cache-dir .cache/build/ --out-dir build/ --no-source-maps 47 | 48 | .PHONY: build-server 49 | build-server: 50 | cd server && yarn build 51 | 52 | .PHONY: staging 53 | staging: build 54 | rsync -rva --checksum --delete build/ $(SERVER):autotable/dist-staging/ 55 | 56 | .PHONY: release 57 | release: build 58 | git push -f origin @:refs/heads/release/client 59 | rsync -rva --checksum --delete build/ $(SERVER):autotable/dist/ 60 | 61 | .PHONY: 62 | test: 63 | cd server && yarn test 64 | -------------------------------------------------------------------------------- /src/thing.ts: -------------------------------------------------------------------------------- 1 | import { Euler } from "three"; 2 | import { ThingType, Place } from "./types"; 3 | import { Slot } from "./slot"; 4 | 5 | export class Thing { 6 | index: number; 7 | type: ThingType; 8 | typeIndex: number; 9 | slot: Slot; 10 | rotationIndex: number; 11 | 12 | claimedBy: number | null; 13 | // used when claimedBy !== null: 14 | readonly heldRotation: Euler; 15 | shiftSlot: Slot | null; 16 | 17 | // For animation 18 | lastShiftSlot: Slot | null; 19 | lastShiftSlotTime: number; 20 | 21 | sent: boolean; 22 | 23 | constructor(index: number, type: ThingType, typeIndex: number, slot: Slot) { 24 | this.index = index; 25 | this.type = type; 26 | this.typeIndex = typeIndex; 27 | this.slot = slot; 28 | this.rotationIndex = 0; 29 | this.claimedBy = null; 30 | this.heldRotation = new Euler(); 31 | this.shiftSlot = null; 32 | 33 | this.lastShiftSlot = null; 34 | this.lastShiftSlotTime = 0; 35 | 36 | this.sent = false; 37 | 38 | this.slot.thing = this; 39 | } 40 | 41 | place(): Place { 42 | return this.slot.placeWithOffset(this.rotationIndex); 43 | } 44 | 45 | flip(rotationIndex?: number): void { 46 | if (rotationIndex === undefined) { 47 | rotationIndex = this.rotationIndex + 1; 48 | } 49 | const r = this.slot.rotations.length; 50 | this.rotationIndex = (rotationIndex + r) % r; 51 | this.sent = false; 52 | } 53 | 54 | prepareMove(): void { 55 | this.slot.thing = null; 56 | } 57 | 58 | moveTo(target: Slot, rotationIndex?: number): void { 59 | if (target.thing !== null) { 60 | throw `slot not empty: ${this.index} ${target.name}`; 61 | } 62 | this.slot = target; 63 | this.rotationIndex = rotationIndex ?? 0; 64 | target.thing = this; 65 | 66 | this.sent = false; 67 | } 68 | 69 | hold(seat: number): void { 70 | this.claimedBy = seat; 71 | this.heldRotation.copy(this.place().rotation); 72 | this.sent = false; 73 | } 74 | 75 | shiftTo(seat: number, slot: Slot): void { 76 | this.claimedBy = seat; 77 | this.shiftSlot = slot; 78 | this.sent = false; 79 | } 80 | 81 | release(local?: boolean): void { 82 | this.claimedBy = null; 83 | this.shiftSlot = null; 84 | if (!local) { 85 | this.sent = false; 86 | } 87 | } 88 | 89 | getTypeIndexNoFlags(): number { 90 | return this.typeIndex & 0xff; 91 | } 92 | 93 | isTransparent(): boolean { 94 | return (this.typeIndex & (1 << 10)) > 0; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /img/hand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /img/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 43 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | 53 | 54 | 55 | 56 | 61 | 69 | 77 | 85 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /src/sound-player.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "./client"; 2 | import { SoundInfo, SoundType } from "./types"; 3 | 4 | class Channel { 5 | private element: HTMLAudioElement; 6 | // TODO intensity 7 | private panner: StereoPannerNode; 8 | private gainer: GainNode; 9 | playing: boolean = false; 10 | 11 | constructor(audioContext: AudioContext) { 12 | this.element = document.createElement('audio'); 13 | this.element.onended = () => { 14 | this.element.currentTime = 0; 15 | this.playing = false; 16 | }; 17 | this.gainer = audioContext.createGain(); 18 | this.panner = audioContext.createStereoPanner(); 19 | const track = audioContext.createMediaElementSource(this.element); 20 | track.connect(this.gainer).connect(this.panner).connect(audioContext.destination); 21 | } 22 | 23 | gain(gain: number): void { 24 | this.gainer.gain.value = gain; 25 | } 26 | 27 | pan(pan: number): void { 28 | this.panner.pan.value = pan; 29 | } 30 | 31 | play(url: string): void { 32 | this.element.src = url; 33 | this.element.play(); 34 | this.playing = true; 35 | } 36 | } 37 | 38 | export class SoundPlayer { 39 | private audioContext: AudioContext; 40 | private channels: Array; 41 | private client: Client; 42 | private urls: Record; 43 | muted: boolean = false; 44 | 45 | constructor(client: Client) { 46 | this.audioContext = new AudioContext(); 47 | this.channels = []; 48 | for (let i = 0; i < 8; i++) { 49 | this.channels.push(new Channel(this.audioContext)); 50 | } 51 | this.client = client; 52 | this.client.sound.on('update', this.onUpdate.bind(this)); 53 | this.urls = { 54 | [SoundType.DISCARD]: this.getSource('sound-discard'), 55 | [SoundType.STICK]: this.getSource('sound-stick'), 56 | }; 57 | } 58 | 59 | play(type: SoundType, side: number | null): void { 60 | this.doPlay(type, side); 61 | this.client.sound.set(0, {type, side, seat: this.client.seat!}); 62 | } 63 | 64 | private onUpdate(entries: Array<[number, SoundInfo | null]>): void { 65 | for (const [, sound] of entries) { 66 | if (sound !== null && sound.seat !== this.client.seat) { 67 | this.doPlay(sound.type, sound.side); 68 | } 69 | } 70 | } 71 | 72 | private getSource(id: string): string { 73 | return (document.getElementById(id) as HTMLAudioElement).src; 74 | } 75 | 76 | private doPlay(type: SoundType, side: number | null): void { 77 | if (this.muted) { 78 | return; 79 | } 80 | 81 | this.audioContext.resume(); 82 | for (const channel of this.channels) { 83 | const rotation = this.client.seat ?? 0; 84 | if (!channel.playing) { 85 | if (side !== null) { 86 | side = (side + 4 - rotation) % 4; 87 | } 88 | let pan = 0; 89 | switch(side) { 90 | case 1: pan = 0.5; break; 91 | case 3: pan = -0.5; break; 92 | } 93 | channel.pan(pan); 94 | channel.gain(0.5); 95 | channel.play(this.urls[type]); 96 | break; 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Vector3, Euler } from "three"; 2 | import { tileMapToString, parseTileString } from "./game-ui"; 3 | 4 | export enum ThingType { 5 | TILE = 'TILE', 6 | STICK = 'STICK', 7 | MARKER = 'MARKER', 8 | } 9 | 10 | export const Size = { 11 | TILE: new Vector3(6, 9, 4), 12 | STICK: new Vector3(20, 2, 1), 13 | MARKER: new Vector3(12, 6, 1), 14 | }; 15 | 16 | export interface Place { 17 | position: Vector3; 18 | rotation: Euler; 19 | size: Vector3; 20 | } 21 | 22 | export interface ThingInfo { 23 | slotName: string; 24 | rotationIndex: number; 25 | claimedBy: number | null; 26 | heldRotation: { x: number; y: number; z: number }; 27 | shiftSlotName: string | null; 28 | } 29 | 30 | export interface MatchInfo { 31 | dealer: number; 32 | honba: number; 33 | conditions: Conditions; 34 | } 35 | 36 | export interface Game { 37 | gameId: string; 38 | num: number; 39 | } 40 | 41 | export enum DealType { 42 | INITIAL = 'INITIAL', 43 | WINDS = 'WINDS', 44 | HANDS = 'HANDS', 45 | } 46 | 47 | export enum GameType { 48 | FOUR_PLAYER = 'FOUR_PLAYER', 49 | THREE_PLAYER = 'THREE_PLAYER', 50 | BAMBOO = 'BAMBOO', 51 | MINEFIELD = 'MINEFIELD', 52 | WASHIZU = 'WASHIZU', 53 | } 54 | 55 | interface GameTypeMeta { 56 | points: Points; 57 | seats: Array; 58 | } 59 | 60 | export const GAME_TYPES: Record = { 61 | FOUR_PLAYER: { points: '25', seats: [0, 1, 2, 3]}, 62 | THREE_PLAYER: { points: '35', seats: [0, 1, 2]}, 63 | BAMBOO: { points: '100', seats: [0, 2]}, 64 | MINEFIELD: { points: '25', seats: [0, 2]}, 65 | WASHIZU: { points: '25', seats: [0, 1, 2, 3] }, 66 | }; 67 | 68 | export type Points = '5' | '8' | '25' | '30' | '35' | '40' | '100'; 69 | 70 | export interface Conditions { 71 | gameType: GameType; 72 | back: number; // 0 or 1 73 | aka: Record; 74 | points: Points; 75 | } 76 | 77 | export namespace Conditions { 78 | export function initial(): Conditions { 79 | return { gameType: GameType.FOUR_PLAYER, back: 0, aka: parseTileString('5m5p5s'), points: '25' }; 80 | } 81 | 82 | export function equals(a: Conditions, b: Conditions): boolean { 83 | return a.gameType === b.gameType && a.back === b.back && tileMapToString(a.aka) === tileMapToString(b.aka); 84 | } 85 | 86 | export function describe(ts: Conditions): string { 87 | const game = { 88 | 'FOUR_PLAYER': '4p', 89 | 'THREE_PLAYER': '3p', 90 | 'BAMBOO': 'b', 91 | 'MINEFIELD': 'm', 92 | 'WASHIZU': 'w', 93 | }[ts.gameType]; 94 | let aka = tileMapToString(ts.aka); 95 | if (ts.aka === undefined || aka === "") { 96 | aka = "no aka"; 97 | } 98 | return `${game}, ${aka}`; 99 | } 100 | } 101 | 102 | export interface MouseInfo { 103 | held: {x: number; y: number; z: number} | null; 104 | mouse: {x: number; y: number; z: number; time: number} | null; 105 | } 106 | 107 | export enum SoundType { 108 | DISCARD = 'DISCARD', 109 | STICK = 'STICK', 110 | }; 111 | 112 | export interface SoundInfo { 113 | type: SoundType; 114 | seat: number; 115 | side: number | null; 116 | } 117 | 118 | export interface SeatInfo { 119 | seat: number | null; 120 | } 121 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Vector3 } from "three"; 2 | 3 | export function shuffle(arr: Array): void { 4 | for (let i = arr.length - 1; i > 0; i--) { 5 | const j = Math.floor(Math.random() * (i + 1)); 6 | const temp = arr[j]; 7 | arr[j] = arr[i]; 8 | arr[i] = temp; 9 | } 10 | } 11 | 12 | export function filterMostCommon(arr: Array, func: (elem: T) => R): Array { 13 | const result = mostCommon(arr, func); 14 | if (result === null) { 15 | return []; 16 | } 17 | return arr.filter(elem => func(elem) === result); 18 | } 19 | 20 | export function mostCommon(arr: Array, func: (elem: T) => R): R | null { 21 | if (arr.length === 0) { 22 | return null; 23 | } 24 | 25 | const counts: Map = new Map(); 26 | for (const elem of arr) { 27 | const result = func(elem); 28 | const current = counts.get(result); 29 | if (current !== undefined) { 30 | counts.set(result, current + 1); 31 | } else { 32 | counts.set(result, 1); 33 | } 34 | } 35 | 36 | const allResults = Array.from(counts.keys()); 37 | allResults.sort((a, b) => counts.get(b)! - counts.get(a)!); 38 | return allResults[0]; 39 | } 40 | 41 | // Overlap of two rectangles, given by midpoint and size 42 | export function rectangleOverlap( 43 | x1: number, y1: number, w1: number, h1: number, 44 | x2: number, y2: number, w2: number, h2: number 45 | ): number { 46 | const xa = Math.max(x1 - w1/2, x2 - w2/2); 47 | const xb = Math.min(x1 + w1/2, x2 + w2/2); 48 | const ya = Math.max(y1 - h1/2, y2 - h2/2); 49 | const yb = Math.min(y1 + h1/2, y2 + h2/2); 50 | 51 | return Math.max(0, xb - xa) * Math.max(0, yb - ya); 52 | } 53 | 54 | export class Animation { 55 | private startTime = 0; 56 | private endTime = 1; 57 | private startPos = 0; 58 | private endPos = 0; 59 | private period: number; 60 | pos = -1; 61 | 62 | constructor(period: number) { 63 | this.period = period; 64 | } 65 | 66 | start(endPos: number): void { 67 | this.startPos = this.pos; 68 | this.startTime = new Date().getTime(); 69 | this.endPos = endPos; 70 | this.endTime = this.startTime + this.period * Math.abs(endPos - this.pos); 71 | } 72 | 73 | update(): boolean { 74 | if (this.pos === this.endPos) { 75 | return false; 76 | } 77 | 78 | const now = new Date().getTime(); 79 | const delta = (now - this.startTime) / (this.endTime - this.startTime); 80 | this.pos = this.startPos + (this.endPos - this.startPos) * Math.min(1, delta); 81 | return true; 82 | } 83 | } 84 | 85 | export function round3(vec: Vector3, factor: number): void { 86 | vec.x = Math.round(vec.x * factor) / factor; 87 | vec.y = Math.round(vec.y * factor) / factor; 88 | vec.z = Math.round(vec.z * factor) / factor; 89 | } 90 | 91 | export function clamp(val: number, min: number, max: number): number { 92 | return Math.min(max, Math.max(min, val)); 93 | } 94 | 95 | export function compareZYX(a: Vector3, b: Vector3): number { 96 | if (a.z !== b.z) { 97 | return a.z - b.z; 98 | } 99 | if (a.y !== b.y) { 100 | return a.y - b.z; 101 | } 102 | if (a.x !== b.x) { 103 | return a.x - b.x; 104 | } 105 | return 0; 106 | } 107 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | import WebSocket from 'ws'; 4 | import { Client, Game, randomString } from './game'; 5 | import { Message } from './protocol'; 6 | 7 | type WebSocketClient = WebSocket & Client; 8 | 9 | export class Server { 10 | port: number; 11 | games: Map = new Map(); 12 | 13 | constructor(port: number) { 14 | this.port = port; 15 | } 16 | 17 | run(): void { 18 | const wss = new WebSocket.Server({ 19 | port: this.port, 20 | }); 21 | 22 | wss.on("connection", ws => { 23 | const client = ws as WebSocketClient; 24 | client.game = null; 25 | client.playerId = null; 26 | client.isAlive = true; 27 | 28 | client.heartbeatIntervalId = setInterval(() => { 29 | if (!client.isAlive) { 30 | client.terminate(); 31 | } 32 | 33 | client.isAlive = false; 34 | client.ping(); 35 | }, 15000); 36 | 37 | client.on("pong", () => { 38 | client.isAlive = true; 39 | }); 40 | 41 | client.on('message', data => { 42 | if (client.game !== null) { 43 | console.debug(`recv ${client.game.gameId}.${client.playerId} ${data}`); 44 | } else { 45 | console.debug(`recv * ${data}`); 46 | } 47 | 48 | const message = JSON.parse(data as string) as Message; 49 | 50 | try { 51 | this.onMessage(client, message); 52 | } catch(err) { 53 | console.error(err); 54 | client.close(); 55 | this.onClose(client); 56 | } 57 | }); 58 | 59 | client.on('error', (e) => { 60 | console.debug('> error', e); 61 | this.onClose(client); 62 | }); 63 | 64 | client.on('close', () => { 65 | console.debug('> disconnect'); 66 | clearInterval(client.heartbeatIntervalId); 67 | this.onClose(client); 68 | }); 69 | }); 70 | 71 | setInterval(this.checkExpiry.bind(this), 5000); 72 | 73 | console.log(`listening at ${this.port}`); 74 | } 75 | 76 | onMessage(client: Client, message: Message): void { 77 | if (client.game) { 78 | client.game.onMessage(client, message); 79 | return; 80 | } 81 | 82 | switch(message.type) { 83 | case 'NEW': { 84 | let gameId: string; 85 | do { 86 | gameId = randomString(); 87 | } while (this.games.has(gameId)); 88 | 89 | const game = new Game(gameId); 90 | this.games.set(gameId, game); 91 | game.join(client); 92 | break; 93 | } 94 | 95 | case 'JOIN': { 96 | let game = this.games.get(message.gameId); 97 | if (!game) { 98 | console.warn(`game not found, creating: ${message.gameId}`); 99 | game = new Game(message.gameId); 100 | this.games.set(message.gameId, game); 101 | } 102 | game.join(client); 103 | break; 104 | } 105 | 106 | default: 107 | throw `unknown message: ${message.type}`; 108 | } 109 | } 110 | 111 | onClose(client: Client): void { 112 | if (client.game !== null) { 113 | client.game.leave(client); 114 | } 115 | } 116 | 117 | checkExpiry(): void { 118 | const now = new Date().getTime(); 119 | for (const [gameId, game] of this.games.entries()) { 120 | if (game.expiryTime !== null && game.expiryTime < now) { 121 | console.log(`deleting expired: ${gameId}`); 122 | this.games.delete(gameId); 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /server/game.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Game } from './game'; 3 | import { Message } from './protocol'; 4 | 5 | class TestClient { 6 | game: Game | null = null; 7 | playerId: string | null = null; 8 | sent: Array = []; 9 | heartbeatIntervalId = setTimeout(() => {} , 0); 10 | isAlive = false; 11 | 12 | isAuthed: boolean = false; 13 | send(msg: string): void { 14 | this.sent.push(JSON.parse(msg) as Message); 15 | } 16 | 17 | last(): Message { 18 | return this.sent[this.sent.length - 1]; 19 | } 20 | } 21 | 22 | describe('Game', function() { 23 | it('join', function() { 24 | const game = new Game('xxx'); 25 | const client = new TestClient(); 26 | game.join(client); 27 | expect(client.game).toBe(game); 28 | expect(client.playerId).not.toBe(null); 29 | expect(client.sent).toEqual([ 30 | { type: 'JOINED', gameId: 'xxx', playerId: client.playerId, isFirst: true }, 31 | { type: 'UPDATE', entries: [], full: true}, 32 | ]); 33 | }); 34 | 35 | it('join second player', function() { 36 | const game = new Game('xxx'); 37 | const client1 = new TestClient(); 38 | const client2 = new TestClient(); 39 | game.join(client1); 40 | game.onMessage(client1, { 41 | type: 'UPDATE', 42 | entries: [ 43 | ['foo', 'bar', 'baz'], 44 | ['foo', 'bar2', 'baz2'], 45 | ], 46 | full: false, 47 | }); 48 | game.onMessage(client1, { 49 | type: 'UPDATE', 50 | entries: [['foo', 'bar', null]], 51 | full: false, 52 | }); 53 | game.join(client2); 54 | expect(client2.game).toBe(game); 55 | expect(client2.playerId).not.toBe(null); 56 | expect(client2.sent).toEqual([ 57 | { type: 'JOINED', gameId: 'xxx', playerId: client2.playerId, isFirst: false }, 58 | { type: 'UPDATE', entries: [['foo', 'bar2', 'baz2']], full: true}, 59 | ]); 60 | }); 61 | 62 | it('unique', function() { 63 | const game = new Game('xxx'); 64 | const client = new TestClient(); 65 | game.join(client); 66 | game.onMessage(client, { 67 | type: 'UPDATE', 68 | entries: [ 69 | ['foo', 'bar', { x: 1 }], 70 | ['foo', 'bar2', { x: 2 }], 71 | ['unique', 'foo', 'x'], 72 | ], 73 | full: false, 74 | }); 75 | game.onMessage(client, { 76 | type: 'UPDATE', 77 | entries: [ 78 | ['foo', 'bar', { x: 2 }], 79 | ], 80 | full: false, 81 | }); 82 | expect(client.last()).toEqual({ 83 | type: 'UPDATE', 84 | entries: [ 85 | ['foo', 'bar', { x: 1 }], 86 | ['foo', 'bar2', { x: 2 }], 87 | ['unique', 'foo', 'x'], 88 | ], 89 | full: true, 90 | }); 91 | }); 92 | 93 | it('perPlayer', function() { 94 | const game = new Game('xxx'); 95 | const client1 = new TestClient(); 96 | const client2 = new TestClient(); 97 | game.join(client1); 98 | game.join(client2); 99 | game.onMessage(client1, { 100 | type: 'UPDATE', 101 | entries: [ 102 | ['perPlayer', 'foo', true], 103 | ['foo', client1.playerId!, 'xxx'], 104 | ], 105 | full: false, 106 | }); 107 | game.onMessage(client2, { 108 | type: 'UPDATE', 109 | entries: [ 110 | ['foo', client2.playerId!, 'yyy'], 111 | ], 112 | full: false, 113 | }); 114 | 115 | game.leave(client2); 116 | expect(client1.last()).toEqual({ 117 | type: 'UPDATE', 118 | entries: [ 119 | ['foo', client2.playerId!, null], 120 | ], 121 | full: false, 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/mouse-tracker.ts: -------------------------------------------------------------------------------- 1 | import { Vector3 } from "three"; 2 | import { Client } from "./client"; 3 | import { clamp } from "./utils"; 4 | 5 | 6 | interface Waypoint { 7 | pos: Vector3; 8 | time: number; 9 | remoteTime: number; 10 | } 11 | 12 | interface Player { 13 | held: Vector3 | null; 14 | waypoints: Array; 15 | } 16 | 17 | const ANIMATION_TIME = 100; 18 | 19 | export class MouseTracker { 20 | private players: Array; 21 | private client: Client; 22 | 23 | constructor(client: Client) { 24 | this.players = []; 25 | for (let i = 0; i < 4; i++) { 26 | this.players.push({held: null, waypoints: []}); 27 | } 28 | this.client = client; 29 | this.client.mouse.on('update', this.onUpdate.bind(this)); 30 | } 31 | 32 | update(mouse: Vector3 | null, held: Vector3 | null): void { 33 | const now = new Date().getTime(); 34 | this.client.mouse.set(this.client.playerId(), { 35 | mouse: mouse && {x: mouse.x, y: mouse.y, z: mouse.z, time: now}, 36 | held: held && {x: held.x, y: held.y, z: held.z}, 37 | }); 38 | } 39 | 40 | getMouse(playerNum: number, now: number): Vector3 | null { 41 | // Debugging 42 | // if (playerNum === 3) { 43 | // const waypoints = this.players[1].waypoints; 44 | // if (waypoints.length === 0) { 45 | // return null; 46 | // } 47 | // return waypoints[waypoints.length-1].pos; 48 | // } 49 | 50 | const waypoints = this.players[playerNum].waypoints; 51 | if (waypoints.length === 0) { 52 | return null; 53 | } 54 | 55 | for (let i = 0; i < waypoints.length - 1; i++) { 56 | const a = waypoints[i]; 57 | const b = waypoints[i+1]; 58 | if (a.time <= now && now <= b.time) { 59 | const pos = a.pos.clone(); 60 | const alpha = (now - a.time) / (b.time - a.time); 61 | pos.lerp(b.pos, alpha); 62 | return pos; 63 | } 64 | } 65 | 66 | return waypoints[waypoints.length - 1].pos; 67 | } 68 | 69 | getHeld(playerNum: number): Vector3 | null { 70 | return this.players[playerNum].held; 71 | } 72 | 73 | private onUpdate(): void { 74 | const now = new Date().getTime(); 75 | 76 | for (let i = 0; i < 4; i++) { 77 | const playerId = this.client.seatPlayers[i]; 78 | const mouseInfo = playerId !== null ? this.client.mouse.get(playerId) : null; 79 | const player = this.players[i]; 80 | 81 | if (!mouseInfo) { 82 | player.held = null; 83 | player.waypoints.splice(0); 84 | continue; 85 | } 86 | 87 | if (mouseInfo.held === null) { 88 | player.held = null; 89 | } else { 90 | if (!player.held) { 91 | player.held = new Vector3(); 92 | } 93 | player.held.set(mouseInfo.held.x, mouseInfo?.held.y, mouseInfo?.held.z); 94 | } 95 | 96 | if (mouseInfo.mouse === null) { 97 | player.waypoints.splice(0); 98 | } else { 99 | const pos = new Vector3(mouseInfo.mouse.x, mouseInfo.mouse.y, mouseInfo.mouse.z); 100 | const remoteTime = mouseInfo.mouse.time; 101 | 102 | if (player.waypoints.length === 0) { 103 | player.waypoints.push({ pos, time: now, remoteTime}); 104 | player.waypoints.push({ pos, time: now + ANIMATION_TIME, remoteTime}); 105 | } else { 106 | let last = player.waypoints[player.waypoints.length-1]; 107 | if (last.time < now - ANIMATION_TIME) { 108 | last = { pos: last.pos, time: now, remoteTime: remoteTime - ANIMATION_TIME}; 109 | player.waypoints.push(last); 110 | } 111 | 112 | let time = last.time + remoteTime - last.remoteTime; 113 | time = clamp(time, now + ANIMATION_TIME * 0.5, now + ANIMATION_TIME * 1.5); 114 | time = Math.max(time, last.time); 115 | player.waypoints.push({ 116 | pos, time, remoteTime 117 | }); 118 | } 119 | 120 | player.waypoints = player.waypoints.filter(w => w.time >= now - ANIMATION_TIME * 2); 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/base-client.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | import { EventEmitter } from 'events'; 4 | 5 | import { Message, Entry } from '../server/protocol'; 6 | import { resolve } from 'path'; 7 | 8 | export interface Game { 9 | gameId: string; 10 | playerId: string; 11 | } 12 | 13 | export class BaseClient { 14 | isAuthed: boolean = false; 15 | private ws: WebSocket | null = null; 16 | private game: Game | null = null; 17 | private events: EventEmitter = new EventEmitter(); 18 | private pending: Array | null = null; 19 | 20 | constructor() { 21 | this.events.setMaxListeners(50); 22 | } 23 | 24 | new(url: string): void { 25 | this.connect(url, () => { 26 | this.send({ type: 'NEW' }); 27 | }); 28 | } 29 | 30 | join(url: string, gameId: string): void { 31 | this.connect(url, () => { 32 | this.send({ type: 'JOIN', gameId }); 33 | }); 34 | } 35 | 36 | private authWaits: Array<[(_: boolean) => void, (_: any) => void] > = []; 37 | 38 | auth(password: string): Promise { 39 | return new Promise((resolve, reject) => { 40 | this.authWaits.push([resolve, reject]); 41 | this.send({ type: 'AUTH', password }); 42 | }); 43 | } 44 | 45 | disconnect(): void { 46 | this.ws?.close(); 47 | } 48 | 49 | private connect(url: string, start: () => void): void { 50 | if (this.ws) { 51 | return; 52 | } 53 | this.ws = new WebSocket(url); 54 | this.ws.onopen = start; 55 | this.ws.onclose = this.onClose.bind(this); 56 | 57 | this.ws.onmessage = event => { 58 | const message = JSON.parse(event.data as string) as Message; 59 | this.onMessage(message); 60 | }; 61 | } 62 | 63 | on(what: 'connect', handler: (game: Game, isFirst: boolean, password: string) => void): void; 64 | on(what: 'disconnect', handler: (game: Game | null) => void): void; 65 | on(what: 'update', handler: (things: Array, full: boolean) => void): void; 66 | on(what: 'authed', handler: (isAuthed: boolean) => void): void; 67 | 68 | on(what: string, handler: (...args: any[]) => void): void { 69 | this.events.on(what, handler); 70 | } 71 | 72 | transaction(func: () => void): void { 73 | this.pending = []; 74 | try { 75 | func(); 76 | if (this.pending !== null && this.pending.length > 0) { 77 | this.send({ type: 'UPDATE', entries: this.pending, full: false }); 78 | } 79 | } finally { 80 | this.pending = null; 81 | } 82 | } 83 | 84 | update(entries: Array): void { 85 | if (this.pending !== null) { 86 | this.pending.push(...entries); 87 | } else { 88 | this.send({ type: 'UPDATE', entries, full: false }); 89 | } 90 | } 91 | 92 | private send(message: Message): void { 93 | if (!this.open) { 94 | return; 95 | } 96 | const data = JSON.stringify(message); 97 | this.ws!.send(data); 98 | } 99 | 100 | private open(): boolean { 101 | return this.ws !== null && this.ws.readyState === WebSocket.OPEN; 102 | } 103 | 104 | connected(): boolean { 105 | return this.open() && this.game !== null; 106 | } 107 | 108 | playerId(): string { 109 | return this.game?.playerId ?? 'offline'; 110 | } 111 | 112 | private onClose(): void { 113 | this.ws = null; 114 | const game = this.game; 115 | this.game = null; 116 | this.events.emit('disconnect', game); 117 | } 118 | 119 | private onMessage(message: Message): void { 120 | switch (message.type) { 121 | case 'JOINED': 122 | this.game = { 123 | gameId: message.gameId, 124 | playerId: message.playerId, 125 | }; 126 | this.events.emit('connect', this.game, message.isFirst, message.password); 127 | break; 128 | 129 | case 'UPDATE': 130 | this.events.emit('update', message.entries, message.full); 131 | break; 132 | 133 | case 'AUTHED': 134 | const wait = this.authWaits.shift(); 135 | if (!wait) { 136 | break; 137 | } 138 | this.isAuthed = message.isAuthed; 139 | this.events.emit('authed', message.isAuthed); 140 | wait[0](message.isAuthed); 141 | break; 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /img/center.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 39 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 62 | 68 | 76 | 78 | 86 | 25000 97 | 98 | 106 | 114 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/client-ui.ts: -------------------------------------------------------------------------------- 1 | import qs from 'qs'; 2 | 3 | import { Client } from "./client"; 4 | import { Game } from './base-client'; 5 | 6 | 7 | const TITLE_DISCONNECTED = 'Autotable'; 8 | const TITLE_CONNECTED = 'Autotable (online)'; 9 | const RECONNECT_DELAY = 2000; 10 | const RECONNECT_ATTEMPTS = 15; 11 | 12 | export class ClientUi { 13 | url: string; 14 | client: Client; 15 | statusElement: HTMLElement; 16 | statusTextElement: HTMLElement; 17 | 18 | disconnecting = false; 19 | reconnectAttempts: number = 0; 20 | reconnectSeat: number | null = null; 21 | 22 | constructor(client: Client) { 23 | this.url = this.getUrl(); 24 | this.client = client; 25 | 26 | this.client.on('connect', this.onConnect.bind(this)); 27 | this.client.on('disconnect', this.onDisconnect.bind(this)); 28 | 29 | const connectButton = document.getElementById('connect')!; 30 | connectButton.onclick = () => this.connect(); 31 | const disconnectButton = document.getElementById('disconnect')!; 32 | disconnectButton.onclick = this.disconnect.bind(this); 33 | const newGameButton = document.getElementById('new-game')!; 34 | newGameButton.onclick = this.newGame.bind(this); 35 | 36 | this.statusElement = document.getElementById('status') as HTMLElement; 37 | this.statusTextElement = document.getElementById('status-text') as HTMLElement; 38 | } 39 | 40 | getUrlState(): string | null { 41 | const query = window.location.search.substr(1); 42 | const q = qs.parse(query) as any; 43 | return q.gameId ?? null; 44 | } 45 | 46 | setUrlState(gameId: string | null): void { 47 | const query = window.location.search.substr(1); 48 | const q = qs.parse(query) as any; 49 | q.gameId = gameId ?? undefined; 50 | const newQuery = qs.stringify(q); 51 | if (newQuery !== query) { 52 | history.pushState(undefined, '', '?' + qs.stringify(q)); 53 | } 54 | } 55 | 56 | start(): void { 57 | if (this.getUrlState() !== null) { 58 | // If connecting right on page load, start from empty seat 59 | // (to prevent sudden change) 60 | this.client.seats.set(this.client.playerId(), { seat: null }); 61 | 62 | this.connect(); 63 | } 64 | } 65 | 66 | getUrl(): string { 67 | // @ts-ignore 68 | const env = process.env.NODE_ENV; 69 | 70 | let path = window.location.pathname; 71 | path = path.substring(1, path.lastIndexOf('/')+1); 72 | const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 73 | const wsHost = window.location.host; 74 | const wsPath = path + 'ws'; 75 | 76 | if (env !== 'production') { 77 | return `${wsProtocol}//${window.location.hostname}:1235/${wsPath}`; 78 | } 79 | 80 | return `${wsProtocol}//${wsHost}/${wsPath}`; 81 | } 82 | 83 | onConnect(game: Game): void { 84 | this.setStatus(null); 85 | document.getElementById('server')!.classList.add('connected'); 86 | this.setUrlState(game.gameId); 87 | document.getElementsByTagName('title')[0].innerText = TITLE_CONNECTED; 88 | 89 | if (this.reconnectSeat !== null) { 90 | this.client.seats.set(this.client.playerId(), { seat: this.reconnectSeat }); 91 | } 92 | } 93 | 94 | onDisconnect(game: Game | null): void { 95 | document.getElementById('server')!.classList.remove('connected'); 96 | document.getElementsByTagName('title')[0].innerText = TITLE_DISCONNECTED; 97 | 98 | if (game && !this.disconnecting) { 99 | this.reconnectSeat = this.client.seat; 100 | setTimeout( 101 | () => this.connect(RECONNECT_ATTEMPTS, this.client.seat ?? undefined), 102 | RECONNECT_DELAY 103 | ); 104 | this.setStatus('Trying to reconnect...'); 105 | } else if (!game && this.reconnectAttempts > 0) { 106 | setTimeout( 107 | () => this.connect(this.reconnectAttempts - 1, this.reconnectSeat ?? undefined), 108 | RECONNECT_DELAY); 109 | } else { 110 | (document.getElementById('connect')! as HTMLButtonElement).disabled = false; 111 | if (!this.disconnecting) { 112 | this.setStatus('Failed to connect.'); 113 | } 114 | } 115 | } 116 | 117 | setStatus(status: string | null): void { 118 | if (status !== null) { 119 | this.statusElement.style.display = 'block'; 120 | this.statusTextElement.innerText = status; 121 | } else { 122 | this.statusElement.style.display = 'none'; 123 | } 124 | } 125 | 126 | connect(reconnectAttempts?: number, reconnectSeat?: number): void { 127 | if (this.client.connected()) { 128 | return; 129 | } 130 | (document.getElementById('connect')! as HTMLButtonElement).disabled = true; 131 | this.reconnectSeat = null; 132 | const gameId = this.getUrlState(); 133 | if (gameId !== null) { 134 | this.client.join(this.url, gameId); 135 | } else { 136 | this.client.new(this.url); 137 | } 138 | this.reconnectAttempts = reconnectAttempts ?? 0; 139 | this.reconnectSeat = reconnectSeat ?? null; 140 | } 141 | 142 | disconnect(): void { 143 | this.disconnecting = true; 144 | this.client.disconnect(); 145 | // this.setUrlState(null); 146 | } 147 | 148 | newGame(): void { 149 | window.location.search = ''; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/asset-loader.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import jpg from '../img/*.jpg'; 3 | // @ts-ignore 4 | import png from '../img/*.png'; 5 | // @ts-ignore 6 | import glbModels from '../img/models.auto.glb'; 7 | 8 | import { Texture, Mesh, TextureLoader, Material, LinearEncoding, 9 | MeshStandardMaterial, MeshLambertMaterial, PlaneGeometry, RepeatWrapping } from 'three'; 10 | import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader'; 11 | import { World } from './world'; 12 | import { Size } from './types'; 13 | 14 | 15 | export class AssetLoader { 16 | static readonly worldSize = World.WIDTH + Size.TILE.y * 2; 17 | private static readonly TableClothLocalStorageKey = "_tableClothDataUrl"; 18 | textures: Record = {}; 19 | meshes: Record = {}; 20 | 21 | makeTable(): Mesh { 22 | const tableGeometry = new PlaneGeometry( 23 | AssetLoader.worldSize, 24 | AssetLoader.worldSize 25 | ); 26 | const tableMaterial = new MeshLambertMaterial({ 27 | color: 0xeeeeee, 28 | map: this.textures.customTableCloth ?? this.textures.table 29 | }); 30 | const tableMesh = new Mesh(tableGeometry, tableMaterial); 31 | return tableMesh; 32 | } 33 | 34 | makeCenter(): Mesh { 35 | return this.cloneMesh(this.meshes.center); 36 | } 37 | 38 | makeNamePlate(): Mesh { 39 | const mesh = this.cloneMesh(this.meshes.name_plate); 40 | // (mesh.material as MeshStandardMaterial).color.setHex(0xddddd0); 41 | return mesh; 42 | } 43 | 44 | makeTableEdge(): Mesh { 45 | const mesh = this.cloneMesh(this.meshes.table_edge); 46 | (mesh.material as MeshStandardMaterial).color.setHex(0xddddd0); 47 | return mesh; 48 | } 49 | 50 | makeTray(): Mesh { 51 | const mesh = this.cloneMesh(this.meshes.tray); 52 | (mesh.material as MeshStandardMaterial).color.setHex(0x363636); 53 | return mesh; 54 | } 55 | 56 | make(what: string): Mesh { 57 | return this.cloneMesh(this.meshes[what]); 58 | } 59 | 60 | makeMarker(): Mesh { 61 | return this.cloneMesh(this.meshes.marker); 62 | } 63 | 64 | cloneMesh(mesh: Mesh): Mesh { 65 | const newMesh = mesh.clone(); 66 | if (Array.isArray(mesh.material)) { 67 | newMesh.material = mesh.material.map(m => m.clone()); 68 | } else { 69 | newMesh.material = mesh.material.clone(); 70 | } 71 | 72 | return newMesh; 73 | } 74 | 75 | loadAll(): Promise { 76 | const tasks = [ 77 | this.loadTexture(jpg['table'], 'table'), 78 | this.loadTexture(png['tiles.washizu.auto'], 'tiles.washizu.auto'), 79 | this.loadModels(glbModels), 80 | (document as any).fonts.load('40px "Segment7Standard"'), 81 | ]; 82 | 83 | const savedCloth = localStorage.getItem(AssetLoader.TableClothLocalStorageKey); 84 | if (savedCloth) { 85 | tasks.push(this.loadTableCloth(savedCloth)); 86 | } 87 | 88 | return Promise.all(tasks).then(() => { 89 | this.textures.table.wrapS = RepeatWrapping; 90 | this.textures.table.wrapT = RepeatWrapping; 91 | this.textures.table.repeat.set(3, 3); 92 | (this.meshes.tile.material as MeshStandardMaterial).color.setHex(0xeeeeee); 93 | }); 94 | } 95 | 96 | loadTableCloth(url: string): Promise { 97 | return this.loadTexture(url, "customTableCloth").then((texture) => { 98 | texture.flipY = true; 99 | if (url.length < 600000) { 100 | localStorage.setItem(AssetLoader.TableClothLocalStorageKey, url); 101 | } 102 | }); 103 | } 104 | 105 | forgetTableCloth() { 106 | localStorage.removeItem(AssetLoader.TableClothLocalStorageKey); 107 | delete this.textures.customTableCloth; 108 | } 109 | 110 | loadTexture(url: string, name: string): Promise { 111 | const loader = new TextureLoader(); 112 | return new Promise(resolve => { 113 | loader.load(url, (texture: Texture) => { 114 | this.textures[name] = this.processTexture(texture); 115 | resolve(this.textures[name]); 116 | }); 117 | }); 118 | } 119 | 120 | loadModels(url: string): Promise { 121 | const loader = new GLTFLoader(); 122 | return new Promise(resolve => { 123 | loader.load(url, (model: GLTF) => { 124 | for (const obj of model.scene.children) { 125 | if ((obj as Mesh).isMesh) { 126 | this.meshes[obj.name] = this.processMesh(obj as Mesh); 127 | } else { 128 | // eslint-disable-next-line no-console 129 | console.warn('unrecognized object', obj); 130 | } 131 | } 132 | resolve(); 133 | }); 134 | }); 135 | } 136 | 137 | processTexture(texture: Texture): Texture { 138 | texture.flipY = false; 139 | texture.anisotropy = 4; 140 | return texture; 141 | } 142 | 143 | private processMesh(mesh: Mesh): Mesh { 144 | if (Array.isArray(mesh.material)) { 145 | mesh.material = mesh.material.map(this.processMaterial.bind(this)); 146 | } else { 147 | mesh.material = this.processMaterial(mesh.material); 148 | } 149 | return mesh; 150 | } 151 | 152 | private processMaterial(material: Material): Material { 153 | const standard = material as MeshStandardMaterial; 154 | const map = standard.map; 155 | if (map !== null) { 156 | map.encoding = LinearEncoding; 157 | map.anisotropy = 4; 158 | } 159 | return new MeshLambertMaterial({map}); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/game.ts: -------------------------------------------------------------------------------- 1 | import { ObjectView } from "./object-view"; 2 | import { World } from "./world"; 3 | import { Client } from "./client"; 4 | import { AssetLoader } from "./asset-loader"; 5 | import { Animation } from "./utils"; 6 | import { MouseUi } from "./mouse-ui"; 7 | import { MainView } from "./main-view"; 8 | import { Group } from "three"; 9 | import { ClientUi } from "./client-ui"; 10 | import { SoundPlayer } from "./sound-player"; 11 | import { GameUi } from './game-ui'; 12 | 13 | export class Game { 14 | private assetLoader: AssetLoader; 15 | private mainGroup: Group; 16 | private client: Client; 17 | private world: World; 18 | private objectView: ObjectView; 19 | private mainView: MainView; 20 | private mouseUi: MouseUi; 21 | private clientUi: ClientUi; 22 | private soundPlayer: SoundPlayer; 23 | private gameUi: GameUi; 24 | 25 | benchmark: boolean = false; 26 | 27 | settings: { 28 | perspective: HTMLInputElement; 29 | benchmark: HTMLInputElement; 30 | muted: HTMLInputElement; 31 | sticky: HTMLInputElement; 32 | }; 33 | 34 | private lookDown = new Animation(150); 35 | private zoom = new Animation(150); 36 | private lookDownState: number = 0; 37 | 38 | keys: Set = new Set(); 39 | 40 | constructor(assetLoader: AssetLoader) { 41 | this.assetLoader = assetLoader; 42 | this.mainGroup = new Group; 43 | this.client = new Client(); 44 | this.objectView = new ObjectView(this.mainGroup, assetLoader, this.client); 45 | this.soundPlayer = new SoundPlayer(this.client); 46 | this.world = new World(this.objectView, this.soundPlayer, this.client); 47 | this.mainView = new MainView(this.mainGroup, this.client, this.world); 48 | this.mouseUi = new MouseUi(this.world, this.mainGroup); 49 | this.clientUi = new ClientUi(this.client); 50 | this.gameUi = new GameUi(this.client, this.world, this.mainView, this.assetLoader, this.objectView); 51 | this.world.registerEvents(); 52 | 53 | this.settings = { 54 | perspective: document.getElementById('perspective') as HTMLInputElement, 55 | benchmark: document.getElementById('benchmark') as HTMLInputElement, 56 | muted: document.getElementById('muted') as HTMLInputElement, 57 | sticky: document.getElementById('sticky') as HTMLInputElement, 58 | }; 59 | 60 | this.setupEvents(); 61 | } 62 | 63 | private setupEvents(): void { 64 | window.addEventListener('keypress', this.onKeyPress.bind(this)); 65 | window.addEventListener('keydown', this.onKeyDown.bind(this)); 66 | window.addEventListener('keyup', this.onKeyUp.bind(this)); 67 | for (const key in this.settings) { 68 | const element = (this.settings as any)[key] as HTMLInputElement; 69 | element.addEventListener('change', this.updateSettings.bind(this)); 70 | } 71 | } 72 | 73 | private updateSettings(): void { 74 | this.mainView.setPerspective(this.settings.perspective.checked); 75 | this.benchmark = this.settings.benchmark.checked; 76 | this.soundPlayer.muted = this.settings.muted.checked; 77 | this.mouseUi.sticky = this.settings.sticky.checked; 78 | } 79 | 80 | start(): void { 81 | this.clientUi.start(); 82 | this.mainLoop(); 83 | } 84 | 85 | mainLoop(): void { 86 | requestAnimationFrame(this.mainLoop.bind(this)); 87 | 88 | if (this.benchmark) { 89 | const start = new Date().getTime(); 90 | let end; 91 | do { 92 | this.update(); 93 | end = new Date().getTime(); 94 | } while (end - start < 15); 95 | } else { 96 | this.update(); 97 | } 98 | } 99 | 100 | private update(): void { 101 | this.lookDown.update(); 102 | this.zoom.update(); 103 | 104 | this.world.updateView(); 105 | this.mainView.updateViewport(); 106 | this.mainView.updateCamera(this.world.seat, this.lookDown.pos, this.zoom.pos, this.mouseUi.mouse2); 107 | this.mainView.updateOutline(this.objectView.selectedObjects); 108 | this.mouseUi.setCamera(this.mainView.camera); 109 | this.mouseUi.updateObjects(); 110 | this.mouseUi.update(); 111 | this.mouseUi.updateCursors(); 112 | this.mainView.render(); 113 | } 114 | 115 | private onKeyPress(event: KeyboardEvent): void { 116 | } 117 | 118 | private onKeyDown(event: KeyboardEvent): void { 119 | if (document.activeElement?.tagName === 'INPUT') { 120 | return; 121 | } 122 | 123 | if (this.keys.has(event.key)) { 124 | return; 125 | } 126 | 127 | this.keys.add(event.key); 128 | 129 | switch(event.key) { 130 | case 'f': 131 | this.world.onFlip(1); 132 | break; 133 | case 'F': 134 | this.world.onFlip(1, true); 135 | break; 136 | case 'r': 137 | this.world.onFlip(-1); 138 | break; 139 | case 'R': 140 | this.world.onFlip(-1, true); 141 | break; 142 | case ' ': 143 | this.lookDown.start(1); 144 | break; 145 | case 'q': 146 | this.lookDownState = 1 - this.lookDownState; 147 | this.lookDown.start(this.lookDownState); 148 | break; 149 | case 'p': 150 | this.settings.perspective.checked = !this.settings.perspective.checked; 151 | this.updateSettings(); 152 | break; 153 | } 154 | } 155 | 156 | private onKeyUp(event: KeyboardEvent): void { 157 | this.keys.delete(event.key); 158 | 159 | switch(event.key) { 160 | case ' ': 161 | this.lookDown.start(0); 162 | break; 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/selection-box.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Adapted from three.js examples: 3 | 4 | https://github.com/mrdoob/three.js/blob/dev/examples/jsm/interactive/SelectionBox.js 5 | */ 6 | 7 | import { Frustum, Vector3, Vector2, OrthographicCamera, PerspectiveCamera, Mesh, Camera, Box3 } from "three"; 8 | 9 | export class SelectionBox { 10 | camera: OrthographicCamera | PerspectiveCamera; 11 | deep: number = Number.MAX_VALUE; 12 | 13 | frustum: Frustum = new Frustum(); 14 | 15 | vectemp1 = new Vector3(); 16 | vectemp2 = new Vector3(); 17 | vectemp3 = new Vector3(); 18 | 19 | vecNear = new Vector3(); 20 | vecTopLeft = new Vector3(); 21 | vecTopRight = new Vector3(); 22 | vecDownRight = new Vector3(); 23 | vecDownLeft = new Vector3(); 24 | 25 | vecFarTopLeft = new Vector3(); 26 | vecFarTopRight = new Vector3(); 27 | vecFarDownRight = new Vector3(); 28 | vecFarDownLeft = new Vector3(); 29 | 30 | constructor(camera: Camera) { 31 | this.camera = camera as (PerspectiveCamera | OrthographicCamera); 32 | this.deep = Number.MAX_VALUE; 33 | } 34 | 35 | update(startPoint: Vector2, endPoint: Vector2): void { 36 | this.camera.updateProjectionMatrix(); 37 | this.camera.updateMatrixWorld(); 38 | 39 | let left = Math.min(startPoint.x, endPoint.x); 40 | let right = Math.max(startPoint.x, endPoint.x); 41 | let down = Math.min(startPoint.y, endPoint.y); 42 | let top = Math.max(startPoint.y, endPoint.y); 43 | const eps = 0.01; 44 | 45 | // Fix narrow / empty selection breaking 46 | if (right - left < eps) { 47 | left -= eps / 2; 48 | right += eps / 2; 49 | } 50 | if (top - down < eps) { 51 | down -= eps / 2; 52 | top += eps / 2; 53 | } 54 | 55 | if ((this.camera as PerspectiveCamera).isPerspectiveCamera) { 56 | this.vecNear.setFromMatrixPosition(this.camera.matrixWorld); 57 | this.vecTopLeft.set( left, top, 0 ); 58 | this.vecTopRight.set( right, top, 0 ); 59 | this.vecDownRight.set( right, down, 0 ); 60 | this.vecDownLeft.set( left, down, 0 ); 61 | 62 | this.vecTopLeft.unproject( this.camera ); 63 | this.vecTopRight.unproject( this.camera ); 64 | this.vecDownRight.unproject( this.camera ); 65 | this.vecDownLeft.unproject( this.camera ); 66 | 67 | this.vectemp1.copy(this.vecTopLeft ).sub(this.vecNear ); 68 | this.vectemp2.copy(this.vecTopRight ).sub(this.vecNear ); 69 | this.vectemp3.copy(this.vecDownRight ).sub(this.vecNear ); 70 | this.vectemp1.normalize(); 71 | this.vectemp2.normalize(); 72 | this.vectemp3.normalize(); 73 | 74 | this.vectemp1.multiplyScalar( this.deep ); 75 | this.vectemp2.multiplyScalar( this.deep ); 76 | this.vectemp3.multiplyScalar( this.deep ); 77 | this.vectemp1.add(this.vecNear ); 78 | this.vectemp2.add(this.vecNear ); 79 | this.vectemp3.add(this.vecNear ); 80 | 81 | const planes = this.frustum.planes; 82 | 83 | planes[ 0 ].setFromCoplanarPoints(this.vecNear,this.vecTopLeft,this.vecTopRight ); 84 | planes[ 1 ].setFromCoplanarPoints(this.vecNear,this.vecTopRight,this.vecDownRight ); 85 | planes[ 2 ].setFromCoplanarPoints(this.vecDownRight,this.vecDownLeft,this.vecNear ); 86 | planes[ 3 ].setFromCoplanarPoints(this.vecDownLeft,this.vecTopLeft,this.vecNear ); 87 | planes[ 4 ].setFromCoplanarPoints(this.vecTopRight,this.vecDownRight,this.vecDownLeft ); 88 | planes[ 5 ].setFromCoplanarPoints(this.vectemp3,this.vectemp2,this.vectemp1 ); 89 | planes[ 5 ].normal.multiplyScalar( - 1 ); 90 | 91 | } else if ( (this.camera as OrthographicCamera).isOrthographicCamera ) { 92 | 93 | this.vecTopLeft.set( left, top, - 1 ); 94 | this.vecTopRight.set( right, top, - 1 ); 95 | this.vecDownRight.set( right, down, - 1 ); 96 | this.vecDownLeft.set( left, down, - 1 ); 97 | 98 | this.vecFarTopLeft.set( left, top, 1 ); 99 | this.vecFarTopRight.set( right, top, 1 ); 100 | this.vecFarDownRight.set( right, down, 1 ); 101 | this.vecFarDownLeft.set( left, down, 1 ); 102 | 103 | this.vecTopLeft.unproject( this.camera ); 104 | this.vecTopRight.unproject( this.camera ); 105 | this.vecDownRight.unproject( this.camera ); 106 | this.vecDownLeft.unproject( this.camera ); 107 | 108 | this.vecFarTopLeft.unproject( this.camera ); 109 | this.vecFarTopRight.unproject( this.camera ); 110 | this.vecFarDownRight.unproject( this.camera ); 111 | this.vecFarDownLeft.unproject( this.camera ); 112 | 113 | const planes = this.frustum.planes; 114 | 115 | planes[ 0 ].setFromCoplanarPoints( this.vecTopLeft, this.vecFarTopLeft, this.vecFarTopRight ); 116 | planes[ 1 ].setFromCoplanarPoints( this.vecTopRight, this.vecFarTopRight, this.vecFarDownRight ); 117 | planes[ 2 ].setFromCoplanarPoints( this.vecFarDownRight, this.vecFarDownLeft, this.vecDownLeft ); 118 | planes[ 3 ].setFromCoplanarPoints( this.vecFarDownLeft, this.vecFarTopLeft, this.vecTopLeft ); 119 | planes[ 4 ].setFromCoplanarPoints( this.vecTopRight, this.vecDownRight, this.vecDownLeft ); 120 | planes[ 5 ].setFromCoplanarPoints( this.vecFarDownRight, this.vecFarTopRight, this.vecFarTopLeft ); 121 | planes[ 5 ].normal.multiplyScalar( - 1 ); 122 | 123 | } else { 124 | throw 'SelectionBox: Unsupported camera type'; 125 | } 126 | } 127 | 128 | select(objects: Array): Array { 129 | const result = []; 130 | const box = new Box3(); 131 | for (const object of objects) { 132 | if (!object.geometry.boundingBox) { 133 | object.geometry.computeBoundingBox(); 134 | } 135 | 136 | box.copy(object.geometry.boundingBox!); 137 | box.applyMatrix4(object.matrixWorld); 138 | 139 | if (this.frustum.intersectsBox(box)) { 140 | result.push(object); 141 | } 142 | } 143 | return result; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # riichi.moe Fork 2 | 3 | This is a fork of the original autotable project. Extra features include: 4 | * Washizu mode 5 | * Face down discarding for yami mahjong 6 | * Spectator mode 7 | * Room passwords for spectating and playing 8 | * Some minor bug fixes 9 | 10 | The documentation below is somewhat out of date, but you can still get the project running locally with make as described. 11 | 12 | # Autotable 13 | 14 | Autotable is a tabletop simulator for Riichi Mahjong. 15 | 16 | * Game: https://autotable.riichi.moe/ 17 | * About page: https://autotable.riichi.moe/about 18 | * Blog post: https://pwmarcz.pl/blog/autotable/ 19 | 20 | ## Running 21 | 22 | This repository uses [Git LFS](https://git-lfs.github.com/) to track large files. To clone all files, you need to install it first. 23 | 24 | You need the following utilities installed and present in `PATH`: 25 | 26 | * GNU make 27 | * node and yarn 28 | * Inkscape 1.0+ (for textures: .svg -> .png conversion) 29 | * Blender (for 3D models: .blend -> .glb conversion) 30 | 31 | Run: 32 | 33 | * `yarn` to install frontend packages 34 | * `cd server && yarn` to install server packages 35 | * `make parcel` to run and serve frontend 36 | * `make files` to re-generate static files (textures and models) 37 | * `make server` to run server 38 | * `make test` to run server tests 39 | 40 | ## Deployment 41 | 42 | The frontend can be served as static files. Run `make build`. 43 | 44 | The server is a WebSocket application. You can run it on the server listening on localhost, and use your HTTP server to expose it to the world. 45 | 46 | By default, the frontend should be under `/autotable/` and server under `/autotable/ws`. 47 | 48 | Here is what I use for nginx: 49 | 50 | location /autotable/ { 51 | expires 0d; 52 | alias /dist/; 53 | } 54 | 55 | location /autotable/ws { 56 | proxy_pass http://127.0.0.1:1235/; 57 | proxy_http_version 1.1; 58 | proxy_set_header Upgrade $http_upgrade; 59 | proxy_set_header Connection "upgrade"; 60 | 61 | # Prevent dropping idle connections 62 | proxy_read_timeout 7d; 63 | } 64 | 65 | ## License 66 | 67 | All of my code is licensed under MIT. See COPYING. 68 | 69 | However, I'm also using external assets, licensed under CC licenses. Note that the tile images are under a Non-Commercial license. 70 | 71 | * The tile images (`img/tiles.svg`) were originally posted at [Kanojo.de blog](https://web.archive.org/web/20160717012415/http://blog.kanojo.de/2011/07/01/more-shirt-stuff-t-shirt-logo-ideas/). They're licensed as **CC BY-NC-SA**. 72 | 73 | * The table texture (`img/table.jpg`) is from [CC0 Textures](https://cc0textures.com/view?id=Fabric030). It's licensed as **CC0**. 74 | 75 | * The sounds (`sound/`) come from [OpenGameArt](https://opengameart.org/) ([Thwack Sounds](https://opengameart.org/content/thwack-sounds), [Casino sound effects](https://opengameart.org/content/54-casino-sound-effects-cards-dice-chips)) and are licensed as **CC0**. 76 | 77 | * The digit font (`img/Segment7Standard.otf`) is the [Segment7 font by Cedders](https://www.fontspace.com/segment7-font-f19825) under **Open Font License**. 78 | 79 | ## Contributions 80 | 81 | This is a very opinionated project. I will be grateful for contributions that fix bugs or improve player experience. However, I will probably not want to merge any of: 82 | 83 | * Making the engine **too general**, at the cost of simplicity. I'm not interested in a general-purpose tabletop engine. 84 | * Other mahjong **variants** than Riichi Mahjong, unless it has a low maintenance cost. Same reason as above - I would rather have a great support for Riichi than mediocre support for all kinds of mahjong. 85 | * Any form of **automation**, such as automatic tile drawing, sorting, scoring etc. This is contrary to the project's philosophy. 86 | 87 | However, please don't feel discouraged from making these changes in your own fork! While I want to do my thing here, I would be very interested to see in what directions people take the project. 88 | 89 | ## Development 90 | 91 | See the blog post for explanation of many technical decisions: https://pwmarcz.pl/blog/autotable/ 92 | 93 | The main parts are: 94 | 95 | * `Game` - main class, connecting it all together 96 | * `src/types.ts` - base data types 97 | * view: 98 | * `MainView` - three.js main scene, lights, camera 99 | * `ObjectView` - drawing things and other objects on screen 100 | * `AssetLoader` - loading and constructing the game assets (textures, models) 101 | * `ThingGroup` - instanced meshes for optimized rendering of many objects 102 | * game logic: 103 | * `World` - main game state 104 | * `Thing` - all moving objects: tiles, sticks, marker 105 | * `Slot` - places for a tile to be in 106 | * `Setup` - preparing the table and re-dealing tiles 107 | * `src/setup-slots.ts`, `src/setup-deal.ts` - mode-specific data for slots and how to deal tiles 108 | * network: 109 | * `server/protocol.ts` - list of messages 110 | * `BaseClient` - base network client, implementing a key-value store 111 | * `Client` - a client with Autotable-specific data handling 112 | 113 | Some terminology: 114 | 115 | - **thing** - all moving objects: tiles, sticks, marker 116 | - **thing type** - tile/stick/marker 117 | - **thing index** - a unique number 118 | - **slot** - a space that can be occupied by a thing 119 | - **slot name** - a string identifying the slot in game 120 | - **seat** - table side (0..3) 121 | - thing **rotation** - a 3D orientation, usually represented by Euler angles 122 | - **place** - information about thing's position, rotation, and dimensions 123 | - **shift** - moving things that currently occupy the destination when dragging; used e.g. when sorting tiles in hand and swapping them 124 | - **collection** - a key-value dictionary stored on the server, a game state consists of various collections 125 | -------------------------------------------------------------------------------- /src/setup-deal.ts: -------------------------------------------------------------------------------- 1 | import { DealType, GameType, Points } from "./types"; 2 | 3 | type DealRange = [string, 0 | 1 | 2 | 3, number]; 4 | 5 | export interface DealPart { 6 | roll?: number; 7 | tiles?: Array; 8 | rotationIndex?: number; 9 | ranges: Array; 10 | absolute?: boolean; 11 | } 12 | 13 | export const DEALS: Record>>> = { 14 | FOUR_PLAYER: { 15 | INITIAL: [ 16 | { 17 | ranges: [ 18 | ['wall.1.0', 0, 34], 19 | ['wall.1.0', 1, 34], 20 | ['wall.1.0', 2, 34], 21 | ['wall.1.0', 3, 34], 22 | ] 23 | }, 24 | ], 25 | WINDS: [ 26 | { 27 | tiles: [27, 28, 29, 30], 28 | ranges: [['hand.5', 0, 4]], 29 | rotationIndex: 2, 30 | }, 31 | { 32 | ranges: [ 33 | ['wall.1.0', 0, 32], 34 | ['wall.1.0', 1, 34], 35 | ['wall.1.0', 2, 32], 36 | ['wall.1.0', 3, 34], 37 | ], 38 | }, 39 | ], 40 | HANDS: [ 41 | { 42 | ranges: [ 43 | ['hand.0', 0, 13], 44 | ['hand.0', 1, 13], 45 | ['hand.0', 2, 13], 46 | ['hand.0', 3, 13], 47 | ], 48 | rotationIndex: 2, 49 | }, 50 | 51 | { roll: 2, ranges: [['wall.16.0', 1, 4], ['wall.0.0', 2, 10], ['wall.6.0', 2, 24], ['wall.1.0', 3, 34], ['wall.1.0', 0, 12]] }, 52 | { roll: 3, ranges: [['wall.15.0', 2, 6], ['wall.0.0', 3, 8], ['wall.5.0', 3, 26], ['wall.1.0', 0, 34], ['wall.1.0', 1, 10]] }, 53 | { roll: 4, ranges: [['wall.14.0', 3, 8], ['wall.0.0', 0, 6], ['wall.4.0', 0, 28], ['wall.1.0', 1, 34], ['wall.1.0', 2, 8]] }, 54 | { roll: 5, ranges: [['wall.13.0', 0, 10], ['wall.0.0', 1, 4], ['wall.3.0', 1, 30], ['wall.1.0', 2, 34], ['wall.1.0', 3, 6]] }, 55 | { roll: 6, ranges: [['wall.12.0', 1, 12], ['wall.0.0', 2, 2], ['wall.2.0', 2, 32], ['wall.1.0', 3, 34], ['wall.1.0', 0, 4]] }, 56 | 57 | { roll: 7, ranges: [['wall.11.0', 2, 14], ['wall.1.0', 3, 34], ['wall.1.0', 0, 34], ['wall.1.0', 1, 2]] }, 58 | 59 | { roll: 8, ranges: [['wall.9.0', 3, 14], ['wall.17.0', 3, 2], ['wall.1.0', 0, 34], ['wall.1.0', 1, 34]] }, 60 | { roll: 9, ranges: [['wall.8.0', 0, 14], ['wall.16.0', 0, 4], ['wall.1.0', 1, 34], ['wall.1.0', 2, 32]] }, 61 | { roll: 10, ranges: [['wall.7.0', 1, 14], ['wall.15.0', 1, 6], ['wall.1.0', 2, 34], ['wall.1.0', 3, 30]] }, 62 | { roll: 11, ranges: [['wall.6.0', 2, 14], ['wall.14.0', 2, 8], ['wall.1.0', 3, 34], ['wall.1.0', 0, 28]] }, 63 | { roll: 12, ranges: [['wall.5.0', 3, 14], ['wall.13.0', 3, 10], ['wall.1.0', 0, 34], ['wall.1.0', 1, 26]] }, 64 | ], 65 | }, 66 | 67 | WASHIZU: { 68 | INITIAL: [ 69 | { 70 | ranges: [ 71 | ['washizu.bag.0', 0, 136], 72 | ], 73 | absolute: true 74 | }, 75 | ], 76 | WINDS: [ 77 | { 78 | tiles: [27, 28, 29, 30], 79 | ranges: [['hand.5', 0, 4]], 80 | rotationIndex: 2, 81 | }, 82 | { 83 | ranges: [ 84 | ['washizu.bag.0', 0, 132], 85 | ], 86 | absolute: true 87 | }, 88 | ], 89 | HANDS: [ 90 | { 91 | ranges: [ 92 | ['hand.0', 0, 13], 93 | ['hand.0', 1, 13], 94 | ['hand.0', 2, 13], 95 | ['hand.0', 3, 13], 96 | ], 97 | rotationIndex: 2, 98 | }, 99 | { 100 | ranges: [ 101 | ['washizu.bag.0', 0, 84], 102 | ], 103 | absolute: true 104 | } 105 | ], 106 | }, 107 | 108 | THREE_PLAYER: { 109 | INITIAL: [ 110 | { 111 | ranges: [ 112 | ['wall.3.0', 0, 28], 113 | ['wall.3.0', 1, 26], 114 | ['wall.2.0', 2, 28], 115 | ['wall.3.0', 3, 26], 116 | ], 117 | absolute: true, 118 | }, 119 | ], 120 | WINDS: [ 121 | { 122 | tiles: [27, 28, 29], 123 | ranges: [['hand.6', 0, 3]], 124 | rotationIndex: 2, 125 | }, 126 | { 127 | ranges: [ 128 | ['wall.3.0', 0, 28], 129 | ['wall.3.0', 1, 26], 130 | ['wall.2.0', 2, 28], 131 | ['wall.3.0', 3, 23], 132 | ], 133 | absolute: true, 134 | }, 135 | ], 136 | 137 | HANDS: [ 138 | { 139 | ranges: [ 140 | ['hand.0', 0, 13], 141 | ['hand.0', 1, 13], 142 | ['hand.0', 2, 13], 143 | ], 144 | rotationIndex: 2, 145 | absolute: true, 146 | }, 147 | { 148 | ranges: [ 149 | ['wall.10.0', 0, 14], 150 | ['wall.3.0', 1, 26], 151 | ['wall.2.0', 2, 29], 152 | ], 153 | } 154 | ], 155 | }, 156 | 157 | BAMBOO: { 158 | INITIAL: [{ ranges: [['wall.1.0', 0, 36]], absolute: true}], 159 | WINDS: [ 160 | { 161 | tiles: [18, 26], 162 | ranges: [['hand.6', 0, 2]], 163 | rotationIndex: 2, 164 | }, 165 | { 166 | ranges: [['wall.1.0', 0, 34]], 167 | }, 168 | ], 169 | HANDS: [ 170 | { 171 | ranges: [ 172 | ['hand.0', 0, 13], 173 | ['hand.0', 2, 13], 174 | ], 175 | rotationIndex: 2, 176 | }, 177 | { 178 | ranges: [['wall.1.0', 0, 10]], 179 | }, 180 | ], 181 | }, 182 | 183 | MINEFIELD: { 184 | HANDS: [ 185 | { 186 | ranges: [ 187 | ['wall.1.0', 1, 34], 188 | ['wall.1.0', 3, 34], 189 | ], 190 | }, 191 | { 192 | ranges: [ 193 | ['wall.open.0.0', 0, 17], 194 | ['wall.open.1.0', 0, 17], 195 | ['wall.open.0.0', 2, 17], 196 | ['wall.open.1.0', 2, 17], 197 | ], 198 | rotationIndex: 1, 199 | }, 200 | ], 201 | }, 202 | }; 203 | 204 | export const POINTS: Record> = { 205 | // -10k, 10k, 5k, 1k, 500, 100 206 | '5': [2, 0, 0, 4, 1, 5], 207 | '8': [2, 0, 0, 7, 1, 5], 208 | '25': [2, 1, 2, 4, 1, 5], 209 | '30': [2, 1, 3, 4, 1, 5], 210 | '35': [2, 2, 2, 4, 1, 5], 211 | '40': [2, 2, 3, 4, 1, 5], 212 | '100': [2, 7, 5, 4, 1, 5], 213 | }; 214 | -------------------------------------------------------------------------------- /src/movement.ts: -------------------------------------------------------------------------------- 1 | import { Slot } from "./slot"; 2 | import { Thing } from "./thing"; 3 | import { Euler } from "three"; 4 | 5 | type SlotOp = (slot: Slot) => Slot | null; 6 | 7 | // Represents a group of things that will be moved to different slots when 8 | // dragging. 9 | // Includes both things dragged directly (thingMap), and shifted to make space 10 | // (shiftMap). 11 | export class Movement { 12 | private thingMap: Map = new Map(); 13 | private reverseMap: Map = new Map(); 14 | private shiftMap: Map = new Map(); 15 | private heldRotation: Euler | null = null; 16 | 17 | move(thing: Thing, slot: Slot): void { 18 | if (this.reverseMap.has(slot)) { 19 | throw `move(): conflict`; 20 | } 21 | const oldSlot = this.thingMap.get(thing); 22 | if (oldSlot !== undefined) { 23 | this.reverseMap.delete(oldSlot); 24 | } 25 | this.thingMap.set(thing, slot); 26 | this.reverseMap.set(slot, thing); 27 | } 28 | 29 | private shift(thing: Thing, slot: Slot): void { 30 | this.shiftMap.set(thing, slot); 31 | this.reverseMap.set(slot, thing); 32 | } 33 | 34 | has(thing: Thing): boolean { 35 | return this.thingMap.has(thing); 36 | } 37 | 38 | get(thing: Thing): Slot | null { 39 | return this.thingMap.get(thing) ?? null; 40 | } 41 | 42 | rotationIndex(thing: Thing): number { 43 | const slot = this.thingMap.get(thing); 44 | if (slot === undefined) { 45 | return 0; 46 | } 47 | return thing.slot.group === slot.group ? thing.rotationIndex : 0; 48 | } 49 | 50 | slots(): Iterable { 51 | return this.thingMap.values(); 52 | } 53 | 54 | things(): Iterable { 55 | return this.thingMap.keys(); 56 | } 57 | 58 | hasSlot(slot: Slot): boolean { 59 | return this.reverseMap.has(slot); 60 | } 61 | 62 | valid(): boolean { 63 | for (const slot of this.reverseMap.keys()) { 64 | if (slot.thing !== null && !this.thingMap.has(slot.thing) && !this.shiftMap.has(slot.thing)) { 65 | return false; 66 | } 67 | } 68 | return true; 69 | } 70 | 71 | apply(): void { 72 | for (const thing of this.thingMap.keys()) { 73 | thing.prepareMove(); 74 | } 75 | for (const thing of this.shiftMap.keys()) { 76 | thing.prepareMove(); 77 | } 78 | for (const [thing, slot] of this.thingMap.entries()) { 79 | let rotationIndex = 0; 80 | if (this.heldRotation !== null && this.heldRotation !== undefined) { 81 | const matchingIndex = slot.rotationOptions.findIndex(o => o.equals(this.heldRotation!)); 82 | if (matchingIndex >= 0) { 83 | rotationIndex = matchingIndex; 84 | } 85 | } else { 86 | if (slot.group === 'meld' && thing.slot.group == 'discard') { 87 | rotationIndex = 1; 88 | } else { 89 | rotationIndex = thing.slot.group === slot.group 90 | ? thing.rotationIndex 91 | : Math.max(0, slot.rotationOptions.findIndex(r => r.equals(thing.slot.rotationOptions[thing.rotationIndex]))); 92 | } 93 | } 94 | 95 | thing.moveTo(slot, rotationIndex); 96 | thing.release(); 97 | } 98 | for (const [thing, slot] of this.shiftMap.entries()) { 99 | const rotationIndex = thing.rotationIndex; 100 | thing.moveTo(slot, rotationIndex); 101 | thing.release(); 102 | } 103 | } 104 | 105 | setHeldRotation(heldRotation: Euler): void { 106 | this.heldRotation = heldRotation; 107 | } 108 | 109 | rotateHeld(): Euler | null { 110 | // Don't rotate more than 1 tile, they may collide 111 | if (this.thingMap.size > 1) { 112 | return null; 113 | } 114 | 115 | for (const [thing, slot] of this.thingMap.entries()) { 116 | if (!slot.rotateHeld) { 117 | return null; 118 | } 119 | 120 | 121 | const rotationIndex = thing.slot.group === slot.group ? thing.rotationIndex : 0; 122 | const rotation = slot.rotations[rotationIndex]; 123 | if (thing.heldRotation.equals(rotation)) { 124 | return null; 125 | } 126 | 127 | thing.heldRotation.copy(rotation); 128 | thing.sent = false; 129 | return slot.rotationOptions[rotationIndex]; 130 | } 131 | 132 | return null; 133 | } 134 | 135 | findShift(allThings: Array, ops: Array): boolean { 136 | let shift: Map | null = new Map(); 137 | for (const thing of allThings) { 138 | if (!this.thingMap.has(thing) && thing.slot?.phantom !== true) { 139 | shift.set(thing.slot, thing); 140 | } 141 | } 142 | 143 | for (const slot of this.thingMap.values()) { 144 | if (shift.has(slot)) { 145 | shift = this.findShiftFor(slot, ops, shift); 146 | if (shift === null) { 147 | return false; 148 | } 149 | } 150 | } 151 | for (const [slot, thing] of shift.entries()) { 152 | if (slot.thing !== thing) { 153 | this.shift(thing, slot); 154 | } 155 | } 156 | return true; 157 | } 158 | 159 | applyShift(seat: number): void { 160 | for (const [thing, slot] of this.shiftMap.entries()) { 161 | thing.shiftTo(seat, slot); 162 | } 163 | } 164 | 165 | private findShiftFor( 166 | slot: Slot, ops: Array, shift: Map 167 | ): Map | null { 168 | if (!shift.has(slot)) { 169 | return null; 170 | } 171 | 172 | // Prefer moving to the left 173 | for (const op of ops) { 174 | const cloned = new Map(shift.entries()); 175 | if (this.tryShift(slot, op, cloned)) { 176 | return cloned; 177 | } 178 | } 179 | return null; 180 | } 181 | 182 | private tryShift(initialSlot: Slot, op: SlotOp, shift: Map): boolean { 183 | let slot = initialSlot; 184 | const thing = shift.get(initialSlot)!; 185 | if (thing.claimedBy !== null) { 186 | return false; 187 | } 188 | while (slot === initialSlot || this.reverseMap.has(slot)) { 189 | const nextSlot = op(slot); 190 | if (nextSlot === null) { 191 | return false; 192 | } 193 | 194 | if (shift.has(nextSlot)) { 195 | if (!this.tryShift(nextSlot, op, shift)) { 196 | return false; 197 | } 198 | } 199 | shift.delete(slot); 200 | shift.set(nextSlot, thing); 201 | slot = nextSlot; 202 | } 203 | return true; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /img/winds.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 41 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 63 | 67 | 72 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/object-view.ts: -------------------------------------------------------------------------------- 1 | import { Group, Mesh, Vector3, MeshBasicMaterial, MeshLambertMaterial, Object3D, PlaneBufferGeometry, InstancedMesh, PlaneGeometry, CanvasTexture, Vector2 } from "three"; 2 | 3 | import { World } from "./world"; 4 | import { Client } from "./client"; 5 | import { AssetLoader } from "./asset-loader"; 6 | import { Center } from "./center"; 7 | import { ThingParams, ThingGroup, TileThingGroup, StickThingGroup, MarkerThingGroup } from "./thing-group"; 8 | import { ThingType, Place, Size } from "./types"; 9 | 10 | export interface Render { 11 | type: ThingType; 12 | thingIndex: number; 13 | place: Place; 14 | selected: boolean; 15 | hovered: boolean; 16 | held: boolean; 17 | temporary: boolean; 18 | bottom: boolean; 19 | hidden: boolean; 20 | } 21 | 22 | const MAX_SHADOWS = 300; 23 | 24 | export class ObjectView { 25 | mainGroup: Group; 26 | private assetLoader: AssetLoader; 27 | 28 | private center: Center; 29 | 30 | private thingGroups: Map; 31 | 32 | private shadowObject: InstancedMesh; 33 | private dropShadowProto: Mesh; 34 | private dropShadowObjects: Array; 35 | 36 | selectedObjects: Array; 37 | tableMesh: Mesh; 38 | 39 | constructor(mainGroup: Group, assetLoader: AssetLoader, client: Client) { 40 | this.mainGroup = mainGroup; 41 | this.assetLoader = assetLoader; 42 | 43 | this.center = new Center(this.assetLoader, client); 44 | this.center.group.position.set(World.WIDTH / 2, World.WIDTH / 2, 0); 45 | this.dropShadowObjects = []; 46 | this.selectedObjects = []; 47 | 48 | this.thingGroups = new Map(); 49 | this.thingGroups.set(ThingType.TILE, new TileThingGroup(this.assetLoader, this.mainGroup)); 50 | this.thingGroups.set(ThingType.STICK, new StickThingGroup(this.assetLoader, this.mainGroup)); 51 | this.thingGroups.set(ThingType.MARKER, new MarkerThingGroup(this.assetLoader, this.mainGroup)); 52 | 53 | const plane = new PlaneBufferGeometry(1, 1, 1); 54 | let material = new MeshBasicMaterial({ 55 | transparent: true, 56 | opacity: 0.1, 57 | color: 0, 58 | depthWrite: false, 59 | }); 60 | this.shadowObject = new InstancedMesh(plane, material, MAX_SHADOWS); 61 | this.shadowObject.visible = true; 62 | this.mainGroup.add(this.shadowObject); 63 | 64 | material = material.clone(); 65 | material.opacity = 0.2; 66 | 67 | this.dropShadowProto = new Mesh(plane, material); 68 | this.dropShadowProto.name = 'dropShadow'; 69 | 70 | this.addStatic(); 71 | } 72 | 73 | replaceThings(params: Map): void { 74 | for (const type of [ThingType.TILE, ThingType.STICK, ThingType.MARKER]) { 75 | const typeParams = [...params.values()].filter(p => p.type === type); 76 | typeParams.sort((a, b) => a.index - b.index); 77 | 78 | if (typeParams.length === 0) { 79 | continue; 80 | } 81 | const startIndex = typeParams[0].index; 82 | const thingGroup = this.thingGroups.get(type)!; 83 | thingGroup.replace(startIndex, typeParams); 84 | } 85 | } 86 | 87 | replaceShadows(places: Array): void { 88 | const dummy = new Object3D(); 89 | 90 | this.shadowObject.count = 0; 91 | for (const place of places) { 92 | dummy.position.set(place.position.x, place.position.y, 0.1); 93 | dummy.scale.set(place.size.x, place.size.y, 1); 94 | dummy.updateMatrix(); 95 | 96 | const idx = this.shadowObject.count++; 97 | this.shadowObject.setMatrixAt(idx, dummy.matrix); 98 | } 99 | this.shadowObject.instanceMatrix.needsUpdate = true; 100 | } 101 | 102 | setTableCloth(): void { 103 | const texture = this.assetLoader.textures.customTableCloth; 104 | if (!texture) { 105 | return; 106 | } 107 | 108 | const material = this.tableMesh.material as MeshLambertMaterial; 109 | material.map = texture; 110 | } 111 | 112 | resetTableCloth(): void { 113 | const material = this.tableMesh.material as MeshLambertMaterial; 114 | material.map = this.assetLoader.textures.table; 115 | } 116 | 117 | 118 | rotateTableCloth(seat: number): void { 119 | this.tableMesh.rotation.set(0, 0, Math.PI / 2 * seat); 120 | this.tableMesh.updateMatrixWorld(); 121 | } 122 | 123 | private addStatic(): void { 124 | this.tableMesh = this.assetLoader.makeTable(); 125 | this.tableMesh.position.set(World.WIDTH / 2, World.WIDTH / 2, -0.01); 126 | this.mainGroup.add(this.tableMesh); 127 | this.tableMesh.updateMatrixWorld(); 128 | 129 | this.mainGroup.add(this.center.group); 130 | this.center.group.updateMatrixWorld(true); 131 | 132 | for (let i = 0; i < 4; i++) { 133 | for (let j = 0; j < 6; j++) { 134 | const trayPos = new Vector3( 135 | 25 + 24 * j - World.WIDTH / 2, 136 | -33 * 1.5 - World.WIDTH / 2, 137 | 0 138 | ); 139 | trayPos.applyAxisAngle(new Vector3(0, 0, 1), Math.PI * i / 2); 140 | 141 | const tray = this.assetLoader.makeTray(); 142 | tray.rotation.z = Math.PI * i / 2; 143 | tray.position.set( 144 | trayPos.x + World.WIDTH / 2, 145 | trayPos.y + World.WIDTH / 2, 146 | 0); 147 | this.mainGroup.add(tray); 148 | tray.updateMatrixWorld(); 149 | } 150 | } 151 | } 152 | 153 | updateScores(scores: Array): void { 154 | this.center.setScores(scores); 155 | this.center.draw(); 156 | } 157 | 158 | updateThings(things: Array): void { 159 | this.selectedObjects.splice(0); 160 | for (const thing of things) { 161 | const thingGroup = this.thingGroups.get(thing.type)!; 162 | const custom = thing.hovered || thing.selected || thing.held || thing.bottom || thing.hidden; 163 | if (!custom && thingGroup.canSetSimple()) { 164 | thingGroup.setSimple(thing.thingIndex, thing.place.position, thing.place.rotation); 165 | continue; 166 | } 167 | 168 | const obj = thingGroup.setCustom( 169 | thing.thingIndex, thing.place.position, thing.place.rotation); 170 | 171 | const material = obj.material as MeshLambertMaterial; 172 | material.emissive.setHex(0); 173 | material.color.setHex(0xeeeeee); 174 | 175 | if (thing.hidden) { 176 | material.transparent = true; 177 | material.opacity = 0.0; 178 | } 179 | 180 | obj.renderOrder = 0; 181 | material.depthTest = true; 182 | 183 | if (thing.hovered) { 184 | material.emissive.setHex(0x111111); 185 | } 186 | 187 | if (thing.bottom) { 188 | material.color.setHex(0xbbbbbb); 189 | } 190 | 191 | if (thing.selected && !thing.hidden) { 192 | this.selectedObjects.push(obj); 193 | } 194 | 195 | if (thing.held) { 196 | material.transparent = true; 197 | material.opacity = thing.temporary ? 0.7 : 1; 198 | material.depthTest = false; 199 | obj.position.z += 1; 200 | obj.renderOrder = 2; 201 | } 202 | 203 | obj.updateMatrix(); 204 | obj.updateMatrixWorld(); 205 | } 206 | } 207 | 208 | updateDropShadows(places: Array): void { 209 | for (const obj of this.dropShadowObjects) { 210 | this.mainGroup.remove(obj); 211 | } 212 | this.dropShadowObjects.splice(0); 213 | 214 | for (const place of places) { 215 | const obj = this.dropShadowProto.clone(); 216 | obj.position.set( 217 | place.position.x, 218 | place.position.y, 219 | place.position.z - place.size.z/2 + 0.2); 220 | obj.scale.set(place.size.x, place.size.y, 1); 221 | this.dropShadowObjects.push(obj); 222 | this.mainGroup.add(obj); 223 | obj.updateMatrixWorld(); 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /server/game.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | import { Message, Entry } from './protocol'; 4 | 5 | export type Client = { 6 | game: Game | null; 7 | isAuthed: boolean; 8 | playerId: string | null; 9 | isAlive: boolean; 10 | heartbeatIntervalId: NodeJS.Timeout; 11 | send(data: string): void; 12 | } 13 | 14 | const MAX_PLAYERS = 16; 15 | 16 | const EXPIRY_HOURS = 2; 17 | const EXPIRY_TIME = EXPIRY_HOURS * 60 * 60 * 1000; 18 | 19 | export class Game { 20 | password: string; 21 | expiryTime: number | null; 22 | private starting: boolean = true; 23 | private clients: Map = new Map(); 24 | 25 | private unique: Map = new Map(); 26 | private ephemeral: Map = new Map(); 27 | private writeProtected: Map = new Map(); 28 | private perPlayer: Map = new Map(); 29 | 30 | private collections: Map> = new Map(); 31 | 32 | constructor(public readonly gameId: string) { 33 | this.password = randomString(); 34 | console.log(`new game: ${this.gameId}`); 35 | this.expiryTime = new Date().getTime() + EXPIRY_TIME; 36 | } 37 | 38 | join(client: Client): void { 39 | if (this.clients.size >= MAX_PLAYERS) { 40 | throw 'too many players'; 41 | } 42 | 43 | let playerId: string; 44 | do { 45 | playerId = randomString(); 46 | } while (this.clients.has(playerId)); 47 | this.clients.set(playerId, client); 48 | client.playerId = playerId; 49 | client.game = this; 50 | client.isAuthed = this.starting; 51 | 52 | console.log(`${this.gameId}: join: ${playerId}`); 53 | 54 | this.send(client, { 55 | type: 'JOINED', 56 | gameId: this.gameId, 57 | playerId, 58 | isFirst: this.starting, 59 | password: this.starting ? this.password : undefined, 60 | }); 61 | this.starting = false; 62 | this.expiryTime = null; 63 | 64 | this.send(client, {type: 'UPDATE', entries: this.allEntries(), full: true }); 65 | } 66 | 67 | private allEntries(): Array { 68 | const entries: Array = []; 69 | for (const [kind, collection] of this.collections.entries()) { 70 | for (const [key, value] of collection.entries()) { 71 | entries.push([kind, key, value]); 72 | } 73 | } 74 | 75 | for (const [kind, value] of this.writeProtected.entries()) { 76 | entries.push(["writeProtected", kind, value]); 77 | } 78 | 79 | return entries; 80 | } 81 | 82 | private isAuthed(playerId: string | null): boolean { 83 | return playerId !== null && this.clients.get(playerId)?.isAuthed === true; 84 | } 85 | 86 | private update(entries: Array, senderId: string | null): void { 87 | if (!this.checkUnique(entries)) { 88 | this.sendAll({type: 'UPDATE', entries: this.allEntries(), full: true}); 89 | return; 90 | } 91 | 92 | const sendToAll: Array = []; 93 | const sendToOthers: Array = []; 94 | 95 | for (const [kind, key, value] of entries) { 96 | if (this.writeProtected.get(kind)){ 97 | if (!senderId || !this.isAuthed(senderId) && (!this.perPlayer.get(kind) || this.clients.get(senderId))) { 98 | continue; 99 | } 100 | sendToAll.push([kind, key, value]); 101 | } else { 102 | sendToOthers.push([kind, key, value]); 103 | } 104 | 105 | if (this.ephemeral.get(kind)) { 106 | continue; 107 | } 108 | 109 | if (kind === 'unique') { 110 | this.unique.set(key as string, value); 111 | continue; 112 | } 113 | 114 | if (kind === 'ephemeral') { 115 | this.ephemeral.set(key as string, value); 116 | continue; 117 | } 118 | 119 | if (kind === 'perPlayer') { 120 | this.perPlayer.set(key as string, value); 121 | continue; 122 | } 123 | 124 | if (kind === 'writeProtected' && this.isAuthed(senderId)) { 125 | this.writeProtected.set(key as string, value); 126 | continue; 127 | } 128 | 129 | let collection = this.collections.get(kind); 130 | if (!collection) { 131 | collection = new Map(); 132 | this.collections.set(kind, collection); 133 | } 134 | if (value !== null) { 135 | collection.set(key, value); 136 | } else { 137 | collection.delete(key); 138 | } 139 | } 140 | 141 | if (sendToOthers.length > 0) { 142 | const message: Message = {type: 'UPDATE', entries: sendToOthers, full: false}; 143 | this.sendAll(message, [senderId]); 144 | } 145 | 146 | if (sendToAll.length > 0) { 147 | const message: Message = {type: 'UPDATE', entries: sendToAll, full: false}; 148 | this.sendAll(message); 149 | } 150 | } 151 | 152 | private checkUnique(entries: Array): boolean { 153 | for (const [kind, field] of this.unique.entries()) { 154 | const collection = this.collections.get(kind); 155 | if (!collection) { 156 | continue; 157 | } 158 | 159 | const filtered = entries.filter(e => e[0] === kind); 160 | const occupied = new Set(); 161 | for (const item of collection.values()) { 162 | if (item === null) { 163 | continue; 164 | } 165 | const value = item[field]; 166 | if (value !== null && value !== undefined) { 167 | occupied.add(value); 168 | } 169 | } 170 | 171 | for (const [, key, ] of filtered) { 172 | const item = collection.get(key); 173 | if (!item) { 174 | continue; 175 | } 176 | const value = item[field]; 177 | if (value !== null && value !== undefined) { 178 | occupied.delete(value); 179 | } 180 | } 181 | 182 | for (const [, , item] of filtered) { 183 | if (item === null) { 184 | continue; 185 | } 186 | const value = item[field]; 187 | if (value !== null && value !== undefined) { 188 | if (occupied.has(value)) { 189 | console.log(`conflict on ${kind}, ${field} = ${value}`); 190 | return false; 191 | } 192 | occupied.add(value); 193 | } 194 | } 195 | } 196 | return true; 197 | } 198 | 199 | leave(client: Client): void { 200 | console.log(`${this.gameId}: leave: ${client.playerId}`); 201 | 202 | this.clients.delete(client.playerId!); 203 | const toUpdate: Array = []; 204 | for (const [kind, isPerPlayer] of this.perPlayer.entries()) { 205 | const collection = this.collections.get(kind); 206 | if (isPerPlayer && collection) { 207 | for (const key of collection.keys()) { 208 | if (key === client.playerId) { 209 | toUpdate.push([kind, key, null]); 210 | } 211 | } 212 | } 213 | } 214 | if (toUpdate.length > 0) { 215 | this.update(toUpdate, client.playerId); 216 | } 217 | if (this.clients.size === 0) { 218 | this.expiryTime = new Date().getTime() + EXPIRY_TIME; 219 | } 220 | } 221 | 222 | private send(client: Client, message: Message): void { 223 | const data = JSON.stringify(message); 224 | console.debug(`send ${this.gameId}.${client.playerId} ${data}`); 225 | client.send(data); 226 | } 227 | 228 | private sendAll(message: Message, blacklist: Array = []): void { 229 | for (const client of this.clients.values()) { 230 | if (client.playerId !== null && blacklist.indexOf(client.playerId) >= 0) { 231 | continue; 232 | } 233 | this.send(client, message); 234 | } 235 | } 236 | 237 | onMessage(client: Client, message: Message): void { 238 | switch (message.type) { 239 | case 'UPDATE': 240 | this.update(message.entries, client.playerId); 241 | break; 242 | 243 | case 'AUTH': { 244 | client.isAuthed = message.password === client.game?.password; 245 | this.send(client, { 246 | type: 'AUTHED', 247 | isAuthed: client.isAuthed, 248 | }); 249 | break; 250 | } 251 | } 252 | } 253 | } 254 | 255 | export function randomString(): string { 256 | const hex = '0123456789ABCDEFGJKLMNPQRSTUVWXYZ'; 257 | let result = ''; 258 | for (let i = 0; i < 5; i++) { 259 | result += hex.charAt(Math.floor(Math.random() * hex.length)); 260 | } 261 | return result; 262 | } 263 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | import { EventEmitter } from 'events'; 4 | 5 | import { Entry } from '../server/protocol'; 6 | 7 | import { BaseClient, Game } from './base-client'; 8 | import { ThingInfo, MatchInfo, MouseInfo, SoundInfo, SeatInfo } from './types'; 9 | 10 | 11 | export class Client extends BaseClient { 12 | match: Collection; 13 | seats: Collection; 14 | things: Collection; 15 | nicks: Collection; 16 | mouse: Collection; 17 | sound: Collection; 18 | spectators: Collection; 19 | 20 | seat: number | null = 0; 21 | seatPlayers: Array = new Array(4).fill(null); 22 | 23 | constructor() { 24 | super(); 25 | 26 | // Make sure match is first, as it triggers reorganization of slots and things. 27 | this.match = new Collection('match', this, { sendOnConnect: true }), 28 | 29 | this.seats = new Collection('seats', this, { unique: 'seat', perPlayer: true }); 30 | this.things = new Collection('things', this, { unique: 'slotName', sendOnConnect: true }); 31 | this.nicks = new Collection('nicks', this, { perPlayer: true }); 32 | this.mouse = new Collection('mouse', this, { rateLimit: 100, perPlayer: true }); 33 | this.sound = new Collection('sound', this, { ephemeral: true }); 34 | this.spectators = new Collection('spectators', this, { writeProtected: false, perPlayer: true }); 35 | this.seats.on('update', this.onSeats.bind(this)); 36 | } 37 | 38 | private onSeats(): void { 39 | this.seat = null; 40 | this.seatPlayers.fill(null); 41 | for (const [playerId, seatInfo] of this.seats.entries()) { 42 | if (playerId === this.playerId()) { 43 | this.seat = seatInfo.seat; 44 | } 45 | if (seatInfo.seat !== null) { 46 | this.seatPlayers[seatInfo.seat] = playerId; 47 | } 48 | } 49 | } 50 | } 51 | 52 | interface CollectionOptions { 53 | // Key that has to be kept unique. Enforced by the server. 54 | // For example, for 'things', the unique key is 'slotName', and if you 55 | // attempt to store two things with the same slots, server will reject the 56 | // update. 57 | unique?: string; 58 | 59 | // Updates will be sent to other players, but not stored on the server (new 60 | // will not receive them on connection). 61 | ephemeral?: boolean; 62 | 63 | // This is a collection indexed by player ID, and values will be deleted 64 | // when a player disconnect. 65 | perPlayer?: boolean; 66 | 67 | // The server will not send all updates, but limit to N per second. 68 | rateLimit?: number; 69 | 70 | // Only authenticated clients can write to this collection 71 | writeProtected?: boolean; 72 | 73 | // If we are initializing the server (i.e. we're the first player), send 74 | // our value. 75 | sendOnConnect?: boolean; 76 | } 77 | 78 | export class Collection { 79 | public options: CollectionOptions; 80 | private kind: string; 81 | private client: Client; 82 | private map: Map = new Map(); 83 | private pending: Map = new Map(); 84 | private events: EventEmitter = new EventEmitter(); 85 | private intervalId: NodeJS.Timeout | null = null; 86 | private lastUpdate: number = 0; 87 | 88 | constructor( 89 | kind: string, 90 | client: Client, 91 | options?: CollectionOptions) { 92 | 93 | this.kind = kind; 94 | this.client = client; 95 | this.options = options ?? {}; 96 | 97 | this.client.on('update', this.onUpdate.bind(this)); 98 | this.client.on('connect', this.onConnect.bind(this)); 99 | this.client.on('disconnect', this.onDisconnect.bind(this)); 100 | } 101 | 102 | entries(): Iterable<[K, V]> { 103 | return this.map.entries(); 104 | } 105 | 106 | get(key: K): V | null { 107 | return this.map.get(key) ?? null; 108 | } 109 | 110 | update(localEntries: Array<[K, V | null]>): void { 111 | if (!this.options.writeProtected) { 112 | this.cacheEntries(localEntries, false); 113 | } 114 | 115 | if(!this.client.connected()) { 116 | return; 117 | } 118 | 119 | const now = new Date().getTime(); 120 | for (const [key, value] of localEntries) { 121 | this.pending.set(key, value); 122 | } 123 | if (!this.options.rateLimit || now > this.lastUpdate + this.options.rateLimit) { 124 | this.sendPending(); 125 | } 126 | } 127 | 128 | set(key: K, value: V | null): void { 129 | this.update([[key, value]]); 130 | } 131 | 132 | on(what: 'update', handler: (localEntries: Array<[K, V | null]>, full: boolean) => void): void; 133 | on(what: 'optionsChanged', handler: (options: CollectionOptions) => void): void; 134 | on(what: string, handler: (...args: any[]) => void): void { 135 | this.events.on(what, handler); 136 | } 137 | 138 | setOption(option: keyof CollectionOptions, value: any) { 139 | if (this.options[option] === value) { 140 | return; 141 | } 142 | 143 | this.options[option] = value; 144 | this.client.update([[option, this.kind, value]]); 145 | this.events.emit("optionsChanged", this.options); 146 | } 147 | 148 | private onUpdate(entries: Array, full: boolean): void { 149 | if (full) { 150 | this.map.clear(); 151 | } 152 | 153 | for (const [kind, key, value] of entries) { 154 | if (key !== this.kind) { 155 | continue; 156 | } 157 | 158 | if (kind === "writeProtected") { 159 | if (this.options.writeProtected === value) { 160 | continue; 161 | } 162 | this.options.writeProtected = value; 163 | this.events.emit("optionsChanged", this.options); 164 | } 165 | } 166 | 167 | this.cacheEntries( 168 | entries.filter(([kind, _, __]) => kind === this.kind).map(([_, k, v]) => [k as K, v as V | null]), 169 | full, 170 | ) 171 | } 172 | 173 | private cacheEntries(entries: Array<[K, V | null]>, full: boolean): void { 174 | const localEntries = []; 175 | for (const [key, value] of entries) { 176 | localEntries.push([key, value]); 177 | if (value !== null) { 178 | this.map.set(key as K, value); 179 | } else { 180 | this.map.delete(key as K); 181 | } 182 | } 183 | if (full || localEntries.length > 0) { 184 | this.events.emit('update', localEntries, full); 185 | } 186 | } 187 | 188 | private onConnect(game: Game, isFirst: boolean): void { 189 | if (isFirst) { 190 | if (this.options.unique) { 191 | this.client.update([['unique', this.kind, this.options.unique]]); 192 | } 193 | if (this.options.writeProtected) { 194 | this.client.update([['writeProtected', this.kind, true]]); 195 | } 196 | if (this.options.ephemeral) { 197 | this.client.update([['ephemeral', this.kind, true]]); 198 | } 199 | if (this.options.perPlayer) { 200 | this.client.update([['perPlayer', this.kind, true]]); 201 | } 202 | if (this.options.sendOnConnect) { 203 | const entries: Array = []; 204 | for (const [key, value] of this.map.entries()) { 205 | entries.push([this.kind, key, value]); 206 | } 207 | this.client.update(entries); 208 | } 209 | } 210 | if (this.options.rateLimit) { 211 | this.intervalId = setInterval(this.sendPending.bind(this), this.options.rateLimit); 212 | } 213 | } 214 | 215 | private onDisconnect(game: Game | null): void { 216 | if (this.intervalId !== null) { 217 | clearInterval(this.intervalId); 218 | this.intervalId = null; 219 | } 220 | if (game && this.options.perPlayer) { 221 | const localEntries: Array = []; 222 | for (const [key, value] of this.map.entries()) { 223 | localEntries.push([this.kind, key, null]); 224 | if (key === game.playerId) { 225 | localEntries.push([this.kind, 'offline', value]); 226 | } 227 | } 228 | this.onUpdate(localEntries, true); 229 | } 230 | } 231 | 232 | private sendPending(): void { 233 | if (this.pending.size > 0) { 234 | const entries: Array = []; 235 | for (const [k, v] of this.pending.entries()) { 236 | entries.push([this.kind, k, v]); 237 | } 238 | this.client.update(entries); 239 | this.lastUpdate = new Date().getTime(); 240 | this.pending.clear(); 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/center.ts: -------------------------------------------------------------------------------- 1 | import { AssetLoader } from "./asset-loader"; 2 | import { Mesh, CanvasTexture, Vector2, MeshLambertMaterial, Group } from "three"; 3 | import { Client } from "./client"; 4 | import { World } from "./world"; 5 | import { Size } from "./types"; 6 | 7 | export class Center { 8 | private mesh: Mesh; 9 | group: Group; 10 | canvas: HTMLCanvasElement; 11 | ctx: CanvasRenderingContext2D; 12 | texture: CanvasTexture; 13 | 14 | scores: Array = new Array(5).fill(null); 15 | nicks: Array = new Array(4).fill(null); 16 | dealer: number | null = null; 17 | honba = 0; 18 | remainingTiles = 0; 19 | 20 | client: Client; 21 | 22 | private readonly namePlateSize = new Vector2( 23 | 128 * 8, 24 | 15.5 * 4 * 8, 25 | ).multiplyScalar(8); 26 | private readonly namePlateContexts: Array = []; 27 | private readonly namePlateCanvases: Array = []; 28 | private readonly namePlateTextures: Array = []; 29 | 30 | dirty = true; 31 | private readonly namePlateColors: Array = [ 32 | '#ba7329', 33 | '#956d5d', 34 | '#fb78a2', 35 | '#3581d5', 36 | ]; 37 | 38 | constructor(loader: AssetLoader, client: Client) { 39 | this.group = new Group(); 40 | this.mesh = loader.makeCenter(); 41 | this.mesh.position.set(0, 0, 0.75); 42 | this.group.add(this.mesh); 43 | this.canvas = document.getElementById('center')! as HTMLCanvasElement; 44 | this.ctx = this.canvas.getContext('2d')!; 45 | 46 | const material = this.mesh.material as MeshLambertMaterial; 47 | const image = material.map!.image as HTMLImageElement; 48 | 49 | this.canvas.width = image.width; 50 | this.canvas.height = image.height; 51 | this.ctx.drawImage(image, 0, 0); 52 | 53 | this.texture = new CanvasTexture(this.canvas); 54 | this.texture.flipY = false; 55 | this.texture.rotation = Math.PI; 56 | this.texture.center = new Vector2(0.5, 0.5); 57 | this.texture.anisotropy = 16; 58 | material.map = this.texture; 59 | 60 | this.client = client; 61 | this.client.nicks.on('update', this.update.bind(this)); 62 | this.client.match.on('update', this.update.bind(this)); 63 | this.client.seats.on('update', this.update.bind(this)); 64 | this.client.things.on('update', this.update.bind(this)); 65 | 66 | const tableEdge = loader.makeTableEdge(); 67 | tableEdge.position.set(0, 0, (-25.5 / 2) + Size.TILE.z); 68 | this.group.add(tableEdge); 69 | tableEdge.updateMatrixWorld(); 70 | 71 | for (let i = 0; i < 4; i++) { 72 | this.namePlateCanvases[i] = document.getElementById(`name-plate-${i}`)! as HTMLCanvasElement; 73 | this.namePlateCanvases[i].width = this.namePlateSize.x; 74 | this.namePlateCanvases[i].height = this.namePlateSize.y; 75 | this.namePlateContexts[i] = this.namePlateCanvases[i].getContext('2d')!; 76 | 77 | const namePlate = loader.makeNamePlate(); 78 | namePlate.position.set(0, -World.WIDTH / 2 - 23, 3); 79 | namePlate.rotateX(Math.PI); 80 | this.group.add(namePlate); 81 | 82 | const group = new Group(); 83 | this.group.add(group); 84 | group.rotateZ(Math.PI * i / 2); 85 | group.add(namePlate); 86 | 87 | group.updateMatrixWorld(true); 88 | 89 | const texture = new CanvasTexture(this.namePlateCanvases[i]); 90 | this.namePlateTextures.push(texture); 91 | texture.flipY = false; 92 | texture.center = new Vector2(0.5, 0.5); 93 | texture.anisotropy = 16; 94 | const material = namePlate.material as MeshLambertMaterial; 95 | material.map = texture; 96 | 97 | this.updateNamePlate(i, this.nicks[i]); 98 | } 99 | 100 | client.on('disconnect', this.update.bind(this)); 101 | } 102 | 103 | private readonly namePlates: Array = []; 104 | 105 | private updateNamePlate(seat: number, nick: string | null): void { 106 | const actualNick = nick ?? ""; 107 | if (this.namePlates[seat] === actualNick) { 108 | return; 109 | } 110 | 111 | this.namePlates[seat] = actualNick; 112 | 113 | const context = this.namePlateContexts[seat]; 114 | 115 | context.resetTransform(); 116 | 117 | context.fillStyle = '#ddddd0'; 118 | context.fillRect(0, 0, this.namePlateSize.x, this.namePlateSize.y); 119 | 120 | context.fillStyle = this.namePlateColors[seat]; 121 | context.fillRect( 122 | 0, 123 | 0, 124 | this.namePlateSize.x, 125 | this.namePlateSize.y 126 | ); 127 | 128 | context.strokeStyle = '#888888'; 129 | context.lineWidth = 2; 130 | 131 | context.textAlign = 'center'; 132 | context.font = `${this.namePlateSize.y / 6}px Koruri`; 133 | context.fillStyle = '#fff'; 134 | context.textBaseline = 'middle'; 135 | context.translate( 136 | this.namePlateSize.x / 2, 137 | this.namePlateSize.y / 3.5 138 | ); 139 | context.fillText(actualNick.substring(0, 14), 0, 0); 140 | this.namePlateTextures[seat].needsUpdate = true; 141 | } 142 | 143 | update(): void { 144 | for (let i = 0; i < 4; i++) { 145 | const playerId = this.client.seatPlayers[i]; 146 | const nick = playerId !== null ? this.client.nicks.get(playerId) : null; 147 | 148 | if (nick === null) { 149 | this.nicks[i] = ''; 150 | continue; 151 | } 152 | 153 | if (nick === '') { 154 | this.nicks[i] = 'Jyanshi'; 155 | continue; 156 | } 157 | 158 | this.nicks[i] = nick; 159 | } 160 | 161 | this.dealer = this.client.match.get(0)?.dealer ?? null; 162 | this.honba = this.client.match.get(0)?.honba ?? 0; 163 | this.remainingTiles = [...this.client.things.entries()].filter(([i, t]) => 164 | t.slotName.startsWith("washizu.bag") && t.claimedBy === null).length; 165 | 166 | this.dirty = true; 167 | } 168 | 169 | setScores(scores: Array): void { 170 | for (let i = 0; i < 5; i++) { 171 | if (scores[i] !== this.scores[i]) { 172 | this.dirty = true; 173 | } 174 | this.scores[i] = scores[i]; 175 | } 176 | } 177 | 178 | draw(): void { 179 | if (!this.dirty) { 180 | return; 181 | } 182 | this.dirty = false; 183 | 184 | const offset = 0.24 * 512; 185 | const width = 0.52 * 512; 186 | 187 | this.ctx.resetTransform(); 188 | 189 | this.ctx.fillStyle = '#000'; 190 | this.ctx.fillRect(offset, offset, width, width); 191 | 192 | this.ctx.textBaseline = 'middle'; 193 | 194 | this.ctx.translate(256, 256); 195 | 196 | for (let i = 0; i < 4; i++) { 197 | this.drawScore(this.scores[i]); 198 | this.drawNick(this.nicks[i]); 199 | this.updateNamePlate(i, this.nicks[i]); 200 | if (this.dealer === i) { 201 | this.drawDealer(); 202 | } 203 | this.ctx.rotate(-Math.PI / 2); 204 | } 205 | this.ctx.rotate(Math.PI/4); 206 | this.texture.needsUpdate = true; 207 | } 208 | 209 | drawScore(score: number | null): void { 210 | if (score === null) { 211 | return; 212 | } 213 | 214 | this.ctx.textAlign = 'right'; 215 | this.ctx.font = '40px Segment7Standard, monospace'; 216 | if (score > 0) { 217 | this.ctx.fillStyle = '#eee'; 218 | } else if (0 <= score && score <= 1000) { 219 | this.ctx.fillStyle = '#e80'; 220 | } else { 221 | this.ctx.fillStyle = '#e00'; 222 | } 223 | const text = '' + score; 224 | this.ctx.fillText(text, 60, 100); 225 | } 226 | 227 | drawNick(nick: string | null): void { 228 | const text = (nick ?? "").substr(0, 10); 229 | this.ctx.textAlign = 'center'; 230 | this.ctx.font = '20px Verdana, Arial'; 231 | this.ctx.fillStyle = '#afa'; 232 | this.ctx.fillText(text ?? "", 0, 55); 233 | } 234 | 235 | drawDealer(): void { 236 | this.ctx.fillStyle = '#a60'; 237 | this.ctx.fillRect(-132, 132, 264, -13); 238 | if (this.honba > 0) { 239 | this.ctx.textAlign = 'right'; 240 | this.ctx.font = '40px Segment7Standard, monospace'; 241 | this.ctx.fillText('' + this.honba, -90, 100); 242 | } 243 | 244 | if (this.remainingTiles > 0) { 245 | if(this.remainingTiles < 14) { 246 | this.ctx.fillStyle = '#f44'; 247 | } else { 248 | this.ctx.fillStyle = '#88f'; 249 | } 250 | this.ctx.textAlign = 'center'; 251 | this.ctx.font = '40px Segment7Standard, monospace'; 252 | this.ctx.fillText('' + this.remainingTiles, 0, 5); 253 | } 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/slot.ts: -------------------------------------------------------------------------------- 1 | import { ThingType, Place, Size } from "./types"; 2 | import { Vector3, Vector2, Euler, Quaternion } from "three"; 3 | import { Thing } from "./thing"; 4 | import { round3 } from "./utils"; 5 | 6 | 7 | interface SlotLinks { 8 | // A slot stacked above/below current one, in a wall. 9 | down?: Slot; 10 | up?: Slot; 11 | 12 | // You have to fill that slot before current one becomes usable. 13 | requires?: Slot; 14 | 15 | // Move things in this slot to left/right when dropping. Used for example for 16 | // sorting your hand. 17 | shiftLeft?: Slot; 18 | shiftRight?: Slot; 19 | 20 | // A rotated tile in current slot pushes this one. Used for riichi. 21 | push?: Slot; 22 | } 23 | 24 | type SlotLinkDesc = Partial>; 25 | 26 | export class Slot { 27 | // Full name: 'wall.1.1@3' 28 | name: string; 29 | 30 | // Group: 'wall' 31 | group: string; 32 | 33 | type: ThingType; 34 | 35 | origin: Vector3; 36 | direction: Vector2; 37 | 38 | // Permitted rotations for things in this slot 39 | readonly rotationOptions: Array; 40 | rotations: Array; 41 | 42 | // Coordinates of this slot, e.g. 'wall.1.2@3' has indexes [1, 2] 43 | indexes: Array = []; 44 | 45 | // Player number 46 | seat: number | null = null; 47 | 48 | // Places (box parameters) corresponding to rotations 49 | places: Array; 50 | 51 | // Offset from origin, recomputed when pushing 52 | offset: Vector2; 53 | 54 | // Current thing in this slot 55 | thing: Thing | null = null; 56 | 57 | // Slots related to this one - first as strings, then as references 58 | links: SlotLinks; 59 | linkDesc: SlotLinkDesc; 60 | 61 | // Can select and filp multiple of this kind 62 | canFlipMultiple: boolean; 63 | 64 | // Draw a permanent shadow for this slot 65 | drawShadow: boolean; 66 | 67 | // Rotation to use for this shadow (for example, 'hand' slots have shadows 68 | // for tiles lying down, even though the tiles are standing by default) 69 | shadowRotation: number; 70 | 71 | // Rotate a tile hovered over this slot. Used for hand. 72 | rotateHeld: boolean; 73 | 74 | phantom: boolean; 75 | 76 | constructor(params: { 77 | name: string; 78 | group: string; 79 | type?: ThingType; 80 | origin: Vector3; 81 | direction?: Vector2; 82 | rotations: Array; 83 | links?: SlotLinkDesc; 84 | canFlipMultiple?: boolean; 85 | drawShadow?: boolean; 86 | shadowRotation?: number; 87 | rotateHeld?: boolean; 88 | phantom?: boolean; 89 | }) { 90 | this.name = params.name; 91 | this.group = params.group; 92 | this.type = params.type ?? ThingType.TILE; 93 | this.origin = params.origin; 94 | this.direction = params.direction ?? new Vector2(1, 1); 95 | this.rotationOptions = params.rotations; 96 | this.rotations = params.rotations; 97 | this.linkDesc = params.links ?? {}; 98 | this.canFlipMultiple = params.canFlipMultiple ?? false; 99 | this.drawShadow = params.drawShadow ?? false; 100 | this.shadowRotation = params.shadowRotation ?? 0; 101 | this.rotateHeld = params.rotateHeld ?? false; 102 | this.phantom = params.phantom ?? false; 103 | 104 | this.places = this.rotations.map(this.makePlace.bind(this)); 105 | this.offset = new Vector2(0, 0); 106 | this.links = {}; 107 | } 108 | 109 | static setLinks(slots: Map): void { 110 | for (const slot of slots.values()) { 111 | for (const key in slot.linkDesc) { 112 | const linkName = key as keyof SlotLinks; 113 | const linkTarget = slot.linkDesc[linkName]; 114 | if (linkTarget !== undefined) { 115 | slot.links[linkName] = slots.get(linkTarget); 116 | } 117 | } 118 | } 119 | } 120 | 121 | static computePushes(slots: Array): Array<[Slot, Slot]> { 122 | const result: Array<[Slot, Slot]> = []; 123 | const seen: Set = new Set(); 124 | 125 | function recurse(slot: Slot): void { 126 | if (seen.has(slot)) { 127 | return; 128 | } 129 | seen.add(slot); 130 | 131 | if (slot.links.push) { 132 | recurse(slot.links.push); 133 | result.push([slot, slot.links.push]); 134 | } 135 | } 136 | 137 | for (const slot of slots) { 138 | recurse(slot); 139 | } 140 | result.reverse(); 141 | return result; 142 | } 143 | 144 | copy(suffix: string): Slot { 145 | const name = this.name + suffix; 146 | const linkDesc: SlotLinkDesc = {}; 147 | for (const key in this.linkDesc) { 148 | const linkName = key as keyof SlotLinks; 149 | const linkTarget = this.linkDesc[linkName]; 150 | if (linkTarget !== undefined) { 151 | linkDesc[linkName] = linkTarget + suffix; 152 | } 153 | } 154 | 155 | const slot = new Slot({ 156 | name, 157 | group: this.group, 158 | type: this.type, 159 | origin: this.origin, 160 | direction: this.direction, 161 | rotations: this.rotations, 162 | phantom: this.phantom, 163 | }); 164 | slot.linkDesc = linkDesc; 165 | slot.canFlipMultiple = this.canFlipMultiple; 166 | slot.drawShadow = this.drawShadow; 167 | slot.shadowRotation = this.shadowRotation; 168 | slot.rotateHeld = this.rotateHeld; 169 | slot.indexes = this.indexes.slice(); 170 | return slot; 171 | } 172 | 173 | rotate(seat: number, worldWidth: number): void { 174 | const rotation = seat * Math.PI / 2; 175 | 176 | const quat = new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), rotation); 177 | 178 | const pos = new Vector3( 179 | this.origin.x - worldWidth / 2, 180 | this.origin.y - worldWidth / 2, 181 | this.origin.z, 182 | ); 183 | pos.applyQuaternion(quat); 184 | this.origin = new Vector3( 185 | pos.x + worldWidth / 2, 186 | pos.y + worldWidth / 2, 187 | pos.z, 188 | ); 189 | 190 | round3(this.origin, 16); 191 | 192 | const dir = new Vector3(this.direction.x, this.direction.y, 0); 193 | dir.applyQuaternion(quat); 194 | this.direction = new Vector2(dir.x, dir.y); 195 | 196 | this.rotations = this.rotations.map(rot => { 197 | const q = new Quaternion().setFromEuler(rot); 198 | q.premultiply(quat); 199 | return new Euler().setFromQuaternion(q); 200 | }); 201 | 202 | this.places = this.rotations.map(this.makePlace.bind(this)); 203 | 204 | this.seat = seat; 205 | } 206 | 207 | makePlace(rotation: Euler): Place { 208 | const dim = Size[this.type]; 209 | 210 | const xv = new Vector3(0, 0, dim.z).applyEuler(rotation); 211 | const yv = new Vector3(0, dim.y, 0).applyEuler(rotation); 212 | const zv = new Vector3(dim.x, 0, 0).applyEuler(rotation); 213 | const maxx = Math.max(Math.abs(xv.x), Math.abs(yv.x), Math.abs(zv.x)); 214 | const maxy = Math.max(Math.abs(xv.y), Math.abs(yv.y), Math.abs(zv.y)); 215 | const maxz = Math.max(Math.abs(xv.z), Math.abs(yv.z), Math.abs(zv.z)); 216 | 217 | const size = new Vector3(maxx, maxy, maxz); 218 | 219 | return { 220 | position: new Vector3( 221 | this.origin.x + maxx / 2 * this.direction.x, 222 | this.origin.y + maxy / 2 * this.direction.y, 223 | this.origin.z + maxz/2, 224 | ), 225 | rotation: rotation, 226 | size, 227 | }; 228 | } 229 | 230 | canBeUsed(playerNum: number): boolean { 231 | return this.thing === null || this.thing.claimedBy === playerNum; 232 | } 233 | 234 | placeWithOffset(rotationIndex: number): Place { 235 | const place = this.places[rotationIndex]; 236 | if (this.offset.x === 0 && this.offset.y === 0) { 237 | return place; 238 | } 239 | const position = place.position.clone(); 240 | position.x += this.offset.x; 241 | position.y += this.offset.y; 242 | return {...place, position }; 243 | } 244 | 245 | handlePush(source: Slot): void { 246 | this.offset.copy(source.offset); 247 | 248 | if (source.thing === null) { 249 | return; 250 | } 251 | const rotationIndex = this.thing ? this.thing.rotationIndex : 0; 252 | 253 | const place = this.places[rotationIndex]; 254 | const sourcePlace = source.places[source.thing.rotationIndex]; 255 | 256 | // Relative slot position 257 | const sdx = this.origin.x - source.origin.x; 258 | const sdy = this.origin.y - source.origin.y; 259 | 260 | if (Math.abs(sdx) > Math.abs(sdy)) { 261 | const dx = place.position.x - sourcePlace.position.x - source.offset.x; 262 | const sizex = (place.size.x + sourcePlace.size.x) / 2; 263 | 264 | const dist = sizex - Math.sign(sdx) * dx; 265 | if (dist > 0) { 266 | this.offset.x = Math.sign(sdx) * dist; 267 | } 268 | } else { 269 | const dy = place.position.y - sourcePlace.position.y - source.offset.y; 270 | const sizey = (place.size.y + sourcePlace.size.y) / 2; 271 | 272 | const dist = sizey - Math.sign(sdy) * dy; 273 | if (dist > 0) { 274 | this.offset.y = Math.sign(sdy) * dist; 275 | } 276 | } 277 | } 278 | 279 | getTop(): Slot { 280 | let top: Slot = this; 281 | while(top.links.up != null) { 282 | top = top.links.up; 283 | } 284 | return top; 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Autotable - an online mahjong table 5 | 6 | 7 | 8 | 9 | 10 | 46 | 47 | 48 | 49 | Fork me on GitHub 51 | 52 |
53 |

54 | Autotable 55 |

56 |
57 | 58 |
59 |

60 | Play now 61 |

62 |

63 | Autotable is an online platform for playing 64 | Riichi Mahjong. 65 |

66 |

67 | Unlike other mahjong programs, it's not really a game, but rather a tabletop simulator. The computer does not enforce the rules or make any moves for you. You are expected to do almost everything yourself: draw the tiles from the wall, sort them, call, make payments, and so on. 68 |

69 |

This is all to make the experience feel more like real-life Mahjong. Think about it as an automatic mahjong table, but on the Internet.

70 |

71 | Autotable should be played with other people over a voice call, such as Jitsi, Google Meet or Discord. 72 |

73 | 74 |

FAQ

75 | 76 |

How to play online?

77 | 78 |

There is no matchmaking, you need to arrange the game with other players yourself.

79 | 80 |

You should also connect with the other players over a video/audio call. Because the game is intentionally free-form and not automated, voice is really important: not only calls (pon/chi/kan/ron), but also payments, deciding who's next, clearing up mistakes, and so on.

81 | 82 |

To start an online game, click "Connect". Then, copy the page address (containing the game ID) and send it to other people.

83 | 84 |

Who's the dealer?

85 |

The first dealer is indicated by the round marker. You can move it around, or flip it to South side when needed.

86 | 87 |
88 | 89 |
90 | 91 | 92 |

The current dealer is shown on the center display, as an orange bar. The repeat count (honba) is also displayed there.

93 | 94 |
95 | 96 |
97 | 98 |

This information is updated automatically when somebody presses Deal, but you can also change it manually using the "Dealer" and "Honba" buttons.

99 | 100 |

How do I pay?

101 |

Use Space or Q to look down, to your tenbo sticks drawer.

102 |

You can put down the sticks on the table, next to the discards. Then the other person will be able to take them:

103 | 104 |
105 | 106 |
107 | 108 |

How do I decide seats?

109 |

110 | You can deal random wind tiles to do that. Select "Wind tiles" from the dropdown, press "Deal", and grab tiles to determine your seats: 111 |

112 | 113 |
114 | 115 |
116 | 117 |

118 | The player that took East stays in their seat. You can move the round marker to that player's side. The other players can switch seats around the table using the "Leave seat" button. 119 |

120 | 121 |

What are the different modes?

122 | 123 |
    124 |
  • Four-player game 125 |
  • Three-player (sanma) - see Sanma on Arcturus wiki, or Three-player mahjong on Wikipedia. Main differences: no 2-8 Man, no chii, North is a special meld. 126 |
  • Minefield - a two-player game from Kaiji manga, see this Minefield game made by the same developer (the page also contains a summary of rules). 127 |
  • Bamboo - an adaptation of the two-player Bamboo game from gamedesign.jp. This is mostly a wait-reading exercise: only bamboo tiles, no melds except closed kan. 128 |
  • Washizu - a game from the Agagi manga, only 1 of each 4 tiles is opaque, the rest are translucent. Draw cards by clicking on the center of the table. The number in the center counts how many are left in the bag (for dead wall). 129 |
130 | 131 |

Attribution

132 | 133 |

This is a fork of the original autotable. The main goal was to add washizu mode and the ability to discard tiles face down for yami mahjong, but it also includes a few bug fixes and a juiced up spectator mode.

134 | 135 |

To make the game, These freely available images and sounds were used:

136 | 137 | 148 | 149 |

The project was made using the following excellent pieces of software:

150 | 159 | 160 |

Contact me

161 | 162 |

This version is maintained by riichinomics, you can contact me at riichinomics@gmail.com or open an issue on github.

163 | 164 |

Links

165 | 166 | 174 | 175 |
176 | 177 | -------------------------------------------------------------------------------- /src/mouse-ui.ts: -------------------------------------------------------------------------------- 1 | import { Raycaster, Camera, Group, Mesh, BoxGeometry, PlaneGeometry, Vector3, Vector2 } from "three"; 2 | import { World } from "./world"; 3 | import { SelectionBox } from "./selection-box"; 4 | 5 | export class MouseUi { 6 | private world: World; 7 | private mainGroup: Group; 8 | private raycastGroup: Group; 9 | private raycaster: Raycaster; 10 | 11 | private selectionBox: SelectionBox | null; 12 | private camera: Camera | null; 13 | 14 | private main: HTMLElement; 15 | private selection: HTMLElement; 16 | private cursors: Array; 17 | 18 | private raycastObjects: Array; 19 | private raycastTable: Mesh; 20 | 21 | private currentObjects: Array = []; 22 | 23 | mouse2: Vector2 | null = null; 24 | private mouse3: Vector3 | null = null; 25 | private selectStart3: Vector3 | null = null; 26 | private dragStart3: Vector3 | null = null; 27 | 28 | sticky: boolean = false; 29 | 30 | constructor(world: World, mainGroup: Group) { 31 | this.world = world; 32 | this.mainGroup = mainGroup; 33 | this.raycaster = new Raycaster(); 34 | 35 | this.camera = null; 36 | this.selectionBox = null; 37 | 38 | this.main = document.getElementById('main')!; 39 | this.selection = document.getElementById('selection')!; 40 | this.cursors = [ 41 | document.querySelector('.cursor.rotate-0')! as HTMLElement, 42 | document.querySelector('.cursor.rotate-1')! as HTMLElement, 43 | document.querySelector('.cursor.rotate-2')! as HTMLElement, 44 | document.querySelector('.cursor.rotate-3')! as HTMLElement, 45 | ]; 46 | 47 | this.raycastObjects = []; 48 | this.raycastGroup = new Group(); 49 | this.mainGroup.add(this.raycastGroup); 50 | this.raycastGroup.visible = false; 51 | this.raycastGroup.matrixAutoUpdate = false; 52 | for (let i = 0; i < this.world.slots.size; i++) { 53 | const obj = new Mesh(new BoxGeometry(1, 1, 1)); 54 | obj.name = 'raycastBox'; 55 | obj.visible = false; 56 | obj.matrixAutoUpdate = false; 57 | this.raycastObjects.push(obj); 58 | this.raycastGroup.add(obj); 59 | } 60 | 61 | this.raycastTable = new Mesh(new PlaneGeometry( 62 | World.WIDTH * 3, 63 | World.WIDTH * 3, 64 | )); 65 | this.raycastTable.visible = false; 66 | this.raycastTable.position.set(World.WIDTH / 2, World.WIDTH / 2, 0); 67 | this.raycastGroup.add(this.raycastTable); 68 | 69 | this.setupEvents(); 70 | } 71 | 72 | private setupEvents(): void { 73 | this.main.addEventListener('mousemove', this.onMouseMove.bind(this)); 74 | this.main.addEventListener('mouseleave', this.onMouseLeave.bind(this)); 75 | this.main.addEventListener('mousedown', this.onMouseDown.bind(this)); 76 | this.main.addEventListener('wheel', this.onWheel.bind(this)); 77 | this.main.addEventListener('contextmenu', e => e.preventDefault()); 78 | window.addEventListener('mouseup', this.onMouseUp.bind(this)); 79 | } 80 | 81 | private onWheel(event: WheelEvent): void { 82 | this.world.onFlip(Math.sign(event.deltaY)); 83 | } 84 | 85 | private onMouseMove(event: MouseEvent): void { 86 | const w = this.main.clientWidth; 87 | const h = this.main.clientHeight; 88 | if (this.mouse2 === null) { 89 | this.mouse2 = new Vector2(0, 0); 90 | } 91 | this.mouse2.x = event.offsetX / w * 2 - 1; 92 | this.mouse2.y = -event.offsetY / h * 2 + 1; 93 | 94 | this.update(); 95 | } 96 | 97 | private onMouseLeave(): void { 98 | this.mouse2 = null; 99 | this.update(); 100 | } 101 | 102 | private onMouseDown(event: MouseEvent): void { 103 | if (this.mouse2 === null || this.mouse3 === null) { 104 | return; 105 | } 106 | 107 | if (event.button === 0) { 108 | if (this.dragStart3 === null) { 109 | if (this.world.onDragStart()) { 110 | this.dragStart3 = this.mouse3.clone(); 111 | } else { 112 | this.selectStart3 = this.mouse3.clone(); 113 | } 114 | } else if (this.sticky) { 115 | this.dragStart3 = null; 116 | this.world.onDragEnd(); 117 | } 118 | 119 | this.update(); 120 | } else if (event.button === 2) { 121 | this.world.onFlip(1); 122 | } 123 | } 124 | 125 | private onMouseUp(event: MouseEvent): void { 126 | if (event.button === 0) { 127 | this.selectStart3 = null; 128 | 129 | if (!this.sticky) { 130 | this.dragStart3 = null; 131 | this.world.onDragEnd(); 132 | } 133 | } 134 | } 135 | 136 | setCamera(camera: Camera): void { 137 | if (this.camera !== camera) { 138 | this.camera = camera; 139 | this.selectionBox = new SelectionBox(camera); 140 | } 141 | } 142 | 143 | updateObjects(): void { 144 | this.currentObjects = this.prepareObjects(); 145 | } 146 | 147 | update(): void { 148 | if (!this.camera || !this.selectionBox || this.mouse2 === null) { 149 | this.world.onHover(null); 150 | this.world.onMove(null); 151 | this.selection.style.visibility = 'hidden'; 152 | return; 153 | } 154 | this.raycaster.setFromCamera(this.mouse2, this.camera); 155 | 156 | const intersects = this.raycaster.intersectObjects(this.currentObjects); 157 | let hovered = null; 158 | let hoverPos = null; 159 | if (intersects.length > 0) { 160 | hovered = intersects[0].object.userData.id; 161 | hoverPos = intersects[0].point.clone(); 162 | this.raycastGroup.worldToLocal(hoverPos); 163 | } 164 | this.world.onHover(hovered); 165 | 166 | this.raycastTable.position.z = this.dragStart3 ? this.dragStart3.z : 0; 167 | this.raycastTable.updateMatrixWorld(); 168 | const intersectsTable = this.raycaster.intersectObject(this.raycastTable); 169 | let levelPos = null; 170 | if (intersectsTable.length > 0) { 171 | levelPos = intersectsTable[0].point.clone(); 172 | this.raycastGroup.worldToLocal(levelPos); 173 | } 174 | 175 | if (this.prepareSelection()) { 176 | const selected = []; 177 | for (const obj of this.selectionBox!.select(this.currentObjects)) { 178 | const id = obj.userData.id; 179 | selected.push(id); 180 | } 181 | this.world.onSelect(selected); 182 | if (levelPos) { 183 | this.mouse3 = levelPos; 184 | } 185 | } else { 186 | if (this.dragStart3) { 187 | this.mouse3 = levelPos ?? this.mouse3; 188 | } else { 189 | this.mouse3 = hoverPos ?? levelPos ?? this.mouse3; 190 | } 191 | } 192 | this.world.onMove(this.mouse3); 193 | } 194 | 195 | updateCursors(): void { 196 | if (!this.camera || !this.selectionBox) { 197 | return; 198 | } 199 | 200 | const w = this.main.clientWidth; 201 | const h = this.main.clientHeight; 202 | 203 | const now = new Date().getTime(); 204 | 205 | const rotation = this.world.seat ?? 0; 206 | for (let i = 0; i < 4; i++) { 207 | const j = (4 + i - rotation) % 4; 208 | 209 | const cursorElement = this.cursors[j]; 210 | const cursorPos = this.world.mouseTracker.getMouse(i, now); 211 | 212 | if (cursorPos && i !== this.world.seat) { 213 | const v = cursorPos.clone(); 214 | this.raycastGroup.localToWorld(v); 215 | v.project(this.camera); 216 | 217 | const x = Math.floor((v.x + 1) / 2 * w); 218 | const y = Math.floor((-v.y + 1) / 2 * h); 219 | cursorElement.style.visibility = 'visible'; 220 | cursorElement.style.left = `${x}px`; 221 | cursorElement.style.top = `${y}px`; 222 | } else { 223 | cursorElement.style.visibility = 'hidden'; 224 | } 225 | } 226 | } 227 | 228 | private prepareSelection(): boolean { 229 | if (!this.selectionBox) { 230 | return false; 231 | } 232 | 233 | if (this.selectStart3 === null || this.mouse2 === null) { 234 | this.selection.style.visibility = 'hidden'; 235 | return false; 236 | } 237 | 238 | const w = this.main.clientWidth; 239 | const h = this.main.clientHeight; 240 | 241 | const p = this.selectStart3.clone(); 242 | this.raycastGroup.localToWorld(p); 243 | const selectStart2 = p.project(this.camera!); 244 | 245 | const x1 = Math.min(selectStart2.x, this.mouse2.x); 246 | const y1 = Math.min(selectStart2.y, this.mouse2.y); 247 | const x2 = Math.max(selectStart2.x, this.mouse2.x); 248 | const y2 = Math.max(selectStart2.y, this.mouse2.y); 249 | 250 | const sx1 = (x1 + 1) * w / 2; 251 | const sx2 = (x2 + 1) * w / 2; 252 | const sy1 = (-y2 + 1) * h / 2; 253 | const sy2 = (-y1 + 1) * h / 2; 254 | 255 | this.selection.style.left = `${sx1}px`; 256 | this.selection.style.top = `${sy1}px`; 257 | this.selection.style.width = `${sx2-sx1}px`; 258 | this.selection.style.height = `${sy2-sy1}px`; 259 | this.selection.style.visibility = 'visible'; 260 | 261 | this.selectionBox.update(new Vector2(x1, y1), new Vector2(x2, y2)); 262 | return true; 263 | } 264 | 265 | private prepareObjects(): Array { 266 | const toSelect = this.world.toSelect(); 267 | const objs = []; 268 | 269 | const minSize = 3; 270 | 271 | for (let i = 0; i < toSelect.length; i++) { 272 | const select = toSelect[i]; 273 | const obj = this.raycastObjects[i]; 274 | obj.position.copy(select.position); 275 | obj.scale.copy(select.size); 276 | 277 | if (obj.scale.x < minSize) { 278 | obj.scale.x = minSize; 279 | } 280 | if (obj.scale.y < minSize) { 281 | obj.scale.y = minSize; 282 | } 283 | 284 | obj.updateMatrix(); 285 | obj.updateMatrixWorld(); 286 | obj.userData.id = select.id; 287 | objs.push(obj); 288 | } 289 | return objs; 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/setup.ts: -------------------------------------------------------------------------------- 1 | import { shuffle } from "./utils"; 2 | import { Conditions, DealType, ThingType, GameType, Points, GAME_TYPES } from "./types"; 3 | import { DEALS, DealPart, POINTS } from "./setup-deal"; 4 | import { makeSlots } from "./setup-slots"; 5 | import { Slot } from "./slot"; 6 | import { Thing } from "./thing"; 7 | 8 | 9 | export class Setup { 10 | slots: Map = new Map(); 11 | slotNames: Array = []; 12 | things: Map = new Map(); 13 | counters: Map = new Map(); 14 | start: Record = { 15 | 'TILE': 0, 16 | 'STICK': 1000, 17 | 'MARKER': 2000, 18 | } 19 | pushes: Array<[Slot, Slot]> = []; 20 | conditions!: Conditions; 21 | 22 | setup(conditions: Conditions): void { 23 | this.conditions = conditions; 24 | 25 | this.addSlots(conditions.gameType); 26 | this.addTiles(conditions); 27 | this.addSticks(conditions.gameType, conditions.points); 28 | this.addMarker(); 29 | this.deal(0, conditions.gameType, DealType.INITIAL); 30 | } 31 | 32 | private wallSlots(): Array { 33 | return [...this.slots.values()].filter( 34 | slot => slot.name.startsWith('wall')); 35 | } 36 | 37 | private addTiles(conditions: Conditions): void { 38 | const wallSlots = this.wallSlots().map(slot => slot.name); 39 | shuffle(wallSlots); 40 | let j = 0; 41 | for (let i = 0; i < 136; i++) { 42 | const tileIndex = this.tileIndex(i, conditions); 43 | if (tileIndex !== null) { 44 | this.addThing(ThingType.TILE, tileIndex, wallSlots[j++]); 45 | } 46 | } 47 | } 48 | 49 | replace(conditions: Conditions): void { 50 | const whatReplace: Record = { 51 | TILE: true, 52 | STICK: ( 53 | conditions.gameType !== this.conditions.gameType || 54 | conditions.points !== this.conditions.points 55 | ), 56 | MARKER: conditions.gameType !== this.conditions.gameType, 57 | }; 58 | 59 | const map = new Map(); 60 | for (const thing of [...this.things.values()]) { 61 | thing.prepareMove(); 62 | if (whatReplace[thing.type]) { 63 | this.things.delete(thing.index); 64 | } else { 65 | map.set(thing.index, thing.slot.name); 66 | } 67 | } 68 | this.addSlots(conditions.gameType); 69 | if (whatReplace.TILE) { 70 | this.counters.set(ThingType.TILE, 0); 71 | this.addTiles(conditions); 72 | } 73 | if (whatReplace.STICK) { 74 | this.counters.set(ThingType.STICK, 0); 75 | this.addSticks(conditions.gameType, conditions.points); 76 | } 77 | if (whatReplace.MARKER) { 78 | this.counters.set(ThingType.MARKER, 0); 79 | this.addMarker(); 80 | } 81 | 82 | for (const thing of this.things.values()) { 83 | if (!whatReplace[thing.type]) { 84 | const slotName = map.get(thing.index); 85 | if (slotName === undefined) { 86 | throw `couldn't recover slot name for thing ${thing.index}`; 87 | } 88 | const slot = this.slots.get(slotName); 89 | if (slot === undefined) { 90 | throw `trying to move thing to slot ${slotName}, but it doesn't exist`; 91 | } 92 | thing.moveTo(slot, thing.rotationIndex); 93 | } 94 | } 95 | this.conditions = conditions; 96 | } 97 | 98 | static readonly suits = [..."mpsz"]; 99 | 100 | private tileIndex(i: number, conditions: Conditions): number | null { 101 | let tileIndex = Math.floor(i / 4); 102 | 103 | if (conditions.gameType === GameType.BAMBOO) { 104 | if (!((18 <= tileIndex && tileIndex < 27) || tileIndex === 36)) { 105 | return null; 106 | } 107 | } 108 | 109 | if (conditions.gameType === GameType.THREE_PLAYER) { 110 | if ((1 <= tileIndex && tileIndex < 8) || tileIndex === 34) { 111 | return null; 112 | } 113 | } 114 | 115 | const tileNumber = tileIndex % 9; 116 | const tileSuit = Setup.suits[Math.floor(tileIndex / 9)]; 117 | 118 | if (conditions.back > 0){ 119 | tileIndex |= 1 << 8; 120 | } 121 | 122 | if ( conditions.aka[(tileNumber + 1) + tileSuit] > i % 4 ) { 123 | tileIndex |= 1 << 9; 124 | } 125 | 126 | if ( conditions.gameType === GameType.WASHIZU && i % 4 !== 0 ) { 127 | tileIndex |= 1 << 10; 128 | } 129 | 130 | return tileIndex; 131 | } 132 | 133 | deal(seat: number, gameType: GameType, dealType: DealType): void { 134 | const roll = Math.floor(Math.random() * 6 + 1) + Math.floor(Math.random() * 6 + 1); 135 | 136 | if (GAME_TYPES[gameType].seats.indexOf(seat) === -1) { 137 | seat = 0; 138 | } 139 | 140 | const dealParts = DEALS[gameType][dealType]!; 141 | 142 | const tiles = [...this.things.values()].filter(thing => thing.type === ThingType.TILE); 143 | for (const thing of tiles) { 144 | thing.prepareMove(); 145 | } 146 | 147 | shuffle(tiles); 148 | for (const part of dealParts) { 149 | this.dealPart(part, tiles, roll, seat); 150 | } 151 | 152 | if (tiles.length !== 0) { 153 | throw `bad deal: ${tiles.length} remaining`; 154 | } 155 | } 156 | 157 | private dealPart(dealPart: DealPart, tiles: Array, roll: number, seat: number): void { 158 | if (dealPart.roll !== undefined && dealPart.roll !== roll) { 159 | return; 160 | } 161 | if (dealPart.tiles !== undefined) { 162 | const searched = [...dealPart.tiles]; 163 | shuffle(searched); 164 | 165 | for (let i = 0; i < searched.length; i++) { 166 | // HACK: typeIndex includes back color 167 | const idx = tiles.findIndex(tile => (tile.getTypeIndexNoFlags() === searched[i] && tile.isTransparent() == false)); 168 | if (idx === -1) { 169 | throw `not found: ${searched[i]}`; 170 | } 171 | const targetIdx = tiles.length - i - 1; 172 | const temp = tiles[targetIdx]; 173 | tiles[targetIdx] = tiles[idx]; 174 | tiles[idx] = temp; 175 | } 176 | } 177 | 178 | for (const [slotName, slotSeat, n] of dealPart.ranges) { 179 | if (tiles.length < n) { 180 | throw `tile underflow at ${slotName}`; 181 | } 182 | 183 | const idx = this.slotNames.indexOf(slotName); 184 | if (idx === -1) { 185 | throw `slot not found: ${slotName}`; 186 | } 187 | const effectiveSeat = dealPart.absolute ? slotSeat : (slotSeat + seat) % 4; 188 | for (let i = idx; i < idx + n; i++) { 189 | const targetSlotName = this.slotNames[i] + '@' + effectiveSeat; 190 | const slot = this.slots.get(targetSlotName); 191 | if (slot === undefined) { 192 | throw `slot not found: ${targetSlotName}`; 193 | } 194 | if (slot.thing !== null) { 195 | throw `slot occupied: ${targetSlotName}`; 196 | } 197 | 198 | const thing = tiles.pop()!; 199 | thing.moveTo(slot, dealPart.rotationIndex); 200 | } 201 | } 202 | } 203 | 204 | private addSticks(gameType: GameType, points: Points): void { 205 | const seats = GAME_TYPES[gameType].seats; 206 | const add = (index: number, n: number, slot: number): void => { 207 | for (const seat of seats) { 208 | for (let j = 0; j < n; j++) { 209 | this.addThing(ThingType.STICK, index, `tray.${slot}.${j}@${seat}`); 210 | } 211 | } 212 | }; 213 | 214 | // Debt 215 | add(5, POINTS[points][0], 0); 216 | // 10k 217 | add(4, POINTS[points][1], 1); 218 | // 5k 219 | add(3, POINTS[points][2], 2); 220 | // 1k 221 | add(2, POINTS[points][3], 3); 222 | // 500 223 | add(1, POINTS[points][4], 4); 224 | // 100 225 | add(0, POINTS[points][5], 5); 226 | } 227 | 228 | private addMarker(): void { 229 | this.addThing(ThingType.MARKER, 0, 'marker@0'); 230 | } 231 | 232 | private addThing( 233 | type: ThingType, 234 | typeIndex: number, 235 | slotName: string, 236 | rotationIndex?: number 237 | ): void { 238 | if (this.slots.get(slotName) === undefined) { 239 | throw `Unknown slot: ${slotName}`; 240 | } 241 | 242 | const counter = this.counters.get(type) ?? 0; 243 | this.counters.set(type, counter + 1); 244 | const thingIndex = this.start[type] + counter; 245 | const slot = this.slots.get(slotName)!; 246 | 247 | const thing = new Thing(thingIndex, type, typeIndex, slot); 248 | this.things.set(thingIndex, thing); 249 | if (rotationIndex !== undefined) { 250 | thing.rotationIndex = rotationIndex; 251 | } 252 | } 253 | 254 | private addSlots(gameType: GameType): void { 255 | this.slots.clear(); 256 | this.slotNames.splice(0); 257 | this.pushes.splice(0); 258 | 259 | const slotNames: Set = new Set(); 260 | for (const slot of makeSlots(gameType)) { 261 | this.slots.set(slot.name, slot); 262 | const shortName = slot.name.replace(/@.*/, ''); 263 | if (!slotNames.has(shortName)) { 264 | slotNames.add(shortName); 265 | } 266 | } 267 | this.slotNames.push(...slotNames.values()); 268 | Slot.setLinks(this.slots); 269 | 270 | this.pushes.push(...Slot.computePushes([...this.slots.values()])); 271 | } 272 | 273 | 274 | getScores(): {seats: Array, remaining: number} { 275 | const scores = new Array(4).fill(-20000); 276 | scores.push((25000 + 20000) * 4); // remaining 277 | const stickScores = [100, 500, 1000, 5000, 10000, 10000]; 278 | 279 | for (const slot of this.slots.values()) { 280 | if (slot.group === 'tray' && slot.thing !== null) { 281 | const score = stickScores[slot.thing.typeIndex]; 282 | scores[slot.seat!] += score; 283 | scores[4] -= score; 284 | } 285 | } 286 | 287 | const result = new Array(4).fill(null); 288 | for (const seat of GAME_TYPES[this.conditions.gameType].seats) { 289 | result[seat] = scores[seat]; 290 | } 291 | 292 | return { 293 | seats: result, 294 | remaining: scores[4] 295 | } 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /src/thing-group.ts: -------------------------------------------------------------------------------- 1 | import { Vector3, Euler, Mesh, Group, Material, InstancedMesh, Matrix4, BufferGeometry, MeshLambertMaterial, InstancedBufferGeometry, InstancedBufferAttribute, Vector4, Color, DoubleSide, MeshLambertMaterialParameters } from "three"; 2 | import { AssetLoader } from "./asset-loader"; 3 | import { ThingType } from "./types"; 4 | import { type } from "jquery"; 5 | 6 | const TILE_DU = 1 / 10; 7 | const TILE_DV = 1 / 8; 8 | const STICK_DV = 1 / 6; 9 | 10 | export interface ThingParams { 11 | type: ThingType; 12 | typeIndex: number; 13 | index: number; 14 | } 15 | 16 | export abstract class ThingGroup { 17 | protected assetLoader: AssetLoader; 18 | protected startIndex: number = 0; 19 | protected meshes: Array = []; 20 | protected group: Group; 21 | 22 | abstract createMesh(typeIndex: number): Mesh; 23 | 24 | constructor(assetLoader: AssetLoader, group: Group) { 25 | this.assetLoader = assetLoader; 26 | this.group = group; 27 | } 28 | 29 | canSetSimple(): boolean { 30 | return false; 31 | } 32 | 33 | setSimple(index: number, position: Vector3, rotation: Euler): void {} 34 | 35 | setCustom(index: number, position: Vector3, rotation: Euler): Mesh { 36 | const mesh = this.meshes[index - this.startIndex]; 37 | mesh.position.copy(position); 38 | mesh.rotation.copy(rotation); 39 | return mesh; 40 | } 41 | 42 | replace(startIndex: number, params: Array): void { 43 | for (const mesh of this.meshes) { 44 | (mesh.material as Material).dispose(); 45 | mesh.geometry.dispose(); 46 | this.group.remove(mesh); 47 | } 48 | this.meshes.splice(0); 49 | 50 | for (const p of params) { 51 | const mesh = this.createMesh(p.typeIndex); 52 | mesh.matrixAutoUpdate = false; 53 | this.meshes.push(mesh); 54 | this.group.add(mesh); 55 | } 56 | this.startIndex = startIndex; 57 | } 58 | } 59 | 60 | export class MarkerThingGroup extends ThingGroup { 61 | createMesh(typeIndex: number): Mesh { 62 | return this.assetLoader.makeMarker(); 63 | } 64 | } 65 | 66 | abstract class InstancedThingGroup extends ThingGroup { 67 | protected instancedMeshGroups: Record | null = null; 68 | protected indexToGroupMap: number[] | null = null; 69 | private zero: Matrix4 = new Matrix4().makeScale(0, 0, 0); 70 | 71 | abstract getOriginalMesh(): Mesh; 72 | abstract getUvChunk(): string; 73 | abstract getOffset(typeIndex: number): Vector3; 74 | 75 | canSetSimple(): boolean { 76 | return true; 77 | } 78 | 79 | protected createMaterial(): MeshLambertMaterial { 80 | const origMesh = this.getOriginalMesh(); 81 | const origMaterial = origMesh.material as MeshLambertMaterial; 82 | 83 | const material = new MeshLambertMaterial({ 84 | map: origMaterial.map, 85 | color: origMaterial.color, 86 | }); 87 | 88 | const paramChunk = ` 89 | attribute vec4 offset; 90 | #include 91 | `; 92 | const uvChunk = this.getUvChunk(); 93 | material.onBeforeCompile = shader => { 94 | shader.vertexShader = shader.vertexShader 95 | .replace('#include ', paramChunk) 96 | .replace('#include ', uvChunk); 97 | }; 98 | 99 | // Fix cache conflict: https://github.com/mrdoob/three.js/issues/19377 100 | material.defines = material.defines ?? {}; 101 | material.defines.THING_TYPE = origMesh.name; 102 | return material; 103 | } 104 | 105 | protected createInstancedMesh(params: Array): InstancedMesh { 106 | const material = this.createMaterial(); 107 | const data = new Float32Array(params.length * 3); 108 | for (let i = 0; i < params.length; i++) { 109 | const v = this.getOffset(params[i].typeIndex); 110 | data[3 * i] = v.x; 111 | data[3 * i + 1] = v.y; 112 | data[3 * i + 2] = v.z; 113 | } 114 | 115 | const origMesh = this.getOriginalMesh(); 116 | const geometry = new InstancedBufferGeometry().copy(origMesh.geometry as BufferGeometry); 117 | geometry.setAttribute('offset', new InstancedBufferAttribute(data, 3)); 118 | const instancedMesh = new InstancedMesh(geometry, material, params.length); 119 | return instancedMesh; 120 | } 121 | 122 | replace(startIndex: number, params: Array): void { 123 | super.replace(startIndex, params); 124 | 125 | if (this.instancedMeshGroups !== null) { 126 | for(const meshType of (Object.values(this.instancedMeshGroups))) { 127 | (meshType.material as Material).dispose(); 128 | meshType.geometry.dispose(); 129 | this.group.remove(meshType); 130 | } 131 | } 132 | 133 | this.instancedMeshGroups = this.createInstanceMeshGroups(params); 134 | for(const meshType of Object.values(this.instancedMeshGroups)) { 135 | this.group.add(meshType); 136 | } 137 | 138 | this.indexToGroupMap = []; 139 | for (const p of params) { 140 | this.indexToGroupMap.push(this.getGroupIndex(p.typeIndex)); 141 | } 142 | } 143 | 144 | protected createInstanceMeshGroups(params: ThingParams[]): Record { 145 | const mesh = this.createInstancedMesh(params); 146 | return { 0: mesh }; 147 | } 148 | 149 | protected getGroupIndex(index: number): number { 150 | return 0; 151 | } 152 | 153 | setSimple(index: number, position: Vector3, rotation: Euler): void { 154 | const i = index - this.startIndex; 155 | const mesh = this.meshes[i]; 156 | if (!mesh.visible && mesh.position.equals(position) && mesh.rotation.equals(rotation)) { 157 | return; 158 | } 159 | mesh.position.copy(position); 160 | mesh.rotation.copy(rotation); 161 | mesh.updateMatrix(); 162 | mesh.visible = false; 163 | 164 | if (this.instancedMeshGroups === null || this.indexToGroupMap === null) { 165 | return; 166 | } 167 | 168 | const activeGroupId = this.indexToGroupMap[i].toString(); 169 | 170 | for(const [groupId, groupMesh] of Object.entries(this.instancedMeshGroups)) { 171 | if (activeGroupId === groupId) { 172 | groupMesh.setMatrixAt(i, mesh.matrix); 173 | } else { 174 | groupMesh.setMatrixAt(i, this.zero); 175 | } 176 | groupMesh.instanceMatrix.needsUpdate = true; 177 | } 178 | } 179 | 180 | setCustom(index: number, position: Vector3, rotation: Euler): Mesh { 181 | const i = index - this.startIndex; 182 | const mesh = this.meshes[i]; 183 | mesh.position.copy(position); 184 | mesh.rotation.copy(rotation); 185 | mesh.visible = true; 186 | 187 | if (this.instancedMeshGroups === null) { 188 | return mesh; 189 | } 190 | 191 | for(const groupMesh of Object.values(this.instancedMeshGroups)) { 192 | groupMesh.setMatrixAt(i, this.zero); 193 | groupMesh.instanceMatrix.needsUpdate = true; 194 | } 195 | 196 | return mesh; 197 | } 198 | } 199 | 200 | export class TileThingGroup extends InstancedThingGroup { 201 | createInstanceMeshGroups(params: ThingParams[]): Record { 202 | const mesh = this.createInstancedMesh(params); 203 | mesh.renderOrder = 1; 204 | const washizuMesh = this.createInstancedMesh(params); 205 | const material = this.createMaterial(); 206 | material.map = this.assetLoader.textures["tiles.washizu.auto"]; 207 | material.transparent = true; 208 | material.depthWrite = false; 209 | material.side = DoubleSide; 210 | washizuMesh.material = material; 211 | return { 212 | 0: mesh, 213 | 1: washizuMesh 214 | }; 215 | } 216 | 217 | getGroupIndex(typeIndex: number): number { 218 | return (typeIndex & (1 << 10)) >> 10; 219 | } 220 | 221 | protected name: string = 'tile'; 222 | 223 | getOriginalMesh(): Mesh { 224 | return this.assetLoader.meshes.tile; 225 | } 226 | 227 | getUvChunk(): string { 228 | return ` 229 | #include 230 | if (vUv.x <= ${TILE_DU}) { 231 | vUv.x += offset.x; 232 | vUv.y += offset.y; 233 | } else { 234 | vUv.y += offset.z; 235 | } 236 | `; 237 | } 238 | 239 | getOffset(typeIndex: number): Vector3 { 240 | const back = (typeIndex & (1 << 8)) >> 8; 241 | const dora = (typeIndex & (1 << 9)) >> 9; 242 | typeIndex &= 0xff; 243 | const x = typeIndex % 40 % 9; 244 | const y = Math.floor(typeIndex % 40 / 9) + dora * 4; 245 | return new Vector3(x * TILE_DU, y * TILE_DV, back * TILE_DV * 4); 246 | } 247 | 248 | createMesh(typeIndex: number): Mesh { 249 | const mesh = this.assetLoader.make('tile'); 250 | 251 | const offset = this.getOffset(typeIndex); 252 | 253 | // Clone geometry and modify front face 254 | const geometry = mesh.geometry.clone() as BufferGeometry; 255 | mesh.geometry = geometry; 256 | const uvs: Float32Array = geometry.attributes.uv.array as Float32Array; 257 | for (let i = 0; i < uvs.length; i += 2) { 258 | if (uvs[i] <= TILE_DU) { 259 | uvs[i] += offset.x; 260 | uvs[i+1] += offset.y; 261 | } else { 262 | uvs[i+1] += offset.z; 263 | } 264 | } 265 | 266 | if (this.getGroupIndex(typeIndex) === 1) { 267 | const material = mesh.material as MeshLambertMaterial; 268 | material.map = this.assetLoader.textures['tiles.washizu.auto']; 269 | material.side = DoubleSide; 270 | material.transparent = true; 271 | material.depthWrite = false; 272 | } else { 273 | mesh.renderOrder = 1; 274 | } 275 | 276 | return mesh; 277 | } 278 | } 279 | 280 | export class StickThingGroup extends InstancedThingGroup { 281 | getOriginalMesh(): Mesh { 282 | return this.assetLoader.meshes.stick; 283 | } 284 | 285 | getUvChunk(): string { 286 | return ` 287 | #include 288 | vUv += offset.xy; 289 | `; 290 | } 291 | 292 | getOffset(typeIndex: number): Vector3 { 293 | return new Vector3(0, typeIndex * STICK_DV, 0); 294 | } 295 | 296 | createMesh(typeIndex: number): Mesh { 297 | const mesh = this.assetLoader.make('stick'); 298 | 299 | const geometry = mesh.geometry.clone() as BufferGeometry; 300 | mesh.geometry = geometry; 301 | const uvs: Float32Array = geometry.attributes.uv.array as Float32Array; 302 | for (let i = 0; i < uvs.length; i += 2) { 303 | uvs[i+1] += typeIndex * STICK_DV; 304 | } 305 | 306 | return mesh; 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/style.sass: -------------------------------------------------------------------------------- 1 | body 2 | background-color: red 3 | 4 | #full 5 | position: fixed 6 | top: 0 7 | bottom: 0 8 | left: 0 9 | right: 0 10 | background-color: #222 11 | 12 | #main 13 | position: absolute 14 | top: 50% 15 | left: 50% 16 | transform: translate(-50%, -50%) 17 | overflow: hidden 18 | 19 | #selection 20 | position: absolute 21 | z-index: 1 22 | border: 1px solid rgba(160, 85, 0, 0.8) 23 | background-color: rgba(160, 85, 0, 0.2) 24 | pointer-events: none 25 | visibility: hidden 26 | 27 | .cursor 28 | position: absolute 29 | background: url(../img/hand.svg) 30 | background-size: 100% 31 | width: 50px 32 | height: 35px 33 | z-index: 2 34 | /* border: 1px solid red */ 35 | 36 | margin-left: -20px 37 | margin-top: -5px 38 | transform-origin: 20px 5px 39 | pointer-events: none 40 | opacity: 0.6 41 | 42 | visibility: hidden 43 | 44 | .cursor.rotate-1 45 | transform: rotate(-90deg) 46 | .cursor.rotate-2 47 | transform: rotate(-180deg) 48 | .cursor.rotate-3 49 | transform: rotate(-270deg) 50 | 51 | .cursor 52 | visibility: visible 53 | top: 50% 54 | left: 20% 55 | 56 | .cursor.rotate-1 57 | visibility: visible 58 | top: 50% 59 | left: 40% 60 | 61 | .cursor.rotate-2 62 | visibility: visible 63 | top: 80% 64 | left: 20% 65 | 66 | .cursor.rotate-3 67 | visibility: visible 68 | top: 80% 69 | left: 40% 70 | 71 | .mark 72 | position: absolute 73 | top: 100px 74 | left: 100px 75 | width: 10px 76 | height: 10px 77 | background: white 78 | 79 | #sidebar 80 | position: absolute 81 | max-width: 300px 82 | top: 0 83 | left: 0 84 | height: 100% 85 | background: rgba(30,30,30, 0.6) 86 | user-select: none 87 | 88 | #sidebar-body 89 | min-width: 220px 90 | 91 | #toggle-sidebar 92 | cursor: pointer 93 | 94 | #toggle-sidebar:hover 95 | color: grey !important 96 | 97 | .dropdown-menu 98 | background: rgba(30,30,30, 0.8) !important 99 | 100 | #server:not(.connected) .server-connected 101 | display: none 102 | 103 | #server.connected .server-disconnected 104 | display: none 105 | 106 | #center, #name-plate-0, #name-plate-1, #name-plate-2, #name-plate-3 107 | display: none 108 | 109 | #deal 110 | position: relative 111 | 112 | .btn-progress 113 | position: absolute 114 | top: 0 115 | left: 0 116 | bottom: 0 117 | width: 0% 118 | background: #fff2ca 119 | transition: width 600ms 120 | .btn-progress-text 121 | position: relative 122 | 123 | #spectator-ui 124 | font-family: 'Koruri' 125 | color: white 126 | position: absolute 127 | top: 0 128 | bottom: 0 129 | left: 0 130 | right: 0 131 | display: flex 132 | flex-direction: column 133 | user-select: none 134 | padding: 3vmin 4vmin 135 | 136 | .player-display 137 | display: flex 138 | text-align: right 139 | padding: 0 5vmin 140 | align-items: flex-end 141 | padding-right: 7vmin 142 | 143 | .point-display 144 | position: relative 145 | font-size: 3vmin 146 | padding: 0vmin 1.5vmin 147 | 148 | .point-display .actions 149 | display: flex 150 | align-items: flex-end 151 | position: absolute 152 | z-index: 0 153 | bottom: 1vmin 154 | 155 | // @media (max-aspect-ratio: 1/1) 156 | 157 | .point-display .action:first-child 158 | color: black 159 | border-radius: 0.5vmin 160 | width: 6vmin 161 | height: 8vmin 162 | text-align: center 163 | line-height: 8vmin 164 | font-size: 4vmin 165 | 166 | .player-display .riichi .action:first-child 167 | width: 5vmin 168 | height: 7vmin 169 | line-height: 7vmin 170 | margin: 0.4vmin 0vmin 171 | background-color: white 172 | background-clip: padding-box 173 | 174 | .player-display .riichi .action:first-child:before 175 | content: '' 176 | z-index: -1 177 | position: absolute 178 | top: 0 179 | right: 0 180 | bottom: 0 181 | left: 0 182 | margin: -0.4vmin 183 | border-radius: inherit 184 | background: linear-gradient(-45deg, #cbb364 0%, #cbb364 76%, #c81b20 76%, #c81b20 100%) 185 | 186 | .point-display .action 187 | margin-left: 1vmin 188 | 189 | .point-display .action 190 | cursor: pointer 191 | position: relative 192 | color: black 193 | background-color: white 194 | border-radius: 0.5vmin 195 | line-height: 4vmin 196 | text-align: center 197 | width: 4vmin 198 | height: 4vmin 199 | font-size: 2vmin 200 | 201 | .point-display .action:hover 202 | font-weight: bold 203 | 204 | .point-display > .points 205 | flex: 1 206 | 207 | .player-display .dealer-indicator 208 | padding-top: 0.5vmin 209 | border-bottom: 0.5vmin solid transparent 210 | 211 | .player-display .dealer .dealer-indicator 212 | border-bottom-color: #be2011 213 | 214 | .name-display 215 | font-size: 2.5vmin 216 | padding-right: 1vmin 217 | 218 | .name-display.gain, .name-display.loss 219 | font-size: 3vmin 220 | padding-right: 1.5vmin 221 | color: #00dd00 222 | 223 | .name-display.loss 224 | color: #dd0000 225 | 226 | #riichi-notification 227 | align-self: center 228 | display: flex 229 | font-size: 5vmin 230 | 231 | #riichi-notification .player-name 232 | border-top-left-radius: 2vmin 233 | border-bottom-left-radius: 2vmin 234 | padding-left: 5vmin 235 | background-color: #c81b20 236 | 237 | $namePlateColors: ("0": #e98e1a, "1": #b7a581, "2": #da89a0, "3": #40a1ca) 238 | $pointDisplayColorBright: ("0": #c26328, "1": #695737, "2": #ffcfe2, "3": #2995d4) 239 | $pointDisplayColorDark: ("0": #261b18, "1": #1f1f1f, "2": #0d050c, "3": #1a1c1f) 240 | 241 | #riichi-notification .riichi 242 | border-top-right-radius: 2vmin 243 | border-bottom-right-radius: 2vmin 244 | padding: 0vmin 5vmin 245 | background: linear-gradient(-85deg, #cbb364 0%, #cbb364 87%, #c81b20 87%, #c81b20 100%) 246 | 247 | @for $i from 0 to 4 248 | [data-seat="#{$i}"] > .point-display 249 | background-image: linear-gradient(to right, map-get($pointDisplayColorBright, "#{$i}") 50%, map-get($pointDisplayColorDark, "#{$i}") 100%) 250 | 251 | [data-seat="#{$i}"] .action:first-child 252 | background: linear-gradient(-45deg, map-get($namePlateColors, "#{$i}"), map-get($namePlateColors, "#{$i}") 20%, white 20%, white 80%, map-get($namePlateColors, "#{$i}") 80%, map-get($namePlateColors, "#{$i}") 100%) 253 | 254 | #spectator-ui > .header 255 | display: flex 256 | align-items: center 257 | padding-right: 2.5vmin 258 | 259 | .header > .view-change-action 260 | border-radius: 5px 261 | background-color:rgba(200, 200, 200, 0.2) 262 | margin-left: 0.75vmin 263 | font-size: 2vmin 264 | padding: 0.75vmin 265 | cursor: pointer 266 | 267 | .header > .view-change-action:hover 268 | background-color: rgba(200, 200, 200, 0.5) 269 | 270 | .match-status-display 271 | display: flex 272 | align-items: center 273 | padding: 1vmin 274 | background-color: rgba(0, 0, 0, 0.5) 275 | border-radius: 2vmin 276 | 277 | .stick-display 278 | display: flex 279 | flex-direction: column 280 | justify-content: space-around 281 | align-self: stretch 282 | margin-left: 2vmin 283 | margin-right: 1vmin 284 | 285 | .stick-display > * 286 | display: flex 287 | align-items: center 288 | line-height: 1.5vmin 289 | font-size: 1.5vmin 290 | 291 | .stick-display .image 292 | position: relative 293 | width: 6vmin 294 | height: 1vmin 295 | border-radius: 0.2vmin 296 | margin-right: 0.5vmin 297 | 298 | .stick-display .riichi .image 299 | background-color: #006ea7 300 | 301 | .stick-display .image:before 302 | content: '' 303 | position: absolute 304 | top: 0 305 | bottom: 0 306 | left: 0 307 | right: 0 308 | background-image: url("../img/sticks.svg") 309 | background-size: 100% 900% 310 | 311 | .stick-display .riichi .image:before 312 | background-position-y: 37% 313 | 314 | .stick-display .honba .image 315 | background-color: white 316 | 317 | .match-status-display .dora 318 | margin-left: 0.5vmin 319 | background-color: white 320 | position: relative 321 | width: 3.2vmin 322 | height: 4vmin 323 | border-radius: 0.25vmin 324 | 325 | .match-status-display .dora > div 326 | width: 100% 327 | height: 100% 328 | background-image: url("../img/tiles.svg") 329 | background-size: 1000% 800% 330 | 331 | .round-display 332 | font-size: 3vmin 333 | line-height: 3vmin 334 | padding: 0.2vmin 1vmin 335 | border-radius: 1vmin 336 | border: 0.5vmin solid white 337 | 338 | .player-display 339 | > div 340 | margin-left: 2vmin 341 | flex: 1 342 | 343 | > .push 344 | order: -1 345 | 346 | .seat-buttons 347 | position: absolute 348 | top: 0 349 | bottom: 0 350 | left: 0 351 | right: 0 352 | background-color: rgba(30, 30, 30, 0.4) 353 | display: block 354 | .seat-button 355 | position: absolute 356 | .seat-button-0 357 | bottom: 20px 358 | left: 50% 359 | transform: translate(-50%, 0) 360 | .seat-button-1 361 | top: 50% 362 | right: 20% 363 | transform: translate(0, -50%) 364 | .seat-button-2 365 | top: 20px 366 | left: 50% 367 | transform: translate(-50%, 0) 368 | .seat-button-3 369 | top: 50% 370 | left: 20% 371 | transform: translate(0, -50%) 372 | 373 | /* https://fontlibrary.org/en/font/segment7 */ 374 | @font-face 375 | font-family: 'Segment7Standard' 376 | src: url('../img/Segment7Standard.otf') format('opentype') 377 | font-weight: normal 378 | font-style: italic 379 | 380 | @font-face 381 | font-family: 'Koruri' 382 | src: url('//cdn.plusminus.io/font/webkoruri/20140628/WebKoruri.eot') 383 | src: url('//cdn.plusminus.io/font/webkoruri/20140628/WebKoruri.eot?#iefix') format('embedded-opentype') 384 | src: url('//cdn.plusminus.io/font/webkoruri/20140628/WebKoruri.woff') format('woff'), url('//cdn.plusminus.io/font/webkoruri/20140628/WebKoruri.ttf') format('truetype') 385 | 386 | /* for fuck'n chrome */ 387 | // src: url(//cdn.plusminus.io/font/webkoruri/20140628/WebKoruri.woff) format('woff') 388 | 389 | .dark-select 390 | background-color: #343a40 !important 391 | border-color: #343a40 !important 392 | color: #fff !important 393 | .dark-select:focus 394 | box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5) !important 395 | 396 | /* Override system setting */ 397 | .collapsing 398 | transition: height 0.2s ease !important 399 | -------------------------------------------------------------------------------- /src/setup-slots.ts: -------------------------------------------------------------------------------- 1 | import { Vector3, Vector2, Euler } from "three"; 2 | import { Slot } from "./slot"; 3 | import { TileThingGroup } from "./thing-group"; 4 | import { Size, ThingType, GameType } from "./types"; 5 | 6 | const WORLD_SIZE = 174; 7 | 8 | export const Rotation = { 9 | FACE_UP: new Euler(0, 0, 0), 10 | FACE_UP_SIDEWAYS: new Euler(0, 0, Math.PI / 2), 11 | STANDING: new Euler(Math.PI / 2, 0, 0), 12 | FACE_DOWN: new Euler(Math.PI, 0, 0), 13 | FACE_DOWN_SIDEWAYS: new Euler(Math.PI, 0, Math.PI / 2), 14 | FACE_DOWN_REVERSE: new Euler(Math.PI, 0, Math.PI), 15 | }; 16 | 17 | type SlotOp = (slots: Array) => Array; 18 | type SlotGroup = Array; 19 | 20 | export function makeSlots(gameType: GameType): Array { 21 | const slots = []; 22 | 23 | for (const group of SLOT_GROUPS[gameType]) { 24 | let current: Array = []; 25 | for (const op of group) { 26 | current = op(current); 27 | } 28 | slots.push(...current); 29 | } 30 | 31 | fixupSlots(slots, gameType); 32 | return slots; 33 | } 34 | 35 | function start(name: string): SlotOp { 36 | return _ => [START[name]]; 37 | } 38 | 39 | interface RepeatOptions { 40 | stack?: boolean; 41 | shift?: boolean; 42 | push?: boolean; 43 | } 44 | 45 | function repeat(count: number, offset: Vector3, options: RepeatOptions = {}): SlotOp { 46 | return (slots: Array) => { 47 | const result: Array = []; 48 | for (const slot of slots) { 49 | const totalOffset = new Vector3(0, 0, 0); 50 | for (let i = 0; i < count; i++) { 51 | const copied = slot.copy(`.${i}`); 52 | copied.origin = copied.origin.clone().add(totalOffset); 53 | copied.indexes.push(i); 54 | result.push(copied); 55 | totalOffset.add(offset); 56 | 57 | if (i < count - 1) { 58 | const next = `${slot.name}.${i+1}`; 59 | if (options.stack) copied.linkDesc.up = next; 60 | if (options.shift) copied.linkDesc.shiftRight = next; 61 | if (options.push) copied.linkDesc.push = next; 62 | } 63 | 64 | if (i > 0) { 65 | const prev = `${slot.name}.${i-1}`; 66 | if (options.stack) copied.linkDesc.down = prev; 67 | if (options.shift) copied.linkDesc.shiftLeft = prev; 68 | } 69 | } 70 | } 71 | return result; 72 | }; 73 | } 74 | 75 | function row(count: number, dx?: number, options: RepeatOptions = {}): SlotOp { 76 | return repeat(count, new Vector3(dx ?? Size.TILE.x, 0, 0), options); 77 | } 78 | 79 | function column(count: number, dy?: number): SlotOp { 80 | return repeat(count, new Vector3(0, dy ?? Size.TILE.y, 0)); 81 | } 82 | 83 | function stack(count?: number, dz?: number): SlotOp { 84 | return repeat(count ?? 2, new Vector3(0, 0, dz ?? Size.TILE.z), {stack: true}); 85 | } 86 | 87 | function seats(which?: Array): SlotOp { 88 | const seats = which ?? [0, 1, 2, 3]; 89 | return (slots: Array) => { 90 | const result: Array = []; 91 | for (const seat of seats) { 92 | for (const slot of slots) { 93 | const copied = slot.copy(`@${seat}`); 94 | copied.rotate(seat, WORLD_SIZE); 95 | result.push(copied); 96 | } 97 | } 98 | return result; 99 | }; 100 | } 101 | 102 | const START: Record = { 103 | 'hand': new Slot({ 104 | name: 'hand', 105 | group: 'hand', 106 | origin: new Vector3(46, 0, 0), 107 | rotations: [Rotation.STANDING, Rotation.FACE_UP, Rotation.FACE_DOWN], 108 | canFlipMultiple: true, 109 | drawShadow: true, 110 | shadowRotation: 1, 111 | rotateHeld: true, 112 | }), 113 | 114 | 'hand.3p': new Slot({ 115 | name: 'hand', 116 | group: 'hand', 117 | origin: new Vector3(37, 0, 0), 118 | rotations: [Rotation.STANDING, Rotation.FACE_UP, Rotation.FACE_DOWN], 119 | canFlipMultiple: true, 120 | drawShadow: true, 121 | shadowRotation: 1, 122 | rotateHeld: true, 123 | }), 124 | 125 | 'hand.extra': new Slot({ 126 | name: `hand.extra`, 127 | group: `hand`, 128 | origin: new Vector3( 129 | 46 + 14.5*Size.TILE.x, 130 | 0, 131 | 0, 132 | ), 133 | rotations: [Rotation.STANDING, Rotation.FACE_UP, Rotation.FACE_DOWN], 134 | canFlipMultiple: true, 135 | rotateHeld: true, 136 | }), 137 | 138 | 'meld': new Slot({ 139 | name: `meld`, 140 | group: `meld`, 141 | origin: new Vector3(174 + Size.TILE.x, 0, 0), 142 | direction: new Vector2(-1, 1), 143 | rotations: [Rotation.FACE_UP, Rotation.FACE_UP_SIDEWAYS, Rotation.FACE_DOWN], 144 | }), 145 | 146 | 'kita': new Slot({ 147 | name: `kita`, 148 | group: `kita`, 149 | origin: new Vector3(146, 0, 0), 150 | direction: new Vector2(-1, 1), 151 | rotations: [Rotation.FACE_UP], 152 | }), 153 | 154 | 'wall': new Slot({ 155 | name: 'wall', 156 | group: 'wall', 157 | origin: new Vector3(30, 20, 0), 158 | rotations: [Rotation.FACE_DOWN, Rotation.FACE_UP], 159 | }), 160 | 161 | 'wall.open': new Slot({ 162 | name: 'wall.open', 163 | group: 'wall.open', 164 | origin: new Vector3(36, 14, 0), 165 | rotations: [Rotation.STANDING, Rotation.FACE_DOWN], 166 | canFlipMultiple: true, 167 | }), 168 | 169 | 'discard': new Slot({ 170 | name: `discard`, 171 | group: `discard`, 172 | origin: new Vector3(69, 60, 0), 173 | direction: new Vector2(1, 1), 174 | rotations: [Rotation.FACE_UP, Rotation.FACE_UP_SIDEWAYS, Rotation.FACE_DOWN, Rotation.FACE_DOWN_SIDEWAYS], 175 | drawShadow: true, 176 | }), 177 | 178 | 'discard.extra': new Slot({ 179 | name: `discard.extra`, 180 | group: `discard`, 181 | origin: new Vector3(69 + 6 * Size.TILE.x, 60 - 2 * Size.TILE.y, 0), 182 | direction: new Vector2(1, 1), 183 | rotations: [Rotation.FACE_UP, Rotation.FACE_UP_SIDEWAYS, Rotation.FACE_DOWN, Rotation.FACE_DOWN_SIDEWAYS], 184 | }), 185 | 186 | 'discard.washizu': new Slot({ 187 | name: `discard`, 188 | group: `discard`, 189 | origin: new Vector3(69, 60, 0), 190 | direction: new Vector2(1, 1), 191 | rotations: [Rotation.FACE_UP, Rotation.FACE_UP_SIDEWAYS], 192 | drawShadow: true, 193 | }), 194 | 195 | 'discard.washizu.extra': new Slot({ 196 | name: `discard.extra`, 197 | group: `discard`, 198 | origin: new Vector3(69 + 6 * Size.TILE.x, 60 - 2 * Size.TILE.y, 0), 199 | direction: new Vector2(1, 1), 200 | rotations: [Rotation.FACE_UP, Rotation.FACE_UP_SIDEWAYS], 201 | }), 202 | 203 | 'washizu.bag': new Slot({ 204 | name: 'washizu.bag', 205 | group: 'washizu.bag', 206 | type: ThingType.TILE, 207 | origin: new Vector3().subVectors( 208 | new Vector3( 209 | WORLD_SIZE, WORLD_SIZE, 0, 210 | ), 211 | Size.TILE).divideScalar(2).add(new Vector3(0, 0, -135 * Size.TILE.z)), 212 | rotations: [Rotation.FACE_DOWN], 213 | phantom: true, 214 | }), 215 | 216 | 'tray': new Slot({ 217 | name: `tray`, 218 | group: `tray`, 219 | type: ThingType.STICK, 220 | origin: new Vector3(15, -41, 0), 221 | rotations: [Rotation.FACE_UP], 222 | }), 223 | 224 | 'payment': new Slot({ 225 | name: 'payment', 226 | group: 'payment', 227 | type: ThingType.STICK, 228 | origin: new Vector3(42, 42, 0), 229 | rotations: [Rotation.FACE_UP_SIDEWAYS], 230 | }), 231 | 232 | 'riichi': new Slot({ 233 | name: 'riichi', 234 | group: 'riichi', 235 | type: ThingType.STICK, 236 | origin: new Vector3( 237 | (WORLD_SIZE - Size.STICK.x) / 2, 238 | 71.5, 239 | 1.5, 240 | ), 241 | rotations: [Rotation.FACE_UP], 242 | }), 243 | 244 | 'marker': new Slot({ 245 | name: 'marker', 246 | group: 'marker', 247 | type: ThingType.MARKER, 248 | origin: new Vector3( 249 | 166, -8, 0, 250 | ), 251 | rotations: [Rotation.FACE_DOWN_REVERSE, Rotation.FACE_UP], 252 | }), 253 | }; 254 | 255 | export const SLOT_GROUPS: Record> = { 256 | FOUR_PLAYER: [ 257 | [start('hand'), row(14, undefined, {shift: true}), seats()], 258 | [start('hand.extra'), seats()], 259 | [start('meld'), column(4), row(4, -Size.TILE.x, {push: true, shift: true}), seats()], 260 | [start('wall'), row(19), stack(), seats()], 261 | [start('discard'), column(3, -Size.TILE.y), row(6, undefined, {push: true}), seats()], 262 | [start('discard.extra'), row(4, undefined, {push: true}), seats()], 263 | 264 | [start('tray'), row(6, 24), column(10, -3), seats()], 265 | [start('payment'), row(8, 3), seats()], 266 | [start('riichi'), seats()], 267 | [start('marker'), seats()], 268 | ], 269 | 270 | THREE_PLAYER: [ 271 | [start('hand.3p'), row(14, undefined, {shift: true}), seats([0, 1, 2])], 272 | [start('meld'), column(4), row(4, -Size.TILE.x, {push: true, shift: true}), seats([0, 1, 2])], 273 | [start('kita'), row(4, -Size.TILE.x, {shift: true}), seats([0, 1, 2])], 274 | [start('wall'), row(19), stack(), seats()], 275 | [start('discard'), column(3, -Size.TILE.y), row(6, undefined, {push: true}), seats([0, 1, 2])], 276 | [start('discard.extra'), row(4, undefined, {push: true}), seats([0, 1, 2])], 277 | 278 | [start('tray'), row(6, 24), column(10, -3), seats([0, 1, 2])], 279 | [start('payment'), row(8, 3), seats()], 280 | [start('riichi'), seats([0, 1, 2])], 281 | [start('marker'), seats([0, 1, 2])], 282 | ], 283 | 284 | BAMBOO: [ 285 | [start('hand'), row(14, undefined, {shift: true}), seats([0, 2])], 286 | [start('hand.extra'), seats([0, 2])], 287 | [start('meld'), column(4), row(4, -Size.TILE.x, {push: true, shift: true}), seats([0, 2])], 288 | [start('wall'), row(19), stack(), seats([0, 2])], 289 | [start('discard'), column(3, -Size.TILE.y), row(6, undefined, {push: true}), seats([0, 2])], 290 | 291 | [start('tray'), row(6, 24), column(10, -3), seats([0, 2])], 292 | [start('payment'), row(8, 3), seats()], 293 | [start('riichi'), seats([0, 2])], 294 | [start('marker'), seats([0, 2])], 295 | ], 296 | 297 | MINEFIELD: [ 298 | [start('hand'), row(13, undefined, {shift: true}), seats([0, 2])], 299 | [start('wall'), row(19), stack(), seats([1, 3])], 300 | [start('wall.open'), column(2, Size.TILE.y * 1.6), row(17, undefined, {shift: true}), seats([0, 2])], 301 | [start('discard'), column(3, -Size.TILE.y), row(6, undefined, {push: true}), seats([0, 2])], 302 | 303 | [start('tray'), row(6, 24), column(10, -3), seats([0, 2])], 304 | [start('payment'), row(8, 3), seats()], 305 | [start('riichi'), seats([0, 2])], 306 | [start('marker'), seats([0, 2])], 307 | ], 308 | 309 | WASHIZU: [ 310 | [start('hand'), row(14, undefined, {shift: true}), seats()], 311 | [start('hand.extra'), seats()], 312 | [start('meld'), column(4), row(4, -Size.TILE.x, {push: true, shift: true}), seats()], 313 | [start('wall'), row(19), stack(), seats()], 314 | [start('washizu.bag'), stack(136), seats([0])], 315 | [start('discard.washizu'), column(3, -Size.TILE.y), row(6, undefined, {push: true}), seats()], 316 | [start('discard.washizu.extra'), row(4, undefined, {push: true}), seats()], 317 | 318 | [start('tray'), row(6, 24), column(10, -3), seats()], 319 | [start('payment'), row(8, 3), seats()], 320 | [start('riichi'), seats()], 321 | [start('marker'), seats()], 322 | ], 323 | }; 324 | 325 | function fixupSlots(slots: Array, gameType: GameType): void { 326 | for (const slot of slots) { 327 | if (slot.name.startsWith('discard.extra')) { 328 | slot.linkDesc.requires = `discard.2.5@${slot.seat}`; 329 | } 330 | if (slot.name.startsWith('discard.2.5')) { 331 | slot.linkDesc.push = `discard.extra.0@${slot.seat}`; 332 | } 333 | if (slot.group === 'wall' && 334 | slot.indexes[0] !== 0 && slot.indexes[0] !== 18 && slot.indexes[1] === 0) { 335 | slot.drawShadow = true; 336 | } 337 | if (slot.group === 'meld' && slot.indexes[0] > 0) { 338 | slot.linkDesc.requires = `meld.${slot.indexes[0]-1}.1@${slot.seat}`; 339 | } 340 | if (slot.group === 'wall.open' && slot.indexes[0] === 1 && slot.indexes[1] === 16) { 341 | slot.linkDesc.shiftRight = `wall.open.0.0@${slot.seat}`; 342 | } 343 | if (slot.group === 'wall.open' && slot.indexes[0] === 0 && slot.indexes[1] === 0) { 344 | slot.linkDesc.shiftLeft = `wall.open.1.16@${slot.seat}`; 345 | } 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /src/spectator-overlay.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "./client"; 2 | import { World } from "./world"; 3 | import { ThingType } from './types'; 4 | import { setVisibility } from './game-ui'; 5 | 6 | function numberWithCommas(x: number, addSign?: boolean): string { 7 | let number = x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); 8 | if (addSign){ 9 | if (x > 0) { 10 | number = "+" + number; 11 | } 12 | } 13 | return number; 14 | } 15 | 16 | export class SpectatorOverlay { 17 | private isEnabled: boolean = false; 18 | 19 | private readonly riichiNotifications: Array = []; 20 | private riichiNotificationTimeout: NodeJS.Timeout | null = null; 21 | private readonly nicks: Array = []; 22 | 23 | private readonly spectatorOverlay: HTMLDivElement; 24 | 25 | private readonly roundDisplay: HTMLDivElement; 26 | private readonly remainingSticksDisplay: HTMLDivElement; 27 | private readonly honbaDisplay: HTMLDivElement; 28 | private readonly matchStatusDisplay: HTMLDivElement; 29 | private readonly riichiNotification: HTMLDivElement; 30 | 31 | private readonly playerDisplays: Array = []; 32 | private readonly playerNames: Array = []; 33 | private readonly playerScores: Array = []; 34 | 35 | constructor(private readonly client: Client, private readonly world: World) { 36 | this.spectatorOverlay = document.getElementById('spectator-ui') as HTMLDivElement; 37 | 38 | this.roundDisplay = document.getElementById('round-display') as HTMLDivElement; 39 | this.remainingSticksDisplay = document.getElementById('remaining-sticks-display') as HTMLDivElement; 40 | this.honbaDisplay = document.getElementById('honba-display') as HTMLDivElement; 41 | this.matchStatusDisplay = document.getElementById('match-status-display') as HTMLDivElement; 42 | 43 | this.riichiNotification = document.getElementById('riichi-notification') as HTMLDivElement; 44 | 45 | for (let i = 0; i < 4; i++) { 46 | this.playerDisplays.push(document.querySelector(`.player-display [data-seat='${i}']`) as HTMLDivElement); 47 | this.playerNames.push(document.querySelector(`.player-display [data-seat='${i}'] .name-display`) as HTMLDivElement); 48 | this.playerScores.push(document.querySelector(`.player-display [data-seat='${i}'] .points`) as HTMLDivElement); 49 | } 50 | 51 | this.client.nicks.on('update', () => { 52 | this.updateNicks(); 53 | }); 54 | 55 | this.client.seats.on('update', () => { 56 | this.updateNicks(); 57 | }); 58 | 59 | this.client.match.on('update', () => { 60 | this.updateHonba(); 61 | this.updateRound(); 62 | this.updateDealer(); 63 | }); 64 | 65 | this.client.things.on('update', (entries, isFull) => { 66 | let updateScores = isFull; 67 | 68 | for (const [key, info] of entries) { 69 | if (!info) { 70 | return; 71 | } 72 | 73 | const thing = this.world.things.get(key); 74 | if (!thing) { 75 | continue; 76 | } 77 | 78 | if (thing.type === ThingType.MARKER) { 79 | const seat = parseInt(info.slotName.substring(info.slotName.indexOf('@') + 1)); 80 | this.updateRound(info.rotationIndex, seat); 81 | this.updateSeatings(seat); 82 | continue; 83 | } 84 | 85 | if (info?.slotName.startsWith("wall") && info.rotationIndex === 1) { 86 | const indicator = this.matchStatusDisplay.querySelector(`[data-dora-id='${key}']`); 87 | if (indicator) { 88 | continue; 89 | } 90 | const index = thing.getTypeIndexNoFlags(); 91 | let x = index % 9; 92 | const y = index % 40 / 9 | 0; 93 | if (y < 3) { 94 | x = (x + 1) % 9; 95 | } else if (x < 4) { 96 | x = (x + 1) % 4; 97 | } else { 98 | x = 4 + (x - 3) % 3; 99 | } 100 | this.matchStatusDisplay.insertAdjacentHTML("beforeend", ` 101 |
102 |
103 |
104 | `); 105 | } else if (thing.slot.name.startsWith("wall")) { 106 | const indicator = this.matchStatusDisplay.querySelector(`[data-dora-id='${key}']`); 107 | if (indicator) { 108 | indicator.remove(); 109 | continue; 110 | } 111 | } 112 | 113 | if (thing.type === ThingType.STICK) { 114 | if (!updateScores) { 115 | const seat = parseInt(info.slotName.substring(info.slotName.indexOf('@') + 1)); 116 | if (seat !== thing.slot.seat || thing.slot.name.substring(3) !== info.slotName?.substring(3)) { 117 | updateScores = true; 118 | } 119 | } 120 | 121 | if (thing.slot.name.startsWith("riichi") !== info?.slotName?.startsWith("riichi")) { 122 | const isRemoved = thing.slot.name.startsWith("riichi"); 123 | const slotName = isRemoved ? thing.slot.name : info.slotName; 124 | const seat = parseInt(slotName.substring(slotName.indexOf('@') + 1)); 125 | if (isRemoved) { 126 | this.playerDisplays[seat].classList.remove("riichi"); 127 | } else { 128 | this.playerDisplays[seat].classList.add("riichi"); 129 | if (!isFull) { 130 | this.showRiichiNotification(seat); 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | if (updateScores) { 138 | this.updateScores(isFull); 139 | } 140 | }); 141 | 142 | setVisibility(this.spectatorOverlay, this.isEnabled); 143 | setVisibility(this.riichiNotification, false); 144 | 145 | this.updateScores(true); 146 | this.updateNicks(); 147 | } 148 | 149 | private readonly scores: Array = []; 150 | private readonly scoreChanges: Array = []; 151 | private scoreUpdateTimeout: NodeJS.Timeout | null = null; 152 | private isAnimatingScore: boolean = false; 153 | 154 | private updateScores(skipAnimation: boolean): void { 155 | setTimeout(() => { 156 | this.updateRemainingSticks(); 157 | 158 | if (this.isAnimatingScore) { 159 | return; 160 | } 161 | 162 | const scores = this.world.setup.getScores().seats; 163 | for (let i = 0; i < 4; i++) { 164 | if (scores[i] === null) { 165 | continue; 166 | } 167 | 168 | const change = scores[i]! - (this.scores[i] ?? 0) - (this.scoreChanges[i] ?? 0); 169 | if (skipAnimation) { 170 | this.scores[i] = (this.scores[i] ?? 0) + change; 171 | this.playerScores[i].innerText = numberWithCommas(this.scores[i]!); 172 | } else { 173 | this.scoreChanges[i] = (this.scoreChanges[i] ?? 0) + change; 174 | } 175 | } 176 | 177 | if (this.scoreChanges.findIndex(s => s) === -1) { 178 | return; 179 | } 180 | 181 | const sticksLeft = [...this.world.slots.values()].find(s => s.name.startsWith("payment") && s.thing !== null); 182 | 183 | if (this.scoreUpdateTimeout !== null) { 184 | clearTimeout(this.scoreUpdateTimeout); 185 | } 186 | 187 | this.scoreUpdateTimeout = setTimeout(() => { 188 | this.scoreUpdateTimeout = null; 189 | this.isAnimatingScore = true; 190 | const changes = [...this.scoreChanges]; 191 | for (let i = 0; i < 4; i++) { 192 | if (this.scoreChanges[i] !== null) { 193 | this.scoreChanges[i] = 0; 194 | } 195 | 196 | if (!changes[i]) { 197 | continue; 198 | } 199 | 200 | if (changes[i]! > 0) { 201 | this.playerNames[i].classList.add("gain"); 202 | } else { 203 | this.playerNames[i].classList.add("loss"); 204 | } 205 | 206 | this.playerNames[i].innerText = numberWithCommas(changes[i]!, true); 207 | } 208 | 209 | setTimeout(() => { 210 | this.animateScoreChange(changes, Date.now()); 211 | }, 2000); 212 | }, sticksLeft ? 30000 : 10000); 213 | }, 0); 214 | } 215 | 216 | private animateScoreChange(changes: Array, startTime: number): void { 217 | const now = Date.now(); 218 | const elapsedTime = now - startTime; 219 | let done = true; 220 | for (let i = 0; i < 4; i++) { 221 | if (changes[i] === null || this.scores[i] === null) { 222 | continue; 223 | } 224 | done = false; 225 | 226 | const change = changes[i]! > 0 ? Math.min(changes[i]!, elapsedTime * 10) : Math.max(changes[i]!, -elapsedTime * 10); 227 | changes[i]! -= change; 228 | this.scores[i] = this.scores[i]! + change; 229 | this.playerNames[i].innerText = numberWithCommas(changes[i]!, true); 230 | this.playerScores[i].innerText = numberWithCommas(this.scores[i] ?? 0); 231 | 232 | if (changes[i] === 0) { 233 | this.playerNames[i].innerText = this.nicks[i]; 234 | this.playerNames[i].classList.remove("loss"); 235 | this.playerNames[i].classList.remove("gain"); 236 | changes[i] = null; 237 | continue; 238 | } 239 | } 240 | 241 | if (!done) { 242 | setTimeout(() => { 243 | this.animateScoreChange(changes, now); 244 | }, 10); 245 | return; 246 | } 247 | 248 | this.isAnimatingScore = false; 249 | this.updateScores(false); 250 | } 251 | 252 | private updateRemainingSticks(): void { 253 | this.remainingSticksDisplay.innerText = ( 254 | (this.world.setup.getScores().remaining 255 | - 1000 * [...this.world.things.values()].filter(t => t.slot.name.startsWith("riichi")).length 256 | ) 257 | / 1000 | 0 258 | ).toString(); 259 | } 260 | 261 | private showRiichiNotification(seat: number): void { 262 | this.riichiNotifications.push(seat); 263 | 264 | if (this.riichiNotificationTimeout !== null) { 265 | return; 266 | } 267 | 268 | this.riichiNotificationTimeout = setTimeout(() => { 269 | this.processRiichiNotification(); 270 | }, 0); 271 | } 272 | 273 | private processRiichiNotification(): void { 274 | this.riichiNotificationTimeout = null; 275 | const notification = this.riichiNotifications.shift(); 276 | if (notification === undefined) { 277 | return; 278 | } 279 | 280 | let delay = 3000; 281 | const playerName = this.riichiNotification.querySelector(".player-name") as HTMLDivElement; 282 | playerName.innerText = this.nicks[notification]; 283 | if (playerName.innerText?.length !== 0) { 284 | setVisibility(this.riichiNotification, true); 285 | } else { 286 | delay = 0; 287 | } 288 | 289 | this.riichiNotificationTimeout = setTimeout(() => { 290 | setVisibility(this.riichiNotification, false); 291 | this.processRiichiNotification(); 292 | }, delay); 293 | } 294 | 295 | private updateNicks(): void { 296 | for (let i = 0; i < 4; i++) { 297 | const playerId = this.client.seatPlayers[i]; 298 | let nick = playerId !== null ? this.client.nicks.get(playerId) : null; 299 | if (nick === null) { 300 | nick = ''; 301 | } else if (nick === '') { 302 | nick = 'Jyanshi'; 303 | } 304 | 305 | nick = nick.substr(0, 14); 306 | 307 | if (this.nicks[i] === nick) { 308 | continue; 309 | } 310 | 311 | this.nicks[i] = nick; 312 | 313 | if (!this.isAnimatingScore) { 314 | this.playerNames[i].classList.remove("gain"); 315 | this.playerNames[i].classList.remove("loss"); 316 | this.playerNames[i].innerText = this.nicks[i]; 317 | } 318 | } 319 | } 320 | 321 | private updateHonba(): void { 322 | this.honbaDisplay.innerText = (this.client.match.get(0)?.honba ?? 0).toString(); 323 | } 324 | 325 | private updateSeatings(seat?: number): void { 326 | const marker = seat ?? [...this.world.things.values()].find(t => t.type === ThingType.MARKER)?.slot.seat; 327 | for (let i = 0; i < 4; i++) { 328 | this.playerDisplays[i].classList.remove("push"); 329 | if (i >= marker!) { 330 | this.playerDisplays[i].classList.add("push"); 331 | } 332 | } 333 | } 334 | 335 | private updateDealer(): void { 336 | const dealer = this.client.match.get(0)?.dealer ?? null; 337 | for (let i = 0; i < 4; i++) { 338 | this.playerDisplays[i].classList.remove("dealer"); 339 | if (dealer === i) { 340 | this.playerDisplays[i].classList.add("dealer"); 341 | } 342 | } 343 | } 344 | 345 | private updateRound(rotation?: number, seat?: number): void { 346 | const marker = [...this.world.things.values()].find(t => t.type === ThingType.MARKER); 347 | if (!marker) { 348 | return; 349 | } 350 | 351 | if (rotation === undefined) { 352 | rotation = marker.rotationIndex; 353 | } 354 | 355 | if (seat === undefined) { 356 | seat = marker.slot.seat!; 357 | } 358 | 359 | this.roundDisplay.innerText = rotation === 0 ? "東" : "南"; 360 | const dealer = this.client.match.get(0)?.dealer ?? 0; 361 | this.roundDisplay.innerText += `${((4 + dealer - seat) % 4) + 1}局`; 362 | } 363 | 364 | setEnabled(isEnabled: boolean): void { 365 | this.isEnabled = isEnabled; 366 | setVisibility(this.spectatorOverlay, this.isEnabled); 367 | } 368 | } 369 | --------------------------------------------------------------------------------