Compare commits

..

77 commits

Author SHA1 Message Date
Zutatensuppe
68a267bd70 only watch build dir 2021-10-10 12:09:50 +02:00
Zutatensuppe
bf4897bf83 fix type hint 2021-07-16 00:05:50 +02:00
Zutatensuppe
b4980e367c fix wording 2021-07-15 23:59:25 +02:00
Zutatensuppe
e7f86b5ef8 cleanup 2021-07-15 23:10:27 +02:00
Zutatensuppe
4e528cc83d store games in db 2021-07-12 01:28:14 +02:00
Zutatensuppe
126384e5bd upper case first letter of title 2021-07-11 22:56:52 +02:00
Zutatensuppe
e5fb49ecb1 build 2021-07-11 21:14:25 +02:00
Zutatensuppe
c11229a5e5 fix info overlay 2021-07-11 21:11:13 +02:00
Zutatensuppe
1008106355 build 2021-07-11 18:49:38 +02:00
Zutatensuppe
65daeb0247 only let uploader edit image 2021-07-11 18:44:59 +02:00
Zutatensuppe
d2d5968d02 send second id (client secret) as well with request 2021-07-11 17:56:04 +02:00
Zutatensuppe
8f31a669d5 send client id header with every request initiated from frontend to backend 2021-07-11 17:48:49 +02:00
Zutatensuppe
e7628895c9 sort finished games by finish date 2021-07-11 17:21:41 +02:00
Zutatensuppe
bbcfd42008 extract event adapter to own file 2021-07-11 17:08:18 +02:00
Zutatensuppe
518092d269 info overlay + script to update images in games and logs 2021-07-11 16:37:34 +02:00
Zutatensuppe
0cb1cec210 type hints 2021-07-09 01:19:35 +02:00
Zutatensuppe
2fb0e959ae add info layer that shows info about current puzzle 2021-07-09 01:17:26 +02:00
Zutatensuppe
7759cdc806 show message while cutting puzzle 2021-07-08 00:25:12 +02:00
Zutatensuppe
2b0dc392da fix older replays 2021-07-08 00:00:17 +02:00
Zutatensuppe
d009f84156 add puzzle center/fit 2021-07-07 22:39:32 +02:00
Zutatensuppe
a406d8abe8 add option to toggle player names 2021-07-07 20:54:34 +02:00
Zutatensuppe
b44ccbf819 rename some labels 2021-07-07 09:58:06 +02:00
Zutatensuppe
b43d45ecc6 remove 'skip non action phases' toggle 2021-07-06 23:57:07 +02:00
Zutatensuppe
ac0116fc52 fix size / responsiveness of image dialogs 2021-07-04 18:38:55 +02:00
Zutatensuppe
9c0ceb685e show image dimensions when creating game 2021-06-20 14:21:48 +02:00
Zutatensuppe
b8673e6a40 add built file 2021-06-20 14:08:08 +02:00
Zutatensuppe
22933ad6b9 add image sizes to db 2021-06-20 14:07:35 +02:00
Zutatensuppe
b8c193b5dc add script to import image sizes of existing images 2021-06-20 14:07:14 +02:00
Zutatensuppe
47381da36f ts by default can use 256m ram 2021-06-20 14:06:58 +02:00
Zutatensuppe
b410f400fa show upload status when uploading images 2021-06-20 13:40:28 +02:00
Zutatensuppe
4b10fbc01b log fix 2021-06-09 09:52:58 +02:00
Zutatensuppe
accd38eb02 make game and replay vue more similar 2021-06-07 00:48:22 +02:00
Zutatensuppe
59d0d0dc2a fix issues with player bgcolor/color and possibly name in replay 2021-06-07 00:41:01 +02:00
Zutatensuppe
10d56e2898 dont change bg colors in replay 2021-06-07 00:32:05 +02:00
Zutatensuppe
ff69a5e195 use ev.code instead of ev.key where possible to support different keyboard layouts 2021-06-06 17:28:37 +02:00
Zutatensuppe
0882d3befd add volume setting 2021-06-06 17:05:10 +02:00
Zutatensuppe
d9ab766e18 fix click to upload 2021-06-06 16:12:20 +02:00
Zutatensuppe
19301cfc81 fix log time 2021-06-06 08:57:42 +02:00
Zutatensuppe
cdb02da14d remove readline and stream import 2021-06-05 23:42:59 +02:00
Zutatensuppe
86ceca4bea max old space when running ts 2021-06-05 23:11:55 +02:00
Zutatensuppe
60ae6e8a08 smaller logs 2021-06-05 23:02:04 +02:00
Zutatensuppe
849d39dac2 fix finalizing in 'real' snapmode, fix reading newly created replays 2021-06-05 18:01:24 +02:00
Zutatensuppe
3447681f10 add hotkeys for replay speed up/down pause 2021-06-05 17:45:55 +02:00
Zutatensuppe
514b3c6b22 split logs so that replay works for long games 2021-06-05 17:13:17 +02:00
Zutatensuppe
22f5ce0065 prepare skipping of non action phases in replay 2021-06-05 13:04:22 +02:00
Zutatensuppe
902f8e51e9 fix styles 2021-06-05 11:08:06 +02:00
Zutatensuppe
47589c0000 show title of image when creating game 2021-06-05 10:27:50 +02:00
Zutatensuppe
82ce1d7b82 style improvements 2021-06-05 09:59:59 +02:00
Zutatensuppe
3ce631d722 allow upload image by drag/drop 2021-06-05 09:54:01 +02:00
Zutatensuppe
95a06972c7 fix replay after opening a game 2021-06-05 08:58:20 +02:00
Zutatensuppe
98acc0dcf6 remove console.log 2021-06-05 07:35:15 +02:00
Zutatensuppe
2a12900614 add other snap mode 2021-06-04 09:26:37 +02:00
Zutatensuppe
42aaf10679 click a bit louder 2021-06-04 07:07:50 +02:00
Zutatensuppe
4133697fd8 lower volume of click 2021-06-03 23:55:39 +02:00
Zutatensuppe
ef547e9825 add join to images 2021-06-03 23:49:03 +02:00
Zutatensuppe
22585b92fe show number of tag usage 2021-06-03 23:46:09 +02:00
Zutatensuppe
38dc23d57b built files 2021-06-03 23:30:40 +02:00
Zutatensuppe
df7584f19d add cheap autocomplete for tags 2021-06-03 23:30:08 +02:00
Zutatensuppe
f303a78631 upper case 2021-06-03 09:32:39 +02:00
Zutatensuppe
2d83fd441f add shape modes any and flat 2021-06-03 09:07:57 +02:00
Zutatensuppe
b88321bb1b fix tags for new images 2021-06-03 00:01:42 +02:00
Zutatensuppe
57a1e9f24d fix tag display 2021-06-02 23:35:01 +02:00
Zutatensuppe
b7aecbb933 built 2021-05-31 23:09:03 +02:00
Zutatensuppe
69a949381f zoom to mouse even when using q/e 2021-05-31 23:06:19 +02:00
Zutatensuppe
870f827e49 log only 5 min after game end + some type hinting :P 2021-05-31 20:05:41 +02:00
Zutatensuppe
c2da0759b9 remove obsolete comment 2021-05-30 23:37:16 +02:00
Zutatensuppe
21d7db5677 sound when pieces connect 2021-05-29 23:14:19 +02:00
Zutatensuppe
472113ad74 fix possible lost messages (OOS) 2021-05-29 18:57:22 +02:00
Zutatensuppe
c6f47c9b25 fix bad import 2021-05-29 18:56:43 +02:00
Zutatensuppe
d4f02c10df add linting, do more type hinting 2021-05-29 17:58:05 +02:00
Zutatensuppe
46f3fc7480 type hints galore! 2021-05-29 15:36:03 +02:00
Zutatensuppe
7b1f270587 server restart only on js file changes 2021-05-29 14:11:40 +02:00
Zutatensuppe
e803945d23 add compression for stuff served via express 2021-05-29 13:08:42 +02:00
Zutatensuppe
ede95ff16c some checks on the replay data request params 2021-05-29 12:49:43 +02:00
Zutatensuppe
b8946ef6a8 change replay to not use WS 2021-05-29 12:40:46 +02:00
Zutatensuppe
81ef9cd704 enable replay (built files) 2021-05-29 11:45:57 +02:00
Zutatensuppe
7a7d6580fc enable replays 2021-05-29 11:44:55 +02:00
73 changed files with 5550 additions and 1629 deletions

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
build
src/frontend/shims-vue.d.ts

17
.eslintrc.cjs Normal file
View file

@ -0,0 +1,17 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
],
parserOptions: {
ecmaVersion: 2020,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {
'@typescript-eslint/no-inferrable-types': 'off'
}
};

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,9 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>🧩 jigsaw.hyottoko.club</title> <title>🧩 jigsaw.hyottoko.club</title>
<script type="module" crossorigin src="/assets/index.50ee8245.js"></script> <script type="module" crossorigin src="/assets/index.63ff8630.js"></script>
<link rel="modulepreload" href="/assets/vendor.b622ee49.js"> <link rel="modulepreload" href="/assets/vendor.684f7bc8.js">
<link rel="stylesheet" href="/assets/index.f7304069.css"> <link rel="stylesheet" href="/assets/index.22dc307c.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because it is too large Load diff

1981
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,6 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"better-sqlite3": "^7.4.0", "better-sqlite3": "^7.4.0",
"body-parser": "^1.19.0",
"exif": "^0.6.0", "exif": "^0.6.0",
"express": "^4.17.1", "express": "^4.17.1",
"image-size": "^0.9.3", "image-size": "^0.9.3",
@ -14,19 +13,24 @@
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^5.4.1", "@types/better-sqlite3": "^5.4.1",
"@types/compression": "^1.7.0",
"@types/exif": "^0.6.2", "@types/exif": "^0.6.2",
"@types/express": "^4.17.11", "@types/express": "^4.17.11",
"@types/multer": "^1.4.5", "@types/multer": "^1.4.5",
"@types/sharp": "^0.28.1", "@types/sharp": "^0.28.1",
"@types/ws": "^7.4.4", "@types/ws": "^7.4.4",
"@typescript-eslint/eslint-plugin": "^4.25.0",
"@typescript-eslint/parser": "^4.25.0",
"@vitejs/plugin-vue": "^1.2.2", "@vitejs/plugin-vue": "^1.2.2",
"@vuedx/typescript-plugin-vue": "^0.6.3", "@vuedx/typescript-plugin-vue": "^0.6.3",
"compression": "^1.7.4",
"eslint": "^7.27.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"rollup": "^2.48.0", "rollup": "^2.48.0",
"rollup-plugin-typescript2": "^0.30.0", "rollup-plugin-typescript2": "^0.30.0",
"rollup-plugin-vue": "^6.0.0-beta.10", "rollup-plugin-vue": "^6.0.0-beta.10",
"ts-node": "^9.1.1", "ts-node": "^9.1.1",
"typescript": "^4.2.4", "typescript": "^4.3.2",
"vite": "^2.3.2" "vite": "^2.3.2"
}, },
"engines": { "engines": {
@ -35,6 +39,7 @@
}, },
"scripts": { "scripts": {
"rollup": "rollup", "rollup": "rollup",
"vite": "vite" "vite": "vite",
"eslint": "eslint"
} }
} }

View file

@ -8,17 +8,18 @@ export default {
format: 'es', format: 'es',
}, },
external: [ external: [
"express", "better-sqlite3",
"multer", "compression",
"body-parser",
"v8",
"fs",
"ws",
"image-size",
"exif", "exif",
"express",
"fs",
"image-size",
"multer",
"path",
"sharp", "sharp",
"url", "url",
"path", "v8",
"ws",
], ],
plugins: [typescript()], plugins: [typescript()],
}; };

View file

@ -0,0 +1,90 @@
import GameCommon from '../src/common/GameCommon'
import GameLog from '../src/server/GameLog'
import { Game } from '../src/common/Types'
import { logger } from '../src/common/Util'
import { DB_FILE, DB_PATCHES_DIR, UPLOAD_DIR } from '../src/server/Dirs'
import Db from '../src/server/Db'
import GameStorage from '../src/server/GameStorage'
import fs from 'fs'
const log = logger('fix_games_image_info.ts')
import Images from '../src/server/Images'
console.log(DB_FILE)
const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch(true)
// ;(async () => {
// let images = db.getMany('images')
// for (let image of images) {
// console.log(image.filename)
// let dim = await Images.getDimensions(`${UPLOAD_DIR}/${image.filename}`)
// console.log(await Images.getDimensions(`${UPLOAD_DIR}/${image.filename}`))
// image.width = dim.w
// image.height = dim.h
// db.upsert('images', image, { id: image.id })
// }
// })()
function fixOne(gameId: string) {
let g = GameCommon.get(gameId)
if (!g) {
return
}
if (!g.puzzle.info.image && g.puzzle.info.imageUrl) {
log.log('game id: ', gameId)
const parts = g.puzzle.info.imageUrl.split('/')
const fileName = parts[parts.length - 1]
const imageRow = db.get('images', {filename: fileName})
if (!imageRow) {
return
}
g.puzzle.info.image = Images.imageFromDb(db, imageRow.id)
log.log(g.puzzle.info.image.title, imageRow.id)
GameStorage.persistGameToDb(db, gameId)
} else if (g.puzzle.info.image?.id) {
const imageId = g.puzzle.info.image.id
g.puzzle.info.image = Images.imageFromDb(db, imageId)
log.log(g.puzzle.info.image.title, imageId)
GameStorage.persistGameToDb(db, gameId)
}
// fix log
const file = GameLog.filename(gameId, 0)
if (!fs.existsSync(file)) {
return
}
const lines = fs.readFileSync(file, 'utf-8').split("\n")
const l = lines.filter(line => !!line).map(line => {
return JSON.parse(`[${line}]`)
})
if (l && l[0] && !l[0][3].id) {
log.log(l[0][3])
l[0][3] = g.puzzle.info.image
const newlines = l.map(ll => {
return JSON.stringify(ll).slice(1, -1)
}).join("\n") + "\n"
console.log(g.puzzle.info.image)
// process.exit(0)
fs.writeFileSync(file, newlines)
}
}
function fix() {
GameStorage.loadGamesFromDisk()
GameCommon.getAllGames().forEach((game: Game) => {
fixOne(game.id)
})
}
fix()

View file

@ -1,23 +0,0 @@
import GameCommon from '../src/common/GameCommon'
import { logger } from '../src/common/Util'
import GameStorage from '../src/server/GameStorage'
const log = logger('fix_image.js')
function fix(gameId) {
GameStorage.loadGame(gameId)
let changed = false
let imgUrl = GameCommon.getImageUrl(gameId)
if (imgUrl.match(/^\/example-images\//)) {
log.log(`found bad imgUrl: ${imgUrl}`)
imgUrl = imgUrl.replace(/^\/example-images\//, '/uploads/')
GameCommon.setImageUrl(gameId, imgUrl)
changed = true
}
if (changed) {
GameStorage.persistGame(gameId)
}
}
fix(process.argv[2])

View file

@ -1,33 +1,38 @@
import GameCommon from '../src/common/GameCommon' import GameCommon from '../src/common/GameCommon'
import { logger } from '../src/common/Util' import { logger } from '../src/common/Util'
import Db from '../src/server/Db'
import { DB_FILE, DB_PATCHES_DIR } from '../src/server/Dirs'
import GameStorage from '../src/server/GameStorage' import GameStorage from '../src/server/GameStorage'
const log = logger('fix_tiles.js') const log = logger('fix_tiles.js')
const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch(true)
function fix_tiles(gameId) { function fix_tiles(gameId) {
GameStorage.loadGame(gameId) GameStorage.loadGameFromDb(db, gameId)
let changed = false let changed = false
const tiles = GameCommon.getTilesSortedByZIndex(gameId) const tiles = GameCommon.getPiecesSortedByZIndex(gameId)
for (let tile of tiles) { for (let tile of tiles) {
if (tile.owner === -1) { if (tile.owner === -1) {
const p = GameCommon.getFinalTilePos(gameId, tile.idx) const p = GameCommon.getFinalPiecePos(gameId, tile.idx)
if (p.x === tile.pos.x && p.y === tile.pos.y) { if (p.x === tile.pos.x && p.y === tile.pos.y) {
// log.log('all good', tile.pos) // log.log('all good', tile.pos)
} else { } else {
log.log('bad tile pos', tile.pos, 'should be: ', p) log.log('bad tile pos', tile.pos, 'should be: ', p)
tile.pos = p tile.pos = p
GameCommon.setTile(gameId, tile.idx, tile) GameCommon.setPiece(gameId, tile.idx, tile)
changed = true changed = true
} }
} else if (tile.owner !== 0) { } else if (tile.owner !== 0) {
tile.owner = 0 tile.owner = 0
log.log('unowning tile', tile.idx) log.log('unowning tile', tile.idx)
GameCommon.setTile(gameId, tile.idx, tile) GameCommon.setPiece(gameId, tile.idx, tile)
changed = true changed = true
} }
} }
if (changed) { if (changed) {
GameStorage.persistGame(gameId) GameStorage.persistGameToDb(db, gameId)
} }
} }

View file

@ -1,47 +0,0 @@
import { DB_FILE, DB_PATCHES_DIR, DATA_DIR } from '../src/server/Dirs'
import Db from '../src/server/Db'
import fs from 'fs'
import { logger } from '../src/common/Util'
const log = logger('import_game_logs.ts')
const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch(true)
for (const file of fs.readdirSync(DATA_DIR)) {
const m = file.match(/^log_(.*)\.log$/)
if (!m) {
continue
}
const gameId = m[1]
log.info(`Importing log for game ${file}`)
const contents = fs.readFileSync(`${DATA_DIR}/${file}`, 'utf-8')
let t = 0
let datas = []
for (const line of contents.split("\n")) {
if (line === '') {
continue
}
let parsed
try {
parsed = JSON.parse(line)
} catch (e) {
log.error('bad game', e)
break
}
if (t === 0) {
t = parsed[4]
} else {
t += parsed[parsed.length - 1]
}
datas.push({
game_id: gameId,
created: t / 1000,
entry: line,
})
}
db.insertMany('game_log', datas)
log.info(`Done.`)
}

27
scripts/import_games.ts Normal file
View file

@ -0,0 +1,27 @@
import GameCommon from '../src/common/GameCommon'
import { Game } from '../src/common/Types'
import { logger } from '../src/common/Util'
import { DB_FILE, DB_PATCHES_DIR } from '../src/server/Dirs'
import Db from '../src/server/Db'
import GameStorage from '../src/server/GameStorage'
const log = logger('import_games.ts')
console.log(DB_FILE)
const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch(true)
function run() {
GameStorage.loadGamesFromDisk()
GameCommon.getAllGames().forEach((game: Game) => {
if (!game.puzzle.info.image?.id) {
log.error(game.id + " has no image")
log.error(game.puzzle.info.image)
return
}
GameStorage.persistGameToDb(db, game.id)
})
}
run()

View file

@ -0,0 +1,18 @@
import { DB_FILE, DB_PATCHES_DIR, UPLOAD_DIR } from '../src/server/Dirs'
import Db from '../src/server/Db'
import Images from '../src/server/Images'
const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch(true)
;(async () => {
let images = db.getMany('images')
for (let image of images) {
console.log(image.filename)
let dim = await Images.getDimensions(`${UPLOAD_DIR}/${image.filename}`)
console.log(await Images.getDimensions(`${UPLOAD_DIR}/${image.filename}`))
image.width = dim.w
image.height = dim.h
db.upsert('images', image, { id: image.id })
}
})()

5
scripts/lint Executable file
View file

@ -0,0 +1,5 @@
#!/bin/sh -e
cd "$RUN_DIR"
npm run eslint src

View file

@ -1,82 +0,0 @@
import fs from 'fs'
import Protocol from '../src/common/Protocol'
import { logger } from '../src/common/Util'
import { DATA_DIR } from '../src/server/Dirs'
const log = logger('rewrite_logs')
const filename = (gameId) => `${DATA_DIR}/log_${gameId}.log`
const rewrite = (gameId) => {
const file = filename(gameId)
log.log(file)
if (!fs.existsSync(file)) {
return []
}
let playerIds = [];
let startTs = null
const lines = fs.readFileSync(file, 'utf-8').split("\n")
const linesNew = lines.filter(line => !!line).map((line) => {
const json = JSON.parse(line)
const m = {
createGame: Protocol.LOG_HEADER,
addPlayer: Protocol.LOG_ADD_PLAYER,
handleInput: Protocol.LOG_HANDLE_INPUT,
}
const action = json[0]
if (action in m) {
json[0] = m[action]
if (json[0] === Protocol.LOG_HANDLE_INPUT) {
const inputm = {
down: Protocol.INPUT_EV_MOUSE_DOWN,
up: Protocol.INPUT_EV_MOUSE_UP,
move: Protocol.INPUT_EV_MOUSE_MOVE,
zoomin: Protocol.INPUT_EV_ZOOM_IN,
zoomout: Protocol.INPUT_EV_ZOOM_OUT,
bg_color: Protocol.INPUT_EV_BG_COLOR,
player_color: Protocol.INPUT_EV_PLAYER_COLOR,
player_name: Protocol.INPUT_EV_PLAYER_NAME,
}
const inputa = json[2][0]
if (inputa in inputm) {
json[2][0] = inputm[inputa]
} else {
throw '[ invalid input log line: "' + line + '" ]'
}
}
} else {
throw '[ invalid general log line: "' + line + '" ]'
}
if (json[0] === Protocol.LOG_ADD_PLAYER) {
if (playerIds.indexOf(json[1]) === -1) {
playerIds.push(json[1])
} else {
json[0] = Protocol.LOG_UPDATE_PLAYER
json[1] = playerIds.indexOf(json[1])
}
}
if (json[0] === Protocol.LOG_HANDLE_INPUT) {
json[1] = playerIds.indexOf(json[1])
if (json[1] === -1) {
throw '[ invalid player ... "' + line + '" ]'
}
}
if (json[0] === Protocol.LOG_HEADER) {
startTs = json[json.length - 1]
json[4] = json[3]
json[3] = json[2]
json[2] = json[1]
json[1] = 1
} else {
json[json.length - 1] = json[json.length - 1] - startTs
}
return JSON.stringify(json)
})
fs.writeFileSync(file, linesNew.join("\n") + "\n")
}
rewrite(process.argv[2])

View file

@ -1,4 +1,4 @@
#!/bin/sh #!/bin/sh
# server for built files # server for built files
nodemon --max-old-space-size=64 build/server/main.js -c config.json nodemon --watch build --max-old-space-size=64 -e js build/server/main.js -c config.json

73
scripts/split_logs.ts Normal file
View file

@ -0,0 +1,73 @@
import fs from 'fs'
import { logger } from '../src/common/Util'
import { DATA_DIR } from '../src/server/Dirs'
import { filename } from '../src/server/GameLog'
const log = logger('rewrite_logs')
interface IdxOld {
total: number
currentFile: string
perFile: number
}
interface Idx {
gameId: string
total: number
lastTs: number
currentFile: string
perFile: number
}
const doit = (idxfile: string): void => {
const gameId: string = (idxfile.match(/^log_([a-z0-9]+)\.idx\.log$/) as any[])[1]
const idxOld: IdxOld = JSON.parse(fs.readFileSync(DATA_DIR + '/' + idxfile, 'utf-8'))
let currentFile = filename(gameId, 0)
const idxNew: Idx = {
gameId: gameId,
total: 0,
lastTs: 0,
currentFile: currentFile,
perFile: idxOld.perFile
}
let firstTs = 0
while (fs.existsSync(currentFile)) {
idxNew.currentFile = currentFile
const log = fs.readFileSync(currentFile, 'utf-8').split("\n")
const newLines = []
const lines = log.filter(line => !!line).map(line => {
return JSON.parse(line)
})
for (const l of lines) {
if (idxNew.total === 0) {
firstTs = l[4]
idxNew.lastTs = l[4]
newLines.push(JSON.stringify(l).slice(1, -1))
} else {
const ts = firstTs + l[l.length - 1]
const diff = ts - idxNew.lastTs
idxNew.lastTs = ts
const newL = l.slice(0, -1)
newL.push(diff)
newLines.push(JSON.stringify(newL).slice(1, -1))
}
idxNew.total++
}
fs.writeFileSync(idxNew.currentFile, newLines.join("\n") + "\n")
currentFile = filename(gameId, idxNew.total)
}
fs.writeFileSync(DATA_DIR + '/' + idxfile, JSON.stringify(idxNew))
console.log('done: ' + gameId)
}
let indexfiles = fs.readdirSync(DATA_DIR)
.filter(f => f.toLowerCase().match(/^log_[a-z0-9]+\.idx\.log$/))
;(async () => {
for (const file of indexfiles) {
await doit(file)
}
})()

View file

@ -1,3 +1,3 @@
#!/bin/sh #!/bin/sh -e
node --experimental-vm-modules node_modules/.bin/jest node --experimental-vm-modules node_modules/.bin/jest

View file

@ -1,3 +1,3 @@
#!/bin/sh -e #!/bin/sh -e
node --experimental-specifier-resolution=node --loader ts-node/esm $@ node --max-old-space-size=256 --experimental-specifier-resolution=node --loader ts-node/esm $@

View file

@ -2,139 +2,35 @@ import Geometry, { Point, Rect } from './Geometry'
import Protocol from './Protocol' import Protocol from './Protocol'
import { Rng } from './Rng' import { Rng } from './Rng'
import Time from './Time' import Time from './Time'
import {
Change,
EncodedPiece,
EvtInfo,
Game,
Input,
Piece,
PieceChange,
Player,
PlayerChange,
Puzzle,
PuzzleData,
PuzzleDataChange,
ScoreMode,
SnapMode,
Timestamp
} from './Types'
import Util from './Util' import Util from './Util'
export type Timestamp = number
export type EncodedPlayer = Array<any>
export type EncodedPiece = Array<any>
export type EncodedPieceShape = number
export interface Tag {
id: number
slug: string
title: string
}
interface GameRng {
obj: Rng
type?: string
}
interface Game {
id: string
players: Array<EncodedPlayer>
puzzle: Puzzle
evtInfos: Record<string, EvtInfo>
scoreMode?: ScoreMode
rng: GameRng
}
export interface Image {
id: number
filename: string
file: string
url: string
title: string
tags: Array<Tag>
created: number
}
export interface GameSettings {
tiles: number
image: Image
scoreMode: ScoreMode
}
export interface Puzzle {
tiles: Array<EncodedPiece>
data: PuzzleData
info: PuzzleInfo
}
interface PuzzleData {
started: number
finished: number
maxGroup: number
maxZ: number
}
interface PuzzleTable {
width: number
height: number
}
enum PieceEdge {
Flat = 0,
Out = 1,
In = -1,
}
export interface PieceShape {
top: PieceEdge
bottom: PieceEdge
left: PieceEdge
right: PieceEdge
}
export interface Piece {
owner: string|number
idx: number
pos: Point
z: number
group: number
}
export interface PuzzleInfo {
table: PuzzleTable
targetTiles: number,
imageUrl: string
width: number
height: number
tileSize: number
tileDrawSize: number
tileMarginWidth: number
tileDrawOffset: number
snapDistance: number
tiles: number
tilesX: number
tilesY: number
shapes: Array<EncodedPieceShape>
}
export interface Player {
id: string
x: number
y: number
d: 0|1
name: string|null
color: string|null
bgcolor: string|null
points: number
ts: number
}
interface EvtInfo {
_last_mouse: Point|null
_last_mouse_down: Point|null
}
export enum ScoreMode {
FINAL = 0,
ANY = 1,
}
const IDLE_TIMEOUT_SEC = 30 const IDLE_TIMEOUT_SEC = 30
// Map<gameId, Game> // Map<gameId, Game>
const GAMES: Record<string, Game> = {} const GAMES: Record<string, Game> = {}
function exists(gameId: string) { function exists(gameId: string): boolean {
return (!!GAMES[gameId]) || false return (!!GAMES[gameId]) || false
} }
function __createPlayerObject(id: string, ts: number): Player { function __createPlayerObject(id: string, ts: Timestamp): Player {
return { return {
id: id, id: id,
x: 0, x: 0,
@ -154,7 +50,7 @@ function setGame(gameId: string, game: Game): void {
function getPlayerIndexById(gameId: string, playerId: string): number { function getPlayerIndexById(gameId: string, playerId: string): number {
let i = 0; let i = 0;
for (let player of GAMES[gameId].players) { for (const player of GAMES[gameId].players) {
if (Util.decodePlayer(player).id === playerId) { if (Util.decodePlayer(player).id === playerId) {
return i return i
} }
@ -170,8 +66,11 @@ function getPlayerIdByIndex(gameId: string, playerIndex: number): string|null {
return null return null
} }
function getPlayer(gameId: string, playerId: string): Player { function getPlayer(gameId: string, playerId: string): Player|null {
const idx = getPlayerIndexById(gameId, playerId) const idx = getPlayerIndexById(gameId, playerId)
if (idx === -1) {
return null
}
return Util.decodePlayer(GAMES[gameId].players[idx]) return Util.decodePlayer(GAMES[gameId].players[idx])
} }
@ -188,8 +87,8 @@ function setPlayer(
} }
} }
function setTile(gameId: string, tileIdx: number, tile: Piece): void { function setPiece(gameId: string, pieceIdx: number, piece: Piece): void {
GAMES[gameId].puzzle.tiles[tileIdx] = Util.encodeTile(tile) GAMES[gameId].puzzle.tiles[pieceIdx] = Util.encodePiece(piece)
} }
function setPuzzleData(gameId: string, data: PuzzleData): void { function setPuzzleData(gameId: string, data: PuzzleData): void {
@ -211,7 +110,7 @@ function getIdlePlayers(gameId: string, ts: number): Array<Player> {
return getAllPlayers(gameId).filter((p: Player) => p.ts < minTs && p.points > 0) return getAllPlayers(gameId).filter((p: Player) => p.ts < minTs && p.points > 0)
} }
function addPlayer(gameId: string, playerId: string, ts: number): void { function addPlayer(gameId: string, playerId: string, ts: Timestamp): void {
if (!playerExists(gameId, playerId)) { if (!playerExists(gameId, playerId)) {
setPlayer(gameId, playerId, __createPlayerObject(playerId, ts)) setPlayer(gameId, playerId, __createPlayerObject(playerId, ts))
} else { } else {
@ -239,12 +138,16 @@ function setEvtInfo(
function getAllGames(): Array<Game> { function getAllGames(): Array<Game> {
return Object.values(GAMES).sort((a: Game, b: Game) => { return Object.values(GAMES).sort((a: Game, b: Game) => {
const finished = isFinished(a.id)
// when both have same finished state, sort by started // when both have same finished state, sort by started
if (isFinished(a.id) === isFinished(b.id)) { if (finished === isFinished(b.id)) {
if (finished) {
return b.puzzle.data.finished - a.puzzle.data.finished
}
return b.puzzle.data.started - a.puzzle.data.started return b.puzzle.data.started - a.puzzle.data.started
} }
// otherwise, sort: unfinished, finished // otherwise, sort: unfinished, finished
return isFinished(a.id) ? 1 : -1 return finished ? 1 : -1
}) })
} }
@ -254,84 +157,107 @@ function getAllPlayers(gameId: string): Array<Player> {
: [] : []
} }
function get(gameId: string) { function get(gameId: string): Game|null {
return GAMES[gameId] return GAMES[gameId] || null
} }
function getTileCount(gameId: string): number { function getPieceCount(gameId: string): number {
return GAMES[gameId].puzzle.tiles.length return GAMES[gameId].puzzle.tiles.length
} }
function getImageUrl(gameId: string): string { function getImageUrl(gameId: string): string {
return GAMES[gameId].puzzle.info.imageUrl const imageUrl = GAMES[gameId].puzzle.info.image?.url
} || GAMES[gameId].puzzle.info.imageUrl
if (!imageUrl) {
function setImageUrl(gameId: string, imageUrl: string): void { throw new Error('[2021-07-11] no image url set')
GAMES[gameId].puzzle.info.imageUrl = imageUrl }
return imageUrl
} }
function getScoreMode(gameId: string): ScoreMode { function getScoreMode(gameId: string): ScoreMode {
return GAMES[gameId].scoreMode || ScoreMode.FINAL return GAMES[gameId].scoreMode
}
function getSnapMode(gameId: string): SnapMode {
return GAMES[gameId].snapMode
} }
function isFinished(gameId: string): boolean { function isFinished(gameId: string): boolean {
return getFinishedTileCount(gameId) === getTileCount(gameId) return getFinishedPiecesCount(gameId) === getPieceCount(gameId)
} }
function getFinishedTileCount(gameId: string): number { function getFinishedPiecesCount(gameId: string): number {
let count = 0 let count = 0
for (let t of GAMES[gameId].puzzle.tiles) { for (const t of GAMES[gameId].puzzle.tiles) {
if (Util.decodeTile(t).owner === -1) { if (Util.decodePiece(t).owner === -1) {
count++ count++
} }
} }
return count return count
} }
function getTilesSortedByZIndex(gameId: string): Piece[] { function getPiecesSortedByZIndex(gameId: string): Piece[] {
const tiles = GAMES[gameId].puzzle.tiles.map(Util.decodeTile) const pieces = GAMES[gameId].puzzle.tiles.map(Util.decodePiece)
return tiles.sort((t1, t2) => t1.z - t2.z) return pieces.sort((t1, t2) => t1.z - t2.z)
} }
function changePlayer( function changePlayer(
gameId: string, gameId: string,
playerId: string, playerId: string,
change: any change: PlayerChange
): void { ): void {
const player = getPlayer(gameId, playerId) const player = getPlayer(gameId, playerId)
for (let k of Object.keys(change)) { if (player === null) {
return
}
for (const k of Object.keys(change)) {
// @ts-ignore // @ts-ignore
player[k] = change[k] player[k] = change[k]
} }
setPlayer(gameId, playerId, player) setPlayer(gameId, playerId, player)
} }
function changeData(gameId: string, change: any): void { function changeData(gameId: string, change: PuzzleDataChange): void {
for (let k of Object.keys(change)) { for (const k of Object.keys(change)) {
// @ts-ignore // @ts-ignore
GAMES[gameId].puzzle.data[k] = change[k] GAMES[gameId].puzzle.data[k] = change[k]
} }
} }
function changeTile(gameId: string, tileIdx: number, change: any): void { function changePiece(
for (let k of Object.keys(change)) { gameId: string,
const tile = Util.decodeTile(GAMES[gameId].puzzle.tiles[tileIdx]) pieceIdx: number,
change: PieceChange
): void {
for (const k of Object.keys(change)) {
const piece = Util.decodePiece(GAMES[gameId].puzzle.tiles[pieceIdx])
// @ts-ignore // @ts-ignore
tile[k] = change[k] piece[k] = change[k]
GAMES[gameId].puzzle.tiles[tileIdx] = Util.encodeTile(tile) GAMES[gameId].puzzle.tiles[pieceIdx] = Util.encodePiece(piece)
} }
} }
const getTile = (gameId: string, tileIdx: number): Piece => { const getPiece = (gameId: string, pieceIdx: number): Piece => {
return Util.decodeTile(GAMES[gameId].puzzle.tiles[tileIdx]) return Util.decodePiece(GAMES[gameId].puzzle.tiles[pieceIdx])
} }
const getTileGroup = (gameId: string, tileIdx: number): number => { const getPieceGroup = (gameId: string, tileIdx: number): number => {
const tile = getTile(gameId, tileIdx) const tile = getPiece(gameId, tileIdx)
return tile.group return tile.group
} }
const getFinalTilePos = (gameId: string, tileIdx: number): Point => { const isCornerPiece = (gameId: string, tileIdx: number): boolean => {
const info = GAMES[gameId].puzzle.info
return (
tileIdx === 0 // top left corner
|| tileIdx === (info.tilesX - 1) // top right corner
|| tileIdx === (info.tiles - info.tilesX) // bottom left corner
|| tileIdx === (info.tiles - 1) // bottom right corner
)
}
const getFinalPiecePos = (gameId: string, tileIdx: number): Point => {
const info = GAMES[gameId].puzzle.info const info = GAMES[gameId].puzzle.info
const boardPos = { const boardPos = {
x: (info.table.width - info.width) / 2, x: (info.table.width - info.width) / 2,
@ -341,8 +267,8 @@ const getFinalTilePos = (gameId: string, tileIdx: number): Point => {
return Geometry.pointAdd(boardPos, srcPos) return Geometry.pointAdd(boardPos, srcPos)
} }
const getTilePos = (gameId: string, tileIdx: number): Point => { const getPiecePos = (gameId: string, tileIdx: number): Point => {
const tile = getTile(gameId, tileIdx) const tile = getPiece(gameId, tileIdx)
return tile.pos return tile.pos
} }
@ -361,9 +287,9 @@ const getBounds = (gameId: string): Rect => {
} }
} }
const getTileBounds = (gameId: string, tileIdx: number): Rect => { const getPieceBounds = (gameId: string, tileIdx: number): Rect => {
const s = getTileSize(gameId) const s = getPieceSize(gameId)
const tile = getTile(gameId, tileIdx) const tile = getPiece(gameId, tileIdx)
return { return {
x: tile.pos.x, x: tile.pos.x,
y: tile.pos.y, y: tile.pos.y,
@ -372,14 +298,13 @@ const getTileBounds = (gameId: string, tileIdx: number): Rect => {
} }
} }
const getTileZIndex = (gameId: string, tileIdx: number): number => { const getPieceZIndex = (gameId: string, pieceIdx: number): number => {
const tile = getTile(gameId, tileIdx) return getPiece(gameId, pieceIdx).z
return tile.z
} }
const getFirstOwnedTileIdx = (gameId: string, playerId: string): number => { const getFirstOwnedPieceIdx = (gameId: string, playerId: string): number => {
for (let t of GAMES[gameId].puzzle.tiles) { for (const t of GAMES[gameId].puzzle.tiles) {
const tile = Util.decodeTile(t) const tile = Util.decodePiece(t)
if (tile.owner === playerId) { if (tile.owner === playerId) {
return tile.idx return tile.idx
} }
@ -387,20 +312,23 @@ const getFirstOwnedTileIdx = (gameId: string, playerId: string): number => {
return -1 return -1
} }
const getFirstOwnedTile = (gameId: string, playerId: string): EncodedPiece|null => { const getFirstOwnedPiece = (
const idx = getFirstOwnedTileIdx(gameId, playerId) gameId: string,
playerId: string
): EncodedPiece|null => {
const idx = getFirstOwnedPieceIdx(gameId, playerId)
return idx < 0 ? null : GAMES[gameId].puzzle.tiles[idx] return idx < 0 ? null : GAMES[gameId].puzzle.tiles[idx]
} }
const getTileDrawOffset = (gameId: string): number => { const getPieceDrawOffset = (gameId: string): number => {
return GAMES[gameId].puzzle.info.tileDrawOffset return GAMES[gameId].puzzle.info.tileDrawOffset
} }
const getTileDrawSize = (gameId: string): number => { const getPieceDrawSize = (gameId: string): number => {
return GAMES[gameId].puzzle.info.tileDrawSize return GAMES[gameId].puzzle.info.tileDrawSize
} }
const getTileSize = (gameId: string): number => { const getPieceSize = (gameId: string): number => {
return GAMES[gameId].puzzle.info.tileSize return GAMES[gameId].puzzle.info.tileSize
} }
@ -420,12 +348,12 @@ const getMaxZIndex = (gameId: string): number => {
return GAMES[gameId].puzzle.data.maxZ return GAMES[gameId].puzzle.data.maxZ
} }
const getMaxZIndexByTileIdxs = (gameId: string, tileIdxs: Array<number>): number => { const getMaxZIndexByPieceIdxs = (gameId: string, pieceIdxs: Array<number>): number => {
let maxZ = 0 let maxZ = 0
for (let tileIdx of tileIdxs) { for (const pieceIdx of pieceIdxs) {
let tileZIndex = getTileZIndex(gameId, tileIdx) const curZ = getPieceZIndex(gameId, pieceIdx)
if (tileZIndex > maxZ) { if (curZ > maxZ) {
maxZ = tileZIndex maxZ = curZ
} }
} }
return maxZ return maxZ
@ -434,7 +362,7 @@ const getMaxZIndexByTileIdxs = (gameId: string, tileIdxs: Array<number>): number
function srcPosByTileIdx(gameId: string, tileIdx: number): Point { function srcPosByTileIdx(gameId: string, tileIdx: number): Point {
const info = GAMES[gameId].puzzle.info const info = GAMES[gameId].puzzle.info
const c = Util.coordByTileIdx(info, tileIdx) const c = Util.coordByPieceIdx(info, tileIdx)
const cx = c.x * info.tileSize const cx = c.x * info.tileSize
const cy = c.y * info.tileSize const cy = c.y * info.tileSize
@ -444,7 +372,7 @@ function srcPosByTileIdx(gameId: string, tileIdx: number): Point {
function getSurroundingTilesByIdx(gameId: string, tileIdx: number) { function getSurroundingTilesByIdx(gameId: string, tileIdx: number) {
const info = GAMES[gameId].puzzle.info const info = GAMES[gameId].puzzle.info
const c = Util.coordByTileIdx(info, tileIdx) const c = Util.coordByPieceIdx(info, tileIdx)
return [ return [
// top // top
@ -458,109 +386,117 @@ function getSurroundingTilesByIdx(gameId: string, tileIdx: number) {
] ]
} }
const setTilesZIndex = (gameId: string, tileIdxs: Array<number>, zIndex: number): void => { const setPiecesZIndex = (gameId: string, tileIdxs: Array<number>, zIndex: number): void => {
for (let tilesIdx of tileIdxs) { for (const tilesIdx of tileIdxs) {
changeTile(gameId, tilesIdx, { z: zIndex }) changePiece(gameId, tilesIdx, { z: zIndex })
} }
} }
const moveTileDiff = (gameId: string, tileIdx: number, diff: Point): void => { const moveTileDiff = (gameId: string, tileIdx: number, diff: Point): void => {
const oldPos = getTilePos(gameId, tileIdx) const oldPos = getPiecePos(gameId, tileIdx)
const pos = Geometry.pointAdd(oldPos, diff) const pos = Geometry.pointAdd(oldPos, diff)
changeTile(gameId, tileIdx, { pos }) changePiece(gameId, tileIdx, { pos })
} }
const moveTilesDiff = ( const movePiecesDiff = (
gameId: string, gameId: string,
tileIdxs: Array<number>, pieceIdxs: Array<number>,
diff: Point diff: Point
): void => { ): void => {
const tileDrawSize = getTileDrawSize(gameId) const drawSize = getPieceDrawSize(gameId)
const bounds = getBounds(gameId) const bounds = getBounds(gameId)
const cappedDiff = diff const cappedDiff = diff
for (let tileIdx of tileIdxs) { for (const pieceIdx of pieceIdxs) {
const t = getTile(gameId, tileIdx) const t = getPiece(gameId, pieceIdx)
if (t.pos.x + diff.x < bounds.x) { if (t.pos.x + diff.x < bounds.x) {
cappedDiff.x = Math.max(bounds.x - t.pos.x, cappedDiff.x) cappedDiff.x = Math.max(bounds.x - t.pos.x, cappedDiff.x)
} else if (t.pos.x + tileDrawSize + diff.x > bounds.x + bounds.w) { } else if (t.pos.x + drawSize + diff.x > bounds.x + bounds.w) {
cappedDiff.x = Math.min(bounds.x + bounds.w - t.pos.x + tileDrawSize, cappedDiff.x) cappedDiff.x = Math.min(bounds.x + bounds.w - t.pos.x + drawSize, cappedDiff.x)
} }
if (t.pos.y + diff.y < bounds.y) { if (t.pos.y + diff.y < bounds.y) {
cappedDiff.y = Math.max(bounds.y - t.pos.y, cappedDiff.y) cappedDiff.y = Math.max(bounds.y - t.pos.y, cappedDiff.y)
} else if (t.pos.y + tileDrawSize + diff.y > bounds.y + bounds.h) { } else if (t.pos.y + drawSize + diff.y > bounds.y + bounds.h) {
cappedDiff.y = Math.min(bounds.y + bounds.h - t.pos.y + tileDrawSize, cappedDiff.y) cappedDiff.y = Math.min(bounds.y + bounds.h - t.pos.y + drawSize, cappedDiff.y)
} }
} }
for (let tileIdx of tileIdxs) { for (const pieceIdx of pieceIdxs) {
moveTileDiff(gameId, tileIdx, cappedDiff) moveTileDiff(gameId, pieceIdx, cappedDiff)
} }
} }
const finishTiles = (gameId: string, tileIdxs: Array<number>): void => { const isFinishedPiece = (gameId: string, pieceIdx: number): boolean => {
for (let tileIdx of tileIdxs) { return getPieceOwner(gameId, pieceIdx) === -1
changeTile(gameId, tileIdx, { owner: -1, z: 1 }) }
const getPieceOwner = (gameId: string, pieceIdx: number): string|number => {
return getPiece(gameId, pieceIdx).owner
}
const finishPieces = (gameId: string, pieceIdxs: Array<number>): void => {
for (const pieceIdx of pieceIdxs) {
changePiece(gameId, pieceIdx, { owner: -1, z: 1 })
} }
} }
const setTilesOwner = ( const setTilesOwner = (
gameId: string, gameId: string,
tileIdxs: Array<number>, pieceIdxs: Array<number>,
owner: string|number owner: string|number
): void => { ): void => {
for (let tileIdx of tileIdxs) { for (const pieceIdx of pieceIdxs) {
changeTile(gameId, tileIdx, { owner }) changePiece(gameId, pieceIdx, { owner })
} }
} }
// get all grouped tiles for a tile // get all grouped tiles for a tile
function getGroupedTileIdxs(gameId: string, tileIdx: number): number[] { function getGroupedPieceIdxs(gameId: string, pieceIdx: number): number[] {
const tiles = GAMES[gameId].puzzle.tiles const pieces = GAMES[gameId].puzzle.tiles
const tile = Util.decodeTile(tiles[tileIdx]) const piece = Util.decodePiece(pieces[pieceIdx])
const grouped = [] const grouped = []
if (tile.group) { if (piece.group) {
for (let other of tiles) { for (const other of pieces) {
const otherTile = Util.decodeTile(other) const otherPiece = Util.decodePiece(other)
if (otherTile.group === tile.group) { if (otherPiece.group === piece.group) {
grouped.push(otherTile.idx) grouped.push(otherPiece.idx)
} }
} }
} else { } else {
grouped.push(tile.idx) grouped.push(piece.idx)
} }
return grouped return grouped
} }
// Returns the index of the puzzle tile with the highest z index // Returns the index of the puzzle tile with the highest z index
// that is not finished yet and that matches the position // that is not finished yet and that matches the position
const freeTileIdxByPos = (gameId: string, pos: Point): number => { const freePieceIdxByPos = (gameId: string, pos: Point): number => {
let info = GAMES[gameId].puzzle.info const info = GAMES[gameId].puzzle.info
let tiles = GAMES[gameId].puzzle.tiles const pieces = GAMES[gameId].puzzle.tiles
let maxZ = -1 let maxZ = -1
let tileIdx = -1 let pieceIdx = -1
for (let idx = 0; idx < tiles.length; idx++) { for (let idx = 0; idx < pieces.length; idx++) {
const tile = Util.decodeTile(tiles[idx]) const piece = Util.decodePiece(pieces[idx])
if (tile.owner !== 0) { if (piece.owner !== 0) {
continue continue
} }
const collisionRect: Rect = { const collisionRect: Rect = {
x: tile.pos.x, x: piece.pos.x,
y: tile.pos.y, y: piece.pos.y,
w: info.tileSize, w: info.tileSize,
h: info.tileSize, h: info.tileSize,
} }
if (Geometry.pointInBounds(pos, collisionRect)) { if (Geometry.pointInBounds(pos, collisionRect)) {
if (maxZ === -1 || tile.z > maxZ) { if (maxZ === -1 || piece.z > maxZ) {
maxZ = tile.z maxZ = piece.z
tileIdx = idx pieceIdx = idx
} }
} }
} }
return tileIdx return pieceIdx
} }
const getPlayerBgColor = (gameId: string, playerId: string): string|null => { const getPlayerBgColor = (gameId: string, playerId: string): string|null => {
@ -589,8 +525,8 @@ const areGrouped = (
tileIdx1: number, tileIdx1: number,
tileIdx2: number tileIdx2: number
): boolean => { ): boolean => {
const g1 = getTileGroup(gameId, tileIdx1) const g1 = getPieceGroup(gameId, tileIdx1)
const g2 = getTileGroup(gameId, tileIdx2) const g2 = getPieceGroup(gameId, tileIdx2)
return !!(g1 && g1 === g2) return !!(g1 && g1 === g2)
} }
@ -621,47 +557,52 @@ const getPuzzleHeight = (gameId: string): number => {
function handleInput( function handleInput(
gameId: string, gameId: string,
playerId: string, playerId: string,
input: any, input: Input,
ts: number ts: Timestamp,
): Array<Array<any>> { onSnap?: (playerId: string) => void
): Array<Change> {
const puzzle = GAMES[gameId].puzzle const puzzle = GAMES[gameId].puzzle
const evtInfo = getEvtInfo(gameId, playerId) const evtInfo = getEvtInfo(gameId, playerId)
const changes = [] as Array<Array<any>> const changes: Array<Change> = []
const _dataChange = (): void => { const _dataChange = (): void => {
changes.push([Protocol.CHANGE_DATA, puzzle.data]) changes.push([Protocol.CHANGE_DATA, puzzle.data])
} }
const _tileChange = (tileIdx: number): void => { const _pieceChange = (pieceIdx: number): void => {
changes.push([ changes.push([
Protocol.CHANGE_TILE, Protocol.CHANGE_TILE,
Util.encodeTile(getTile(gameId, tileIdx)), Util.encodePiece(getPiece(gameId, pieceIdx)),
]) ])
} }
const _tileChanges = (tileIdxs: Array<number>): void => { const _pieceChanges = (pieceIdxs: Array<number>): void => {
for (const tileIdx of tileIdxs) { for (const pieceIdx of pieceIdxs) {
_tileChange(tileIdx) _pieceChange(pieceIdx)
} }
} }
const _playerChange = (): void => { const _playerChange = (): void => {
const player = getPlayer(gameId, playerId)
if (!player) {
return
}
changes.push([ changes.push([
Protocol.CHANGE_PLAYER, Protocol.CHANGE_PLAYER,
Util.encodePlayer(getPlayer(gameId, playerId)), Util.encodePlayer(player),
]) ])
} }
// put both tiles (and their grouped tiles) in the same group // put both tiles (and their grouped tiles) in the same group
const groupTiles = ( const groupTiles = (
gameId: string, gameId: string,
tileIdx1: number, pieceIdx1: number,
tileIdx2: number pieceIdx2: number
): void => { ): void => {
const tiles = GAMES[gameId].puzzle.tiles const pieces = GAMES[gameId].puzzle.tiles
const group1 = getTileGroup(gameId, tileIdx1) const group1 = getPieceGroup(gameId, pieceIdx1)
const group2 = getTileGroup(gameId, tileIdx2) const group2 = getPieceGroup(gameId, pieceIdx2)
let group let group
const searchGroups = [] const searchGroups = []
@ -682,18 +623,18 @@ function handleInput(
group = getMaxGroup(gameId) group = getMaxGroup(gameId)
} }
changeTile(gameId, tileIdx1, { group }) changePiece(gameId, pieceIdx1, { group })
_tileChange(tileIdx1) _pieceChange(pieceIdx1)
changeTile(gameId, tileIdx2, { group }) changePiece(gameId, pieceIdx2, { group })
_tileChange(tileIdx2) _pieceChange(pieceIdx2)
// TODO: strange // TODO: strange
if (searchGroups.length > 0) { if (searchGroups.length > 0) {
for (const t of tiles) { for (const p of pieces) {
const tile = Util.decodeTile(t) const piece = Util.decodePiece(p)
if (searchGroups.includes(tile.group)) { if (searchGroups.includes(piece.group)) {
changeTile(gameId, tile.idx, { group }) changePiece(gameId, piece.idx, { group })
_tileChange(tile.idx) _pieceChange(piece.idx)
} }
} }
} }
@ -712,6 +653,16 @@ function handleInput(
const name = `${input[1]}`.substr(0, 16) const name = `${input[1]}`.substr(0, 16)
changePlayer(gameId, playerId, { name, ts }) changePlayer(gameId, playerId, { name, ts })
_playerChange() _playerChange()
} else if (type === Protocol.INPUT_EV_MOVE) {
const w = input[1]
const h = input[2]
const player = getPlayer(gameId, playerId)
if (player) {
const x = player.x - w
const y = player.y - h
changePlayer(gameId, playerId, { ts, x, y })
_playerChange()
}
} else if (type === Protocol.INPUT_EV_MOUSE_DOWN) { } else if (type === Protocol.INPUT_EV_MOUSE_DOWN) {
const x = input[1] const x = input[1]
const y = input[2] const y = input[2]
@ -721,15 +672,15 @@ function handleInput(
_playerChange() _playerChange()
evtInfo._last_mouse_down = pos evtInfo._last_mouse_down = pos
const tileIdxAtPos = freeTileIdxByPos(gameId, pos) const tileIdxAtPos = freePieceIdxByPos(gameId, pos)
if (tileIdxAtPos >= 0) { if (tileIdxAtPos >= 0) {
let maxZ = getMaxZIndex(gameId) + 1 const maxZ = getMaxZIndex(gameId) + 1
changeData(gameId, { maxZ }) changeData(gameId, { maxZ })
_dataChange() _dataChange()
const tileIdxs = getGroupedTileIdxs(gameId, tileIdxAtPos) const tileIdxs = getGroupedPieceIdxs(gameId, tileIdxAtPos)
setTilesZIndex(gameId, tileIdxs, getMaxZIndex(gameId)) setPiecesZIndex(gameId, tileIdxs, getMaxZIndex(gameId))
setTilesOwner(gameId, tileIdxs, playerId) setTilesOwner(gameId, tileIdxs, playerId)
_tileChanges(tileIdxs) _pieceChanges(tileIdxs)
} }
evtInfo._last_mouse = pos evtInfo._last_mouse = pos
@ -743,19 +694,19 @@ function handleInput(
changePlayer(gameId, playerId, {x, y, ts}) changePlayer(gameId, playerId, {x, y, ts})
_playerChange() _playerChange()
} else { } else {
let tileIdx = getFirstOwnedTileIdx(gameId, playerId) const pieceIdx = getFirstOwnedPieceIdx(gameId, playerId)
if (tileIdx >= 0) { if (pieceIdx >= 0) {
// player is moving a tile (and hand) // player is moving a tile (and hand)
changePlayer(gameId, playerId, {x, y, ts}) changePlayer(gameId, playerId, {x, y, ts})
_playerChange() _playerChange()
// check if pos is on the tile, otherwise dont move // check if pos is on the tile, otherwise dont move
// (mouse could be out of table, but tile stays on it) // (mouse could be out of table, but tile stays on it)
const tileIdxs = getGroupedTileIdxs(gameId, tileIdx) const pieceIdxs = getGroupedPieceIdxs(gameId, pieceIdx)
let anyOk = Geometry.pointInBounds(pos, getBounds(gameId)) let anyOk = Geometry.pointInBounds(pos, getBounds(gameId))
&& Geometry.pointInBounds(evtInfo._last_mouse_down, getBounds(gameId)) && Geometry.pointInBounds(evtInfo._last_mouse_down, getBounds(gameId))
for (let idx of tileIdxs) { for (const idx of pieceIdxs) {
const bounds = getTileBounds(gameId, idx) const bounds = getPieceBounds(gameId, idx)
if (Geometry.pointInBounds(pos, bounds)) { if (Geometry.pointInBounds(pos, bounds)) {
anyOk = true anyOk = true
break break
@ -766,9 +717,9 @@ function handleInput(
const diffY = y - evtInfo._last_mouse_down.y const diffY = y - evtInfo._last_mouse_down.y
const diff = { x: diffX, y: diffY } const diff = { x: diffX, y: diffY }
moveTilesDiff(gameId, tileIdxs, diff) movePiecesDiff(gameId, pieceIdxs, diff)
_tileChanges(tileIdxs) _pieceChanges(pieceIdxs)
} }
} else { } else {
// player is just moving map, so no change in position! // player is just moving map, so no change in position!
@ -788,26 +739,44 @@ function handleInput(
evtInfo._last_mouse_down = null evtInfo._last_mouse_down = null
let tileIdx = getFirstOwnedTileIdx(gameId, playerId) const pieceIdx = getFirstOwnedPieceIdx(gameId, playerId)
if (tileIdx >= 0) { if (pieceIdx >= 0) {
// drop the tile(s) // drop the tile(s)
let tileIdxs = getGroupedTileIdxs(gameId, tileIdx) const pieceIdxs = getGroupedPieceIdxs(gameId, pieceIdx)
setTilesOwner(gameId, tileIdxs, 0) setTilesOwner(gameId, pieceIdxs, 0)
_tileChanges(tileIdxs) _pieceChanges(pieceIdxs)
// Check if the tile was dropped near the final location // Check if the tile was dropped near the final location
let tilePos = getTilePos(gameId, tileIdx) const tilePos = getPiecePos(gameId, pieceIdx)
let finalPos = getFinalTilePos(gameId, tileIdx) const finalPos = getFinalPiecePos(gameId, pieceIdx)
if (Geometry.pointDistance(finalPos, tilePos) < puzzle.info.snapDistance) {
let diff = Geometry.pointSub(finalPos, tilePos) let canSnapToFinal = false
if (getSnapMode(gameId) === SnapMode.REAL) {
// only can snap to final if any of the grouped pieces are
// corner pieces
for (const pieceIdxTmp of pieceIdxs) {
if (isCornerPiece(gameId, pieceIdxTmp)) {
canSnapToFinal = true
break
}
}
} else {
canSnapToFinal = true
}
if (
canSnapToFinal
&& Geometry.pointDistance(finalPos, tilePos) < puzzle.info.snapDistance
) {
const diff = Geometry.pointSub(finalPos, tilePos)
// Snap the tile to the final destination // Snap the tile to the final destination
moveTilesDiff(gameId, tileIdxs, diff) movePiecesDiff(gameId, pieceIdxs, diff)
finishTiles(gameId, tileIdxs) finishPieces(gameId, pieceIdxs)
_tileChanges(tileIdxs) _pieceChanges(pieceIdxs)
let points = getPlayerPoints(gameId, playerId) let points = getPlayerPoints(gameId, playerId)
if (getScoreMode(gameId) === ScoreMode.FINAL) { if (getScoreMode(gameId) === ScoreMode.FINAL) {
points += tileIdxs.length points += pieceIdxs.length
} else if (getScoreMode(gameId) === ScoreMode.ANY) { } else if (getScoreMode(gameId) === ScoreMode.ANY) {
points += 1 points += 1
} else { } else {
@ -818,10 +787,13 @@ function handleInput(
_playerChange() _playerChange()
// check if the puzzle is finished // check if the puzzle is finished
if (getFinishedTileCount(gameId) === getTileCount(gameId)) { if (getFinishedPiecesCount(gameId) === getPieceCount(gameId)) {
changeData(gameId, { finished: ts }) changeData(gameId, { finished: ts })
_dataChange() _dataChange()
} }
if (onSnap) {
onSnap(playerId)
}
} else { } else {
// Snap to other tiles // Snap to other tiles
const check = ( const check = (
@ -830,40 +802,44 @@ function handleInput(
otherTileIdx: number, otherTileIdx: number,
off: Array<number> off: Array<number>
): boolean => { ): boolean => {
let info = GAMES[gameId].puzzle.info const info = GAMES[gameId].puzzle.info
if (otherTileIdx < 0) { if (otherTileIdx < 0) {
return false return false
} }
if (areGrouped(gameId, tileIdx, otherTileIdx)) { if (areGrouped(gameId, tileIdx, otherTileIdx)) {
return false return false
} }
const tilePos = getTilePos(gameId, tileIdx) const tilePos = getPiecePos(gameId, tileIdx)
const dstPos = Geometry.pointAdd( const dstPos = Geometry.pointAdd(
getTilePos(gameId, otherTileIdx), getPiecePos(gameId, otherTileIdx),
{x: off[0] * info.tileSize, y: off[1] * info.tileSize} {x: off[0] * info.tileSize, y: off[1] * info.tileSize}
) )
if (Geometry.pointDistance(tilePos, dstPos) < info.snapDistance) { if (Geometry.pointDistance(tilePos, dstPos) < info.snapDistance) {
let diff = Geometry.pointSub(dstPos, tilePos) const diff = Geometry.pointSub(dstPos, tilePos)
let tileIdxs = getGroupedTileIdxs(gameId, tileIdx) let pieceIdxs = getGroupedPieceIdxs(gameId, tileIdx)
moveTilesDiff(gameId, tileIdxs, diff) movePiecesDiff(gameId, pieceIdxs, diff)
groupTiles(gameId, tileIdx, otherTileIdx) groupTiles(gameId, tileIdx, otherTileIdx)
tileIdxs = getGroupedTileIdxs(gameId, tileIdx) pieceIdxs = getGroupedPieceIdxs(gameId, tileIdx)
const zIndex = getMaxZIndexByTileIdxs(gameId, tileIdxs) if (isFinishedPiece(gameId, otherTileIdx)) {
setTilesZIndex(gameId, tileIdxs, zIndex) finishPieces(gameId, pieceIdxs)
_tileChanges(tileIdxs) } else {
const zIndex = getMaxZIndexByPieceIdxs(gameId, pieceIdxs)
setPiecesZIndex(gameId, pieceIdxs, zIndex)
}
_pieceChanges(pieceIdxs)
return true return true
} }
return false return false
} }
let snapped = false let snapped = false
for (let tileIdxTmp of getGroupedTileIdxs(gameId, tileIdx)) { for (const pieceIdxTmp of getGroupedPieceIdxs(gameId, pieceIdx)) {
let othersIdxs = getSurroundingTilesByIdx(gameId, tileIdxTmp) const othersIdxs = getSurroundingTilesByIdx(gameId, pieceIdxTmp)
if ( if (
check(gameId, tileIdxTmp, othersIdxs[0], [0, 1]) // top check(gameId, pieceIdxTmp, othersIdxs[0], [0, 1]) // top
|| check(gameId, tileIdxTmp, othersIdxs[1], [-1, 0]) // right || check(gameId, pieceIdxTmp, othersIdxs[1], [-1, 0]) // right
|| check(gameId, tileIdxTmp, othersIdxs[2], [0, -1]) // bottom || check(gameId, pieceIdxTmp, othersIdxs[2], [0, -1]) // bottom
|| check(gameId, tileIdxTmp, othersIdxs[3], [1, 0]) // left || check(gameId, pieceIdxTmp, othersIdxs[3], [1, 0]) // left
) { ) {
snapped = true snapped = true
break break
@ -877,6 +853,16 @@ function handleInput(
changePlayer(gameId, playerId, { d, ts }) changePlayer(gameId, playerId, { d, ts })
_playerChange() _playerChange()
} }
if (snapped && getSnapMode(gameId) === SnapMode.REAL) {
if (getFinishedPiecesCount(gameId) === getPieceCount(gameId)) {
changeData(gameId, { finished: ts })
_dataChange()
}
}
if (snapped && onSnap) {
onSnap(playerId)
}
} }
} else { } else {
changePlayer(gameId, playerId, { d, ts }) changePlayer(gameId, playerId, { d, ts })
@ -905,17 +891,15 @@ function handleInput(
} }
export default { export default {
__createPlayerObject,
setGame, setGame,
exists, exists,
playerExists, playerExists,
getActivePlayers, getActivePlayers,
getIdlePlayers, getIdlePlayers,
addPlayer, addPlayer,
getFinishedTileCount, getFinishedPiecesCount,
getTileCount, getPieceCount,
getImageUrl, getImageUrl,
setImageUrl,
get, get,
getAllGames, getAllGames,
getPlayerBgColor, getPlayerBgColor,
@ -925,7 +909,7 @@ export default {
getPlayerIdByIndex, getPlayerIdByIndex,
changePlayer, changePlayer,
setPlayer, setPlayer,
setTile, setPiece,
setPuzzleData, setPuzzleData,
getTableWidth, getTableWidth,
getTableHeight, getTableHeight,
@ -933,11 +917,11 @@ export default {
getRng, getRng,
getPuzzleWidth, getPuzzleWidth,
getPuzzleHeight, getPuzzleHeight,
getTilesSortedByZIndex, getPiecesSortedByZIndex,
getFirstOwnedTile, getFirstOwnedPiece,
getTileDrawOffset, getPieceDrawOffset,
getTileDrawSize, getPieceDrawSize,
getFinalTilePos, getFinalPiecePos,
getStartTs, getStartTs,
getFinishTs, getFinishTs,
handleInput, handleInput,

View file

@ -40,10 +40,8 @@ EV_SERVER_INIT: event sent to one client after that client
*/ */
const EV_SERVER_EVENT = 1 const EV_SERVER_EVENT = 1
const EV_SERVER_INIT = 4 const EV_SERVER_INIT = 4
const EV_SERVER_INIT_REPLAY = 5
const EV_CLIENT_EVENT = 2 const EV_CLIENT_EVENT = 2
const EV_CLIENT_INIT = 3 const EV_CLIENT_INIT = 3
const EV_CLIENT_INIT_REPLAY = 6
const LOG_HEADER = 1 const LOG_HEADER = 1
const LOG_ADD_PLAYER = 2 const LOG_ADD_PLAYER = 2
@ -60,6 +58,17 @@ const INPUT_EV_PLAYER_COLOR = 7
const INPUT_EV_PLAYER_NAME = 8 const INPUT_EV_PLAYER_NAME = 8
const INPUT_EV_MOVE = 9 const INPUT_EV_MOVE = 9
const INPUT_EV_TOGGLE_PREVIEW = 10 const INPUT_EV_TOGGLE_PREVIEW = 10
const INPUT_EV_TOGGLE_SOUNDS = 11
const INPUT_EV_REPLAY_TOGGLE_PAUSE = 12
const INPUT_EV_REPLAY_SPEED_UP = 13
const INPUT_EV_REPLAY_SPEED_DOWN = 14
const INPUT_EV_TOGGLE_PLAYER_NAMES = 15
const INPUT_EV_CENTER_FIT_PUZZLE = 16
const INPUT_EV_TOGGLE_FIXED_PIECES = 17
const INPUT_EV_TOGGLE_LOOSE_PIECES = 18
const CHANGE_DATA = 1 const CHANGE_DATA = 1
const CHANGE_TILE = 2 const CHANGE_TILE = 2
@ -68,10 +77,8 @@ const CHANGE_PLAYER = 3
export default { export default {
EV_SERVER_EVENT, EV_SERVER_EVENT,
EV_SERVER_INIT, EV_SERVER_INIT,
EV_SERVER_INIT_REPLAY,
EV_CLIENT_EVENT, EV_CLIENT_EVENT,
EV_CLIENT_INIT, EV_CLIENT_INIT,
EV_CLIENT_INIT_REPLAY,
LOG_HEADER, LOG_HEADER,
LOG_ADD_PLAYER, LOG_ADD_PLAYER,
@ -91,6 +98,17 @@ export default {
INPUT_EV_PLAYER_NAME, INPUT_EV_PLAYER_NAME,
INPUT_EV_TOGGLE_PREVIEW, INPUT_EV_TOGGLE_PREVIEW,
INPUT_EV_TOGGLE_SOUNDS,
INPUT_EV_REPLAY_TOGGLE_PAUSE,
INPUT_EV_REPLAY_SPEED_UP,
INPUT_EV_REPLAY_SPEED_DOWN,
INPUT_EV_TOGGLE_PLAYER_NAMES,
INPUT_EV_CENTER_FIT_PUZZLE,
INPUT_EV_TOGGLE_FIXED_PIECES,
INPUT_EV_TOGGLE_LOOSE_PIECES,
CHANGE_DATA, CHANGE_DATA,
CHANGE_TILE, CHANGE_TILE,

View file

@ -1,4 +1,4 @@
interface RngSerialized { export interface RngSerialized {
rand_high: number, rand_high: number,
rand_low: number, rand_low: number,
} }
@ -15,7 +15,7 @@ export class Rng {
random (min: number, max: number): number { random (min: number, max: number): number {
this.rand_high = ((this.rand_high << 16) + (this.rand_high >> 16) + this.rand_low) & 0xffffffff; this.rand_high = ((this.rand_high << 16) + (this.rand_high >> 16) + this.rand_low) & 0xffffffff;
this.rand_low = (this.rand_low + this.rand_high) & 0xffffffff; this.rand_low = (this.rand_low + this.rand_high) & 0xffffffff;
var n = (this.rand_high >>> 0) / 0xffffffff; const n = (this.rand_high >>> 0) / 0xffffffff;
return (min + n * (max-min+1))|0; return (min + n * (max-min+1))|0;
} }

255
src/common/Types.ts Normal file
View file

@ -0,0 +1,255 @@
import { Point } from "./Geometry"
import { Rng, RngSerialized } from "./Rng"
// @see https://stackoverflow.com/a/59906630/392905
type ArrayLengthMutationKeys = 'splice' | 'push' | 'pop' | 'shift' | 'unshift' | number
type ArrayItems<T extends Array<any>> = T extends Array<infer TItems> ? TItems : never
export type FixedLengthArray<T extends any[]> =
Pick<T, Exclude<keyof T, ArrayLengthMutationKeys>>
& { [Symbol.iterator]: () => IterableIterator< ArrayItems<T> > }
export type Timestamp = number
export type Input = any
export type Change = Array<any>
export type GameEvent = Array<any>
export type ServerEvent = Array<any>
export type ClientEvent = Array<any>
export type EncodedPlayer = FixedLengthArray<[
string,
number,
number,
0|1,
string|null,
string|null,
string|null,
number,
Timestamp,
]>
export type EncodedPiece = FixedLengthArray<[
number,
number,
number,
number,
string|number,
number,
]>
export type EncodedPieceShape = number
export type EncodedGame = FixedLengthArray<[
string,
string,
RngSerialized,
Puzzle,
Array<EncodedPlayer>,
Record<string, EvtInfo>,
ScoreMode,
ShapeMode,
SnapMode,
number|null,
]>
export interface ReplayData {
log: any[],
game: EncodedGame|null
}
export interface Tag {
id: number
slug: string
title: string
total: number
}
interface GameRng {
obj: Rng
type?: string
}
export interface Game {
id: string
creatorUserId: number|null
players: Array<EncodedPlayer>
puzzle: Puzzle
evtInfos: Record<string, EvtInfo>
scoreMode: ScoreMode
shapeMode: ShapeMode
snapMode: SnapMode
rng: GameRng
}
export interface Image {
id: number
filename: string
file: string
url: string
title: string
tags: Array<Tag>
created: number
}
export interface GameSettings {
tiles: number
image: ImageInfo
scoreMode: ScoreMode
shapeMode: ShapeMode
snapMode: SnapMode
}
export interface Puzzle {
tiles: Array<EncodedPiece>
data: PuzzleData
info: PuzzleInfo
}
export interface PuzzleData {
started: number
finished: number
maxGroup: number
maxZ: number
}
export interface PuzzleDataChange {
started?: number
finished?: number
maxGroup?: number
maxZ?: number
}
interface PuzzleTable {
width: number
height: number
}
enum PieceEdge {
Flat = 0,
Out = 1,
In = -1,
}
export interface PieceShape {
top: PieceEdge
bottom: PieceEdge
left: PieceEdge
right: PieceEdge
}
export interface Piece {
owner: string|number
idx: number
pos: Point
z: number
group: number
}
export interface PieceChange {
owner?: string|number
idx?: number
pos?: Point
z?: number
group?: number
}
export interface ImageInfo
{
id: number
uploaderUserId: number|null
filename: string
url: string
title: string
tags: Tag[]
created: Timestamp
width: number
height: number
}
export interface PuzzleInfo {
table: PuzzleTable
targetTiles: number
imageUrl?: string // deprecated, use image.url instead
image?: ImageInfo
width: number
height: number
tileSize: number
tileDrawSize: number
tileMarginWidth: number
tileDrawOffset: number
snapDistance: number
tiles: number
tilesX: number
tilesY: number
shapes: Array<EncodedPieceShape>
}
export interface Player {
id: string
x: number
y: number
d: 0|1
name: string|null
color: string|null
bgcolor: string|null
points: number
ts: Timestamp
}
export interface PlayerChange {
id?: string
x?: number
y?: number
d?: 0|1
name?: string|null
color?: string|null
bgcolor?: string|null
points?: number
ts?: Timestamp
}
export interface EvtInfo {
_last_mouse: Point|null
_last_mouse_down: Point|null
}
export enum ScoreMode {
FINAL = 0,
ANY = 1,
}
export enum ShapeMode {
NORMAL = 0,
ANY = 1,
FLAT = 2,
}
export enum SnapMode {
NORMAL = 0,
REAL = 1,
}
export const DefaultScoreMode = (v: any): ScoreMode => {
if (v === ScoreMode.FINAL || v === ScoreMode.ANY) {
return v
}
return ScoreMode.FINAL
}
export const DefaultShapeMode = (v: any): ShapeMode => {
if (v === ShapeMode.NORMAL || v === ShapeMode.ANY || v === ShapeMode.FLAT) {
return v
}
return ShapeMode.NORMAL
}
export const DefaultSnapMode = (v: any): SnapMode => {
if (v === SnapMode.NORMAL || v === SnapMode.REAL) {
return v
}
return SnapMode.NORMAL
}

View file

@ -1,15 +1,29 @@
import { EncodedPiece, EncodedPieceShape, EncodedPlayer, Piece, PieceShape, Player } from './GameCommon' import { PuzzleCreationInfo } from '../server/Puzzle'
import {
EncodedGame,
EncodedPiece,
EncodedPieceShape,
EncodedPlayer,
Game,
Piece,
PieceShape,
Player,
PuzzleInfo,
ScoreMode,
ShapeMode,
SnapMode
} from './Types'
import { Point } from './Geometry' import { Point } from './Geometry'
import { Rng } from './Rng' import { Rng } from './Rng'
const slug = (str: string) => { const slug = (str: string): string => {
let tmp = str.toLowerCase() let tmp = str.toLowerCase()
tmp = tmp.replace(/[^a-z0-9]+/g, '-') tmp = tmp.replace(/[^a-z0-9]+/g, '-')
tmp = tmp.replace(/^-|-$/, '') tmp = tmp.replace(/^-|-$/, '')
return tmp return tmp
} }
const pad = (x: any, pad: string) => { const pad = (x: number, pad: string): string => {
const str = `${x}` const str = `${x}`
if (str.length >= pad.length) { if (str.length >= pad.length) {
return str return str
@ -17,8 +31,11 @@ const pad = (x: any, pad: string) => {
return pad.substr(0, pad.length - str.length) + str return pad.substr(0, pad.length - str.length) + str
} }
export const logger = (...pre: Array<any>) => { type LogArgs = Array<any>
const log = (m: 'log'|'info'|'error') => (...args: Array<any>) => { type LogFn = (...args: LogArgs) => void
export const logger = (...pre: string[]): { log: LogFn, error: LogFn, info: LogFn } => {
const log = (m: 'log'|'info'|'error') => (...args: LogArgs): void => {
const d = new Date() const d = new Date()
const hh = pad(d.getHours(), '00') const hh = pad(d.getHours(), '00')
const mm = pad(d.getMinutes(), '00') const mm = pad(d.getMinutes(), '00')
@ -33,7 +50,9 @@ export const logger = (...pre: Array<any>) => {
} }
// get a unique id // get a unique id
export const uniqId = () => Date.now().toString(36) + Math.random().toString(36).substring(2) export const uniqId = (): string => {
return Date.now().toString(36) + Math.random().toString(36).substring(2)
}
function encodeShape(data: PieceShape): EncodedPieceShape { function encodeShape(data: PieceShape): EncodedPieceShape {
/* encoded in 1 byte: /* encoded in 1 byte:
@ -58,11 +77,11 @@ function decodeShape(data: EncodedPieceShape): PieceShape {
} }
} }
function encodeTile(data: Piece): EncodedPiece { function encodePiece(data: Piece): EncodedPiece {
return [data.idx, data.pos.x, data.pos.y, data.z, data.owner, data.group] return [data.idx, data.pos.x, data.pos.y, data.z, data.owner, data.group]
} }
function decodeTile(data: EncodedPiece): Piece { function decodePiece(data: EncodedPiece): Piece {
return { return {
idx: data[0], idx: data[0],
pos: { pos: {
@ -103,25 +122,22 @@ function decodePlayer(data: EncodedPlayer): Player {
} }
} }
function encodeGame(data: any): Array<any> { function encodeGame(data: Game): EncodedGame {
if (Array.isArray(data)) {
return data
}
return [ return [
data.id, data.id,
data.rng.type, data.rng.type || '',
Rng.serialize(data.rng.obj), Rng.serialize(data.rng.obj),
data.puzzle, data.puzzle,
data.players, data.players,
data.evtInfos, data.evtInfos,
data.scoreMode, data.scoreMode,
data.shapeMode,
data.snapMode,
data.creatorUserId,
] ]
} }
function decodeGame(data: any) { function decodeGame(data: EncodedGame): Game {
if (!Array.isArray(data)) {
return data
}
return { return {
id: data[0], id: data[0],
rng: { rng: {
@ -132,14 +148,17 @@ function decodeGame(data: any) {
players: data[4], players: data[4],
evtInfos: data[5], evtInfos: data[5],
scoreMode: data[6], scoreMode: data[6],
shapeMode: data[7],
snapMode: data[8],
creatorUserId: data[9],
} }
} }
function coordByTileIdx(info: any, tileIdx: number): Point { function coordByPieceIdx(info: PuzzleInfo|PuzzleCreationInfo, pieceIdx: number): Point {
const wTiles = info.width / info.tileSize const wTiles = info.width / info.tileSize
return { return {
x: tileIdx % wTiles, x: pieceIdx % wTiles,
y: Math.floor(tileIdx / wTiles), y: Math.floor(pieceIdx / wTiles),
} }
} }
@ -147,16 +166,16 @@ const hash = (str: string): number => {
let hash = 0 let hash = 0
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
let chr = str.charCodeAt(i); const chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr; hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer hash |= 0; // Convert to 32bit integer
} }
return hash; return hash;
} }
function asQueryArgs(data: any) { function asQueryArgs(data: Record<string, any>): string {
const q = [] const q = []
for (let k in data) { for (const k in data) {
const pair = [k, data[k]].map(encodeURIComponent) const pair = [k, data[k]].map(encodeURIComponent)
q.push(pair.join('=')) q.push(pair.join('='))
} }
@ -174,8 +193,8 @@ export default {
encodeShape, encodeShape,
decodeShape, decodeShape,
encodeTile, encodePiece,
decodeTile, decodePiece,
encodePlayer, encodePlayer,
decodePlayer, decodePlayer,
@ -183,7 +202,7 @@ export default {
encodeGame, encodeGame,
decodeGame, decodeGame,
coordByTileIdx, coordByPieceIdx,
asQueryArgs, asQueryArgs,
} }

View file

@ -1,7 +0,0 @@
CREATE TABLE game_log (
game_id TEXT,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
entry TEXT NOT NULL
);

View file

@ -0,0 +1,22 @@
-- Add width/height to images table
CREATE TABLE images_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created TIMESTAMP NOT NULL,
filename TEXT NOT NULL UNIQUE,
filename_original TEXT NOT NULL,
title TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
INSERT INTO images_new
SELECT id, created, filename, filename_original, title, 0, 0
FROM images;
DROP TABLE images;
ALTER TABLE images_new RENAME TO images;

View file

@ -0,0 +1,45 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created TIMESTAMP NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL
);
CREATE TABLE images_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uploader_user_id INTEGER,
created TIMESTAMP NOT NULL,
filename TEXT NOT NULL UNIQUE,
filename_original TEXT NOT NULL,
title TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL
);
CREATE TABLE image_x_category_new (
image_id INTEGER NOT NULL,
category_id INTEGER NOT NULL
);
INSERT INTO images_new
SELECT id, NULL, created, filename, filename_original, title, width, height
FROM images;
INSERT INTO image_x_category_new
SELECT image_id, category_id
FROM image_x_category;
PRAGMA foreign_keys = OFF;
DROP TABLE images;
DROP TABLE image_x_category;
ALTER TABLE images_new RENAME TO images;
ALTER TABLE image_x_category_new RENAME TO image_x_category;
PRAGMA foreign_keys = ON;

View file

@ -0,0 +1,11 @@
CREATE TABLE games (
id TEXT PRIMARY KEY,
creator_user_id INTEGER,
image_id INTEGER NOT NULL,
created TIMESTAMP NOT NULL,
finished TIMESTAMP NOT NULL,
data TEXT NOT NULL
);

View file

@ -1,7 +1,7 @@
<template> <template>
<div id="app"> <div id="app">
<ul class="nav" v-if="showNav"> <ul class="nav" v-if="showNav">
<li><router-link class="btn" :to="{name: 'index'}">Index</router-link></li> <li><router-link class="btn" :to="{name: 'index'}">Games overview</router-link></li>
<li><router-link class="btn" :to="{name: 'new-game'}">New game</router-link></li> <li><router-link class="btn" :to="{name: 'new-game'}">New game</router-link></li>
</ul> </ul>

View file

@ -11,6 +11,12 @@ export default function Camera () {
let y = 0 let y = 0
let curZoom = 1 let curZoom = 1
const reset = () => {
x = 0
y = 0
curZoom = 1
}
const move = (byX: number, byY: number) => { const move = (byX: number, byY: number) => {
x += byX / curZoom x += byX / curZoom
y += byY / curZoom y += byY / curZoom
@ -116,15 +122,32 @@ export default function Camera () {
} }
} }
const viewportDimToWorld = (viewportDim: Dim): Dim => {
const { w, h } = viewportDimToWorldRaw(viewportDim)
return { w: Math.round(w), h: Math.round(h) }
}
const viewportDimToWorldRaw = (viewportDim: Dim): Dim => {
return { return {
w: viewportDim.w / curZoom,
h: viewportDim.h / curZoom,
}
}
return {
getCurrentZoom: () => curZoom,
reset,
move, move,
canZoom, canZoom,
zoom, zoom,
setZoom,
worldToViewport, worldToViewport,
worldToViewportRaw, worldToViewportRaw,
worldDimToViewport, // not used outside worldDimToViewport, // not used outside
worldDimToViewportRaw, worldDimToViewportRaw,
viewportToWorld, viewportToWorld,
viewportToWorldRaw, // not used outside viewportToWorldRaw, // not used outside
viewportDimToWorld,
viewportDimToWorldRaw,
} }
} }

View file

@ -1,7 +1,9 @@
"use strict" "use strict"
import { logger } from '../common/Util' import { ClientEvent, EncodedGame, GameEvent, ReplayData, ServerEvent } from '../common/Types'
import Util, { logger } from '../common/Util'
import Protocol from './../common/Protocol' import Protocol from './../common/Protocol'
import xhr from './xhr'
const log = logger('Communication.js') const log = logger('Communication.js')
@ -15,25 +17,41 @@ const CONN_STATE_CONNECTING = 3 // connecting
const CONN_STATE_CLOSED = 4 // not connected (closed on purpose) const CONN_STATE_CLOSED = 4 // not connected (closed on purpose)
let ws: WebSocket let ws: WebSocket
let changesCallback = (msg: Array<any>) => {}
let connectionStateChangeCallback = (state: number) => {}
// TODO: change these to something like on(EVT, cb) let missedMessages: ServerEvent[] = []
function onServerChange(callback: (msg: Array<any>) => void) { let changesCallback = (msg: ServerEvent) => {
changesCallback = callback missedMessages.push(msg)
} }
function onConnectionStateChange(callback: (state: number) => void) {
let missedStateChanges: Array<number> = []
let connectionStateChangeCallback = (state: number) => {
missedStateChanges.push(state)
}
function onServerChange(callback: (msg: ServerEvent) => void): void {
changesCallback = callback
for (const missedMessage of missedMessages) {
changesCallback(missedMessage)
}
missedMessages = []
}
function onConnectionStateChange(callback: (state: number) => void): void {
connectionStateChangeCallback = callback connectionStateChangeCallback = callback
for (const missedStateChange of missedStateChanges) {
connectionStateChangeCallback(missedStateChange)
}
missedStateChanges = []
} }
let connectionState = CONN_STATE_NOT_CONNECTED let connectionState = CONN_STATE_NOT_CONNECTED
const setConnectionState = (state: number) => { const setConnectionState = (state: number): void => {
if (connectionState !== state) { if (connectionState !== state) {
connectionState = state connectionState = state
connectionStateChangeCallback(state) connectionStateChangeCallback(state)
} }
} }
function send(message: Array<any>): void { function send(message: ClientEvent): void {
if (connectionState === CONN_STATE_CONNECTED) { if (connectionState === CONN_STATE_CONNECTED) {
try { try {
ws.send(JSON.stringify(message)) ws.send(JSON.stringify(message))
@ -43,26 +61,25 @@ function send(message: Array<any>): void {
} }
} }
let clientSeq: number let clientSeq: number
let events: Record<number, any> let events: Record<number, GameEvent>
function connect( function connect(
address: string, address: string,
gameId: string, gameId: string,
clientId: string clientId: string
): Promise<any> { ): Promise<EncodedGame> {
clientSeq = 0 clientSeq = 0
events = {} events = {}
setConnectionState(CONN_STATE_CONNECTING) setConnectionState(CONN_STATE_CONNECTING)
return new Promise(resolve => { return new Promise(resolve => {
ws = new WebSocket(address, clientId + '|' + gameId) ws = new WebSocket(address, clientId + '|' + gameId)
ws.onopen = (e) => { ws.onopen = () => {
setConnectionState(CONN_STATE_CONNECTED) setConnectionState(CONN_STATE_CONNECTED)
send([Protocol.EV_CLIENT_INIT]) send([Protocol.EV_CLIENT_INIT])
} }
ws.onmessage = (e) => { ws.onmessage = (e: MessageEvent) => {
const msg = JSON.parse(e.data) const msg: ServerEvent = JSON.parse(e.data)
const msgType = msg[0] const msgType = msg[0]
if (msgType === Protocol.EV_SERVER_INIT) { if (msgType === Protocol.EV_SERVER_INIT) {
const game = msg[1] const game = msg[1]
@ -81,12 +98,12 @@ function connect(
} }
} }
ws.onerror = (e) => { ws.onerror = () => {
setConnectionState(CONN_STATE_DISCONNECTED) setConnectionState(CONN_STATE_DISCONNECTED)
throw `[ 2021-05-15 onerror ]` throw `[ 2021-05-15 onerror ]`
} }
ws.onclose = (e) => { ws.onclose = (e: CloseEvent) => {
if (e.code === CODE_CUSTOM_DISCONNECT || e.code === CODE_GOING_AWAY) { if (e.code === CODE_CUSTOM_DISCONNECT || e.code === CODE_GOING_AWAY) {
setConnectionState(CONN_STATE_CLOSED) setConnectionState(CONN_STATE_CLOSED)
} else { } else {
@ -96,47 +113,14 @@ function connect(
}) })
} }
// TOOD: change replay stuff async function requestReplayData(
function connectReplay(
address: string,
gameId: string, gameId: string,
clientId: string offset: number
): Promise<{ game: any, log: Array<any> }> { ): Promise<ReplayData> {
clientSeq = 0 const args = { gameId, offset }
events = {} const res = await xhr.get(`/api/replay-data${Util.asQueryArgs(args)}`, {})
setConnectionState(CONN_STATE_CONNECTING) const json: ReplayData = await res.json()
return new Promise(resolve => { return json
ws = new WebSocket(address, clientId + '|' + gameId)
ws.onopen = (e) => {
setConnectionState(CONN_STATE_CONNECTED)
send([Protocol.EV_CLIENT_INIT_REPLAY])
}
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
const msgType = msg[0]
if (msgType === Protocol.EV_SERVER_INIT_REPLAY) {
const game = msg[1]
const log = msg[2]
const replay: { game: any, log: Array<any> } = { game, log }
resolve(replay)
} else {
throw `[ 2021-05-09 invalid connectReplay msgType ${msgType} ]`
}
}
ws.onerror = (e) => {
setConnectionState(CONN_STATE_DISCONNECTED)
throw `[ 2021-05-15 onerror ]`
}
ws.onclose = (e) => {
if (e.code === CODE_CUSTOM_DISCONNECT || e.code === CODE_GOING_AWAY) {
setConnectionState(CONN_STATE_CLOSED)
} else {
setConnectionState(CONN_STATE_DISCONNECTED)
}
}
})
} }
function disconnect(): void { function disconnect(): void {
@ -147,7 +131,7 @@ function disconnect(): void {
events = {} events = {}
} }
function sendClientEvent(evt: any): void { function sendClientEvent(evt: GameEvent): void {
// when sending event, increase number of sent events // when sending event, increase number of sent events
// and add the event locally // and add the event locally
clientSeq++; clientSeq++;
@ -157,7 +141,7 @@ function sendClientEvent(evt: any): void {
export default { export default {
connect, connect,
connectReplay, requestReplayData,
disconnect, disconnect,
sendClientEvent, sendClientEvent,
onServerChange, onServerChange,

View file

@ -7,12 +7,12 @@ const log = logger('Debug.js')
let _pt = 0 let _pt = 0
let _mindiff = 0 let _mindiff = 0
const checkpoint_start = (mindiff: number) => { const checkpoint_start = (mindiff: number): void => {
_pt = performance.now() _pt = performance.now()
_mindiff = mindiff _mindiff = mindiff
} }
const checkpoint = (label: string) => { const checkpoint = (label: string): void => {
const now = performance.now() const now = performance.now()
const diff = now - _pt const diff = now - _pt
if (diff > _mindiff) { if (diff > _mindiff) {

View file

@ -0,0 +1,177 @@
import Protocol from "../common/Protocol"
import { GameEvent } from "../common/Types"
import { MODE_REPLAY } from "./game"
function EventAdapter (
canvas: HTMLCanvasElement,
window: any,
viewport: any,
MODE: string
) {
let events: Array<GameEvent> = []
let KEYS_ON = true
let LEFT = false
let RIGHT = false
let UP = false
let DOWN = false
let ZOOM_IN = false
let ZOOM_OUT = false
let SHIFT = false
const toWorldPoint = (x: number, y: number): [number, number] => {
const pos = viewport.viewportToWorld({x, y})
return [pos.x, pos.y]
}
const mousePos = (ev: MouseEvent) => toWorldPoint(ev.offsetX, ev.offsetY)
const canvasCenter = () => toWorldPoint(canvas.width / 2, canvas.height / 2)
const key = (state: boolean, ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.code === 'ShiftLeft' || ev.code === 'ShiftRight') {
SHIFT = state
} else if (ev.code === 'ArrowUp' || ev.code === 'KeyW') {
UP = state
} else if (ev.code === 'ArrowDown' || ev.code === 'KeyS') {
DOWN = state
} else if (ev.code === 'ArrowLeft' || ev.code === 'KeyA') {
LEFT = state
} else if (ev.code === 'ArrowRight' || ev.code === 'KeyD') {
RIGHT = state
} else if (ev.code === 'KeyQ') {
ZOOM_OUT = state
} else if (ev.code === 'KeyE') {
ZOOM_IN = state
}
}
let lastMouse: [number, number]|null = null
canvas.addEventListener('mousedown', (ev) => {
lastMouse = mousePos(ev)
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_DOWN, ...lastMouse])
}
})
canvas.addEventListener('mouseup', (ev) => {
lastMouse = mousePos(ev)
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_UP, ...lastMouse])
}
})
canvas.addEventListener('mousemove', (ev) => {
lastMouse = mousePos(ev)
addEvent([Protocol.INPUT_EV_MOUSE_MOVE, ...lastMouse])
})
canvas.addEventListener('wheel', (ev) => {
lastMouse = mousePos(ev)
if (viewport.canZoom(ev.deltaY < 0 ? 'in' : 'out')) {
const evt = ev.deltaY < 0
? Protocol.INPUT_EV_ZOOM_IN
: Protocol.INPUT_EV_ZOOM_OUT
addEvent([evt, ...lastMouse])
}
})
window.addEventListener('keydown', (ev: KeyboardEvent) => key(true, ev))
window.addEventListener('keyup', (ev: KeyboardEvent) => key(false, ev))
window.addEventListener('keypress', (ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.code === 'Space') {
addEvent([Protocol.INPUT_EV_TOGGLE_PREVIEW])
}
if (MODE === MODE_REPLAY) {
if (ev.code === 'KeyI') {
addEvent([Protocol.INPUT_EV_REPLAY_SPEED_UP])
}
if (ev.code === 'KeyO') {
addEvent([Protocol.INPUT_EV_REPLAY_SPEED_DOWN])
}
if (ev.code === 'KeyP') {
addEvent([Protocol.INPUT_EV_REPLAY_TOGGLE_PAUSE])
}
}
if (ev.code === 'KeyF') {
addEvent([Protocol.INPUT_EV_TOGGLE_FIXED_PIECES])
}
if (ev.code === 'KeyG') {
addEvent([Protocol.INPUT_EV_TOGGLE_LOOSE_PIECES])
}
if (ev.code === 'KeyM') {
addEvent([Protocol.INPUT_EV_TOGGLE_SOUNDS])
}
if (ev.code === 'KeyN') {
addEvent([Protocol.INPUT_EV_TOGGLE_PLAYER_NAMES])
}
if (ev.code === 'KeyC') {
addEvent([Protocol.INPUT_EV_CENTER_FIT_PUZZLE])
}
})
const addEvent = (event: GameEvent) => {
events.push(event)
}
const consumeAll = (): GameEvent[] => {
if (events.length === 0) {
return []
}
const all = events.slice()
events = []
return all
}
const createKeyEvents = (): void => {
const w = (LEFT ? 1 : 0) - (RIGHT ? 1 : 0)
const h = (UP ? 1 : 0) - (DOWN ? 1 : 0)
if (w !== 0 || h !== 0) {
const amount = (SHIFT ? 24 : 12) * Math.sqrt(viewport.getCurrentZoom())
const pos = viewport.viewportDimToWorld({w: w * amount, h: h * amount})
addEvent([Protocol.INPUT_EV_MOVE, pos.w, pos.h])
if (lastMouse) {
lastMouse[0] -= pos.w
lastMouse[1] -= pos.h
}
}
if (ZOOM_IN && ZOOM_OUT) {
// cancel each other out
} else if (ZOOM_IN) {
if (viewport.canZoom('in')) {
const target = lastMouse || canvasCenter()
addEvent([Protocol.INPUT_EV_ZOOM_IN, ...target])
}
} else if (ZOOM_OUT) {
if (viewport.canZoom('out')) {
const target = lastMouse || canvasCenter()
addEvent([Protocol.INPUT_EV_ZOOM_OUT, ...target])
}
}
}
const setHotkeys = (state: boolean) => {
KEYS_ON = state
}
return {
addEvent,
consumeAll,
createKeyEvents,
setHotkeys,
}
}
export default EventAdapter

View file

@ -1,7 +1,6 @@
"use strict" "use strict"
import { Rng } from '../common/Rng' import { Rng } from '../common/Rng'
import Util from '../common/Util'
let minVx = -10 let minVx = -10
let deltaVx = 20 let deltaVx = 20
@ -108,11 +107,11 @@ class Bomb {
} }
class Particle { class Particle {
px: any px: number
py: any py: number
vx: number vx: number
vy: number vy: number
color: any color: string
duration: number duration: number
alive: boolean alive: boolean
radius: number radius: number
@ -171,7 +170,7 @@ class Controller {
}) })
} }
setSpeedParams() { setSpeedParams(): void {
let heightReached = 0 let heightReached = 0
let vy = 0 let vy = 0
@ -188,11 +187,11 @@ class Controller {
deltaVx = 2 * vx deltaVx = 2 * vx
} }
resize() { resize(): void {
this.setSpeedParams() this.setSpeedParams()
} }
init() { init(): void {
this.readyBombs = [] this.readyBombs = []
this.explodedBombs = [] this.explodedBombs = []
this.particles = [] this.particles = []
@ -202,7 +201,7 @@ class Controller {
} }
} }
update() { update(): void {
if (Math.random() * 100 < percentChanceNewBomb) { if (Math.random() * 100 < percentChanceNewBomb) {
this.readyBombs.push(new Bomb(this.rng)) this.readyBombs.push(new Bomb(this.rng))
} }
@ -250,7 +249,7 @@ class Controller {
this.particles = aliveParticles this.particles = aliveParticles
} }
render() { render(): void {
this.ctx.beginPath() this.ctx.beginPath()
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.1)' // Ghostly effect this.ctx.fillStyle = 'rgba(0, 0, 0, 0.1)' // Ghostly effect
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height) this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)

View file

@ -3,22 +3,22 @@
import Geometry, { Rect } from '../common/Geometry' import Geometry, { Rect } from '../common/Geometry'
import Graphics from './Graphics' import Graphics from './Graphics'
import Util, { logger } from './../common/Util' import Util, { logger } from './../common/Util'
import { Puzzle, PuzzleInfo, PieceShape } from './../common/GameCommon' import { Puzzle, PuzzleInfo, PieceShape, EncodedPiece } from './../common/Types'
const log = logger('PuzzleGraphics.js') const log = logger('PuzzleGraphics.js')
async function createPuzzleTileBitmaps( async function createPuzzleTileBitmaps(
img: ImageBitmap, img: ImageBitmap,
tiles: Array<any>, pieces: EncodedPiece[],
info: PuzzleInfo info: PuzzleInfo
): Promise<Array<ImageBitmap>> { ): Promise<Array<ImageBitmap>> {
log.log('start createPuzzleTileBitmaps') log.log('start createPuzzleTileBitmaps')
var tileSize = info.tileSize const tileSize = info.tileSize
var tileMarginWidth = info.tileMarginWidth const tileMarginWidth = info.tileMarginWidth
var tileDrawSize = info.tileDrawSize const tileDrawSize = info.tileDrawSize
var tileRatio = tileSize / 100.0 const tileRatio = tileSize / 100.0
var curvyCoords = [ const curvyCoords = [
0, 0, 40, 15, 37, 5, 0, 0, 40, 15, 37, 5,
37, 5, 40, 0, 38, -5, 37, 5, 40, 0, 38, -5,
38, -5, 20, -20, 50, -20, 38, -5, 20, -20, 50, -20,
@ -27,7 +27,7 @@ async function createPuzzleTileBitmaps(
63, 5, 65, 15, 100, 0 63, 5, 65, 15, 100, 0
]; ];
const bitmaps: Array<ImageBitmap> = new Array(tiles.length) const bitmaps: Array<ImageBitmap> = new Array(pieces.length)
const paths: Record<string, Path2D> = {} const paths: Record<string, Path2D> = {}
function pathForShape(shape: PieceShape) { function pathForShape(shape: PieceShape) {
@ -65,9 +65,9 @@ async function createPuzzleTileBitmaps(
} }
if (shape.bottom !== 0) { if (shape.bottom !== 0) {
for (let i = 0; i < curvyCoords.length / 6; i++) { for (let i = 0; i < curvyCoords.length / 6; i++) {
let p1 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 0] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 1] * tileRatio }) const p1 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 0] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 1] * tileRatio })
let p2 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 2] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 3] * tileRatio }) const p2 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 2] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 3] * tileRatio })
let p3 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 4] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 5] * tileRatio }) const p3 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 4] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 5] * tileRatio })
path.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y); path.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
} }
} else { } else {
@ -75,9 +75,9 @@ async function createPuzzleTileBitmaps(
} }
if (shape.left !== 0) { if (shape.left !== 0) {
for (let i = 0; i < curvyCoords.length / 6; i++) { for (let i = 0; i < curvyCoords.length / 6; i++) {
let p1 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 1] * tileRatio, y: curvyCoords[i * 6 + 0] * tileRatio }) const p1 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 1] * tileRatio, y: curvyCoords[i * 6 + 0] * tileRatio })
let p2 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 3] * tileRatio, y: curvyCoords[i * 6 + 2] * tileRatio }) const p2 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 3] * tileRatio, y: curvyCoords[i * 6 + 2] * tileRatio })
let p3 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 5] * tileRatio, y: curvyCoords[i * 6 + 4] * tileRatio }) const p3 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 5] * tileRatio, y: curvyCoords[i * 6 + 4] * tileRatio })
path.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y); path.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
} }
} else { } else {
@ -93,10 +93,10 @@ async function createPuzzleTileBitmaps(
const c2 = Graphics.createCanvas(tileDrawSize, tileDrawSize) const c2 = Graphics.createCanvas(tileDrawSize, tileDrawSize)
const ctx2 = c2.getContext('2d') as CanvasRenderingContext2D const ctx2 = c2.getContext('2d') as CanvasRenderingContext2D
for (const t of tiles) { for (const p of pieces) {
const tile = Util.decodeTile(t) const piece = Util.decodePiece(p)
const srcRect = srcRectByIdx(info, tile.idx) const srcRect = srcRectByIdx(info, piece.idx)
const path = pathForShape(Util.decodeShape(info.shapes[tile.idx])) const path = pathForShape(Util.decodeShape(info.shapes[piece.idx]))
ctx.clearRect(0, 0, tileDrawSize, tileDrawSize) ctx.clearRect(0, 0, tileDrawSize, tileDrawSize)
@ -195,7 +195,7 @@ async function createPuzzleTileBitmaps(
ctx2.restore() ctx2.restore()
ctx.drawImage(c2, 0, 0) ctx.drawImage(c2, 0, 0)
bitmaps[tile.idx] = await createImageBitmap(c) bitmaps[piece.idx] = await createImageBitmap(c)
} }
log.log('end createPuzzleTileBitmaps') log.log('end createPuzzleTileBitmaps')
@ -203,7 +203,7 @@ async function createPuzzleTileBitmaps(
} }
function srcRectByIdx(puzzleInfo: PuzzleInfo, idx: number): Rect { function srcRectByIdx(puzzleInfo: PuzzleInfo, idx: number): Rect {
const c = Util.coordByTileIdx(puzzleInfo, idx) const c = Util.coordByPieceIdx(puzzleInfo, idx)
return { return {
x: c.x * puzzleInfo.tileSize, x: c.x * puzzleInfo.tileSize,
y: c.y * puzzleInfo.tileSize, y: c.y * puzzleInfo.tileSize,

BIN
src/frontend/click.mp3 Normal file

Binary file not shown.

View file

@ -23,7 +23,7 @@
<!-- TODO: autocomplete tags --> <!-- TODO: autocomplete tags -->
<td><label>Tags</label></td> <td><label>Tags</label></td>
<td> <td>
<tags-input v-model="tags" /> <tags-input v-model="tags" :autocompleteTags="autocompleteTags" />
</td> </td>
</tr> </tr>
</table> </table>
@ -38,7 +38,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from 'vue' import { defineComponent, PropType } from 'vue'
import { Image, Tag } from '../../common/GameCommon' import { Image, Tag } from '../../common/Types'
import ResponsiveImage from './ResponsiveImage.vue' import ResponsiveImage from './ResponsiveImage.vue'
import TagsInput from './TagsInput.vue' import TagsInput from './TagsInput.vue'
@ -54,6 +54,9 @@ export default defineComponent({
type: Object as PropType<Image>, type: Object as PropType<Image>,
required: true, required: true,
}, },
autocompleteTags: {
type: Function,
},
}, },
emits: { emits: {
bgclick: null, bgclick: null,
@ -93,7 +96,17 @@ export default defineComponent({
height: 90%; height: 90%;
width: 80%; width: 80%;
} }
@media (max-width: 1400px) and (min-height: 720px),
(max-width: 1000px) {
.edit-image-dialog .overlay-content {
grid-template-columns: auto;
grid-template-rows: 1fr min-content min-content;
grid-template-areas:
"image"
"settings"
"buttons";
}
}
.edit-image-dialog .area-image { .edit-image-dialog .area-image {
grid-area: image; grid-area: image;
margin: 20px; margin: 20px;

View file

@ -7,7 +7,7 @@
{{time(game.started, game.finished)}}<br /> {{time(game.started, game.finished)}}<br />
</span> </span>
</router-link> </router-link>
<router-link v-if="false && game.hasReplay" class="game-replay" :to="{ name: 'replay', params: { id: game.id } }"> <router-link v-if="game.hasReplay" class="game-replay" :to="{ name: 'replay', params: { id: game.id } }">
Watch replay Watch replay
</router-link> </router-link>
</div> </div>

View file

@ -10,8 +10,17 @@
<tr><td>🔍+ Zoom in:</td><td><div><kbd>E</kbd>/🖱-Wheel</div></td></tr> <tr><td>🔍+ Zoom in:</td><td><div><kbd>E</kbd>/🖱-Wheel</div></td></tr>
<tr><td>🔍- Zoom out:</td><td><div><kbd>Q</kbd>/🖱-Wheel</div></td></tr> <tr><td>🔍- Zoom out:</td><td><div><kbd>Q</kbd>/🖱-Wheel</div></td></tr>
<tr><td>🖼 Toggle preview:</td><td><div><kbd>Space</kbd></div></td></tr> <tr><td>🖼 Toggle preview:</td><td><div><kbd>Space</kbd></div></td></tr>
<tr><td>🎯 Center puzzle in screen:</td><td><div><kbd>C</kbd></div></td></tr>
<tr><td>🧩 Toggle fixed pieces:</td><td><div><kbd>F</kbd></div></td></tr> <tr><td>🧩 Toggle fixed pieces:</td><td><div><kbd>F</kbd></div></td></tr>
<tr><td>🧩 Toggle loose pieces:</td><td><div><kbd>G</kbd></div></td></tr> <tr><td>🧩 Toggle loose pieces:</td><td><div><kbd>G</kbd></div></td></tr>
<tr><td>👤 Toggle player names:</td><td><div><kbd>N</kbd></div></td></tr>
<tr><td>🔉 Toggle sounds:</td><td><div><kbd>M</kbd></div></td></tr>
<tr><td> Speed up (replay):</td><td><div><kbd>I</kbd></div></td></tr>
<tr><td> Speed down (replay):</td><td><div><kbd>O</kbd></div></td></tr>
<tr><td> Pause (replay):</td><td><div><kbd>P</kbd></div></td></tr>
</table> </table>
</div> </div>
</template> </template>

View file

@ -5,7 +5,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { Image } from '../../common/GameCommon' import { Image } from '../../common/Types'
import ImageTeaser from './ImageTeaser.vue' import ImageTeaser from './ImageTeaser.vue'

View file

@ -3,7 +3,7 @@
class="imageteaser" class="imageteaser"
:style="style" :style="style"
@click="onClick"> @click="onClick">
<div class="btn edit" @click.stop="onEditClick"></div> <div class="btn edit" v-if="canEdit" @click.stop="onEditClick"></div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -18,12 +18,18 @@ export default defineComponent({
}, },
}, },
computed: { computed: {
style (): object { style(): object {
const url = this.image.url.replace('uploads/', 'uploads/r/') + '-150x100.webp' const url = this.image.url.replace('uploads/', 'uploads/r/') + '-150x100.webp'
return { return {
'backgroundImage': `url("${url}")`, 'backgroundImage': `url("${url}")`,
} }
}, },
canEdit(): boolean {
if (!this.$me.id) {
return false
}
return this.$me.id === this.image.uploaderUserId
},
}, },
emits: { emits: {
click: null, click: null,

View file

@ -0,0 +1,68 @@
<template>
<div class="overlay transparent" @click="$emit('bgclick')">
<table class="overlay-content help" @click.stop="">
<tr>
<td colspan="2">Info about this puzzle</td>
</tr>
<tr>
<td>Image Title: </td>
<td>{{game.puzzle.info.image.title}}</td>
</tr>
<tr>
<td>Scoring: </td>
<td><span :title="snapMode[1]">{{scoreMode[0]}}</span></td>
</tr>
<tr>
<td>Shapes: </td>
<td><span :title="snapMode[1]">{{shapeMode[0]}}</span></td>
</tr>
<tr>
<td>Snapping: </td>
<td><span :title="snapMode[1]">{{snapMode[0]}}</span></td>
</tr>
</table>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { Game, ScoreMode, ShapeMode, SnapMode } from '../../common/Types'
export default defineComponent({
name: 'help-overlay',
emits: {
bgclick: null,
},
props: {
game: {
type: Object as PropType<Game>,
required: true,
},
},
computed: {
scoreMode () {
switch (this.game.scoreMode) {
case ScoreMode.ANY: return ['Any', 'Score when pieces are connected to each other or on final location']
case ScoreMode.FINAL:
default: return ['Final', 'Score when pieces are put to their final location']
}
},
shapeMode () {
switch (this.game.shapeMode) {
case ShapeMode.FLAT: return ['Flat', 'All pieces flat on all sides']
case ShapeMode.ANY: return ['Any', 'Flat pieces can occur anywhere']
case ShapeMode.NORMAL:
default:
return ['Normal', '']
}
},
snapMode () {
switch (this.game.snapMode) {
case SnapMode.REAL: return ['Real', 'Pieces snap only to corners, already snapped pieces and to each other']
case SnapMode.NORMAL:
default:
return ['Normal', 'Pieces snap to final destination and to each other']
}
},
},
})
</script>

View file

@ -6,6 +6,10 @@
<div class="has-image"> <div class="has-image">
<responsive-image :src="image.url" :title="image.title" /> <responsive-image :src="image.url" :title="image.title" />
</div> </div>
<div class="image-title" v-if="image.title || image.width || image.height">
<span class="image-title-title" v-if="image.title">"{{image.title}}"</span>
<span class="image-title-dim" v-if="image.width || image.height">({{image.width}} {{image.height}})</span>
</div>
</div> </div>
<div class="area-settings"> <div class="area-settings">
@ -17,9 +21,34 @@
<tr> <tr>
<td><label>Scoring: </label></td> <td><label>Scoring: </label></td>
<td> <td>
<label><input type="radio" v-model="scoreMode" value="1" /> Any (Score when pieces are connected to each other or on final location)</label> <label><input type="radio" v-model="scoreMode" value="1" />
Any (Score when pieces are connected to each other or on final location)</label>
<br /> <br />
<label><input type="radio" v-model="scoreMode" value="0" /> Final (Score when pieces are put to their final location)</label> <label><input type="radio" v-model="scoreMode" value="0" />
Final (Score when pieces are put to their final location)</label>
</td>
</tr>
<tr>
<td><label>Shapes: </label></td>
<td>
<label><input type="radio" v-model="shapeMode" value="0" />
Normal</label>
<br />
<label><input type="radio" v-model="shapeMode" value="1" />
Any (Flat pieces can occur anywhere)</label>
<br />
<label><input type="radio" v-model="shapeMode" value="2" />
Flat (All pieces flat on all sides)</label>
</td>
</tr>
<tr>
<td><label>Snapping: </label></td>
<td>
<label><input type="radio" v-model="snapMode" value="0" />
Normal (Pieces snap to final destination and to each other)</label>
<br />
<label><input type="radio" v-model="snapMode" value="1" />
Real (Pieces snap only to corners, already snapped pieces and to each other)</label>
</td> </td>
</tr> </tr>
</table> </table>
@ -36,7 +65,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { GameSettings, ScoreMode } from './../../common/GameCommon' import { GameSettings, ScoreMode, ShapeMode, SnapMode } from './../../common/Types'
import ResponsiveImage from './ResponsiveImage.vue' import ResponsiveImage from './ResponsiveImage.vue'
export default defineComponent({ export default defineComponent({
@ -58,6 +87,8 @@ export default defineComponent({
return { return {
tiles: 1000, tiles: 1000,
scoreMode: ScoreMode.ANY, scoreMode: ScoreMode.ANY,
shapeMode: ShapeMode.NORMAL,
snapMode: SnapMode.NORMAL,
} }
}, },
methods: { methods: {
@ -66,6 +97,8 @@ export default defineComponent({
tiles: this.tilesInt, tiles: this.tilesInt,
image: this.image, image: this.image,
scoreMode: this.scoreModeInt, scoreMode: this.scoreModeInt,
shapeMode: this.shapeModeInt,
snapMode: this.snapModeInt,
} as GameSettings) } as GameSettings)
}, },
}, },
@ -84,6 +117,12 @@ export default defineComponent({
scoreModeInt (): number { scoreModeInt (): number {
return parseInt(`${this.scoreMode}`, 10) return parseInt(`${this.scoreMode}`, 10)
}, },
shapeModeInt (): number {
return parseInt(`${this.shapeMode}`, 10)
},
snapModeInt (): number {
return parseInt(`${this.snapMode}`, 10)
},
tilesInt (): number { tilesInt (): number {
return parseInt(`${this.tiles}`, 10) return parseInt(`${this.tiles}`, 10)
}, },
@ -106,7 +145,26 @@ export default defineComponent({
} }
.new-game-dialog .area-image { .new-game-dialog .area-image {
grid-area: image; grid-area: image;
margin: 20px; display: grid;
grid-template-rows: 1fr min-content;
grid-template-areas:
"image"
"image-title";
margin-right: 1em;
}
@media (max-width: 1400px) and (min-height: 720px),
(max-width: 1000px) {
.new-game-dialog .overlay-content {
grid-template-columns: auto;
grid-template-rows: 1fr min-content min-content;
grid-template-areas:
"image"
"settings"
"buttons";
}
.new-game-dialog .area-image {
margin-right: 0;
}
} }
.new-game-dialog .area-settings { .new-game-dialog .area-settings {
grid-area: settings; grid-area: settings;
@ -124,13 +182,29 @@ export default defineComponent({
width: 100%; width: 100%;
} }
.new-game-dialog .has-image { .new-game-dialog .has-image {
box-sizing: border-box;
grid-area: image;
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: solid 1px;
} }
.new-game-dialog .image-title {
grid-area: image-title;
text-align: center;
padding: .5em 0;
background: var(--main-color);
color: #262523;
}
.new-game-dialog .has-image .remove { .new-game-dialog .has-image .remove {
position: absolute; position: absolute;
top: .5em; top: .5em;
left: .5em; left: .5em;
} }
.new-game-dialog .image-title > span { margin-right: .5em; }
.new-game-dialog .image-title > span:last-child { margin-right: 0; }
.image-title-dim { display: inline-block; white-space: no-wrap; }
</style> </style>

View file

@ -7,15 +7,21 @@ gallery", if possible!
<div class="overlay new-image-dialog" @click="$emit('bgclick')"> <div class="overlay new-image-dialog" @click="$emit('bgclick')">
<div class="overlay-content" @click.stop=""> <div class="overlay-content" @click.stop="">
<div class="area-image" :class="{'has-image': !!previewUrl, 'no-image': !previewUrl}"> <div
class="area-image"
:class="{'has-image': !!previewUrl, 'no-image': !previewUrl, droppable: droppable}"
@drop="onDrop"
@dragover="onDragover"
@dragleave="onDragleave">
<!-- TODO: ... --> <!-- TODO: ... -->
<div class="drop-target"></div>
<div v-if="previewUrl" class="has-image"> <div v-if="previewUrl" class="has-image">
<span class="remove btn" @click="previewUrl=''">X</span> <span class="remove btn" @click="previewUrl=''">X</span>
<responsive-image :src="previewUrl" /> <responsive-image :src="previewUrl" />
</div> </div>
<div v-else> <div v-else>
<label class="upload"> <label class="upload">
<input type="file" style="display: none" @change="preview" accept="image/*" /> <input type="file" style="display: none" @change="onFileSelect" accept="image/*" />
<span class="btn">Upload File</span> <span class="btn">Upload File</span>
</label> </label>
</div> </div>
@ -36,32 +42,57 @@ gallery", if possible!
<!-- TODO: autocomplete tags --> <!-- TODO: autocomplete tags -->
<td><label>Tags</label></td> <td><label>Tags</label></td>
<td> <td>
<tags-input v-model="tags" /> <tags-input v-model="tags" :autocompleteTags="autocompleteTags" />
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
<div class="area-buttons"> <div class="area-buttons">
<button class="btn" :disabled="!canPostToGallery" @click="postToGallery">🖼 Post to gallery</button> <button class="btn"
<button class="btn" :disabled="!canSetupGameClick" @click="setupGameClick">🧩 Post to gallery <br /> + set up game</button> :disabled="!canPostToGallery"
@click="postToGallery"
>
<template v-if="uploading === 'postToGallery'">Uploading ({{uploadProgressPercent}}%)</template>
<template v-else>🖼 Post to gallery</template>
</button>
<button class="btn"
:disabled="!canSetupGameClick"
@click="setupGameClick"
>
<template v-if="uploading === 'setupGame'">Uploading ({{uploadProgressPercent}}%)</template>
<template v-else>🧩 Post to gallery <br /> + set up game</template>
</button>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { logger } from '../../common/Util'
import ResponsiveImage from './ResponsiveImage.vue' import ResponsiveImage from './ResponsiveImage.vue'
import TagsInput from './TagsInput.vue' import TagsInput from './TagsInput.vue'
const log = logger('NewImageDialog.vue')
export default defineComponent({ export default defineComponent({
name: 'new-image-dialog', name: 'new-image-dialog',
components: { components: {
ResponsiveImage, ResponsiveImage,
TagsInput, TagsInput,
}, },
props: {
autocompleteTags: {
type: Function,
},
uploadProgress: {
type: Number,
},
uploading: {
type: String,
},
},
emits: { emits: {
bgclick: null, bgclick: null,
setupGameClick: null, setupGameClick: null,
@ -73,23 +104,47 @@ export default defineComponent({
file: null as File|null, file: null as File|null,
title: '', title: '',
tags: [] as string[], tags: [] as string[],
droppable: false,
} }
}, },
computed: { computed: {
uploadProgressPercent (): number {
return this.uploadProgress ? Math.round(this.uploadProgress * 100) : 0
},
canPostToGallery (): boolean { canPostToGallery (): boolean {
if (this.uploading) {
return false
}
return !!(this.previewUrl && this.file) return !!(this.previewUrl && this.file)
}, },
canSetupGameClick (): boolean { canSetupGameClick (): boolean {
if (this.uploading) {
return false
}
return !!(this.previewUrl && this.file) return !!(this.previewUrl && this.file)
}, },
}, },
methods: { methods: {
preview (evt: Event) { imageFromDragEvt (evt: DragEvent): DataTransferItem|null {
const items = evt.dataTransfer?.items
if (!items || items.length === 0) {
return null
}
const item = items[0]
if (!item.type.startsWith('image/')) {
return null
}
return item
},
onFileSelect (evt: Event) {
const target = (evt.target as HTMLInputElement) const target = (evt.target as HTMLInputElement)
if (!target.files) return; if (!target.files) return;
const file = target.files[0] const file = target.files[0]
if (!file) return; if (!file) return;
this.preview(file)
},
preview (file: File) {
const r = new FileReader() const r = new FileReader()
r.readAsDataURL(file) r.readAsDataURL(file)
r.onload = (ev: any) => { r.onload = (ev: any) => {
@ -111,6 +166,34 @@ export default defineComponent({
tags: this.tags, tags: this.tags,
}) })
}, },
onDrop (evt: DragEvent): boolean {
this.droppable = false
const img = this.imageFromDragEvt(evt)
if (!img) {
return false
}
const f = img.getAsFile()
if (!f) {
return false
}
this.file = f
this.preview(f)
evt.preventDefault()
return false
},
onDragover (evt: DragEvent): boolean {
const img = this.imageFromDragEvt(evt)
if (!img) {
return false
}
this.droppable = true
evt.preventDefault()
return false
},
onDragleave () {
log.info('onDragleave')
this.droppable = false
},
}, },
}) })
</script> </script>
@ -127,18 +210,36 @@ export default defineComponent({
height: 90%; height: 90%;
width: 80%; width: 80%;
} }
@media (max-width: 1400px) and (min-height: 720px),
(max-width: 1000px) {
.new-image-dialog .overlay-content {
grid-template-columns: auto;
grid-template-rows: 1fr min-content min-content;
grid-template-areas:
"image"
"settings"
"buttons";
}
.new-image-dialog .overlay-content .area-buttons .btn br {
display: none;
}
}
.new-image-dialog .area-image { .new-image-dialog .area-image {
grid-area: image; grid-area: image;
margin: 20px; margin: .5em;
border: solid 6px transparent;
} }
.new-image-dialog .area-image.no-image { .new-image-dialog .area-image.no-image {
align-content: center; align-content: center;
display: grid; display: grid;
text-align: center; text-align: center;
border: dashed 6px; border: solid 6px;
position: relative; position: relative;
} }
.new-image-dialog .area-image.droppable {
border: dashed 6px;
}
.new-image-dialog .area-image .has-image { .new-image-dialog .area-image .has-image {
position: relative; position: relative;
width: 100%; width: 100%;
@ -180,4 +281,16 @@ export default defineComponent({
top: 50%; top: 50%;
transform: translate(-50%,-50%); transform: translate(-50%,-50%);
} }
.area-image .drop-target {
display: none;
}
.area-image.droppable .drop-target {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
}
</style> </style>

View file

@ -13,6 +13,28 @@
<td><label>Name: </label></td> <td><label>Name: </label></td>
<td><input type="text" maxLength="16" v-model="modelValue.name" /></td> <td><input type="text" maxLength="16" v-model="modelValue.name" /></td>
</tr> </tr>
<tr>
<td><label>Sounds: </label></td>
<td><input type="checkbox" v-model="modelValue.soundsEnabled" /></td>
</tr>
<tr>
<td><label>Sounds Volume: </label></td>
<td class="sound-volume">
<span @click="decreaseVolume">🔉</span>
<input
type="range"
min="0"
max="100"
:value="modelValue.soundsVolume"
@change="updateVolume"
/>
<span @click="increaseVolume">🔊</span>
</td>
</tr>
<tr>
<td><label>Show player names: </label></td>
<td><input type="checkbox" v-model="modelValue.showPlayerNames" /></td>
</tr>
</table> </table>
</div> </div>
</template> </template>
@ -26,7 +48,23 @@ export default defineComponent({
'update:modelValue': null, 'update:modelValue': null,
}, },
props: { props: {
modelValue: Object, modelValue: {
type: Object,
required: true,
},
},
methods: {
updateVolume (ev: Event): void {
(this.modelValue as any).soundsVolume = (ev.target as HTMLInputElement).value
},
decreaseVolume (): void {
const vol = parseInt(this.modelValue.soundsVolume, 10) - 5
this.modelValue.soundsVolume = Math.max(0, vol)
},
increaseVolume (): void {
const vol = parseInt(this.modelValue.soundsVolume, 10) + 5
this.modelValue.soundsVolume = Math.min(100, vol)
},
}, },
created () { created () {
// TODO: ts type PlayerSettings // TODO: ts type PlayerSettings
@ -36,3 +74,7 @@ export default defineComponent({
}, },
}) })
</script> </script>
<style scoped>
.sound-volume span { cursor: pointer; user-select: none; }
.sound-volume input { vertical-align: middle; }
</style>

View file

@ -1,19 +1,42 @@
<template> <template>
<div> <div>
<input class="input" type="text" v-model="input" placeholder="Plants, People" @keydown.enter="add" @keyup="onKeyUp" /> <input
ref="input"
class="input"
type="text"
v-model="input"
placeholder="Plants, People"
@change="onChange"
@keydown.enter="add"
@keyup="onKeyUp"
/>
<div v-if="autocomplete.values" class="autocomplete">
<ul>
<li
v-for="(val,idx) in autocomplete.values"
:key="idx"
:class="{active: idx===autocomplete.idx}"
@click="addVal(val)"
>{{val}}</li>
</ul>
</div>
<span v-for="(tag,idx) in values" :key="idx" class="bit" @click="rm(tag)">{{tag}} </span> <span v-for="(tag,idx) in values" :key="idx" class="bit" @click="rm(tag)">{{tag}} </span>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from 'vue' import { defineComponent, PropType } from 'vue'
import { Tag } from '../../common/Types'
export default defineComponent({ export default defineComponent({
name: 'tags-input', name: 'tags-input',
props: { props: {
modelValue: { modelValue: {
type: Array as PropType<Array<string>>, type: Array as PropType<string[]>,
required: true, required: true,
}, },
autocompleteTags: {
type: Function,
},
}, },
emits: { emits: {
'update:modelValue': null, 'update:modelValue': null,
@ -21,7 +44,11 @@ export default defineComponent({
data () { data () {
return { return {
input: '', input: '',
values: [] as Array<string>, values: [] as string[],
autocomplete: {
idx: -1,
values: [] as string[],
},
} }
}, },
created () { created () {
@ -29,14 +56,39 @@ export default defineComponent({
}, },
methods: { methods: {
onKeyUp (ev: KeyboardEvent) { onKeyUp (ev: KeyboardEvent) {
if (ev.code === 'ArrowDown' && this.autocomplete.values.length > 0) {
if (this.autocomplete.idx < this.autocomplete.values.length - 1) {
this.autocomplete.idx++
}
ev.stopPropagation()
return false
}
if (ev.code === 'ArrowUp' && this.autocomplete.values.length > 0) {
if (this.autocomplete.idx > 0) {
this.autocomplete.idx--
}
ev.stopPropagation()
return false
}
if (ev.key === ',') { if (ev.key === ',') {
this.add() this.add()
ev.stopPropagation() ev.stopPropagation()
return false return false
} }
if (this.input && this.autocompleteTags) {
this.autocomplete.values = this.autocompleteTags(
this.input,
this.values
)
this.autocomplete.idx = -1
} else {
this.autocomplete.values = []
this.autocomplete.idx = -1
}
}, },
add () { addVal (value: string) {
const newval = this.input.replace(/,/g, '').trim() const newval = value.replace(/,/g, '').trim()
if (!newval) { if (!newval) {
return return
} }
@ -44,7 +96,16 @@ export default defineComponent({
this.values.push(newval) this.values.push(newval)
} }
this.input = '' this.input = ''
this.autocomplete.values = []
this.autocomplete.idx = -1
this.$emit('update:modelValue', this.values) this.$emit('update:modelValue', this.values)
;(this.$refs.input as HTMLInputElement).focus()
},
add () {
const value = this.autocomplete.idx >= 0
? this.autocomplete.values[this.autocomplete.idx]
: this.input
this.addVal(value)
}, },
rm (val: string) { rm (val: string) {
this.values = this.values.filter(v => v !== val) this.values = this.values.filter(v => v !== val)
@ -57,4 +118,31 @@ export default defineComponent({
.input { .input {
margin-bottom: .5em; margin-bottom: .5em;
} }
.autocomplete {
position: relative;
}
.autocomplete ul { list-style: none;
padding: 0;
margin: 0;
position: absolute;
left: 0;
right: 0;
background: #333230;
top: -.5em;
}
.autocomplete ul li {
position: relative;
padding: .5em .5em .5em 1.5em;
cursor: pointer;
}
.autocomplete ul li.active {
color: var(--link-hover-color);
background: var(--input-bg-color);
}
.autocomplete ul li.active:before {
content: '▶';
display: block;
position: absolute;
left: .5em;
}
</style> </style>

View file

@ -6,6 +6,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import xhr from '../xhr'
export default defineComponent({ export default defineComponent({
name: 'upload', name: 'upload',
@ -21,8 +22,7 @@ export default defineComponent({
if (!file) return; if (!file) return;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
const res = await fetch('/upload', { const res = await xhr.post('/upload', {
method: 'post',
body: formData, body: formData,
}) })
const j = await res.json() const j = await res.json()

View file

@ -1,34 +1,44 @@
"use strict" "use strict"
import {run} from './gameloop' import { GameLoopInstance, run } from './gameloop'
import Camera from './Camera' import Camera from './Camera'
import Graphics from './Graphics' import Graphics from './Graphics'
import Debug from './Debug' import Debug from './Debug'
import Communication from './Communication' import Communication from './Communication'
import Util from './../common/Util' import Util, { logger } from './../common/Util'
import PuzzleGraphics from './PuzzleGraphics' import PuzzleGraphics from './PuzzleGraphics'
import Game, { Player, Piece } from './../common/GameCommon' import Game from './../common/GameCommon'
import fireworksController from './Fireworks' import fireworksController from './Fireworks'
import Protocol from '../common/Protocol' import Protocol from '../common/Protocol'
import Time from '../common/Time' import Time from '../common/Time'
import settings from './settings'
import { SETTINGS } from './settings'
import { Dim, Point } from '../common/Geometry' import { Dim, Point } from '../common/Geometry'
import {
FixedLengthArray,
Game as GameType,
Player,
Piece,
EncodedGame,
ReplayData,
Timestamp,
ServerEvent,
} from '../common/Types'
import EventAdapter from './EventAdapter'
declare global { declare global {
interface Window { interface Window {
DEBUG?: boolean DEBUG?: boolean
} }
} }
// @see https://stackoverflow.com/a/59906630/392905 const log = logger('game.ts')
type ArrayLengthMutationKeys = 'splice' | 'push' | 'pop' | 'shift' | 'unshift' | number
type ArrayItems<T extends Array<any>> = T extends Array<infer TItems> ? TItems : never
type FixedLengthArray<T extends any[]> =
Pick<T, Exclude<keyof T, ArrayLengthMutationKeys>>
& { [Symbol.iterator]: () => IterableIterator< ArrayItems<T> > }
// @ts-ignore // @ts-ignore
const images = import.meta.globEager('./*.png') const images = import.meta.globEager('./*.png')
// @ts-ignore
const sounds = import.meta.globEager('./*.mp3')
export const MODE_PLAY = 'play' export const MODE_PLAY = 'play'
export const MODE_REPLAY = 'replay' export const MODE_REPLAY = 'replay'
@ -36,6 +46,7 @@ let PIECE_VIEW_FIXED = true
let PIECE_VIEW_LOOSE = true let PIECE_VIEW_LOOSE = true
interface Hud { interface Hud {
setPuzzleCut: () => void
setActivePlayers: (v: Array<any>) => void setActivePlayers: (v: Array<any>) => void
setIdlePlayers: (v: Array<any>) => void setIdlePlayers: (v: Array<any>) => void
setFinished: (v: boolean) => void setFinished: (v: boolean) => void
@ -44,18 +55,24 @@ interface Hud {
setPiecesTotal: (v: number) => void setPiecesTotal: (v: number) => void
setConnectionState: (v: number) => void setConnectionState: (v: number) => void
togglePreview: () => void togglePreview: () => void
toggleSoundsEnabled: () => void
togglePlayerNames: () => void
setReplaySpeed?: (v: number) => void setReplaySpeed?: (v: number) => void
setReplayPaused?: (v: boolean) => void setReplayPaused?: (v: boolean) => void
} }
interface Replay { interface Replay {
log: Array<any> final: boolean
logIdx: number log: Array<any> // current log entries
logPointer: number // pointer to current item in the log array
speeds: Array<number> speeds: Array<number>
speedIdx: number speedIdx: number
paused: boolean paused: boolean
lastRealTs: number lastRealTs: number
lastGameTs: number lastGameTs: number
gameStartTs: number gameStartTs: number
skipNonActionPhases: boolean
//
dataOffset: number
} }
const shouldDrawPiece = (piece: Piece) => { const shouldDrawPiece = (piece: Piece) => {
@ -79,139 +96,6 @@ function addCanvasToDom(TARGET_EL: HTMLElement, canvas: HTMLCanvasElement) {
return canvas return canvas
} }
function EventAdapter (canvas: HTMLCanvasElement, window: any, viewport: any) {
let events: Array<Array<any>> = []
let KEYS_ON = true
let LEFT = false
let RIGHT = false
let UP = false
let DOWN = false
let ZOOM_IN = false
let ZOOM_OUT = false
let SHIFT = false
const toWorldPoint = (x: number, y: number) => {
const pos = viewport.viewportToWorld({x, y})
return [pos.x, pos.y]
}
const mousePos = (ev: MouseEvent) => toWorldPoint(ev.offsetX, ev.offsetY)
const canvasCenter = () => toWorldPoint(canvas.width / 2, canvas.height / 2)
const key = (state: boolean, ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.key === 'Shift') {
SHIFT = state
} else if (ev.key === 'ArrowUp' || ev.key === 'w' || ev.key === 'W') {
UP = state
} else if (ev.key === 'ArrowDown' || ev.key === 's' || ev.key === 'S') {
DOWN = state
} else if (ev.key === 'ArrowLeft' || ev.key === 'a' || ev.key === 'A') {
LEFT = state
} else if (ev.key === 'ArrowRight' || ev.key === 'd' || ev.key === 'D') {
RIGHT = state
} else if (ev.key === 'q') {
ZOOM_OUT = state
} else if (ev.key === 'e') {
ZOOM_IN = state
}
}
canvas.addEventListener('mousedown', (ev) => {
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_DOWN, ...mousePos(ev)])
}
})
canvas.addEventListener('mouseup', (ev) => {
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_UP, ...mousePos(ev)])
}
})
canvas.addEventListener('mousemove', (ev) => {
addEvent([Protocol.INPUT_EV_MOUSE_MOVE, ...mousePos(ev)])
})
canvas.addEventListener('wheel', (ev) => {
if (viewport.canZoom(ev.deltaY < 0 ? 'in' : 'out')) {
const evt = ev.deltaY < 0
? Protocol.INPUT_EV_ZOOM_IN
: Protocol.INPUT_EV_ZOOM_OUT
addEvent([evt, ...mousePos(ev)])
}
})
window.addEventListener('keydown', (ev: KeyboardEvent) => key(true, ev))
window.addEventListener('keyup', (ev: KeyboardEvent) => key(false, ev))
window.addEventListener('keypress', (ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.key === ' ') {
addEvent([Protocol.INPUT_EV_TOGGLE_PREVIEW])
}
if (ev.key === 'F' || ev.key === 'f') {
PIECE_VIEW_FIXED = !PIECE_VIEW_FIXED
RERENDER = true
}
if (ev.key === 'G' || ev.key === 'g') {
PIECE_VIEW_LOOSE = !PIECE_VIEW_LOOSE
RERENDER = true
}
})
const addEvent = (event: Array<any>) => {
events.push(event)
}
const consumeAll = () => {
if (events.length === 0) {
return []
}
const all = events.slice()
events = []
return all
}
const createKeyEvents = () => {
const amount = SHIFT ? 20 : 10
const x = (LEFT ? amount : 0) - (RIGHT ? amount : 0)
const y = (UP ? amount : 0) - (DOWN ? amount : 0)
if (x !== 0 || y !== 0) {
addEvent([Protocol.INPUT_EV_MOVE, x, y])
}
if (ZOOM_IN && ZOOM_OUT) {
// cancel each other out
} else if (ZOOM_IN) {
if (viewport.canZoom('in')) {
addEvent([Protocol.INPUT_EV_ZOOM_IN, ...canvasCenter()])
}
} else if (ZOOM_OUT) {
if (viewport.canZoom('out')) {
addEvent([Protocol.INPUT_EV_ZOOM_OUT, ...canvasCenter()])
}
}
}
const setHotkeys = (state: boolean) => {
KEYS_ON = state
}
return {
addEvent,
consumeAll,
createKeyEvents,
setHotkeys,
}
}
export async function main( export async function main(
gameId: string, gameId: string,
clientId: string, clientId: string,
@ -226,6 +110,9 @@ export async function main(
return MODE === MODE_REPLAY || player.id !== clientId return MODE === MODE_REPLAY || player.id !== clientId
} }
const click = sounds['./click.mp3'].default
const clickAudio = new Audio(click)
const cursorGrab = await Graphics.loadImageToBitmap(images['./grab.png'].default) const cursorGrab = await Graphics.loadImageToBitmap(images['./grab.png'].default)
const cursorHand = await Graphics.loadImageToBitmap(images['./hand.png'].default) const cursorHand = await Graphics.loadImageToBitmap(images['./hand.png'].default)
const cursorGrabMask = await Graphics.loadImageToBitmap(images['./grab_mask.png'].default) const cursorGrabMask = await Graphics.loadImageToBitmap(images['./grab_mask.png'].default)
@ -260,36 +147,63 @@ export async function main(
// stuff only available in replay mode... // stuff only available in replay mode...
// TODO: refactor // TODO: refactor
const REPLAY: Replay = { const REPLAY: Replay = {
final: false,
log: [], log: [],
logIdx: 0, logPointer: 0,
speeds: [0.5, 1, 2, 5, 10, 20, 50], speeds: [0.5, 1, 2, 5, 10, 20, 50, 100, 250, 500],
speedIdx: 1, speedIdx: 1,
paused: false, paused: false,
lastRealTs: 0, lastRealTs: 0,
lastGameTs: 0, lastGameTs: 0,
gameStartTs: 0, gameStartTs: 0,
skipNonActionPhases: true,
dataOffset: 0,
} }
Communication.onConnectionStateChange((state) => { Communication.onConnectionStateChange((state) => {
HUD.setConnectionState(state) HUD.setConnectionState(state)
}) })
const queryNextReplayBatch = async (
gameId: string
): Promise<ReplayData> => {
const offset = REPLAY.dataOffset
REPLAY.dataOffset += 10000 // meh
const replay: ReplayData = await Communication.requestReplayData(
gameId,
offset
)
// cut log that was already handled
REPLAY.log = REPLAY.log.slice(REPLAY.logPointer)
REPLAY.logPointer = 0
REPLAY.log.push(...replay.log)
if (replay.log.length === 0) {
REPLAY.final = true
}
return replay
}
let TIME: () => number = () => 0 let TIME: () => number = () => 0
const connect = async () => { const connect = async () => {
if (MODE === MODE_PLAY) { if (MODE === MODE_PLAY) {
const game = await Communication.connect(wsAddress, gameId, clientId) const game: EncodedGame = await Communication.connect(wsAddress, gameId, clientId)
const gameObject = Util.decodeGame(game) const gameObject: GameType = Util.decodeGame(game)
Game.setGame(gameObject.id, gameObject) Game.setGame(gameObject.id, gameObject)
TIME = () => Time.timestamp() TIME = () => Time.timestamp()
} else if (MODE === MODE_REPLAY) { } else if (MODE === MODE_REPLAY) {
// TODO: change how replay connect is done... const replay: ReplayData = await queryNextReplayBatch(gameId)
const replay: {game: any, log: Array<any>} = await Communication.connectReplay(wsAddress, gameId, clientId) if (!replay.game) {
const gameObject = Util.decodeGame(replay.game) throw '[ 2021-05-29 no game received ]'
}
const gameObject: GameType = Util.decodeGame(replay.game)
Game.setGame(gameObject.id, gameObject) Game.setGame(gameObject.id, gameObject)
REPLAY.log = replay.log
REPLAY.lastRealTs = Time.timestamp() REPLAY.lastRealTs = Time.timestamp()
REPLAY.gameStartTs = parseInt(REPLAY.log[0][REPLAY.log[0].length - 2], 10) REPLAY.gameStartTs = parseInt(replay.log[0][4], 10)
REPLAY.lastGameTs = REPLAY.gameStartTs REPLAY.lastGameTs = REPLAY.gameStartTs
TIME = () => REPLAY.lastGameTs TIME = () => REPLAY.lastGameTs
} else { } else {
throw '[ 2020-12-22 MODE invalid, must be play|replay ]' throw '[ 2020-12-22 MODE invalid, must be play|replay ]'
@ -301,8 +215,8 @@ export async function main(
await connect() await connect()
const TILE_DRAW_OFFSET = Game.getTileDrawOffset(gameId) const PIECE_DRAW_OFFSET = Game.getPieceDrawOffset(gameId)
const TILE_DRAW_SIZE = Game.getTileDrawSize(gameId) const PIECE_DRAW_SIZE = Game.getPieceDrawSize(gameId)
const PUZZLE_WIDTH = Game.getPuzzleWidth(gameId) const PUZZLE_WIDTH = Game.getPuzzleWidth(gameId)
const PUZZLE_HEIGHT = Game.getPuzzleHeight(gameId) const PUZZLE_HEIGHT = Game.getPuzzleHeight(gameId)
const TABLE_WIDTH = Game.getTableWidth(gameId) const TABLE_WIDTH = Game.getTableWidth(gameId)
@ -317,8 +231,8 @@ export async function main(
h: PUZZLE_HEIGHT, h: PUZZLE_HEIGHT,
} }
const PIECE_DIM = { const PIECE_DIM = {
w: TILE_DRAW_SIZE, w: PIECE_DRAW_SIZE,
h: TILE_DRAW_SIZE, h: PIECE_DRAW_SIZE,
} }
const bitmaps = await PuzzleGraphics.loadPuzzleBitmaps(Game.getPuzzle(gameId)) const bitmaps = await PuzzleGraphics.loadPuzzleBitmaps(Game.getPuzzle(gameId))
@ -328,17 +242,40 @@ export async function main(
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
canvas.classList.add('loaded') canvas.classList.add('loaded')
HUD.setPuzzleCut()
// initialize some view data // initialize some view data
// this global data will change according to input events // this global data will change according to input events
const viewport = Camera() const viewport = Camera()
// center viewport
const centerPuzzle = () => {
// center on the puzzle
viewport.reset()
viewport.move( viewport.move(
-(TABLE_WIDTH - canvas.width) /2, -(TABLE_WIDTH - canvas.width) /2,
-(TABLE_HEIGHT - canvas.height) /2 -(TABLE_HEIGHT - canvas.height) /2
) )
const evts = EventAdapter(canvas, window, viewport) // zoom viewport to fit whole puzzle in
const x = viewport.worldDimToViewport(BOARD_DIM)
const border = 20
const targetW = canvas.width - (border * 2)
const targetH = canvas.height - (border * 2)
if (
(x.w > targetW || x.h > targetH)
|| (x.w < targetW && x.h < targetH)
) {
const zoom = Math.min(targetW / x.w, targetH / x.h)
viewport.setZoom(zoom, {
x: canvas.width / 2,
y: canvas.height / 2,
})
}
}
centerPuzzle()
const evts = EventAdapter(canvas, window, viewport, MODE)
const previewImageUrl = Game.getImageUrl(gameId) const previewImageUrl = Game.getImageUrl(gameId)
@ -352,8 +289,8 @@ export async function main(
} }
updateTimerElements() updateTimerElements()
HUD.setPiecesDone(Game.getFinishedTileCount(gameId)) HUD.setPiecesDone(Game.getFinishedPiecesCount(gameId))
HUD.setPiecesTotal(Game.getTileCount(gameId)) HUD.setPiecesTotal(Game.getPieceCount(gameId))
const ts = TIME() const ts = TIME()
HUD.setActivePlayers(Game.getActivePlayers(gameId, ts)) HUD.setActivePlayers(Game.getActivePlayers(gameId, ts))
HUD.setIdlePlayers(Game.getIdlePlayers(gameId, ts)) HUD.setIdlePlayers(Game.getIdlePlayers(gameId, ts))
@ -362,20 +299,42 @@ export async function main(
let finished = longFinished let finished = longFinished
const justFinished = () => finished && !longFinished const justFinished = () => finished && !longFinished
const playerSoundVolume = (): number => {
return settings.getInt(SETTINGS.SOUND_VOLUME, 100)
}
const playerSoundEnabled = (): boolean => {
return settings.getBool(SETTINGS.SOUND_ENABLED, false)
}
const showPlayerNames = (): boolean => {
return settings.getBool(SETTINGS.SHOW_PLAYER_NAMES, true)
}
const playClick = () => {
const vol = playerSoundVolume()
clickAudio.volume = vol / 100
clickAudio.play()
}
const playerBgColor = () => { const playerBgColor = () => {
return (Game.getPlayerBgColor(gameId, clientId) if (MODE === MODE_REPLAY) {
|| localStorage.getItem('bg_color') return settings.getStr(SETTINGS.COLOR_BACKGROUND, '#222222')
|| '#222222') }
return Game.getPlayerBgColor(gameId, clientId)
|| settings.getStr(SETTINGS.COLOR_BACKGROUND, '#222222')
} }
const playerColor = () => { const playerColor = () => {
return (Game.getPlayerColor(gameId, clientId) if (MODE === MODE_REPLAY) {
|| localStorage.getItem('player_color') return settings.getStr(SETTINGS.PLAYER_COLOR, '#ffffff')
|| '#ffffff') }
return Game.getPlayerColor(gameId, clientId)
|| settings.getStr(SETTINGS.PLAYER_COLOR, '#ffffff')
} }
const playerName = () => { const playerName = () => {
return (Game.getPlayerName(gameId, clientId) if (MODE === MODE_REPLAY) {
|| localStorage.getItem('player_name') return settings.getStr(SETTINGS.PLAYER_NAME, 'anon')
|| 'anon') }
return Game.getPlayerName(gameId, clientId)
|| settings.getStr(SETTINGS.PLAYER_NAME, 'anon')
} }
let cursorDown: string = '' let cursorDown: string = ''
@ -419,14 +378,35 @@ export async function main(
doSetSpeedStatus() doSetSpeedStatus()
} }
const intervals: NodeJS.Timeout[] = []
let to: NodeJS.Timeout
const clearIntervals = () => {
intervals.forEach(inter => {
clearInterval(inter)
})
if (to) {
clearTimeout(to)
}
}
let gameLoopInstance: GameLoopInstance
const unload = () => {
clearIntervals()
if (gameLoopInstance) {
gameLoopInstance.stop()
}
}
if (MODE === MODE_PLAY) { if (MODE === MODE_PLAY) {
setInterval(updateTimerElements, 1000) intervals.push(setInterval(() => {
updateTimerElements()
}, 1000))
} else if (MODE === MODE_REPLAY) { } else if (MODE === MODE_REPLAY) {
doSetSpeedStatus() doSetSpeedStatus()
} }
if (MODE === MODE_PLAY) { if (MODE === MODE_PLAY) {
Communication.onServerChange((msg) => { Communication.onServerChange((msg: ServerEvent) => {
const msgType = msg[0] const msgType = msg[0]
const evClientId = msg[1] const evClientId = msg[1]
const evClientSeq = msg[2] const evClientSeq = msg[2]
@ -441,8 +421,8 @@ export async function main(
} }
} break; } break;
case Protocol.CHANGE_TILE: { case Protocol.CHANGE_TILE: {
const t = Util.decodeTile(changeData) const t = Util.decodePiece(changeData)
Game.setTile(gameId, t.idx, t) Game.setPiece(gameId, t.idx, t)
RERENDER = true RERENDER = true
} break; } break;
case Protocol.CHANGE_DATA: { case Protocol.CHANGE_DATA: {
@ -454,64 +434,91 @@ export async function main(
finished = !! Game.getFinishTs(gameId) finished = !! Game.getFinishTs(gameId)
}) })
} else if (MODE === MODE_REPLAY) { } else if (MODE === MODE_REPLAY) {
// no external communication for replay mode, const handleLogEntry = (logEntry: any[], ts: Timestamp) => {
// only the REPLAY.log is relevant const entry = logEntry
let inter = setInterval(() => { if (entry[0] === Protocol.LOG_ADD_PLAYER) {
const playerId = entry[1]
Game.addPlayer(gameId, playerId, ts)
return true
}
if (entry[0] === Protocol.LOG_UPDATE_PLAYER) {
const playerId = Game.getPlayerIdByIndex(gameId, entry[1])
if (!playerId) {
throw '[ 2021-05-17 player not found (update player) ]'
}
Game.addPlayer(gameId, playerId, ts)
return true
}
if (entry[0] === Protocol.LOG_HANDLE_INPUT) {
const playerId = Game.getPlayerIdByIndex(gameId, entry[1])
if (!playerId) {
throw '[ 2021-05-17 player not found (handle input) ]'
}
const input = entry[2]
Game.handleInput(gameId, playerId, input, ts)
return true
}
return false
}
let GAME_TS = REPLAY.lastGameTs
const next = async () => {
if (REPLAY.logPointer + 1 >= REPLAY.log.length) {
await queryNextReplayBatch(gameId)
}
const realTs = Time.timestamp() const realTs = Time.timestamp()
if (REPLAY.paused) { if (REPLAY.paused) {
REPLAY.lastRealTs = realTs REPLAY.lastRealTs = realTs
to = setTimeout(next, 50)
return return
} }
const timePassedReal = realTs - REPLAY.lastRealTs const timePassedReal = realTs - REPLAY.lastRealTs
const timePassedGame = timePassedReal * REPLAY.speeds[REPLAY.speedIdx] const timePassedGame = timePassedReal * REPLAY.speeds[REPLAY.speedIdx]
const maxGameTs = REPLAY.lastGameTs + timePassedGame let maxGameTs = REPLAY.lastGameTs + timePassedGame
do { do {
if (REPLAY.paused) { if (REPLAY.paused) {
break break
} }
const nextIdx = REPLAY.logIdx + 1 const nextIdx = REPLAY.logPointer + 1
if (nextIdx >= REPLAY.log.length) { if (nextIdx >= REPLAY.log.length) {
clearInterval(inter)
break break
} }
const logEntry = REPLAY.log[nextIdx] const currLogEntry = REPLAY.log[REPLAY.logPointer]
const nextTs = REPLAY.gameStartTs + logEntry[logEntry.length - 1] const currTs: Timestamp = GAME_TS + currLogEntry[currLogEntry.length - 1]
const nextLogEntry = REPLAY.log[nextIdx]
const diffToNext = nextLogEntry[nextLogEntry.length - 1]
const nextTs: Timestamp = currTs + diffToNext
if (nextTs > maxGameTs) { if (nextTs > maxGameTs) {
// next log entry is too far into the future
if (REPLAY.skipNonActionPhases && (maxGameTs + 500 * Time.MS < nextTs)) {
maxGameTs += diffToNext
}
break break
} }
const entryWithTs = logEntry.slice() GAME_TS = currTs
if (entryWithTs[0] === Protocol.LOG_ADD_PLAYER) { if (handleLogEntry(nextLogEntry, nextTs)) {
const playerId = entryWithTs[1]
Game.addPlayer(gameId, playerId, nextTs)
RERENDER = true
} else if (entryWithTs[0] === Protocol.LOG_UPDATE_PLAYER) {
const playerId = Game.getPlayerIdByIndex(gameId, entryWithTs[1])
if (!playerId) {
throw '[ 2021-05-17 player not found (update player) ]'
}
Game.addPlayer(gameId, playerId, nextTs)
RERENDER = true
} else if (entryWithTs[0] === Protocol.LOG_HANDLE_INPUT) {
const playerId = Game.getPlayerIdByIndex(gameId, entryWithTs[1])
if (!playerId) {
throw '[ 2021-05-17 player not found (handle input) ]'
}
const input = entryWithTs[2]
Game.handleInput(gameId, playerId, input, nextTs)
RERENDER = true RERENDER = true
} }
REPLAY.logIdx = nextIdx REPLAY.logPointer = nextIdx
} while (true) } while (true)
REPLAY.lastRealTs = realTs REPLAY.lastRealTs = realTs
REPLAY.lastGameTs = maxGameTs REPLAY.lastGameTs = maxGameTs
updateTimerElements() updateTimerElements()
}, 50)
if (!REPLAY.final) {
to = setTimeout(next, 50)
}
}
next()
} }
let _last_mouse_down: Point|null = null let _last_mouse_down: Point|null = null
const onUpdate = () => { const onUpdate = (): void => {
// handle key downs once per onUpdate // handle key downs once per onUpdate
// this will create Protocol.INPUT_EV_MOVE events if something // this will create Protocol.INPUT_EV_MOVE events if something
// relevant is pressed // relevant is pressed
@ -523,12 +530,13 @@ export async function main(
// ------------------------------------------------------------- // -------------------------------------------------------------
const type = evt[0] const type = evt[0]
if (type === Protocol.INPUT_EV_MOVE) { if (type === Protocol.INPUT_EV_MOVE) {
const diffX = evt[1] const w = evt[1]
const diffY = evt[2] const h = evt[2]
const dim = viewport.worldDimToViewport({w, h})
RERENDER = true RERENDER = true
viewport.move(diffX, diffY) viewport.move(dim.w, dim.h)
} else if (type === Protocol.INPUT_EV_MOUSE_MOVE) { } else if (type === Protocol.INPUT_EV_MOUSE_MOVE) {
if (_last_mouse_down && !Game.getFirstOwnedTile(gameId, clientId)) { if (_last_mouse_down && !Game.getFirstOwnedPiece(gameId, clientId)) {
// move the cam // move the cam
const pos = { x: evt[1], y: evt[2] } const pos = { x: evt[1], y: evt[2] }
const mouse = viewport.worldToViewport(pos) const mouse = viewport.worldToViewport(pos)
@ -558,12 +566,34 @@ export async function main(
viewport.zoom('out', viewport.worldToViewport(pos)) viewport.zoom('out', viewport.worldToViewport(pos))
} else if (type === Protocol.INPUT_EV_TOGGLE_PREVIEW) { } else if (type === Protocol.INPUT_EV_TOGGLE_PREVIEW) {
HUD.togglePreview() HUD.togglePreview()
} else if (type === Protocol.INPUT_EV_TOGGLE_SOUNDS) {
HUD.toggleSoundsEnabled()
} else if (type === Protocol.INPUT_EV_TOGGLE_PLAYER_NAMES) {
HUD.togglePlayerNames()
} else if (type === Protocol.INPUT_EV_CENTER_FIT_PUZZLE) {
centerPuzzle()
} else if (type === Protocol.INPUT_EV_TOGGLE_FIXED_PIECES) {
PIECE_VIEW_FIXED = !PIECE_VIEW_FIXED
RERENDER = true
} else if (type === Protocol.INPUT_EV_TOGGLE_LOOSE_PIECES) {
PIECE_VIEW_LOOSE = !PIECE_VIEW_LOOSE
RERENDER = true
} }
// LOCAL + SERVER CHANGES // LOCAL + SERVER CHANGES
// ------------------------------------------------------------- // -------------------------------------------------------------
const ts = TIME() const ts = TIME()
const changes = Game.handleInput(gameId, clientId, evt, ts) const changes = Game.handleInput(
gameId,
clientId,
evt,
ts,
(playerId: string) => {
if (playerSoundEnabled()) {
playClick()
}
}
)
if (changes.length > 0) { if (changes.length > 0) {
RERENDER = true RERENDER = true
} }
@ -572,7 +602,13 @@ export async function main(
// LOCAL ONLY CHANGES // LOCAL ONLY CHANGES
// ------------------------------------------------------------- // -------------------------------------------------------------
const type = evt[0] const type = evt[0]
if (type === Protocol.INPUT_EV_MOVE) { if (type === Protocol.INPUT_EV_REPLAY_TOGGLE_PAUSE) {
replayOnPauseToggle()
} else if (type === Protocol.INPUT_EV_REPLAY_SPEED_DOWN) {
replayOnSpeedDown()
} else if (type === Protocol.INPUT_EV_REPLAY_SPEED_UP) {
replayOnSpeedUp()
} else if (type === Protocol.INPUT_EV_MOVE) {
const diffX = evt[1] const diffX = evt[1]
const diffY = evt[2] const diffY = evt[2]
RERENDER = true RERENDER = true
@ -589,11 +625,15 @@ export async function main(
_last_mouse_down = mouse _last_mouse_down = mouse
} }
} else if (type === Protocol.INPUT_EV_PLAYER_COLOR) {
updatePlayerCursorColor(evt[1])
} else if (type === Protocol.INPUT_EV_MOUSE_DOWN) { } else if (type === Protocol.INPUT_EV_MOUSE_DOWN) {
const pos = { x: evt[1], y: evt[2] } const pos = { x: evt[1], y: evt[2] }
_last_mouse_down = viewport.worldToViewport(pos) _last_mouse_down = viewport.worldToViewport(pos)
updatePlayerCursorState(true)
} else if (type === Protocol.INPUT_EV_MOUSE_UP) { } else if (type === Protocol.INPUT_EV_MOUSE_UP) {
_last_mouse_down = null _last_mouse_down = null
updatePlayerCursorState(false)
} else if (type === Protocol.INPUT_EV_ZOOM_IN) { } else if (type === Protocol.INPUT_EV_ZOOM_IN) {
const pos = { x: evt[1], y: evt[2] } const pos = { x: evt[1], y: evt[2] }
RERENDER = true RERENDER = true
@ -604,6 +644,18 @@ export async function main(
viewport.zoom('out', viewport.worldToViewport(pos)) viewport.zoom('out', viewport.worldToViewport(pos))
} else if (type === Protocol.INPUT_EV_TOGGLE_PREVIEW) { } else if (type === Protocol.INPUT_EV_TOGGLE_PREVIEW) {
HUD.togglePreview() HUD.togglePreview()
} else if (type === Protocol.INPUT_EV_TOGGLE_SOUNDS) {
HUD.toggleSoundsEnabled()
} else if (type === Protocol.INPUT_EV_TOGGLE_PLAYER_NAMES) {
HUD.togglePlayerNames()
} else if (type === Protocol.INPUT_EV_CENTER_FIT_PUZZLE) {
centerPuzzle()
} else if (type === Protocol.INPUT_EV_TOGGLE_FIXED_PIECES) {
PIECE_VIEW_FIXED = !PIECE_VIEW_FIXED
RERENDER = true
} else if (type === Protocol.INPUT_EV_TOGGLE_LOOSE_PIECES) {
PIECE_VIEW_LOOSE = !PIECE_VIEW_LOOSE
RERENDER = true
} }
} }
} }
@ -615,7 +667,7 @@ export async function main(
} }
} }
const onRender = async () => { const onRender = async (): Promise<void> => {
if (!RERENDER) { if (!RERENDER) {
return return
} }
@ -648,7 +700,7 @@ export async function main(
// DRAW TILES // DRAW TILES
// --------------------------------------------------------------- // ---------------------------------------------------------------
const tiles = Game.getTilesSortedByZIndex(gameId) const tiles = Game.getPiecesSortedByZIndex(gameId)
if (window.DEBUG) Debug.checkpoint('get tiles done') if (window.DEBUG) Debug.checkpoint('get tiles done')
dim = viewport.worldDimToViewportRaw(PIECE_DIM) dim = viewport.worldDimToViewportRaw(PIECE_DIM)
@ -658,8 +710,8 @@ export async function main(
} }
bmp = bitmaps[tile.idx] bmp = bitmaps[tile.idx]
pos = viewport.worldToViewportRaw({ pos = viewport.worldToViewportRaw({
x: TILE_DRAW_OFFSET + tile.pos.x, x: PIECE_DRAW_OFFSET + tile.pos.x,
y: TILE_DRAW_OFFSET + tile.pos.y, y: PIECE_DRAW_OFFSET + tile.pos.y,
}) })
ctx.drawImage(bmp, ctx.drawImage(bmp,
0, 0, bmp.width, bmp.height, 0, 0, bmp.width, bmp.height,
@ -679,12 +731,14 @@ export async function main(
bmp = await getPlayerCursor(p) bmp = await getPlayerCursor(p)
pos = viewport.worldToViewport(p) pos = viewport.worldToViewport(p)
ctx.drawImage(bmp, pos.x - CURSOR_W_2, pos.y - CURSOR_H_2) ctx.drawImage(bmp, pos.x - CURSOR_W_2, pos.y - CURSOR_H_2)
if (showPlayerNames()) {
// performance: // performance:
// not drawing text directly here, to have less ctx // not drawing text directly here, to have less ctx
// switches between drawImage and fillTxt // switches between drawImage and fillTxt
texts.push([`${p.name} (${p.points})`, pos.x, pos.y + CURSOR_H]) texts.push([`${p.name} (${p.points})`, pos.x, pos.y + CURSOR_H])
} }
} }
}
// Names // Names
ctx.fillStyle = 'white' ctx.fillStyle = 'white'
@ -699,7 +753,7 @@ export async function main(
// --------------------------------------------------------------- // ---------------------------------------------------------------
HUD.setActivePlayers(Game.getActivePlayers(gameId, ts)) HUD.setActivePlayers(Game.getActivePlayers(gameId, ts))
HUD.setIdlePlayers(Game.getIdlePlayers(gameId, ts)) HUD.setIdlePlayers(Game.getIdlePlayers(gameId, ts))
HUD.setPiecesDone(Game.getFinishedTileCount(gameId)) HUD.setPiecesDone(Game.getFinishedPiecesCount(gameId))
if (window.DEBUG) Debug.checkpoint('HUD done') if (window.DEBUG) Debug.checkpoint('HUD done')
// --------------------------------------------------------------- // ---------------------------------------------------------------
@ -710,7 +764,7 @@ export async function main(
RERENDER = false RERENDER = false
} }
run({ gameLoopInstance = run({
update: onUpdate, update: onUpdate,
render: onRender, render: onRender,
}) })
@ -720,17 +774,27 @@ export async function main(
evts.setHotkeys(state) evts.setHotkeys(state)
}, },
onBgChange: (value: string) => { onBgChange: (value: string) => {
localStorage.setItem('bg_color', value) settings.setStr(SETTINGS.COLOR_BACKGROUND, value)
evts.addEvent([Protocol.INPUT_EV_BG_COLOR, value]) evts.addEvent([Protocol.INPUT_EV_BG_COLOR, value])
}, },
onColorChange: (value: string) => { onColorChange: (value: string) => {
localStorage.setItem('player_color', value) settings.setStr(SETTINGS.PLAYER_COLOR, value)
evts.addEvent([Protocol.INPUT_EV_PLAYER_COLOR, value]) evts.addEvent([Protocol.INPUT_EV_PLAYER_COLOR, value])
}, },
onNameChange: (value: string) => { onNameChange: (value: string) => {
localStorage.setItem('player_name', value) settings.setStr(SETTINGS.PLAYER_NAME, value)
evts.addEvent([Protocol.INPUT_EV_PLAYER_NAME, value]) evts.addEvent([Protocol.INPUT_EV_PLAYER_NAME, value])
}, },
onSoundsEnabledChange: (value: boolean) => {
settings.setBool(SETTINGS.SOUND_ENABLED, value)
},
onSoundsVolumeChange: (value: number) => {
settings.setInt(SETTINGS.SOUND_VOLUME, value)
playClick()
},
onShowPlayerNamesChange: (value: boolean) => {
settings.setBool(SETTINGS.SHOW_PLAYER_NAMES, value)
},
replayOnSpeedUp, replayOnSpeedUp,
replayOnSpeedDown, replayOnSpeedDown,
replayOnPauseToggle, replayOnPauseToggle,
@ -739,8 +803,13 @@ export async function main(
background: playerBgColor(), background: playerBgColor(),
color: playerColor(), color: playerColor(),
name: playerName(), name: playerName(),
soundsEnabled: playerSoundEnabled(),
soundsVolume: playerSoundVolume(),
showPlayerNames: showPlayerNames(),
}, },
game: Game.get(gameId),
disconnect: Communication.disconnect, disconnect: Communication.disconnect,
connect: connect, connect: connect,
unload: unload,
} }
} }

View file

@ -3,10 +3,20 @@
interface GameLoopOptions { interface GameLoopOptions {
fps?: number fps?: number
slow?: number slow?: number
update: (step: number) => any update: (step: number) => void
render: (passed: number) => any render: (passed: number) => void
} }
export const run = (options: GameLoopOptions) => {
export interface GameLoopInstance {
stop: () => void
}
export const run = (options: GameLoopOptions): GameLoopInstance => {
let stopped = false
const stop = () => {
stopped = true
}
const fps = options.fps || 60 const fps = options.fps || 60
const slow = options.slow || 1 const slow = options.slow || 1
const update = options.update const update = options.update
@ -28,10 +38,15 @@ export const run = (options: GameLoopOptions) => {
} }
render(dt / slow) render(dt / slow)
last = now last = now
if (!stopped) {
raf(frame) raf(frame)
} }
}
raf(frame) raf(frame)
return {
stop,
}
} }
export default { export default {

View file

@ -7,19 +7,36 @@ import NewGame from './views/NewGame.vue'
import Game from './views/Game.vue' import Game from './views/Game.vue'
import Replay from './views/Replay.vue' import Replay from './views/Replay.vue'
import Util from './../common/Util' import Util from './../common/Util'
import settings from './settings'
import xhr from './xhr'
(async () => { (async () => {
const res = await fetch(`/api/conf`) function initClientSecret() {
const conf = await res.json() let SECRET = settings.getStr('SECRET', '')
if (!SECRET) {
function initme() { SECRET = Util.uniqId()
let ID = localStorage.getItem('ID') settings.setStr('SECRET', SECRET)
}
return SECRET
}
function initClientId() {
let ID = settings.getStr('ID', '')
if (!ID) { if (!ID) {
ID = Util.uniqId() ID = Util.uniqId()
localStorage.setItem('ID', ID) settings.setStr('ID', ID)
} }
return ID return ID
} }
const clientId = initClientId()
const clientSecret = initClientSecret()
xhr.setClientId(clientId)
xhr.setClientSecret(clientSecret)
const meRes = await xhr.get(`/api/me`, {})
const me = await meRes.json()
const confRes = await xhr.get(`/api/conf`, {})
const conf = await confRes.json()
const router = VueRouter.createRouter({ const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(), history: VueRouter.createWebHashHistory(),
@ -39,8 +56,9 @@ import Util from './../common/Util'
}) })
const app = Vue.createApp(App) const app = Vue.createApp(App)
app.config.globalProperties.$me = me
app.config.globalProperties.$config = conf app.config.globalProperties.$config = conf
app.config.globalProperties.$clientId = initme() app.config.globalProperties.$clientId = clientId
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
})() })()

66
src/frontend/settings.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* Player settings
*/
export const SETTINGS = {
SOUND_VOLUME: 'sound_volume',
SOUND_ENABLED: 'sound_enabled',
COLOR_BACKGROUND: 'bg_color',
PLAYER_COLOR: 'player_color',
PLAYER_NAME: 'player_name',
SHOW_PLAYER_NAMES: 'show_player_names',
}
const set = (setting: string, value: string): void => {
localStorage.setItem(setting, value)
}
const get = (setting: string): any => {
return localStorage.getItem(setting)
}
const setInt = (setting: string, val: number): void => {
set(setting, `${val}`)
}
const getInt = (setting: string, def: number): number => {
const value = get(setting)
if (value === null) {
return def
}
const vol = parseInt(value, 10)
return isNaN(vol) ? def : vol
}
const setBool = (setting: string, val: boolean): void => {
set(setting, val ? '1' : '0')
}
const getBool = (setting: string, def: boolean): boolean => {
const value = get(setting)
if (value === null) {
return def
}
return value === '1'
}
const setStr = (setting: string, val: string): void => {
set(setting, val)
}
const getStr = (setting: string, def: string): string => {
const value = get(setting)
if (value === null) {
return def
}
return value
}
export default {
setInt,
getInt,
setBool,
getBool,
setStr,
getStr,
}

View file

@ -127,7 +127,7 @@ input:focus {
} }
.overlay { .overlay {
position: absolute; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;

View file

@ -2,8 +2,15 @@
<div id="game"> <div id="game">
<settings-overlay v-show="overlay === 'settings'" @bgclick="toggle('settings', true)" v-model="g.player" /> <settings-overlay v-show="overlay === 'settings'" @bgclick="toggle('settings', true)" v-model="g.player" />
<preview-overlay v-show="overlay === 'preview'" @bgclick="toggle('preview', false)" :img="g.previewImageUrl" /> <preview-overlay v-show="overlay === 'preview'" @bgclick="toggle('preview', false)" :img="g.previewImageUrl" />
<info-overlay v-if="g.game" v-show="overlay === 'info'" @bgclick="toggle('info', true)" :game="g.game" />
<help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" /> <help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" />
<div class="overlay" v-if="cuttingPuzzle">
<div class="overlay-content">
<div> Cutting puzzle, please wait... </div>
</div>
</div>
<connection-overlay <connection-overlay
:connectionState="connectionState" :connectionState="connectionState"
@reconnect="reconnect" @reconnect="reconnect"
@ -21,7 +28,8 @@
<router-link class="opener" :to="{name: 'index'}" target="_blank">🧩 Puzzles</router-link> <router-link class="opener" :to="{name: 'index'}" target="_blank">🧩 Puzzles</router-link>
<div class="opener" @click="toggle('preview', false)">🖼 Preview</div> <div class="opener" @click="toggle('preview', false)">🖼 Preview</div>
<div class="opener" @click="toggle('settings', true)">🛠 Settings</div> <div class="opener" @click="toggle('settings', true)">🛠 Settings</div>
<div class="opener" @click="toggle('help', true)"> Help</div> <div class="opener" @click="toggle('info', true)"> Info</div>
<div class="opener" @click="toggle('help', true)"> Hotkeys</div>
</div> </div>
</div> </div>
@ -35,11 +43,12 @@ import Scores from './../components/Scores.vue'
import PuzzleStatus from './../components/PuzzleStatus.vue' import PuzzleStatus from './../components/PuzzleStatus.vue'
import SettingsOverlay from './../components/SettingsOverlay.vue' import SettingsOverlay from './../components/SettingsOverlay.vue'
import PreviewOverlay from './../components/PreviewOverlay.vue' import PreviewOverlay from './../components/PreviewOverlay.vue'
import InfoOverlay from './../components/InfoOverlay.vue'
import ConnectionOverlay from './../components/ConnectionOverlay.vue' import ConnectionOverlay from './../components/ConnectionOverlay.vue'
import HelpOverlay from './../components/HelpOverlay.vue' import HelpOverlay from './../components/HelpOverlay.vue'
import { main, MODE_PLAY } from './../game' import { main, MODE_PLAY } from './../game'
import { Player } from '../../common/GameCommon' import { Game, Player } from '../../common/Types'
export default defineComponent({ export default defineComponent({
name: 'game', name: 'game',
@ -48,6 +57,7 @@ export default defineComponent({
Scores, Scores,
SettingsOverlay, SettingsOverlay,
PreviewOverlay, PreviewOverlay,
InfoOverlay,
ConnectionOverlay, ConnectionOverlay,
HelpOverlay, HelpOverlay,
}, },
@ -64,20 +74,29 @@ export default defineComponent({
overlay: '', overlay: '',
connectionState: 0, connectionState: 0,
cuttingPuzzle: true,
g: { g: {
player: { player: {
background: '', background: '',
color: '', color: '',
name: '', name: '',
soundsEnabled: false,
soundsVolume: 100,
showPlayerNames: true,
}, },
game: null as Game|null,
previewImageUrl: '', previewImageUrl: '',
setHotkeys: (v: boolean) => {}, setHotkeys: (v: boolean) => {},
onBgChange: (v: string) => {}, onBgChange: (v: string) => {},
onColorChange: (v: string) => {}, onColorChange: (v: string) => {},
onNameChange: (v: string) => {}, onNameChange: (v: string) => {},
disconnect: () => {}, onSoundsEnabledChange: (v: boolean) => {},
onSoundsVolumeChange: (v: number) => {},
onShowPlayerNamesChange: (v: boolean) => {},
connect: () => {}, connect: () => {},
disconnect: () => {},
unload: () => {},
}, },
} }
}, },
@ -94,6 +113,15 @@ export default defineComponent({
this.$watch(() => this.g.player.name, (value: string) => { this.$watch(() => this.g.player.name, (value: string) => {
this.g.onNameChange(value) this.g.onNameChange(value)
}) })
this.$watch(() => this.g.player.soundsEnabled, (value: boolean) => {
this.g.onSoundsEnabledChange(value)
})
this.$watch(() => this.g.player.soundsVolume, (value: number) => {
this.g.onSoundsVolumeChange(value)
})
this.$watch(() => this.g.player.showPlayerNames, (value: boolean) => {
this.g.onShowPlayerNamesChange(value)
})
this.g = await main( this.g = await main(
`${this.$route.params.id}`, `${this.$route.params.id}`,
// @ts-ignore // @ts-ignore
@ -103,18 +131,22 @@ export default defineComponent({
MODE_PLAY, MODE_PLAY,
this.$el, this.$el,
{ {
setPuzzleCut: () => { this.cuttingPuzzle = false },
setActivePlayers: (v: Array<Player>) => { this.activePlayers = v }, setActivePlayers: (v: Array<Player>) => { this.activePlayers = v },
setIdlePlayers: (v: Array<Player>) => { this.idlePlayers = v }, setIdlePlayers: (v: Array<Player>) => { this.idlePlayers = v },
setFinished: (v: boolean) => { this.finished = v }, setFinished: (v: boolean) => { this.finished = v },
setDuration: (v: number) => { this.duration = v }, setDuration: (v: number) => { this.duration = v },
setPiecesDone: (v: number) => { this.piecesDone = v }, setPiecesDone: (v: number) => { this.piecesDone = v },
setPiecesTotal: (v: number) => { this.piecesTotal = v }, setPiecesTotal: (v: number) => { this.piecesTotal = v },
setConnectionState: (v: number) => { this.connectionState = v },
togglePreview: () => { this.toggle('preview', false) }, togglePreview: () => { this.toggle('preview', false) },
setConnectionState: (v: number) => { this.connectionState = v },
toggleSoundsEnabled: () => { this.g.player.soundsEnabled = !this.g.player.soundsEnabled },
togglePlayerNames: () => { this.g.player.showPlayerNames = !this.g.player.showPlayerNames },
} }
) )
}, },
unmounted () { unmounted () {
this.g.unload()
this.g.disconnect() this.g.disconnect()
}, },
methods: { methods: {

View file

@ -13,6 +13,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import xhr from '../xhr'
import GameTeaser from './../components/GameTeaser.vue' import GameTeaser from './../components/GameTeaser.vue'
@ -27,7 +28,7 @@ export default defineComponent({
} }
}, },
async created() { async created() {
const res = await fetch('/api/index-data') const res = await xhr.get('/api/index-data', {})
const json = await res.json() const json = await res.json()
this.gamesRunning = json.gamesRunning this.gamesRunning = json.gamesRunning
this.gamesFinished = json.gamesFinished this.gamesFinished = json.gamesFinished

View file

@ -15,7 +15,12 @@ in jigsawpuzzles.io
<div> <div>
<label v-if="tags.length > 0"> <label v-if="tags.length > 0">
Tags: Tags:
<span class="bit" v-for="(t,idx) in tags" :key="idx" @click="toggleTag(t)" :class="{on: filters.tags.includes(t.slug)}">{{t.title}}</span> <span
class="bit"
v-for="(t,idx) in relevantTags"
:key="idx"
@click="toggleTag(t)"
:class="{on: filters.tags.includes(t.slug)}">{{t.title}} ({{t.total}})</span>
<!-- <select v-model="filters.tags" @change="filtersChanged"> <!-- <select v-model="filters.tags" @change="filtersChanged">
<option value="">All</option> <option value="">All</option>
<option v-for="(c, idx) in tags" :key="idx" :value="c.slug">{{c.title}}</option> <option v-for="(c, idx) in tags" :key="idx" :value="c.slug">{{c.title}}</option>
@ -31,10 +36,30 @@ in jigsawpuzzles.io
</select> </select>
</label> </label>
</div> </div>
<image-library :images="images" @imageClicked="onImageClicked" @imageEditClicked="onImageEditClicked" /> <image-library
<new-image-dialog v-if="dialog==='new-image'" @bgclick="dialog=''" @postToGalleryClick="postToGalleryClick" @setupGameClick="setupGameClick" /> :images="images"
<edit-image-dialog v-if="dialog==='edit-image'" @bgclick="dialog=''" @saveClick="onSaveImageClick" :image="image" /> @imageClicked="onImageClicked"
<new-game-dialog v-if="image && dialog==='new-game'" @bgclick="dialog=''" @newGame="onNewGame" :image="image" /> @imageEditClicked="onImageEditClicked" />
<new-image-dialog
v-if="dialog==='new-image'"
:autocompleteTags="autocompleteTags"
@bgclick="dialog=''"
:uploadProgress="uploadProgress"
:uploading="uploading"
@postToGalleryClick="postToGalleryClick"
@setupGameClick="setupGameClick"
/>
<edit-image-dialog
v-if="dialog==='edit-image'"
:autocompleteTags="autocompleteTags"
@bgclick="dialog=''"
@saveClick="onSaveImageClick"
:image="image" />
<new-game-dialog
v-if="image && dialog==='new-game'"
@bgclick="dialog=''"
@newGame="onNewGame"
:image="image" />
</div> </div>
</template> </template>
@ -45,8 +70,9 @@ import ImageLibrary from './../components/ImageLibrary.vue'
import NewImageDialog from './../components/NewImageDialog.vue' import NewImageDialog from './../components/NewImageDialog.vue'
import EditImageDialog from './../components/EditImageDialog.vue' import EditImageDialog from './../components/EditImageDialog.vue'
import NewGameDialog from './../components/NewGameDialog.vue' import NewGameDialog from './../components/NewGameDialog.vue'
import { GameSettings, Image, Tag } from '../../common/GameCommon' import { GameSettings, Image, Tag } from '../../common/Types'
import Util from '../../common/Util' import Util from '../../common/Util'
import xhr from '../xhr'
export default defineComponent({ export default defineComponent({
components: { components: {
@ -62,7 +88,7 @@ export default defineComponent({
tags: [] as string[], tags: [] as string[],
}, },
images: [], images: [],
tags: [], tags: [] as Tag[],
image: { image: {
id: 0, id: 0,
@ -75,12 +101,29 @@ export default defineComponent({
} as Image, } as Image,
dialog: '', dialog: '',
uploading: '',
uploadProgress: 0,
} }
}, },
async created() { async created() {
await this.loadImages() await this.loadImages()
}, },
computed: {
relevantTags (): Tag[] {
return this.tags.filter((tag: Tag) => tag.total > 0)
},
},
methods: { methods: {
autocompleteTags (input: string, exclude: string[]): string[] {
return this.tags
.filter((tag: Tag) => {
return !exclude.includes(tag.title)
&& tag.title.toLowerCase().startsWith(input.toLowerCase())
})
.slice(0, 10)
.map((tag: Tag) => tag.title)
},
toggleTag (t: Tag) { toggleTag (t: Tag) {
if (this.filters.tags.includes(t.slug)) { if (this.filters.tags.includes(t.slug)) {
this.filters.tags = this.filters.tags.filter(slug => slug !== t.slug) this.filters.tags = this.filters.tags.filter(slug => slug !== t.slug)
@ -90,7 +133,7 @@ export default defineComponent({
this.filtersChanged() this.filtersChanged()
}, },
async loadImages () { async loadImages () {
const res = await fetch(`/api/newgame-data${Util.asQueryArgs(this.filters)}`) const res = await xhr.get(`/api/newgame-data${Util.asQueryArgs(this.filters)}`, {})
const json = await res.json() const json = await res.json()
this.images = json.images this.images = json.images
this.tags = json.tags this.tags = json.tags
@ -107,20 +150,22 @@ export default defineComponent({
this.dialog = 'edit-image' this.dialog = 'edit-image'
}, },
async uploadImage (data: any) { async uploadImage (data: any) {
this.uploadProgress = 0
const formData = new FormData(); const formData = new FormData();
formData.append('file', data.file, data.file.name); formData.append('file', data.file, data.file.name);
formData.append('title', data.title) formData.append('title', data.title)
formData.append('tags', data.tags) formData.append('tags', data.tags)
const res = await xhr.post('/api/upload', {
const res = await fetch('/api/upload', {
method: 'post',
body: formData, body: formData,
onUploadProgress: (evt: ProgressEvent<XMLHttpRequestEventTarget>): void => {
this.uploadProgress = evt.loaded / evt.total
},
}) })
this.uploadProgress = 1
return await res.json() return await res.json()
}, },
async saveImage (data: any) { async saveImage (data: any) {
const res = await fetch('/api/save-image', { const res = await xhr.post('/api/save-image', {
method: 'post',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -134,24 +179,31 @@ export default defineComponent({
return await res.json() return await res.json()
}, },
async onSaveImageClick(data: any) { async onSaveImageClick(data: any) {
await this.saveImage(data) const res = await this.saveImage(data)
if (res.ok) {
this.dialog = '' this.dialog = ''
await this.loadImages() await this.loadImages()
} else {
alert(res.error)
}
}, },
async postToGalleryClick(data: any) { async postToGalleryClick(data: any) {
this.uploading = 'postToGallery'
await this.uploadImage(data) await this.uploadImage(data)
this.uploading = ''
this.dialog = '' this.dialog = ''
await this.loadImages() await this.loadImages()
}, },
async setupGameClick (data: any) { async setupGameClick (data: any) {
this.uploading = 'setupGame'
const image = await this.uploadImage(data) const image = await this.uploadImage(data)
this.uploading = ''
this.loadImages() // load images in background this.loadImages() // load images in background
this.image = image this.image = image
this.dialog = 'new-game' this.dialog = 'new-game'
}, },
async onNewGame(gameSettings: GameSettings) { async onNewGame(gameSettings: GameSettings) {
const res = await fetch('/newgame', { const res = await xhr.post('/api/newgame', {
method: 'post',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'

View file

@ -2,8 +2,15 @@
<div id="replay"> <div id="replay">
<settings-overlay v-show="overlay === 'settings'" @bgclick="toggle('settings', true)" v-model="g.player" /> <settings-overlay v-show="overlay === 'settings'" @bgclick="toggle('settings', true)" v-model="g.player" />
<preview-overlay v-show="overlay === 'preview'" @bgclick="toggle('preview', false)" :img="g.previewImageUrl" /> <preview-overlay v-show="overlay === 'preview'" @bgclick="toggle('preview', false)" :img="g.previewImageUrl" />
<info-overlay v-if="g.game" v-show="overlay === 'info'" @bgclick="toggle('info', true)" :game="g.game" />
<help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" /> <help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" />
<div class="overlay" v-if="cuttingPuzzle">
<div class="overlay-content">
<div> Cutting puzzle, please wait... </div>
</div>
</div>
<puzzle-status <puzzle-status
:finished="finished" :finished="finished"
:duration="duration" :duration="duration"
@ -23,7 +30,8 @@
<router-link class="opener" :to="{name: 'index'}" target="_blank">🧩 Puzzles</router-link> <router-link class="opener" :to="{name: 'index'}" target="_blank">🧩 Puzzles</router-link>
<div class="opener" @click="toggle('preview', false)">🖼 Preview</div> <div class="opener" @click="toggle('preview', false)">🖼 Preview</div>
<div class="opener" @click="toggle('settings', true)">🛠 Settings</div> <div class="opener" @click="toggle('settings', true)">🛠 Settings</div>
<div class="opener" @click="toggle('help', true)"> Help</div> <div class="opener" @click="toggle('info', true)"> Info</div>
<div class="opener" @click="toggle('help', true)"> Hotkeys</div>
</div> </div>
</div> </div>
@ -37,9 +45,11 @@ import Scores from './../components/Scores.vue'
import PuzzleStatus from './../components/PuzzleStatus.vue' import PuzzleStatus from './../components/PuzzleStatus.vue'
import SettingsOverlay from './../components/SettingsOverlay.vue' import SettingsOverlay from './../components/SettingsOverlay.vue'
import PreviewOverlay from './../components/PreviewOverlay.vue' import PreviewOverlay from './../components/PreviewOverlay.vue'
import InfoOverlay from './../components/InfoOverlay.vue'
import HelpOverlay from './../components/HelpOverlay.vue' import HelpOverlay from './../components/HelpOverlay.vue'
import { main, MODE_REPLAY } from './../game' import { main, MODE_REPLAY } from './../game'
import { Game, Player } from '../../common/Types'
export default defineComponent({ export default defineComponent({
name: 'replay', name: 'replay',
@ -48,12 +58,13 @@ export default defineComponent({
Scores, Scores,
SettingsOverlay, SettingsOverlay,
PreviewOverlay, PreviewOverlay,
InfoOverlay,
HelpOverlay, HelpOverlay,
}, },
data() { data() {
return { return {
activePlayers: [] as Array<any>, activePlayers: [] as Array<Player>,
idlePlayers: [] as Array<any>, idlePlayers: [] as Array<Player>,
finished: false, finished: false,
duration: 0, duration: 0,
@ -63,22 +74,32 @@ export default defineComponent({
overlay: '', overlay: '',
connectionState: 0, connectionState: 0,
cuttingPuzzle: true,
g: { g: {
player: { player: {
background: '', background: '',
color: '', color: '',
name: '', name: '',
soundsEnabled: false,
soundsVolume: 100,
showPlayerNames: true,
}, },
game: null as Game|null,
previewImageUrl: '', previewImageUrl: '',
setHotkeys: (v: boolean) => {}, setHotkeys: (v: boolean) => {},
onBgChange: (v: string) => {}, onBgChange: (v: string) => {},
onColorChange: (v: string) => {}, onColorChange: (v: string) => {},
onNameChange: (v: string) => {}, onNameChange: (v: string) => {},
onSoundsEnabledChange: (v: boolean) => {},
onSoundsVolumeChange: (v: number) => {},
onShowPlayerNamesChange: (v: boolean) => {},
replayOnSpeedUp: () => {}, replayOnSpeedUp: () => {},
replayOnSpeedDown: () => {}, replayOnSpeedDown: () => {},
replayOnPauseToggle: () => {}, replayOnPauseToggle: () => {},
connect: () => {},
disconnect: () => {}, disconnect: () => {},
unload: () => {},
}, },
replay: { replay: {
@ -100,6 +121,15 @@ export default defineComponent({
this.$watch(() => this.g.player.name, (value: string) => { this.$watch(() => this.g.player.name, (value: string) => {
this.g.onNameChange(value) this.g.onNameChange(value)
}) })
this.$watch(() => this.g.player.soundsEnabled, (value: boolean) => {
this.g.onSoundsEnabledChange(value)
})
this.$watch(() => this.g.player.soundsVolume, (value: number) => {
this.g.onSoundsVolumeChange(value)
})
this.$watch(() => this.g.player.showPlayerNames, (value: boolean) => {
this.g.onShowPlayerNamesChange(value)
})
this.g = await main( this.g = await main(
`${this.$route.params.id}`, `${this.$route.params.id}`,
// @ts-ignore // @ts-ignore
@ -109,8 +139,9 @@ export default defineComponent({
MODE_REPLAY, MODE_REPLAY,
this.$el, this.$el,
{ {
setActivePlayers: (v: Array<any>) => { this.activePlayers = v }, setPuzzleCut: () => { this.cuttingPuzzle = false },
setIdlePlayers: (v: Array<any>) => { this.idlePlayers = v }, setActivePlayers: (v: Array<Player>) => { this.activePlayers = v },
setIdlePlayers: (v: Array<Player>) => { this.idlePlayers = v },
setFinished: (v: boolean) => { this.finished = v }, setFinished: (v: boolean) => { this.finished = v },
setDuration: (v: number) => { this.duration = v }, setDuration: (v: number) => { this.duration = v },
setPiecesDone: (v: number) => { this.piecesDone = v }, setPiecesDone: (v: number) => { this.piecesDone = v },
@ -119,10 +150,13 @@ export default defineComponent({
setConnectionState: (v: number) => { this.connectionState = v }, setConnectionState: (v: number) => { this.connectionState = v },
setReplaySpeed: (v: number) => { this.replay.speed = v }, setReplaySpeed: (v: number) => { this.replay.speed = v },
setReplayPaused: (v: boolean) => { this.replay.paused = v }, setReplayPaused: (v: boolean) => { this.replay.paused = v },
toggleSoundsEnabled: () => { this.g.player.soundsEnabled = !this.g.player.soundsEnabled },
togglePlayerNames: () => { this.g.player.showPlayerNames = !this.g.player.showPlayerNames },
} }
) )
}, },
unmounted () { unmounted () {
this.g.unload()
this.g.disconnect() this.g.disconnect()
}, },
methods: { methods: {

68
src/frontend/xhr.ts Normal file
View file

@ -0,0 +1,68 @@
export interface Response {
status: number,
text: string,
json: () => Promise<any>,
}
export interface Options {
body: FormData|string,
headers?: any,
onUploadProgress?: (ev: ProgressEvent<XMLHttpRequestEventTarget>) => any,
}
let xhrClientId: string = ''
let xhrClientSecret: string = ''
const request = async (
method: string,
url: string,
options: Options
): Promise<Response> => {
return new Promise((resolve, reject) => {
const xhr = new window.XMLHttpRequest()
xhr.open(method, url, true)
xhr.withCredentials = true
for (const k in options.headers || {}) {
xhr.setRequestHeader(k, options.headers[k])
}
xhr.setRequestHeader('Client-Id', xhrClientId)
xhr.setRequestHeader('Client-Secret', xhrClientSecret)
xhr.addEventListener('load', function (ev: ProgressEvent<XMLHttpRequestEventTarget>
) {
resolve({
status: this.status,
text: this.responseText,
json: async () => JSON.parse(this.responseText),
})
})
xhr.addEventListener('error', function (ev: ProgressEvent<XMLHttpRequestEventTarget>) {
reject(new Error('xhr error'))
})
if (xhr.upload && options.onUploadProgress) {
xhr.upload.addEventListener('progress', function (ev: ProgressEvent<XMLHttpRequestEventTarget>) {
// typescript complains without this extra check
if (options.onUploadProgress) {
options.onUploadProgress(ev)
}
})
}
xhr.send(options.body || null)
})
}
export default {
request,
get: (url: string, options: any): Promise<Response> => {
return request('get', url, options)
},
post: (url: string, options: any): Promise<Response> => {
return request('post', url, options)
},
setClientId: (clientId: string): void => {
xhrClientId = clientId
},
setClientSecret: (clientSecret: string): void => {
xhrClientSecret = clientSecret
},
}

View file

@ -5,10 +5,6 @@ import { logger } from '../common/Util'
const log = logger('Db.ts') const log = logger('Db.ts')
// assume 32766 SQLITE_MAX_VARIABLE_NUMBER
// @see https://sqlite.org/limits.html
const SQLITE_MAX_VARIABLE_NUMBER = 32766
/** /**
* TODO: create a more specific type for OrderBy. * TODO: create a more specific type for OrderBy.
* It looks like this (example): * It looks like this (example):
@ -17,11 +13,12 @@ const SQLITE_MAX_VARIABLE_NUMBER = 32766
* {name: 1}, // then by name ascending * {name: 1}, // then by name ascending
* ] * ]
*/ */
type OrderBy = Array<any>
type Data = Record<string, any> type Data = Record<string, any>
type WhereRaw = Record<string, any>
type Params = Array<any> type Params = Array<any>
export type WhereRaw = Record<string, any>
export type OrderBy = Array<Record<string, 1|-1>>
interface Where { interface Where {
sql: string sql: string
values: Array<any> values: Array<any>
@ -92,7 +89,7 @@ class Db {
let prop = '$nin' let prop = '$nin'
if (where[k][prop]) { if (where[k][prop]) {
if (where[k][prop].length > 0) { if (where[k][prop].length > 0) {
wheres.push(k + ' NOT IN (' + where[k][prop].map((_: any) => '?') + ')') wheres.push(k + ' NOT IN (' + where[k][prop].map(() => '?') + ')')
values.push(...where[k][prop]) values.push(...where[k][prop])
} }
continue continue
@ -100,7 +97,7 @@ class Db {
prop = '$in' prop = '$in'
if (where[k][prop]) { if (where[k][prop]) {
if (where[k][prop].length > 0) { if (where[k][prop].length > 0) {
wheres.push(k + ' IN (' + where[k][prop].map((_: any) => '?') + ')') wheres.push(k + ' IN (' + where[k][prop].map(() => '?') + ')')
values.push(...where[k][prop]) values.push(...where[k][prop])
} }
continue continue
@ -190,57 +187,15 @@ class Db {
return this.get(table, check)[idcol] // get id manually return this.get(table, check)[idcol] // get id manually
} }
/**
* Inserts data into table and returns the last insert id
*/
insert (table: string, data: Data): Integer.IntLike { insert (table: string, data: Data): Integer.IntLike {
const keys = Object.keys(data) const keys = Object.keys(data)
const values = keys.map(k => data[k]) const values = keys.map(k => data[k])
const sql = 'INSERT INTO '+ table const sql = 'INSERT INTO '+ table
+ ' (' + keys.join(',') + ')' + ' (' + keys.join(',') + ')'
+ ' VALUES (' + keys.map(k => '?').join(',') + ')' + ' VALUES (' + keys.map(() => '?').join(',') + ')'
return this.run(sql, values).lastInsertRowid return this.run(sql, values).lastInsertRowid
} }
/**
* Inserts multiple datas into table. Returns the total number
* of changes.
*/
insertMany (table: string, datas: Data[]): number {
if (datas.length === 0) {
return 0
}
const keys = Object.keys(datas[0])
const runChunk = (vars: string[], values: any[]) => {
const sql = `INSERT INTO ${table}
(${keys.join(',')})
VALUES ${vars.join(',')}`
return this.run(sql, values).changes
}
let len: number = 0
let vars: string[] = []
let values: any[] = []
let changes = 0
for (const data of datas) {
if (len + keys.length > SQLITE_MAX_VARIABLE_NUMBER) {
changes += runChunk(vars, values)
len = 0
vars = []
values = []
}
len += keys.length
vars.push('(' + keys.map(_ => '?').join(',') + ')')
values.push(...keys.map(k => data[k]))
}
if (len > 0) {
changes += runChunk(vars, values)
}
return changes
}
update (table: string, data: Data, whereRaw: WhereRaw = {}): void { update (table: string, data: Data, whereRaw: WhereRaw = {}): void {
const keys = Object.keys(data) const keys = Object.keys(data)
if (keys.length === 0) { if (keys.length === 0) {

View file

@ -1,53 +1,88 @@
import GameCommon, { ScoreMode } from './../common/GameCommon' import GameCommon from './../common/GameCommon'
import Util from './../common/Util' import { Change, Game, Input, ScoreMode, ShapeMode, SnapMode,ImageInfo, Timestamp, GameSettings } from './../common/Types'
import Util, { logger } from './../common/Util'
import { Rng } from './../common/Rng' import { Rng } from './../common/Rng'
import GameLog from './GameLog' import GameLog from './GameLog'
import { createPuzzle } from './Puzzle' import { createPuzzle } from './Puzzle'
import Protocol from './../common/Protocol' import Protocol from './../common/Protocol'
import GameStorage from './GameStorage' import GameStorage from './GameStorage'
const log = logger('Game.ts')
async function createGameObject( async function createGameObject(
gameId: string, gameId: string,
targetTiles: number, targetTiles: number,
image: { file: string, url: string }, image: ImageInfo,
ts: number, ts: Timestamp,
scoreMode: ScoreMode scoreMode: ScoreMode,
) { shapeMode: ShapeMode,
snapMode: SnapMode,
creatorUserId: number|null
): Promise<Game> {
const seed = Util.hash(gameId + ' ' + ts) const seed = Util.hash(gameId + ' ' + ts)
const rng = new Rng(seed) const rng = new Rng(seed)
return { return {
id: gameId, id: gameId,
creatorUserId,
rng: { type: 'Rng', obj: rng }, rng: { type: 'Rng', obj: rng },
puzzle: await createPuzzle(rng, targetTiles, image, ts), puzzle: await createPuzzle(rng, targetTiles, image, ts, shapeMode),
players: [], players: [],
evtInfos: {}, evtInfos: {},
scoreMode, scoreMode,
shapeMode,
snapMode,
} }
} }
async function createGame( async function createNewGame(
gameId: string, gameSettings: GameSettings,
targetTiles: number, ts: Timestamp,
image: { file: string, url: string }, creatorUserId: number
ts: number, ): Promise<string> {
scoreMode: ScoreMode let gameId;
): Promise<void> { do {
const gameObject = await createGameObject(gameId, targetTiles, image, ts, scoreMode) gameId = Util.uniqId()
} while (GameCommon.exists(gameId))
GameLog.create(gameId) const gameObject = await createGameObject(
GameLog.log(gameId, Protocol.LOG_HEADER, 1, targetTiles, image, ts, scoreMode) gameId,
gameSettings.tiles,
gameSettings.image,
ts,
gameSettings.scoreMode,
gameSettings.shapeMode,
gameSettings.snapMode,
creatorUserId
)
GameLog.create(gameId, ts)
GameLog.log(
gameId,
Protocol.LOG_HEADER,
1,
gameSettings.tiles,
gameSettings.image,
ts,
gameSettings.scoreMode,
gameSettings.shapeMode,
gameSettings.snapMode,
gameObject.creatorUserId
)
GameCommon.setGame(gameObject.id, gameObject) GameCommon.setGame(gameObject.id, gameObject)
GameStorage.setDirty(gameId) GameStorage.setDirty(gameId)
return gameId
} }
function addPlayer(gameId: string, playerId: string, ts: number): void { function addPlayer(gameId: string, playerId: string, ts: Timestamp): void {
if (GameLog.shouldLog(GameCommon.getFinishTs(gameId), ts)) {
const idx = GameCommon.getPlayerIndexById(gameId, playerId) const idx = GameCommon.getPlayerIndexById(gameId, playerId)
const diff = ts - GameCommon.getStartTs(gameId)
if (idx === -1) { if (idx === -1) {
GameLog.log(gameId, Protocol.LOG_ADD_PLAYER, playerId, diff) GameLog.log(gameId, Protocol.LOG_ADD_PLAYER, playerId, ts)
} else { } else {
GameLog.log(gameId, Protocol.LOG_UPDATE_PLAYER, idx, diff) GameLog.log(gameId, Protocol.LOG_UPDATE_PLAYER, idx, ts)
}
} }
GameCommon.addPlayer(gameId, playerId, ts) GameCommon.addPlayer(gameId, playerId, ts)
@ -57,12 +92,13 @@ function addPlayer(gameId: string, playerId: string, ts: number): void {
function handleInput( function handleInput(
gameId: string, gameId: string,
playerId: string, playerId: string,
input: any, input: Input,
ts: number ts: Timestamp
): Array<Array<any>> { ): Array<Change> {
if (GameLog.shouldLog(GameCommon.getFinishTs(gameId), ts)) {
const idx = GameCommon.getPlayerIndexById(gameId, playerId) const idx = GameCommon.getPlayerIndexById(gameId, playerId)
const diff = ts - GameCommon.getStartTs(gameId) GameLog.log(gameId, Protocol.LOG_HANDLE_INPUT, idx, input, ts)
GameLog.log(gameId, Protocol.LOG_HANDLE_INPUT, idx, input, diff) }
const ret = GameCommon.handleInput(gameId, playerId, input, ts) const ret = GameCommon.handleInput(gameId, playerId, input, ts)
GameStorage.setDirty(gameId) GameStorage.setDirty(gameId)
@ -71,17 +107,7 @@ function handleInput(
export default { export default {
createGameObject, createGameObject,
createGame, createNewGame,
addPlayer, addPlayer,
handleInput, handleInput,
getAllGames: GameCommon.getAllGames,
getActivePlayers: GameCommon.getActivePlayers,
getFinishedTileCount: GameCommon.getFinishedTileCount,
getImageUrl: GameCommon.getImageUrl,
getTileCount: GameCommon.getTileCount,
exists: GameCommon.exists,
playerExists: GameCommon.playerExists,
get: GameCommon.get,
getStartTs: GameCommon.getStartTs,
getFinishTs: GameCommon.getFinishTs,
} }

View file

@ -1,51 +1,106 @@
import fs from 'fs' import fs from 'fs'
import Protocol from '../common/Protocol'
import Time from '../common/Time'
import { DefaultScoreMode, DefaultShapeMode, DefaultSnapMode, Timestamp } from '../common/Types'
import { logger } from './../common/Util' import { logger } from './../common/Util'
import { DATA_DIR } from './../server/Dirs' import { DATA_DIR } from './../server/Dirs'
const log = logger('GameLog.js') const log = logger('GameLog.js')
const filename = (gameId: string) => `${DATA_DIR}/log_${gameId}.log` const LINES_PER_LOG_FILE = 10000
const POST_GAME_LOG_DURATION = 5 * Time.MIN
const create = (gameId: string) => { const shouldLog = (finishTs: Timestamp, currentTs: Timestamp): boolean => {
const file = filename(gameId) // when not finished yet, always log
if (!fs.existsSync(file)) { if (!finishTs) {
fs.appendFileSync(file, '') return true
}
// in finished games, log max POST_GAME_LOG_DURATION after
// the game finished, to record winning dance moves etc :P
const timeSinceGameEnd = currentTs - finishTs
return timeSinceGameEnd <= POST_GAME_LOG_DURATION
}
export const filename = (gameId: string, offset: number) => `${DATA_DIR}/log_${gameId}-${offset}.log`
export const idxname = (gameId: string) => `${DATA_DIR}/log_${gameId}.idx.log`
const create = (gameId: string, ts: Timestamp): void => {
const idxfile = idxname(gameId)
if (!fs.existsSync(idxfile)) {
fs.appendFileSync(idxfile, JSON.stringify({
gameId: gameId,
total: 0,
lastTs: ts,
currentFile: '',
perFile: LINES_PER_LOG_FILE,
}))
} }
} }
const exists = (gameId: string) => { const exists = (gameId: string): boolean => {
const file = filename(gameId) const idxfile = idxname(gameId)
return fs.existsSync(file) return fs.existsSync(idxfile)
} }
const _log = (gameId: string, ...args: Array<any>) => { const _log = (gameId: string, type: number, ...args: Array<any>): void => {
const file = filename(gameId) const idxfile = idxname(gameId)
if (!fs.existsSync(file)) { if (!fs.existsSync(idxfile)) {
return return
} }
const str = JSON.stringify(args)
fs.appendFileSync(file, str + "\n") const idxObj = JSON.parse(fs.readFileSync(idxfile, 'utf-8'))
if (idxObj.total % idxObj.perFile === 0) {
idxObj.currentFile = filename(gameId, idxObj.total)
}
const tsIdx = type === Protocol.LOG_HEADER ? 3 : (args.length - 1)
const ts: Timestamp = args[tsIdx]
if (type !== Protocol.LOG_HEADER) {
// for everything but header save the diff to last log entry
args[tsIdx] = ts - idxObj.lastTs
}
const line = JSON.stringify([type, ...args]).slice(1, -1)
fs.appendFileSync(idxObj.currentFile, line + "\n")
idxObj.total++
idxObj.lastTs = ts
fs.writeFileSync(idxfile, JSON.stringify(idxObj))
} }
const get = (gameId: string) => { const get = (
const file = filename(gameId) gameId: string,
offset: number = 0,
): any[] => {
const idxfile = idxname(gameId)
if (!fs.existsSync(idxfile)) {
return []
}
const file = filename(gameId, offset)
if (!fs.existsSync(file)) { if (!fs.existsSync(file)) {
return [] return []
} }
const lines = fs.readFileSync(file, 'utf-8').split("\n") const lines = fs.readFileSync(file, 'utf-8').split("\n")
return lines.filter((line: string) => !!line).map((line: string) => { const log = lines.filter(line => !!line).map(line => {
try { return JSON.parse(`[${line}]`)
return JSON.parse(line)
} catch (e) {
log.log(line)
log.log(e)
}
}) })
if (offset === 0 && log.length > 0) {
log[0][5] = DefaultScoreMode(log[0][5])
log[0][6] = DefaultShapeMode(log[0][6])
log[0][7] = DefaultSnapMode(log[0][7])
log[0][8] = log[0][8] || null // creatorUserId
}
return log
} }
export default { export default {
shouldLog,
create, create,
exists, exists,
log: _log, log: _log,
get, get,
filename,
idxname,
} }

View file

@ -1,21 +1,88 @@
import fs from 'fs' import fs from 'fs'
import GameCommon, { Piece, ScoreMode } from './../common/GameCommon' import GameCommon from './../common/GameCommon'
import { DefaultScoreMode, DefaultShapeMode, DefaultSnapMode, Game, Piece } from './../common/Types'
import Util, { logger } from './../common/Util' import Util, { logger } from './../common/Util'
import { Rng } from './../common/Rng' import { Rng } from './../common/Rng'
import { DATA_DIR } from './Dirs' import { DATA_DIR } from './Dirs'
import Time from './../common/Time' import Time from './../common/Time'
import Db from './Db'
const log = logger('GameStorage.js') const log = logger('GameStorage.js')
const DIRTY_GAMES = {} as any const dirtyGames: Record<string, boolean> = {}
function setDirty(gameId: string): void { function setDirty(gameId: string): void {
DIRTY_GAMES[gameId] = true dirtyGames[gameId] = true
} }
function setClean(gameId: string): void { function setClean(gameId: string): void {
delete DIRTY_GAMES[gameId] delete dirtyGames[gameId]
}
function loadGamesFromDb(db: Db): void {
const gameRows = db.getMany('games')
for (const gameRow of gameRows) {
loadGameFromDb(db, gameRow.id)
}
} }
function loadGames(): void { function loadGameFromDb(db: Db, gameId: string): void {
const gameRow = db.get('games', {id: gameId})
let game
try {
game = JSON.parse(gameRow.data)
} catch {
log.log(`[ERR] unable to load game from db ${gameId}`);
}
if (typeof game.puzzle.data.started === 'undefined') {
game.puzzle.data.started = gameRow.created
}
if (typeof game.puzzle.data.finished === 'undefined') {
game.puzzle.data.finished = gameRow.finished
}
if (!Array.isArray(game.players)) {
game.players = Object.values(game.players)
}
const gameObject: Game = storeDataToGame(game, game.creator_user_id)
GameCommon.setGame(gameObject.id, gameObject)
}
function persistGamesToDb(db: Db): void {
for (const gameId of Object.keys(dirtyGames)) {
persistGameToDb(db, gameId)
}
}
function persistGameToDb(db: Db, gameId: string): void {
const game: Game|null = GameCommon.get(gameId)
if (!game) {
log.error(`[ERROR] unable to persist non existing game ${gameId}`)
return
}
if (game.id in dirtyGames) {
setClean(game.id)
}
db.upsert('games', {
id: game.id,
creator_user_id: game.creatorUserId,
image_id: game.puzzle.info.image?.id,
created: game.puzzle.data.started,
finished: game.puzzle.data.finished,
data: gameToStoreData(game)
}, {
id: game.id,
})
log.info(`[INFO] persisted game ${game.id}`)
}
/**
* @deprecated
*/
function loadGamesFromDisk(): void {
const files = fs.readdirSync(DATA_DIR) const files = fs.readdirSync(DATA_DIR)
for (const f of files) { for (const f of files) {
const m = f.match(/^([a-z0-9]+)\.json$/) const m = f.match(/^([a-z0-9]+)\.json$/)
@ -23,11 +90,14 @@ function loadGames(): void {
continue continue
} }
const gameId = m[1] const gameId = m[1]
loadGame(gameId) loadGameFromDisk(gameId)
} }
} }
function loadGame(gameId: string): void { /**
* @deprecated
*/
function loadGameFromDisk(gameId: string): void {
const file = `${DATA_DIR}/${gameId}.json` const file = `${DATA_DIR}/${gameId}.json`
const contents = fs.readFileSync(file, 'utf-8') const contents = fs.readFileSync(file, 'utf-8')
let game let game
@ -41,39 +111,36 @@ function loadGame(gameId: string): void {
} }
if (typeof game.puzzle.data.finished === 'undefined') { if (typeof game.puzzle.data.finished === 'undefined') {
const unfinished = game.puzzle.tiles const unfinished = game.puzzle.tiles
.map(Util.decodeTile) .map(Util.decodePiece)
.find((t: Piece) => t.owner !== -1) .find((t: Piece) => t.owner !== -1)
game.puzzle.data.finished = unfinished ? 0 : Time.timestamp() game.puzzle.data.finished = unfinished ? 0 : Time.timestamp()
} }
if (!Array.isArray(game.players)) { if (!Array.isArray(game.players)) {
game.players = Object.values(game.players) game.players = Object.values(game.players)
} }
const gameObject = { const gameObject: Game = storeDataToGame(game, null)
id: game.id,
rng: {
type: game.rng ? game.rng.type : '_fake_',
obj: game.rng ? Rng.unserialize(game.rng.obj) : new Rng(0),
},
puzzle: game.puzzle,
players: game.players,
evtInfos: {},
scoreMode: game.scoreMode || ScoreMode.FINAL,
}
GameCommon.setGame(gameObject.id, gameObject) GameCommon.setGame(gameObject.id, gameObject)
} }
function persistGames(): void { function storeDataToGame(storeData: any, creatorUserId: number|null): Game {
for (const gameId of Object.keys(DIRTY_GAMES)) { return {
persistGame(gameId) id: storeData.id,
creatorUserId,
rng: {
type: storeData.rng ? storeData.rng.type : '_fake_',
obj: storeData.rng ? Rng.unserialize(storeData.rng.obj) : new Rng(0),
},
puzzle: storeData.puzzle,
players: storeData.players,
evtInfos: {},
scoreMode: DefaultScoreMode(storeData.scoreMode),
shapeMode: DefaultShapeMode(storeData.shapeMode),
snapMode: DefaultSnapMode(storeData.snapMode),
} }
} }
function persistGame(gameId: string): void { function gameToStoreData(game: Game): string {
const game = GameCommon.get(gameId) return JSON.stringify({
if (game.id in DIRTY_GAMES) {
setClean(game.id)
}
fs.writeFileSync(`${DATA_DIR}/${game.id}.json`, JSON.stringify({
id: game.id, id: game.id,
rng: { rng: {
type: game.rng.type, type: game.rng.type,
@ -82,14 +149,20 @@ function persistGame(gameId: string): void {
puzzle: game.puzzle, puzzle: game.puzzle,
players: game.players, players: game.players,
scoreMode: game.scoreMode, scoreMode: game.scoreMode,
})) shapeMode: game.shapeMode,
log.info(`[INFO] persisted game ${game.id}`) snapMode: game.snapMode,
});
} }
export default { export default {
loadGames, // disk functions are deprecated
loadGame, loadGamesFromDisk,
persistGames, loadGameFromDisk,
persistGame,
loadGamesFromDb,
loadGameFromDb,
persistGamesToDb,
persistGameToDb,
setDirty, setDirty,
} }

View file

@ -4,10 +4,14 @@ import exif from 'exif'
import sharp from 'sharp' import sharp from 'sharp'
import {UPLOAD_DIR, UPLOAD_URL} from './Dirs' import {UPLOAD_DIR, UPLOAD_URL} from './Dirs'
import Db from './Db' import Db, { OrderBy, WhereRaw } from './Db'
import { Dim } from '../common/Geometry' import { Dim } from '../common/Geometry'
import Util, { logger } from '../common/Util'
import { Tag, ImageInfo } from '../common/Types'
const resizeImage = async (filename: string) => { const log = logger('Images.ts')
const resizeImage = async (filename: string): Promise<void> => {
if (!filename.toLowerCase().match(/\.(jpe?g|webp|png)$/)) { if (!filename.toLowerCase().match(/\.(jpe?g|webp|png)$/)) {
return return
} }
@ -30,55 +34,82 @@ const resizeImage = async (filename: string) => {
[150, 100], [150, 100],
[375, 210], [375, 210],
] ]
for (let [w,h] of sizes) { for (const [w,h] of sizes) {
console.log(w, h, imagePath) log.info(w, h, imagePath)
await sharpImg.resize(w, h, { fit: 'contain' }).toFile(`${imageOutPath}-${w}x${h}.webp`) await sharpImg
.resize(w, h, { fit: 'contain' })
.toFile(`${imageOutPath}-${w}x${h}.webp`)
} }
} }
async function getExifOrientation(imagePath: string) { async function getExifOrientation(imagePath: string): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
new exif.ExifImage({ image: imagePath }, function (error, exifData) { new exif.ExifImage({ image: imagePath }, (error, exifData) => {
if (error) { if (error) {
resolve(0) resolve(0)
} else { } else {
resolve(exifData.image.Orientation) resolve(exifData.image.Orientation || 0)
} }
}) })
}) })
} }
const getTags = (db: Db, imageId: number) => { const getAllTags = (db: Db): Tag[] => {
const query = `
select c.id, c.slug, c.title, count(*) as total from categories c
inner join image_x_category ixc on c.id = ixc.category_id
inner join images i on i.id = ixc.image_id
group by c.id order by total desc;`
return db._getMany(query).map(row => ({
id: parseInt(row.id, 10) || 0,
slug: row.slug,
title: row.title,
total: parseInt(row.total, 10) || 0,
}))
}
const getTags = (db: Db, imageId: number): Tag[] => {
const query = ` const query = `
select * from categories c select * from categories c
inner join image_x_category ixc on c.id = ixc.category_id inner join image_x_category ixc on c.id = ixc.category_id
where ixc.image_id = ?` where ixc.image_id = ?`
return db._getMany(query, [imageId]) return db._getMany(query, [imageId]).map(row => ({
id: parseInt(row.id, 10) || 0,
slug: row.slug,
title: row.title,
total: 0,
}))
} }
const imageFromDb = (db: Db, imageId: number) => { const imageFromDb = (db: Db, imageId: number): ImageInfo => {
const i = db.get('images', { id: imageId }) const i = db.get('images', { id: imageId })
return { return {
id: i.id, id: i.id,
uploaderUserId: i.uploader_user_id,
filename: i.filename, filename: i.filename,
file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
tags: getTags(db, i.id) as any[], tags: getTags(db, i.id),
created: i.created * 1000, created: i.created * 1000,
width: i.width,
height: i.height,
} }
} }
const allImagesFromDb = (db: Db, tagSlugs: string[], sort: string) => { const allImagesFromDb = (
const sortMap = { db: Db,
tagSlugs: string[],
orderBy: string
): ImageInfo[] => {
const orderByMap = {
alpha_asc: [{filename: 1}], alpha_asc: [{filename: 1}],
alpha_desc: [{filename: -1}], alpha_desc: [{filename: -1}],
date_asc: [{created: 1}], date_asc: [{created: 1}],
date_desc: [{created: -1}], date_desc: [{created: -1}],
} as Record<string, any> } as Record<string, OrderBy>
// TODO: .... clean up // TODO: .... clean up
const wheresRaw: Record<string, any> = {} const wheresRaw: WhereRaw = {}
if (tagSlugs.length > 0) { if (tagSlugs.length > 0) {
const c = db.getMany('categories', {slug: {'$in': tagSlugs}}) const c = db.getMany('categories', {slug: {'$in': tagSlugs}})
if (!c) { if (!c) {
@ -96,42 +127,52 @@ inner join images i on i.id = ixc.image_id ${where.sql};
} }
wheresRaw['id'] = {'$in': ids} wheresRaw['id'] = {'$in': ids}
} }
const images = db.getMany('images', wheresRaw, sortMap[sort]) const images = db.getMany('images', wheresRaw, orderByMap[orderBy])
return images.map(i => ({ return images.map(i => ({
id: i.id as number, id: i.id as number,
uploaderUserId: i.uploader_user_id,
filename: i.filename, filename: i.filename,
file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
tags: getTags(db, i.id) as any[], tags: getTags(db, i.id),
created: i.created * 1000, created: i.created * 1000,
width: i.width,
height: i.height,
})) }))
} }
const allImagesFromDisk = (tags: string[], sort: string) => { /**
* @deprecated old function, now database is used
*/
const allImagesFromDisk = (
tags: string[],
sort: string
): ImageInfo[] => {
let images = fs.readdirSync(UPLOAD_DIR) let images = fs.readdirSync(UPLOAD_DIR)
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/)) .filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
.map(f => ({ .map(f => ({
id: 0, id: 0,
uploaderUserId: null,
filename: f, filename: f,
file: `${UPLOAD_DIR}/${f}`,
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`, url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
title: f.replace(/\.[a-z]+$/, ''), title: f.replace(/\.[a-z]+$/, ''),
tags: [] as any[], tags: [] as Tag[],
created: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(), created: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(),
width: 0, // may have to fill when the function is used again
height: 0, // may have to fill when the function is used again
})) }))
switch (sort) { switch (sort) {
case 'alpha_asc': case 'alpha_asc':
images = images.sort((a, b) => { images = images.sort((a, b) => {
return a.file > b.file ? 1 : -1 return a.filename > b.filename ? 1 : -1
}) })
break; break;
case 'alpha_desc': case 'alpha_desc':
images = images.sort((a, b) => { images = images.sort((a, b) => {
return a.file < b.file ? 1 : -1 return a.filename < b.filename ? 1 : -1
}) })
break; break;
@ -152,7 +193,7 @@ const allImagesFromDisk = (tags: string[], sort: string) => {
} }
async function getDimensions(imagePath: string): Promise<Dim> { async function getDimensions(imagePath: string): Promise<Dim> {
let dimensions = sizeOf(imagePath) const dimensions = sizeOf(imagePath)
const orientation = await getExifOrientation(imagePath) const orientation = await getExifOrientation(imagePath)
// when image is rotated to the left or right, switch width/height // when image is rotated to the left or right, switch width/height
// https://jdhao.github.io/2019/07/31/image_rotation_exif_info/ // https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
@ -168,10 +209,26 @@ async function getDimensions(imagePath: string): Promise<Dim> {
} }
} }
const setTags = (db: Db, imageId: number, tags: string[]): void => {
db.delete('image_x_category', { image_id: imageId })
tags.forEach((tag: string) => {
const slug = Util.slug(tag)
const id = db.upsert('categories', { slug, title: tag }, { slug }, 'id')
if (id) {
db.insert('image_x_category', {
image_id: imageId,
category_id: id,
})
}
})
}
export default { export default {
allImagesFromDisk, allImagesFromDisk,
imageFromDb, imageFromDb,
allImagesFromDb, allImagesFromDb,
getAllTags,
resizeImage, resizeImage,
getDimensions, getDimensions,
setTags,
} }

View file

@ -1,10 +1,11 @@
import Util from './../common/Util' import Util from './../common/Util'
import { Rng } from './../common/Rng' import { Rng } from './../common/Rng'
import Images from './Images' import Images from './Images'
import { EncodedPiece, EncodedPieceShape, PieceShape, Puzzle } from '../common/GameCommon' import { EncodedPiece, EncodedPieceShape, PieceShape, Puzzle, ShapeMode, ImageInfo } from '../common/Types'
import { Point } from '../common/Geometry' import { Dim, Point } from '../common/Geometry'
import { UPLOAD_DIR } from './Dirs'
interface PuzzleCreationInfo { export interface PuzzleCreationInfo {
width: number width: number
height: number height: number
tileSize: number tileSize: number
@ -22,10 +23,11 @@ const TILE_SIZE = 64
async function createPuzzle( async function createPuzzle(
rng: Rng, rng: Rng,
targetTiles: number, targetTiles: number,
image: { file: string, url: string }, image: ImageInfo,
ts: number ts: number,
shapeMode: ShapeMode
): Promise<Puzzle> { ): Promise<Puzzle> {
const imagePath = image.file const imagePath = `${UPLOAD_DIR}/${image.filename}`
const imageUrl = image.url const imageUrl = image.url
// determine puzzle information from the image dimensions // determine puzzle information from the image dimensions
@ -33,22 +35,18 @@ async function createPuzzle(
if (!dim.w || !dim.h) { if (!dim.w || !dim.h) {
throw `[ 2021-05-16 invalid dimension for path ${imagePath} ]` throw `[ 2021-05-16 invalid dimension for path ${imagePath} ]`
} }
const info: PuzzleCreationInfo = determinePuzzleInfo( const info: PuzzleCreationInfo = determinePuzzleInfo(dim, targetTiles)
dim.w,
dim.h,
targetTiles
)
let tiles = new Array(info.tiles) const rawPieces = new Array(info.tiles)
for (let i = 0; i < tiles.length; i++) { for (let i = 0; i < rawPieces.length; i++) {
tiles[i] = { idx: i } rawPieces[i] = { idx: i }
} }
const shapes = determinePuzzleTileShapes(rng, info) const shapes = determinePuzzleTileShapes(rng, info, shapeMode)
let positions: Point[] = new Array(info.tiles) let positions: Point[] = new Array(info.tiles)
for (let tile of tiles) { for (const piece of rawPieces) {
const coord = Util.coordByTileIdx(info, tile.idx) const coord = Util.coordByPieceIdx(info, piece.idx)
positions[tile.idx] = { positions[piece.idx] = {
// instead of info.tileSize, we use info.tileDrawSize // instead of info.tileSize, we use info.tileDrawSize
// to spread the tiles a bit // to spread the tiles a bit
x: coord.x * info.tileSize * 1.5, x: coord.x * info.tileSize * 1.5,
@ -60,7 +58,7 @@ async function createPuzzle(
const tableHeight = info.height * 3 const tableHeight = info.height * 3
const off = info.tileSize * 1.5 const off = info.tileSize * 1.5
let last: Point = { const last: Point = {
x: info.width - (1 * off), x: info.width - (1 * off),
y: info.height - (2 * off), y: info.height - (2 * off),
} }
@ -70,7 +68,7 @@ async function createPuzzle(
let diffX = off let diffX = off
let diffY = 0 let diffY = 0
let index = 0 let index = 0
for (let pos of positions) { for (const pos of positions) {
pos.x = last.x pos.x = last.x
pos.y = last.y pos.y = last.y
last.x+=diffX last.x+=diffX
@ -97,9 +95,9 @@ async function createPuzzle(
// then shuffle the positions // then shuffle the positions
positions = rng.shuffle(positions) positions = rng.shuffle(positions)
const pieces: Array<EncodedPiece> = tiles.map(tile => { const pieces: Array<EncodedPiece> = rawPieces.map(piece => {
return Util.encodeTile({ return Util.encodePiece({
idx: tile.idx, // index of tile in the array idx: piece.idx, // index of tile in the array
group: 0, // if grouped with other tiles group: 0, // if grouped with other tiles
z: 0, // z index of the tile z: 0, // z index of the tile
@ -112,7 +110,7 @@ async function createPuzzle(
// physical current position of the tile (x/y in pixels) // physical current position of the tile (x/y in pixels)
// this position is the initial position only and is the // this position is the initial position only and is the
// value that changes when moving a tile // value that changes when moving a tile
pos: positions[tile.idx], pos: positions[piece.idx],
}) })
}) })
@ -137,7 +135,8 @@ async function createPuzzle(
}, },
// information that was used to create the puzzle // information that was used to create the puzzle
targetTiles: targetTiles, targetTiles: targetTiles,
imageUrl, imageUrl, // todo: remove
image: image,
width: info.width, // actual puzzle width (same as bitmap.width) width: info.width, // actual puzzle width (same as bitmap.width)
height: info.height, // actual puzzle height (same as bitmap.height) height: info.height, // actual puzzle height (same as bitmap.height)
@ -161,16 +160,27 @@ async function createPuzzle(
}, },
} }
} }
function determineTabs (shapeMode: ShapeMode): number[] {
switch(shapeMode) {
case ShapeMode.ANY:
return [-1, 0, 1]
case ShapeMode.FLAT:
return [0]
case ShapeMode.NORMAL:
default:
return [-1, 1]
}
}
function determinePuzzleTileShapes( function determinePuzzleTileShapes(
rng: Rng, rng: Rng,
info: PuzzleCreationInfo info: PuzzleCreationInfo,
shapeMode: ShapeMode
): Array<EncodedPieceShape> { ): Array<EncodedPieceShape> {
const tabs = [-1, 1] const tabs: number[] = determineTabs(shapeMode)
const shapes: Array<PieceShape> = new Array(info.tiles) const shapes: Array<PieceShape> = new Array(info.tiles)
for (let i = 0; i < info.tiles; i++) { for (let i = 0; i < info.tiles; i++) {
let coord = Util.coordByTileIdx(info, i) const coord = Util.coordByPieceIdx(info, i)
shapes[i] = { shapes[i] = {
top: coord.y === 0 ? 0 : shapes[i - info.tilesX].bottom * -1, top: coord.y === 0 ? 0 : shapes[i - info.tilesX].bottom * -1,
right: coord.x === info.tilesX - 1 ? 0 : rng.choice(tabs), right: coord.x === info.tilesX - 1 ? 0 : rng.choice(tabs),
@ -181,9 +191,12 @@ function determinePuzzleTileShapes(
return shapes.map(Util.encodeShape) return shapes.map(Util.encodeShape)
} }
const determineTilesXY = (w: number, h: number, targetTiles: number) => { const determineTilesXY = (
const w_ = w < h ? (w * h) : (w * w) dim: Dim,
const h_ = w < h ? (h * h) : (w * h) targetTiles: number
): { tilesX: number, tilesY: number } => {
const w_ = dim.w < dim.h ? (dim.w * dim.h) : (dim.w * dim.w)
const h_ = dim.w < dim.h ? (dim.h * dim.h) : (dim.w * dim.h)
let size = 0 let size = 0
let tiles = 0 let tiles = 0
do { do {
@ -198,11 +211,10 @@ const determineTilesXY = (w: number, h: number, targetTiles: number) => {
} }
const determinePuzzleInfo = ( const determinePuzzleInfo = (
w: number, dim: Dim,
h: number,
targetTiles: number targetTiles: number
): PuzzleCreationInfo => { ): PuzzleCreationInfo => {
const {tilesX, tilesY} = determineTilesXY(w, h, targetTiles) const {tilesX, tilesY} = determineTilesXY(dim, targetTiles)
const tiles = tilesX * tilesY const tiles = tilesX * tilesY
const tileSize = TILE_SIZE const tileSize = TILE_SIZE
const width = tilesX * tileSize const width = tilesX * tileSize

36
src/server/Users.ts Normal file
View file

@ -0,0 +1,36 @@
import Time from '../common/Time'
import Db from './Db'
const TABLE = 'users'
const HEADER_CLIENT_ID = 'client-id'
const HEADER_CLIENT_SECRET = 'client-secret'
const getOrCreateUser = (db: Db, req: any): any => {
let user = getUser(db, req)
if (!user) {
db.insert(TABLE, {
'client_id': req.headers[HEADER_CLIENT_ID],
'client_secret': req.headers[HEADER_CLIENT_SECRET],
'created': Time.timestamp(),
})
user = getUser(db, req)
}
return user
}
const getUser = (db: Db, req: any): any => {
const user = db.get(TABLE, {
'client_id': req.headers[HEADER_CLIENT_ID],
'client_secret': req.headers[HEADER_CLIENT_SECRET],
})
if (user) {
user.id = parseInt(user.id, 10)
}
return user
}
export default {
getOrCreateUser,
getUser,
}

View file

@ -14,17 +14,17 @@ config = {
*/ */
class EvtBus { class EvtBus {
private _on: any private _on: Record<string, Function[]>
constructor() { constructor() {
this._on = {} as any this._on = {}
} }
on(type: string, callback: Function) { on (type: string, callback: Function): void {
this._on[type] = this._on[type] || [] this._on[type] = this._on[type] || []
this._on[type].push(callback) this._on[type].push(callback)
} }
dispatch(type: string, ...args: Array<any>) { dispatch (type: string, ...args: Array<any>): void {
(this._on[type] || []).forEach((cb: Function) => { (this._on[type] || []).forEach((cb: Function) => {
cb(...args) cb(...args)
}) })
@ -43,20 +43,20 @@ class WebSocketServer {
this.evt = new EvtBus() this.evt = new EvtBus()
} }
on(type: string, callback: Function) { on (type: string, callback: Function): void {
this.evt.on(type, callback) this.evt.on(type, callback)
} }
listen() { listen (): void {
this._websocketserver = new WebSocket.Server(this.config) this._websocketserver = new WebSocket.Server(this.config)
this._websocketserver.on('connection', (socket: WebSocket, request: Request) => { this._websocketserver.on('connection', (socket: WebSocket, request: Request): void => {
const pathname = new URL(this.config.connectstring).pathname const pathname = new URL(this.config.connectstring).pathname
if (request.url.indexOf(pathname) !== 0) { if (request.url.indexOf(pathname) !== 0) {
log.log('bad request url: ', request.url) log.log('bad request url: ', request.url)
socket.close() socket.close()
return return
} }
socket.on('message', (data: any) => { socket.on('message', (data: WebSocket.Data) => {
log.log(`ws`, socket.protocol, data) log.log(`ws`, socket.protocol, data)
this.evt.dispatch('message', {socket, data}) this.evt.dispatch('message', {socket, data})
}) })
@ -66,13 +66,13 @@ class WebSocketServer {
}) })
} }
close() { close (): void {
if (this._websocketserver) { if (this._websocketserver) {
this._websocketserver.close() this._websocketserver.close()
} }
} }
notifyOne(data: any, socket: WebSocket) { notifyOne (data: any, socket: WebSocket): void {
socket.send(JSON.stringify(data)) socket.send(JSON.stringify(data))
} }
} }

View file

@ -1,11 +1,11 @@
import WebSocketServer from './WebSocketServer' import WebSocketServer from './WebSocketServer'
import WebSocket from 'ws' import WebSocket from 'ws'
import express from 'express' import express from 'express'
import compression from 'compression'
import multer from 'multer' import multer from 'multer'
import Protocol from './../common/Protocol' import Protocol from './../common/Protocol'
import Util, { logger } from './../common/Util' import Util, { logger } from './../common/Util'
import Game from './Game' import Game from './Game'
import bodyParser from 'body-parser'
import v8 from 'v8' import v8 from 'v8'
import fs from 'fs' import fs from 'fs'
import GameLog from './GameLog' import GameLog from './GameLog'
@ -16,11 +16,13 @@ import {
DB_FILE, DB_FILE,
DB_PATCHES_DIR, DB_PATCHES_DIR,
PUBLIC_DIR, PUBLIC_DIR,
UPLOAD_DIR UPLOAD_DIR,
} from './Dirs' } from './Dirs'
import { GameSettings, ScoreMode } from '../common/GameCommon' import GameCommon from '../common/GameCommon'
import { ServerEvent, Game as GameType, GameSettings } from '../common/Types'
import GameStorage from './GameStorage' import GameStorage from './GameStorage'
import Db from './Db' import Db from './Db'
import Users from './Users'
const db = new Db(DB_FILE, DB_PATCHES_DIR) const db = new Db(DB_FILE, DB_PATCHES_DIR)
db.patch() db.patch()
@ -46,6 +48,8 @@ const port = config.http.port
const hostname = config.http.hostname const hostname = config.http.hostname
const app = express() const app = express()
app.use(compression())
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: UPLOAD_DIR, destination: UPLOAD_DIR,
filename: function (req, file, cb) { filename: function (req, file, cb) {
@ -54,33 +58,76 @@ const storage = multer.diskStorage({
}) })
const upload = multer({storage}).single('file'); const upload = multer({storage}).single('file');
app.get('/api/conf', (req, res) => { app.get('/api/me', (req, res): void => {
let user = Users.getUser(db, req)
res.send({
id: user ? user.id : null,
created: user ? user.created : null,
})
})
app.get('/api/conf', (req, res): void => {
res.send({ res.send({
WS_ADDRESS: config.ws.connectstring, WS_ADDRESS: config.ws.connectstring,
}) })
}) })
app.get('/api/newgame-data', (req, res) => { app.get('/api/replay-data', async (req, res): Promise<void> => {
const q = req.query as any const q: Record<string, any> = req.query
const offset = parseInt(q.offset, 10) || 0
if (offset < 0) {
res.status(400).send({ reason: 'bad offset' })
return
}
const size = parseInt(q.size, 10) || 10000
if (size < 0 || size > 10000) {
res.status(400).send({ reason: 'bad size' })
return
}
const gameId = q.gameId || ''
if (!GameLog.exists(q.gameId)) {
res.status(404).send({ reason: 'no log found' })
return
}
const log = GameLog.get(gameId, offset)
let game: GameType|null = null
if (offset === 0) {
// also need the game
game = await Game.createGameObject(
gameId,
log[0][2],
log[0][3], // must be ImageInfo
log[0][4],
log[0][5],
log[0][6],
log[0][7],
log[0][8], // creatorUserId
)
}
res.send({ log, game: game ? Util.encodeGame(game) : null })
})
app.get('/api/newgame-data', (req, res): void => {
const q: Record<string, any> = req.query
const tagSlugs: string[] = q.tags ? q.tags.split(',') : [] const tagSlugs: string[] = q.tags ? q.tags.split(',') : []
res.send({ res.send({
images: Images.allImagesFromDb(db, tagSlugs, q.sort), images: Images.allImagesFromDb(db, tagSlugs, q.sort),
tags: db.getMany('categories', {}, [{ title: 1 }]), tags: Images.getAllTags(db),
}) })
}) })
app.get('/api/index-data', (req, res) => { app.get('/api/index-data', (req, res): void => {
const ts = Time.timestamp() const ts = Time.timestamp()
const games = [ const games = [
...Game.getAllGames().map((game: any) => ({ ...GameCommon.getAllGames().map((game: GameType) => ({
id: game.id, id: game.id,
hasReplay: GameLog.exists(game.id), hasReplay: GameLog.exists(game.id),
started: Game.getStartTs(game.id), started: GameCommon.getStartTs(game.id),
finished: Game.getFinishTs(game.id), finished: GameCommon.getFinishTs(game.id),
tilesFinished: Game.getFinishedTileCount(game.id), tilesFinished: GameCommon.getFinishedPiecesCount(game.id),
tilesTotal: Game.getTileCount(game.id), tilesTotal: GameCommon.getPieceCount(game.id),
players: Game.getActivePlayers(game.id, ts).length, players: GameCommon.getActivePlayers(game.id, ts).length,
imageUrl: Game.getImageUrl(game.id), imageUrl: GameCommon.getImageUrl(game.id),
})), })),
] ]
@ -96,78 +143,77 @@ interface SaveImageRequestData {
tags: string[] tags: string[]
} }
const setImageTags = (db: Db, imageId: number, tags: string[]) => { app.post('/api/save-image', express.json(), (req, res): void => {
tags.forEach((tag: string) => { const user = Users.getUser(db, req)
const slug = Util.slug(tag) if (!user || !user.id) {
const id = db.upsert('categories', { slug, title: tag }, { slug }, 'id') res.status(403).send({ ok: false, error: 'forbidden' })
if (id) { return
db.insert('image_x_category', {
image_id: imageId,
category_id: id,
})
} }
})
}
app.post('/api/save-image', bodyParser.json(), (req, res) => {
const data = req.body as SaveImageRequestData const data = req.body as SaveImageRequestData
const image = db.get('images', {id: data.id})
if (parseInt(image.uploader_user_id, 10) !== user.id) {
res.status(403).send({ ok: false, error: 'forbidden' })
return
}
db.update('images', { db.update('images', {
title: data.title, title: data.title,
}, { }, {
id: data.id, id: data.id,
}) })
db.delete('image_x_category', { image_id: data.id }) Images.setTags(db, data.id, data.tags || [])
if (data.tags) {
setImageTags(db, data.id, data.tags)
}
res.send({ ok: true }) res.send({ ok: true })
}) })
app.post('/api/upload', (req, res) => { app.post('/api/upload', (req, res): void => {
upload(req, res, async (err: any) => { upload(req, res, async (err: any): Promise<void> => {
if (err) { if (err) {
log.log(err) log.log(err)
res.status(400).send("Something went wrong!"); res.status(400).send("Something went wrong!")
return
} }
try { try {
await Images.resizeImage(req.file.filename) await Images.resizeImage(req.file.filename)
} catch (err) { } catch (err) {
log.log(err) log.log(err)
res.status(400).send("Something went wrong!"); res.status(400).send("Something went wrong!")
return
} }
const user = Users.getOrCreateUser(db, req)
const dim = await Images.getDimensions(
`${UPLOAD_DIR}/${req.file.filename}`
)
const imageId = db.insert('images', { const imageId = db.insert('images', {
uploader_user_id: user.id,
filename: req.file.filename, filename: req.file.filename,
filename_original: req.file.originalname, filename_original: req.file.originalname,
title: req.body.title || '', title: req.body.title || '',
created: Time.timestamp(), created: Time.timestamp(),
width: dim.w,
height: dim.h,
}) })
if (req.body.tags) { if (req.body.tags) {
setImageTags(db, imageId as number, req.body.tags) const tags = req.body.tags.split(',').filter((tag: string) => !!tag)
Images.setTags(db, imageId as number, tags)
} }
res.send(Images.imageFromDb(db, imageId as number)) res.send(Images.imageFromDb(db, imageId as number))
}) })
}) })
app.post('/newgame', bodyParser.json(), async (req, res) => { app.post('/api/newgame', express.json(), async (req, res): Promise<void> => {
const gameSettings = req.body as GameSettings const user = Users.getOrCreateUser(db, req)
log.log(gameSettings) const gameId = await Game.createNewGame(
const gameId = Util.uniqId() req.body as GameSettings,
if (!Game.exists(gameId)) { Time.timestamp(),
const ts = Time.timestamp() user.id
await Game.createGame(
gameId,
gameSettings.tiles,
gameSettings.image,
ts,
gameSettings.scoreMode
) )
}
res.send({ id: gameId }) res.send({ id: gameId })
}) })
@ -176,17 +222,18 @@ app.use('/', express.static(PUBLIC_DIR))
const wss = new WebSocketServer(config.ws); const wss = new WebSocketServer(config.ws);
const notify = (data: any, sockets: Array<WebSocket>) => { const notify = (data: ServerEvent, sockets: Array<WebSocket>): void => {
// TODO: throttle? for (const socket of sockets) {
for (let socket of sockets) {
wss.notifyOne(data, socket) wss.notifyOne(data, socket)
} }
} }
wss.on('close', async ({socket} : {socket: WebSocket}) => { wss.on('close', async (
{socket} : { socket: WebSocket }
): Promise<void> => {
try { try {
const proto = socket.protocol.split('|') const proto = socket.protocol.split('|')
const clientId = proto[0] // const clientId = proto[0]
const gameId = proto[1] const gameId = proto[1]
GameSockets.removeSocket(gameId, socket) GameSockets.removeSocket(gameId, socket)
} catch (e) { } catch (e) {
@ -194,40 +241,30 @@ wss.on('close', async ({socket} : {socket: WebSocket}) => {
} }
}) })
wss.on('message', async ({socket, data} : { socket: WebSocket, data: any }) => { wss.on('message', async (
{socket, data} : { socket: WebSocket, data: WebSocket.Data }
): Promise<void> => {
try { try {
const proto = socket.protocol.split('|') const proto = socket.protocol.split('|')
const clientId = proto[0] const clientId = proto[0]
const gameId = proto[1] const gameId = proto[1]
const msg = JSON.parse(data) // TODO: maybe handle different types of data
// (but atm only string comes through)
const msg = JSON.parse(data as string)
const msgType = msg[0] const msgType = msg[0]
switch (msgType) { switch (msgType) {
case Protocol.EV_CLIENT_INIT_REPLAY: {
if (!GameLog.exists(gameId)) {
throw `[gamelog ${gameId} does not exist... ]`
}
const log = GameLog.get(gameId)
const game = await Game.createGameObject(
gameId,
log[0][2],
log[0][3],
log[0][4],
log[0][5] || ScoreMode.FINAL
)
notify(
[Protocol.EV_SERVER_INIT_REPLAY, Util.encodeGame(game), log],
[socket]
)
} break
case Protocol.EV_CLIENT_INIT: { case Protocol.EV_CLIENT_INIT: {
if (!Game.exists(gameId)) { if (!GameCommon.exists(gameId)) {
throw `[game ${gameId} does not exist... ]` throw `[game ${gameId} does not exist... ]`
} }
const ts = Time.timestamp() const ts = Time.timestamp()
Game.addPlayer(gameId, clientId, ts) Game.addPlayer(gameId, clientId, ts)
GameSockets.addSocket(gameId, socket) GameSockets.addSocket(gameId, socket)
const game = Game.get(gameId)
const game: GameType|null = GameCommon.get(gameId)
if (!game) {
throw `[game ${gameId} does not exist (anymore)... ]`
}
notify( notify(
[Protocol.EV_SERVER_INIT, Util.encodeGame(game)], [Protocol.EV_SERVER_INIT, Util.encodeGame(game)],
[socket] [socket]
@ -235,7 +272,7 @@ wss.on('message', async ({socket, data} : { socket: WebSocket, data: any }) => {
} break } break
case Protocol.EV_CLIENT_EVENT: { case Protocol.EV_CLIENT_EVENT: {
if (!Game.exists(gameId)) { if (!GameCommon.exists(gameId)) {
throw `[game ${gameId} does not exist... ]` throw `[game ${gameId} does not exist... ]`
} }
const clientSeq = msg[1] const clientSeq = msg[1]
@ -243,7 +280,7 @@ wss.on('message', async ({socket, data} : { socket: WebSocket, data: any }) => {
const ts = Time.timestamp() const ts = Time.timestamp()
let sendGame = false let sendGame = false
if (!Game.playerExists(gameId, clientId)) { if (!GameCommon.playerExists(gameId, clientId)) {
Game.addPlayer(gameId, clientId, ts) Game.addPlayer(gameId, clientId, ts)
sendGame = true sendGame = true
} }
@ -252,7 +289,10 @@ wss.on('message', async ({socket, data} : { socket: WebSocket, data: any }) => {
sendGame = true sendGame = true
} }
if (sendGame) { if (sendGame) {
const game = Game.get(gameId) const game: GameType|null = GameCommon.get(gameId)
if (!game) {
throw `[game ${gameId} does not exist (anymore)... ]`
}
notify( notify(
[Protocol.EV_SERVER_INIT, Util.encodeGame(game)], [Protocol.EV_SERVER_INIT, Util.encodeGame(game)],
[socket] [socket]
@ -271,7 +311,7 @@ wss.on('message', async ({socket, data} : { socket: WebSocket, data: any }) => {
} }
}) })
GameStorage.loadGames() GameStorage.loadGamesFromDb(db)
const server = app.listen( const server = app.listen(
port, port,
hostname, hostname,
@ -280,9 +320,9 @@ const server = app.listen(
wss.listen() wss.listen()
const memoryUsageHuman = () => { const memoryUsageHuman = (): void => {
const totalHeapSize = v8.getHeapStatistics().total_available_size const totalHeapSize = v8.getHeapStatistics().total_available_size
let totalHeapSizeInGB = (totalHeapSize / 1024 / 1024 / 1024).toFixed(2) const totalHeapSizeInGB = (totalHeapSize / 1024 / 1024 / 1024).toFixed(2)
log.log(`Total heap size (bytes) ${totalHeapSize}, (GB ~${totalHeapSizeInGB})`) log.log(`Total heap size (bytes) ${totalHeapSize}, (GB ~${totalHeapSizeInGB})`)
const used = process.memoryUsage().heapUsed / 1024 / 1024 const used = process.memoryUsage().heapUsed / 1024 / 1024
@ -294,19 +334,19 @@ memoryUsageHuman()
// persist games in fixed interval // persist games in fixed interval
const persistInterval = setInterval(() => { const persistInterval = setInterval(() => {
log.log('Persisting games...') log.log('Persisting games...')
GameStorage.persistGames() GameStorage.persistGamesToDb(db)
memoryUsageHuman() memoryUsageHuman()
}, config.persistence.interval) }, config.persistence.interval)
const gracefulShutdown = (signal: any) => { const gracefulShutdown = (signal: string): void => {
log.log(`${signal} received...`) log.log(`${signal} received...`)
log.log('clearing persist interval...') log.log('clearing persist interval...')
clearInterval(persistInterval) clearInterval(persistInterval)
log.log('persisting games...') log.log('persisting games...')
GameStorage.persistGames() GameStorage.persistGamesToDb(db)
log.log('shutting down webserver...') log.log('shutting down webserver...')
server.close() server.close()
@ -319,14 +359,14 @@ const gracefulShutdown = (signal: any) => {
} }
// used by nodemon // used by nodemon
process.once('SIGUSR2', function () { process.once('SIGUSR2', (): void => {
gracefulShutdown('SIGUSR2') gracefulShutdown('SIGUSR2')
}) })
process.once('SIGINT', function (code) { process.once('SIGINT', (): void => {
gracefulShutdown('SIGINT') gracefulShutdown('SIGINT')
}) })
process.once('SIGTERM', function (code) { process.once('SIGTERM', (): void => {
gracefulShutdown('SIGTERM') gracefulShutdown('SIGTERM')
}) })