Compare commits

..

1 commit

Author SHA1 Message Date
Zutatensuppe
9a13838d33 replay with ws 2021-06-05 15:14:36 +02:00
51 changed files with 945 additions and 1823 deletions

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.63ff8630.js"></script> <script type="module" crossorigin src="/assets/index.7efa4c6c.js"></script>
<link rel="modulepreload" href="/assets/vendor.684f7bc8.js"> <link rel="modulepreload" href="/assets/vendor.684f7bc8.js">
<link rel="stylesheet" href="/assets/index.22dc307c.css"> <link rel="stylesheet" href="/assets/index.8f0efd0f.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -3,6 +3,8 @@ import express from 'express';
import compression from 'compression'; import compression from 'compression';
import multer from 'multer'; import multer from 'multer';
import fs from 'fs'; import fs from 'fs';
import readline from 'readline';
import stream from 'stream';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
import sizeOf from 'image-size'; import sizeOf from 'image-size';
@ -11,6 +13,29 @@ import sharp from 'sharp';
import v8 from 'v8'; import v8 from 'v8';
import bsqlite from 'better-sqlite3'; import bsqlite from 'better-sqlite3';
var PieceEdge;
(function (PieceEdge) {
PieceEdge[PieceEdge["Flat"] = 0] = "Flat";
PieceEdge[PieceEdge["Out"] = 1] = "Out";
PieceEdge[PieceEdge["In"] = -1] = "In";
})(PieceEdge || (PieceEdge = {}));
var ScoreMode;
(function (ScoreMode) {
ScoreMode[ScoreMode["FINAL"] = 0] = "FINAL";
ScoreMode[ScoreMode["ANY"] = 1] = "ANY";
})(ScoreMode || (ScoreMode = {}));
var ShapeMode;
(function (ShapeMode) {
ShapeMode[ShapeMode["NORMAL"] = 0] = "NORMAL";
ShapeMode[ShapeMode["ANY"] = 1] = "ANY";
ShapeMode[ShapeMode["FLAT"] = 2] = "FLAT";
})(ShapeMode || (ShapeMode = {}));
var SnapMode;
(function (SnapMode) {
SnapMode[SnapMode["NORMAL"] = 0] = "NORMAL";
SnapMode[SnapMode["REAL"] = 1] = "REAL";
})(SnapMode || (SnapMode = {}));
class Rng { class Rng {
constructor(seed) { constructor(seed) {
this.rand_high = seed || 0xDEADC0DE; this.rand_high = seed || 0xDEADC0DE;
@ -152,10 +177,9 @@ function encodeGame(data) {
data.puzzle, data.puzzle,
data.players, data.players,
data.evtInfos, data.evtInfos,
data.scoreMode, data.scoreMode || ScoreMode.FINAL,
data.shapeMode, data.shapeMode || ShapeMode.ANY,
data.snapMode, data.snapMode || SnapMode.NORMAL,
data.creatorUserId,
]; ];
} }
function decodeGame(data) { function decodeGame(data) {
@ -171,7 +195,6 @@ function decodeGame(data) {
scoreMode: data[6], scoreMode: data[6],
shapeMode: data[7], shapeMode: data[7],
snapMode: data[8], snapMode: data[8],
creatorUserId: data[9],
}; };
} }
function coordByPieceIdx(info, pieceIdx) { function coordByPieceIdx(info, pieceIdx) {
@ -337,13 +360,6 @@ 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_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;
const CHANGE_PLAYER = 3; const CHANGE_PLAYER = 3;
@ -367,13 +383,6 @@ var Protocol = {
INPUT_EV_PLAYER_NAME, INPUT_EV_PLAYER_NAME,
INPUT_EV_TOGGLE_PREVIEW, INPUT_EV_TOGGLE_PREVIEW,
INPUT_EV_TOGGLE_SOUNDS, 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,
CHANGE_PLAYER, CHANGE_PLAYER,
@ -477,47 +486,6 @@ var Time = {
durationStr, durationStr,
}; };
var PieceEdge;
(function (PieceEdge) {
PieceEdge[PieceEdge["Flat"] = 0] = "Flat";
PieceEdge[PieceEdge["Out"] = 1] = "Out";
PieceEdge[PieceEdge["In"] = -1] = "In";
})(PieceEdge || (PieceEdge = {}));
var ScoreMode;
(function (ScoreMode) {
ScoreMode[ScoreMode["FINAL"] = 0] = "FINAL";
ScoreMode[ScoreMode["ANY"] = 1] = "ANY";
})(ScoreMode || (ScoreMode = {}));
var ShapeMode;
(function (ShapeMode) {
ShapeMode[ShapeMode["NORMAL"] = 0] = "NORMAL";
ShapeMode[ShapeMode["ANY"] = 1] = "ANY";
ShapeMode[ShapeMode["FLAT"] = 2] = "FLAT";
})(ShapeMode || (ShapeMode = {}));
var SnapMode;
(function (SnapMode) {
SnapMode[SnapMode["NORMAL"] = 0] = "NORMAL";
SnapMode[SnapMode["REAL"] = 1] = "REAL";
})(SnapMode || (SnapMode = {}));
const DefaultScoreMode = (v) => {
if (v === ScoreMode.FINAL || v === ScoreMode.ANY) {
return v;
}
return ScoreMode.FINAL;
};
const DefaultShapeMode = (v) => {
if (v === ShapeMode.NORMAL || v === ShapeMode.ANY || v === ShapeMode.FLAT) {
return v;
}
return ShapeMode.NORMAL;
};
const DefaultSnapMode = (v) => {
if (v === SnapMode.NORMAL || v === SnapMode.REAL) {
return v;
}
return SnapMode.NORMAL;
};
const IDLE_TIMEOUT_SEC = 30; const IDLE_TIMEOUT_SEC = 30;
// Map<gameId, Game> // Map<gameId, Game>
const GAMES = {}; const GAMES = {};
@ -612,16 +580,12 @@ function setEvtInfo(gameId, playerId, evtInfo) {
} }
function getAllGames() { function getAllGames() {
return Object.values(GAMES).sort((a, b) => { return Object.values(GAMES).sort((a, b) => {
const finished = isFinished(a.id);
// when both have same finished state, sort by started // when both have same finished state, sort by started
if (finished === isFinished(b.id)) { if (isFinished(a.id) === 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 finished ? 1 : -1; return isFinished(a.id) ? 1 : -1;
}); });
} }
function getAllPlayers(gameId) { function getAllPlayers(gameId) {
@ -636,18 +600,16 @@ function getPieceCount(gameId) {
return GAMES[gameId].puzzle.tiles.length; return GAMES[gameId].puzzle.tiles.length;
} }
function getImageUrl(gameId) { function getImageUrl(gameId) {
const imageUrl = GAMES[gameId].puzzle.info.image?.url return GAMES[gameId].puzzle.info.imageUrl;
|| GAMES[gameId].puzzle.info.imageUrl; }
if (!imageUrl) { function setImageUrl(gameId, imageUrl) {
throw new Error('[2021-07-11] no image url set'); GAMES[gameId].puzzle.info.imageUrl = imageUrl;
}
return imageUrl;
} }
function getScoreMode(gameId) { function getScoreMode(gameId) {
return GAMES[gameId].scoreMode; return GAMES[gameId].scoreMode || ScoreMode.FINAL;
} }
function getSnapMode(gameId) { function getSnapMode(gameId) {
return GAMES[gameId].snapMode; return GAMES[gameId].snapMode || SnapMode.NORMAL;
} }
function isFinished(gameId) { function isFinished(gameId) {
return getFinishedPiecesCount(gameId) === getPieceCount(gameId); return getFinishedPiecesCount(gameId) === getPieceCount(gameId);
@ -1207,12 +1169,6 @@ function handleInput$1(gameId, playerId, input, ts, onSnap) {
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) { if (snapped && onSnap) {
onSnap(playerId); onSnap(playerId);
} }
@ -1255,6 +1211,7 @@ var GameCommon = {
getFinishedPiecesCount, getFinishedPiecesCount,
getPieceCount, getPieceCount,
getImageUrl, getImageUrl,
setImageUrl,
get: get$1, get: get$1,
getAllGames, getAllGames,
getPlayerBgColor, getPlayerBgColor,
@ -1292,7 +1249,6 @@ const PUBLIC_DIR = `${BASE_DIR}/build/public/`;
const DB_PATCHES_DIR = `${BASE_DIR}/src/dbpatches`; const DB_PATCHES_DIR = `${BASE_DIR}/src/dbpatches`;
const DB_FILE = `${BASE_DIR}/data/db.sqlite`; const DB_FILE = `${BASE_DIR}/data/db.sqlite`;
const LINES_PER_LOG_FILE = 10000;
const POST_GAME_LOG_DURATION = 5 * Time.MIN; const POST_GAME_LOG_DURATION = 5 * Time.MIN;
const shouldLog = (finishTs, currentTs) => { const shouldLog = (finishTs, currentTs) => {
// when not finished yet, always log // when not finished yet, always log
@ -1304,65 +1260,55 @@ const shouldLog = (finishTs, currentTs) => {
const timeSinceGameEnd = currentTs - finishTs; const timeSinceGameEnd = currentTs - finishTs;
return timeSinceGameEnd <= POST_GAME_LOG_DURATION; return timeSinceGameEnd <= POST_GAME_LOG_DURATION;
}; };
const filename = (gameId, offset) => `${DATA_DIR}/log_${gameId}-${offset}.log`; const filename = (gameId) => `${DATA_DIR}/log_${gameId}.log`;
const idxname = (gameId) => `${DATA_DIR}/log_${gameId}.idx.log`; const create = (gameId) => {
const create = (gameId, ts) => { const file = filename(gameId);
const idxfile = idxname(gameId); if (!fs.existsSync(file)) {
if (!fs.existsSync(idxfile)) { fs.appendFileSync(file, '');
fs.appendFileSync(idxfile, JSON.stringify({
gameId: gameId,
total: 0,
lastTs: ts,
currentFile: '',
perFile: LINES_PER_LOG_FILE,
}));
} }
}; };
const exists = (gameId) => { const exists = (gameId) => {
const idxfile = idxname(gameId); const file = filename(gameId);
return fs.existsSync(idxfile); return fs.existsSync(file);
}; };
const _log = (gameId, type, ...args) => { const _log = (gameId, ...args) => {
const idxfile = idxname(gameId); const file = filename(gameId);
if (!fs.existsSync(idxfile)) { if (!fs.existsSync(file)) {
return; return;
} }
const idxObj = JSON.parse(fs.readFileSync(idxfile, 'utf-8')); const str = JSON.stringify(args);
if (idxObj.total % idxObj.perFile === 0) { fs.appendFileSync(file, str + "\n");
idxObj.currentFile = filename(gameId, idxObj.total);
}
const tsIdx = type === Protocol.LOG_HEADER ? 3 : (args.length - 1);
const ts = 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, offset = 0) => { const get = async (gameId, offset = 0, size = 10000) => {
const idxfile = idxname(gameId); const file = filename(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"); return new Promise((resolve) => {
const log = lines.filter(line => !!line).map(line => { const instream = fs.createReadStream(file);
return JSON.parse(`[${line}]`); const outstream = new stream.Writable();
}); const rl = readline.createInterface(instream, outstream);
if (offset === 0 && log.length > 0) { const lines = [];
log[0][5] = DefaultScoreMode(log[0][5]); let i = -1;
log[0][6] = DefaultShapeMode(log[0][6]); rl.on('line', (line) => {
log[0][7] = DefaultSnapMode(log[0][7]); if (!line) {
log[0][8] = log[0][8] || null; // skip empty
return;
} }
return log; i++;
if (offset > i) {
return;
}
if (offset + size <= i) {
rl.close();
return;
}
lines.push(JSON.parse(line));
});
rl.on('close', () => {
resolve(lines);
});
});
}; };
var GameLog = { var GameLog = {
shouldLog, shouldLog,
@ -1370,8 +1316,6 @@ var GameLog = {
exists, exists,
log: _log, log: _log,
get, get,
filename,
idxname,
}; };
const log$4 = logger('Images.ts'); const log$4 = logger('Images.ts');
@ -1446,14 +1390,12 @@ const imageFromDb = (db, imageId) => {
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), tags: getTags(db, i.id),
created: i.created * 1000, created: i.created * 1000,
width: i.width,
height: i.height,
}; };
}; };
const allImagesFromDb = (db, tagSlugs, orderBy) => { const allImagesFromDb = (db, tagSlugs, orderBy) => {
@ -1485,42 +1427,35 @@ inner join images i on i.id = ixc.image_id ${where.sql};
const images = db.getMany('images', wheresRaw, orderByMap[orderBy]); const images = db.getMany('images', wheresRaw, orderByMap[orderBy]);
return images.map(i => ({ return images.map(i => ({
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), tags: getTags(db, i.id),
created: i.created * 1000, created: i.created * 1000,
width: i.width,
height: i.height,
})); }));
}; };
/**
* @deprecated old function, now database is used
*/
const allImagesFromDisk = (tags, sort) => { const allImagesFromDisk = (tags, sort) => {
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: [], tags: [],
created: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(), created: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(),
width: 0,
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.filename > b.filename ? 1 : -1; return a.file > b.file ? 1 : -1;
}); });
break; break;
case 'alpha_desc': case 'alpha_desc':
images = images.sort((a, b) => { images = images.sort((a, b) => {
return a.filename < b.filename ? 1 : -1; return a.file < b.file ? 1 : -1;
}); });
break; break;
case 'date_asc': case 'date_asc':
@ -1566,7 +1501,7 @@ var Images = {
// final resized version of the puzzle image // final resized version of the puzzle image
const TILE_SIZE = 64; const TILE_SIZE = 64;
async function createPuzzle(rng, targetTiles, image, ts, shapeMode) { async function createPuzzle(rng, targetTiles, image, ts, shapeMode) {
const imagePath = `${UPLOAD_DIR}/${image.filename}`; const imagePath = image.file;
const imageUrl = image.url; const imageUrl = image.url;
// determine puzzle information from the image dimensions // determine puzzle information from the image dimensions
const dim = await Images.getDimensions(imagePath); const dim = await Images.getDimensions(imagePath);
@ -1665,7 +1600,6 @@ async function createPuzzle(rng, targetTiles, image, ts, shapeMode) {
// information that was used to create the puzzle // information that was used to create the puzzle
targetTiles: targetTiles, targetTiles: targetTiles,
imageUrl, imageUrl,
image: image,
width: info.width, width: info.width,
height: info.height, height: info.height,
tileSize: info.tileSize, tileSize: info.tileSize,
@ -1756,63 +1690,7 @@ function setDirty(gameId) {
function setClean(gameId) { function setClean(gameId) {
delete dirtyGames[gameId]; delete dirtyGames[gameId];
} }
function loadGamesFromDb(db) { function loadGames() {
const gameRows = db.getMany('games');
for (const gameRow of gameRows) {
loadGameFromDb(db, gameRow.id);
}
}
function loadGameFromDb(db, gameId) {
const gameRow = db.get('games', { id: gameId });
let game;
try {
game = JSON.parse(gameRow.data);
}
catch {
log$3.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 = storeDataToGame(game, game.creator_user_id);
GameCommon.setGame(gameObject.id, gameObject);
}
function persistGamesToDb(db) {
for (const gameId of Object.keys(dirtyGames)) {
persistGameToDb(db, gameId);
}
}
function persistGameToDb(db, gameId) {
const game = GameCommon.get(gameId);
if (!game) {
log$3.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$3.info(`[INFO] persisted game ${game.id}`);
}
/**
* @deprecated
*/
function loadGamesFromDisk() {
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$/);
@ -1820,13 +1698,10 @@ function loadGamesFromDisk() {
continue; continue;
} }
const gameId = m[1]; const gameId = m[1];
loadGameFromDisk(gameId); loadGame(gameId);
} }
} }
/** function loadGame(gameId) {
* @deprecated
*/
function loadGameFromDisk(gameId) {
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;
@ -1848,21 +1723,27 @@ function loadGameFromDisk(gameId) {
if (!Array.isArray(game.players)) { if (!Array.isArray(game.players)) {
game.players = Object.values(game.players); game.players = Object.values(game.players);
} }
const gameObject = storeDataToGame(game, null); const gameObject = {
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,
shapeMode: game.shapeMode || ShapeMode.ANY,
snapMode: game.snapMode || SnapMode.NORMAL,
};
GameCommon.setGame(gameObject.id, gameObject); GameCommon.setGame(gameObject.id, gameObject);
} }
/** function persistGames() {
* @deprecated
*/
function persistGamesToDisk() {
for (const gameId of Object.keys(dirtyGames)) { for (const gameId of Object.keys(dirtyGames)) {
persistGameToDisk(gameId); persistGame(gameId);
} }
} }
/** function persistGame(gameId) {
* @deprecated
*/
function persistGameToDisk(gameId) {
const game = GameCommon.get(gameId); const game = GameCommon.get(gameId);
if (!game) { if (!game) {
log$3.error(`[ERROR] unable to persist non existing game ${gameId}`); log$3.error(`[ERROR] unable to persist non existing game ${gameId}`);
@ -1871,27 +1752,7 @@ function persistGameToDisk(gameId) {
if (game.id in dirtyGames) { if (game.id in dirtyGames) {
setClean(game.id); setClean(game.id);
} }
fs.writeFileSync(`${DATA_DIR}/${game.id}.json`, gameToStoreData(game)); fs.writeFileSync(`${DATA_DIR}/${game.id}.json`, JSON.stringify({
log$3.info(`[INFO] persisted game ${game.id}`);
}
function storeDataToGame(storeData, creatorUserId) {
return {
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 gameToStoreData(game) {
return JSON.stringify({
id: game.id, id: game.id,
rng: { rng: {
type: game.rng.type, type: game.rng.type,
@ -1902,27 +1763,22 @@ function gameToStoreData(game) {
scoreMode: game.scoreMode, scoreMode: game.scoreMode,
shapeMode: game.shapeMode, shapeMode: game.shapeMode,
snapMode: game.snapMode, snapMode: game.snapMode,
}); }));
log$3.info(`[INFO] persisted game ${game.id}`);
} }
var GameStorage = { var GameStorage = {
// disk functions are deprecated loadGames,
loadGamesFromDisk, loadGame,
loadGameFromDisk, persistGames,
persistGamesToDisk, persistGame,
persistGameToDisk,
loadGamesFromDb,
loadGameFromDb,
persistGamesToDb,
persistGameToDb,
setDirty, setDirty,
}; };
async function createGameObject(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode, creatorUserId) { async function createGameObject(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode) {
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, shapeMode), puzzle: await createPuzzle(rng, targetTiles, image, ts, shapeMode),
players: [], players: [],
@ -1932,21 +1788,22 @@ async function createGameObject(gameId, targetTiles, image, ts, scoreMode, shape
snapMode, snapMode,
}; };
} }
async function createGame(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode, creatorUserId) { async function createGame(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode) {
const gameObject = await createGameObject(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode, creatorUserId); const gameObject = await createGameObject(gameId, targetTiles, image, ts, scoreMode, shapeMode, snapMode);
GameLog.create(gameId, ts); GameLog.create(gameId);
GameLog.log(gameId, Protocol.LOG_HEADER, 1, targetTiles, image, ts, scoreMode, shapeMode, snapMode, gameObject.creatorUserId); GameLog.log(gameId, Protocol.LOG_HEADER, 1, targetTiles, image, ts, scoreMode, shapeMode, snapMode);
GameCommon.setGame(gameObject.id, gameObject); GameCommon.setGame(gameObject.id, gameObject);
GameStorage.setDirty(gameId); GameStorage.setDirty(gameId);
} }
function addPlayer(gameId, playerId, ts) { function addPlayer(gameId, playerId, ts) {
if (GameLog.shouldLog(GameCommon.getFinishTs(gameId), ts)) { 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, ts); GameLog.log(gameId, Protocol.LOG_ADD_PLAYER, playerId, diff);
} }
else { else {
GameLog.log(gameId, Protocol.LOG_UPDATE_PLAYER, idx, ts); GameLog.log(gameId, Protocol.LOG_UPDATE_PLAYER, idx, diff);
} }
} }
GameCommon.addPlayer(gameId, playerId, ts); GameCommon.addPlayer(gameId, playerId, ts);
@ -1955,7 +1812,8 @@ function addPlayer(gameId, playerId, ts) {
function handleInput(gameId, playerId, input, ts) { function handleInput(gameId, playerId, input, ts) {
if (GameLog.shouldLog(GameCommon.getFinishTs(gameId), ts)) { if (GameLog.shouldLog(GameCommon.getFinishTs(gameId), ts)) {
const idx = GameCommon.getPlayerIndexById(gameId, playerId); const idx = GameCommon.getPlayerIndexById(gameId, playerId);
GameLog.log(gameId, Protocol.LOG_HANDLE_INPUT, idx, input, ts); const diff = ts - GameCommon.getStartTs(gameId);
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);
@ -2181,13 +2039,6 @@ const storage = multer.diskStorage({
} }
}); });
const upload = multer({ storage }).single('file'); const upload = multer({ storage }).single('file');
app.get('/api/me', (req, res) => {
let user = getUser(db, req);
res.send({
id: user ? user.id : null,
created: user ? user.created : null,
});
});
app.get('/api/conf', (req, res) => { app.get('/api/conf', (req, res) => {
res.send({ res.send({
WS_ADDRESS: config.ws.connectstring, WS_ADDRESS: config.ws.connectstring,
@ -2210,12 +2061,11 @@ app.get('/api/replay-data', async (req, res) => {
res.status(404).send({ reason: 'no log found' }); res.status(404).send({ reason: 'no log found' });
return; return;
} }
const log = GameLog.get(gameId, offset); const log = await GameLog.get(gameId, offset, size);
let game = null; let game = null;
if (offset === 0) { if (offset === 0) {
// also need the game // also need the game
game = await Game.createGameObject(gameId, log[0][2], log[0][3], // must be ImageInfo game = await Game.createGameObject(gameId, log[0][2], log[0][3], log[0][4], log[0][5] || ScoreMode.FINAL, log[0][6] || ShapeMode.NORMAL, log[0][7] || SnapMode.NORMAL);
log[0][4], log[0][5], log[0][6], log[0][7], log[0][8]);
} }
res.send({ log, game: game ? Util.encodeGame(game) : null }); res.send({ log, game: game ? Util.encodeGame(game) : null });
}); });
@ -2246,28 +2096,6 @@ app.get('/api/index-data', (req, res) => {
gamesFinished: games.filter(g => !!g.finished), gamesFinished: games.filter(g => !!g.finished),
}); });
}); });
const getOrCreateUser = (db, req) => {
let user = getUser(db, req);
if (!user) {
db.insert('users', {
'client_id': req.headers['client-id'],
'client_secret': req.headers['client-secret'],
'created': Time.timestamp(),
});
user = getUser(db, req);
}
return user;
};
const getUser = (db, req) => {
let user = db.get('users', {
'client_id': req.headers['client-id'],
'client_secret': req.headers['client-secret'],
});
if (user) {
user.id = parseInt(user.id, 10);
}
return user;
};
const setImageTags = (db, imageId, tags) => { const setImageTags = (db, imageId, tags) => {
tags.forEach((tag) => { tags.forEach((tag) => {
const slug = Util.slug(tag); const slug = Util.slug(tag);
@ -2281,17 +2109,7 @@ const setImageTags = (db, imageId, tags) => {
}); });
}; };
app.post('/api/save-image', express.json(), (req, res) => { app.post('/api/save-image', express.json(), (req, res) => {
let user = getUser(db, req);
if (!user || !user.id) {
res.status(403).send({ ok: false, error: 'forbidden' });
return;
}
const data = req.body; const data = req.body;
let 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,
}, { }, {
@ -2316,16 +2134,11 @@ app.post('/api/upload', (req, res) => {
log.log(err); log.log(err);
res.status(400).send("Something went wrong!"); res.status(400).send("Something went wrong!");
} }
const user = 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) {
const tags = req.body.tags.split(',').filter((tag) => !!tag); const tags = req.body.tags.split(',').filter((tag) => !!tag);
@ -2335,17 +2148,12 @@ app.post('/api/upload', (req, res) => {
}); });
}); });
app.post('/api/newgame', express.json(), async (req, res) => { app.post('/api/newgame', express.json(), async (req, res) => {
let user = getOrCreateUser(db, req);
if (!user || !user.id) {
res.status(403).send({ ok: false, error: 'forbidden' });
return;
}
const gameSettings = req.body; const gameSettings = req.body;
log.log(gameSettings); log.log(gameSettings);
const gameId = Util.uniqId(); const gameId = Util.uniqId();
if (!GameCommon.exists(gameId)) { if (!GameCommon.exists(gameId)) {
const ts = Time.timestamp(); const ts = Time.timestamp();
await Game.createGame(gameId, gameSettings.tiles, gameSettings.image, ts, gameSettings.scoreMode, gameSettings.shapeMode, gameSettings.snapMode, user.id); await Game.createGame(gameId, gameSettings.tiles, gameSettings.image, ts, gameSettings.scoreMode, gameSettings.shapeMode, gameSettings.snapMode);
} }
res.send({ id: gameId }); res.send({ id: gameId });
}); });
@ -2427,7 +2235,7 @@ wss.on('message', async ({ socket, data }) => {
log.error(e); log.error(e);
} }
}); });
GameStorage.loadGamesFromDb(db); GameStorage.loadGames();
const server = app.listen(port, hostname, () => log.log(`server running on http://${hostname}:${port}`)); const server = app.listen(port, hostname, () => log.log(`server running on http://${hostname}:${port}`));
wss.listen(); wss.listen();
const memoryUsageHuman = () => { const memoryUsageHuman = () => {
@ -2441,7 +2249,7 @@ 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.persistGamesToDb(db); GameStorage.persistGames();
memoryUsageHuman(); memoryUsageHuman();
}, config.persistence.interval); }, config.persistence.interval);
const gracefulShutdown = (signal) => { const gracefulShutdown = (signal) => {
@ -2449,7 +2257,7 @@ const gracefulShutdown = (signal) => {
log.log('clearing persist interval...'); log.log('clearing persist interval...');
clearInterval(persistInterval); clearInterval(persistInterval);
log.log('persisting games...'); log.log('persisting games...');
GameStorage.persistGamesToDb(db); GameStorage.persistGames();
log.log('shutting down webserver...'); log.log('shutting down webserver...');
server.close(); server.close();
log.log('shutting down websocketserver...'); log.log('shutting down websocketserver...');

View file

@ -16,7 +16,9 @@ export default {
"image-size", "image-size",
"multer", "multer",
"path", "path",
"readline",
"sharp", "sharp",
"stream",
"url", "url",
"v8", "v8",
"ws", "ws",

View file

@ -1,90 +0,0 @@
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()

23
scripts/fix_image.ts Normal file
View file

@ -0,0 +1,23 @@
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,16 +1,11 @@
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.loadGameFromDb(db, gameId) GameStorage.loadGame(gameId)
let changed = false let changed = false
const tiles = GameCommon.getPiecesSortedByZIndex(gameId) const tiles = GameCommon.getPiecesSortedByZIndex(gameId)
for (let tile of tiles) { for (let tile of tiles) {
@ -32,7 +27,7 @@ function fix_tiles(gameId) {
} }
} }
if (changed) { if (changed) {
GameStorage.persistGameToDb(db, gameId) GameStorage.persistGame(gameId)
} }
} }

View file

@ -1,27 +0,0 @@
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

@ -1,18 +0,0 @@
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 })
}
})()

82
scripts/rewrite_logs.ts Normal file
View file

@ -0,0 +1,82 @@
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 --watch build --max-old-space-size=64 -e js build/server/main.js -c config.json nodemon --max-old-space-size=64 -e js build/server/main.js -c config.json

View file

@ -1,73 +0,0 @@
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 -e #!/bin/sh -e
node --max-old-space-size=256 --experimental-specifier-resolution=node --loader ts-node/esm $@ node --experimental-specifier-resolution=node --loader ts-node/esm $@

View file

@ -138,16 +138,12 @@ 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 (finished === isFinished(b.id)) { if (isFinished(a.id) === 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 finished ? 1 : -1 return isFinished(a.id) ? 1 : -1
}) })
} }
@ -166,20 +162,19 @@ function getPieceCount(gameId: string): number {
} }
function getImageUrl(gameId: string): string { function getImageUrl(gameId: string): string {
const imageUrl = GAMES[gameId].puzzle.info.image?.url return GAMES[gameId].puzzle.info.imageUrl
|| GAMES[gameId].puzzle.info.imageUrl }
if (!imageUrl) {
throw new Error('[2021-07-11] no image url set') function setImageUrl(gameId: string, imageUrl: string): void {
} GAMES[gameId].puzzle.info.imageUrl = imageUrl
return imageUrl
} }
function getScoreMode(gameId: string): ScoreMode { function getScoreMode(gameId: string): ScoreMode {
return GAMES[gameId].scoreMode return GAMES[gameId].scoreMode || ScoreMode.FINAL
} }
function getSnapMode(gameId: string): SnapMode { function getSnapMode(gameId: string): SnapMode {
return GAMES[gameId].snapMode return GAMES[gameId].snapMode || SnapMode.NORMAL
} }
function isFinished(gameId: string): boolean { function isFinished(gameId: string): boolean {
@ -853,13 +848,6 @@ 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) { if (snapped && onSnap) {
onSnap(playerId) onSnap(playerId)
} }
@ -900,6 +888,7 @@ export default {
getFinishedPiecesCount, getFinishedPiecesCount,
getPieceCount, getPieceCount,
getImageUrl, getImageUrl,
setImageUrl,
get, get,
getAllGames, getAllGames,
getPlayerBgColor, getPlayerBgColor,

View file

@ -43,6 +43,11 @@ const EV_SERVER_INIT = 4
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 = 5
const EV_SERVER_INIT_REPLAY = 6
const EV_CLIENT_REPLAY_EVENT = 7
const EV_SERVER_REPLAY_EVENT = 8
const LOG_HEADER = 1 const LOG_HEADER = 1
const LOG_ADD_PLAYER = 2 const LOG_ADD_PLAYER = 2
const LOG_UPDATE_PLAYER = 4 const LOG_UPDATE_PLAYER = 4
@ -60,16 +65,6 @@ 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_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
const CHANGE_PLAYER = 3 const CHANGE_PLAYER = 3
@ -80,6 +75,11 @@ export default {
EV_CLIENT_EVENT, EV_CLIENT_EVENT,
EV_CLIENT_INIT, EV_CLIENT_INIT,
EV_CLIENT_INIT_REPLAY,
EV_SERVER_INIT_REPLAY,
EV_CLIENT_REPLAY_EVENT,
EV_SERVER_REPLAY_EVENT,
LOG_HEADER, LOG_HEADER,
LOG_ADD_PLAYER, LOG_ADD_PLAYER,
LOG_UPDATE_PLAYER, LOG_UPDATE_PLAYER,
@ -100,16 +100,6 @@ export default {
INPUT_EV_TOGGLE_PREVIEW, INPUT_EV_TOGGLE_PREVIEW,
INPUT_EV_TOGGLE_SOUNDS, 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,
CHANGE_PLAYER, CHANGE_PLAYER,

View file

@ -51,7 +51,6 @@ export type EncodedGame = FixedLengthArray<[
ScoreMode, ScoreMode,
ShapeMode, ShapeMode,
SnapMode, SnapMode,
number|null,
]> ]>
export interface ReplayData { export interface ReplayData {
@ -73,13 +72,12 @@ interface GameRng {
export interface Game { export interface Game {
id: string id: string
creatorUserId: number|null
players: Array<EncodedPlayer> players: Array<EncodedPlayer>
puzzle: Puzzle puzzle: Puzzle
evtInfos: Record<string, EvtInfo> evtInfos: Record<string, EvtInfo>
scoreMode: ScoreMode scoreMode?: ScoreMode
shapeMode: ShapeMode shapeMode?: ShapeMode
snapMode: SnapMode snapMode?: SnapMode
rng: GameRng rng: GameRng
} }
@ -95,7 +93,7 @@ export interface Image {
export interface GameSettings { export interface GameSettings {
tiles: number tiles: number
image: ImageInfo image: Image
scoreMode: ScoreMode scoreMode: ScoreMode
shapeMode: ShapeMode shapeMode: ShapeMode
snapMode: SnapMode snapMode: SnapMode
@ -154,24 +152,10 @@ export interface PieceChange {
group?: 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 { export interface PuzzleInfo {
table: PuzzleTable table: PuzzleTable
targetTiles: number targetTiles: number,
imageUrl?: string // deprecated, use image.url instead imageUrl: string
image?: ImageInfo
width: number width: number
height: number height: number
@ -232,24 +216,3 @@ export enum SnapMode {
NORMAL = 0, NORMAL = 0,
REAL = 1, 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

@ -130,10 +130,9 @@ function encodeGame(data: Game): EncodedGame {
data.puzzle, data.puzzle,
data.players, data.players,
data.evtInfos, data.evtInfos,
data.scoreMode, data.scoreMode || ScoreMode.FINAL,
data.shapeMode, data.shapeMode || ShapeMode.ANY,
data.snapMode, data.snapMode || SnapMode.NORMAL,
data.creatorUserId,
] ]
} }
@ -150,7 +149,6 @@ function decodeGame(data: EncodedGame): Game {
scoreMode: data[6], scoreMode: data[6],
shapeMode: data[7], shapeMode: data[7],
snapMode: data[8], snapMode: data[8],
creatorUserId: data[9],
} }
} }

View file

@ -1,22 +0,0 @@
-- 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

@ -1,45 +0,0 @@
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

@ -1,11 +0,0 @@
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'}">Games overview</router-link></li> <li><router-link class="btn" :to="{name: 'index'}">Index</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,12 +11,6 @@ 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
@ -136,11 +130,9 @@ export default function Camera () {
return { return {
getCurrentZoom: () => curZoom, getCurrentZoom: () => curZoom,
reset,
move, move,
canZoom, canZoom,
zoom, zoom,
setZoom,
worldToViewport, worldToViewport,
worldToViewportRaw, worldToViewportRaw,
worldDimToViewport, // not used outside worldDimToViewport, // not used outside

View file

@ -3,7 +3,6 @@
import { ClientEvent, EncodedGame, GameEvent, ReplayData, ServerEvent } from '../common/Types' import { ClientEvent, EncodedGame, GameEvent, ReplayData, ServerEvent } from '../common/Types'
import Util, { logger } from '../common/Util' 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')
@ -113,12 +112,61 @@ function connect(
}) })
} }
let onReplayDataCallback = (logEntry: any[]) => {}
const onReplayData = (fn: any) => {
onReplayDataCallback = fn
}
function connectReplay(
address: string,
gameId: string,
clientId: string
): Promise<EncodedGame> {
clientSeq = 0
events = {}
setConnectionState(CONN_STATE_CONNECTING)
return new Promise(resolve => {
ws = new WebSocket(address, clientId + '|' + gameId)
ws.onopen = () => {
setConnectionState(CONN_STATE_CONNECTED)
send([Protocol.EV_CLIENT_INIT_REPLAY])
}
ws.onmessage = (e: MessageEvent) => {
const msg: ServerEvent = JSON.parse(e.data)
const msgType = msg[0]
if (msgType === Protocol.EV_SERVER_INIT_REPLAY) {
const game = msg[1]
resolve(game)
} else if (msgType === Protocol.EV_SERVER_REPLAY_EVENT) {
onReplayDataCallback(msg[1])
} else {
throw `[ 2021-06-05 invalid connectReplay msgType ${msgType} ]`
}
}
ws.onerror = () => {
setConnectionState(CONN_STATE_DISCONNECTED)
throw `[ 2021-05-15 onerror ]`
}
ws.onclose = (e: CloseEvent) => {
if (e.code === CODE_CUSTOM_DISCONNECT || e.code === CODE_GOING_AWAY) {
setConnectionState(CONN_STATE_CLOSED)
} else {
setConnectionState(CONN_STATE_DISCONNECTED)
}
}
})
}
async function requestReplayData( async function requestReplayData(
gameId: string, gameId: string,
offset: number offset: number,
size: number
): Promise<ReplayData> { ): Promise<ReplayData> {
const args = { gameId, offset } const args = { gameId, offset, size }
const res = await xhr.get(`/api/replay-data${Util.asQueryArgs(args)}`, {}) const res = await fetch(`/api/replay-data${Util.asQueryArgs(args)}`)
const json: ReplayData = await res.json() const json: ReplayData = await res.json()
return json return json
} }
@ -139,7 +187,17 @@ function sendClientEvent(evt: GameEvent): void {
send([Protocol.EV_CLIENT_EVENT, clientSeq, events[clientSeq]]) send([Protocol.EV_CLIENT_EVENT, clientSeq, events[clientSeq]])
} }
function requestMoreReplayData(): void {
send([Protocol.EV_CLIENT_REPLAY_EVENT])
}
export default { export default {
connectReplay,
requestMoreReplayData,
onReplayData,
connect, connect,
requestReplayData, requestReplayData,
disconnect, disconnect,

View file

@ -1,177 +0,0 @@
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

@ -3,7 +3,7 @@
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, EncodedPiece } from './../common/Types' import { Puzzle, PuzzleInfo, PieceShape, EncodedPiece } from './../common/GameCommon'
const log = logger('PuzzleGraphics.js') const log = logger('PuzzleGraphics.js')

View file

@ -96,17 +96,7 @@ 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

@ -10,17 +10,9 @@
<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>🔉 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

@ -3,7 +3,7 @@
class="imageteaser" class="imageteaser"
:style="style" :style="style"
@click="onClick"> @click="onClick">
<div class="btn edit" v-if="canEdit" @click.stop="onEditClick"></div> <div class="btn edit" @click.stop="onEditClick"></div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -18,18 +18,12 @@ 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

@ -1,68 +0,0 @@
<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,10 +6,7 @@
<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"> <div class="image-title" v-if="image.title">"{{image.title}}"</div>
<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">
@ -21,34 +18,27 @@
<tr> <tr>
<td><label>Scoring: </label></td> <td><label>Scoring: </label></td>
<td> <td>
<label><input type="radio" v-model="scoreMode" value="1" /> <label><input type="radio" v-model="scoreMode" value="1" /> Any (Score when pieces are connected to each other or on final location)</label>
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" /> <label><input type="radio" v-model="scoreMode" value="0" /> Final (Score when pieces are put to their final location)</label>
Final (Score when pieces are put to their final location)</label>
</td> </td>
</tr> </tr>
<tr> <tr>
<td><label>Shapes: </label></td> <td><label>Shapes: </label></td>
<td> <td>
<label><input type="radio" v-model="shapeMode" value="0" /> <label><input type="radio" v-model="shapeMode" value="0" /> Normal</label>
Normal</label>
<br /> <br />
<label><input type="radio" v-model="shapeMode" value="1" /> <label><input type="radio" v-model="shapeMode" value="1" /> Any (flat pieces can occur anywhere)</label>
Any (Flat pieces can occur anywhere)</label>
<br /> <br />
<label><input type="radio" v-model="shapeMode" value="2" /> <label><input type="radio" v-model="shapeMode" value="2" /> Flat (all pieces flat on all sides)</label>
Flat (All pieces flat on all sides)</label>
</td> </td>
</tr> </tr>
<tr> <tr>
<td><label>Snapping: </label></td> <td><label>Snapping: </label></td>
<td> <td>
<label><input type="radio" v-model="snapMode" value="0" /> <label><input type="radio" v-model="snapMode" value="0" /> Normal (pieces snap to final destination and to each other)</label>
Normal (Pieces snap to final destination and to each other)</label>
<br /> <br />
<label><input type="radio" v-model="snapMode" value="1" /> <label><input type="radio" v-model="snapMode" value="1" /> Real (pieces snap only to corners, already snapped pieces and to each other)</label>
Real (Pieces snap only to corners, already snapped pieces and to each other)</label>
</td> </td>
</tr> </tr>
</table> </table>
@ -152,8 +142,7 @@ export default defineComponent({
"image-title"; "image-title";
margin-right: 1em; margin-right: 1em;
} }
@media (max-width: 1400px) and (min-height: 720px), @media (max-width: 1400px) {
(max-width: 1000px) {
.new-game-dialog .overlay-content { .new-game-dialog .overlay-content {
grid-template-columns: auto; grid-template-columns: auto;
grid-template-rows: 1fr min-content min-content; grid-template-rows: 1fr min-content min-content;
@ -203,8 +192,4 @@ export default defineComponent({
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

@ -14,7 +14,6 @@ gallery", if possible!
@dragover="onDragover" @dragover="onDragover"
@dragleave="onDragleave"> @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" />
@ -49,21 +48,10 @@ gallery", if possible!
</div> </div>
<div class="area-buttons"> <div class="area-buttons">
<button class="btn" <button class="btn" :disabled="!canPostToGallery" @click="postToGallery">🖼 Post to gallery</button>
:disabled="!canPostToGallery" <button class="btn" :disabled="!canSetupGameClick" @click="setupGameClick">🧩 Post to gallery <br /> + set up game</button>
@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>
@ -86,12 +74,6 @@ export default defineComponent({
autocompleteTags: { autocompleteTags: {
type: Function, type: Function,
}, },
uploadProgress: {
type: Number,
},
uploading: {
type: String,
},
}, },
emits: { emits: {
bgclick: null, bgclick: null,
@ -108,19 +90,10 @@ export default defineComponent({
} }
}, },
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)
}, },
}, },
@ -210,8 +183,7 @@ export default defineComponent({
height: 90%; height: 90%;
width: 80%; width: 80%;
} }
@media (max-width: 1400px) and (min-height: 720px), @media (max-width: 1400px) {
(max-width: 1000px) {
.new-image-dialog .overlay-content { .new-image-dialog .overlay-content {
grid-template-columns: auto; grid-template-columns: auto;
grid-template-rows: 1fr min-content min-content; grid-template-rows: 1fr min-content min-content;
@ -240,6 +212,9 @@ export default defineComponent({
.new-image-dialog .area-image.droppable { .new-image-dialog .area-image.droppable {
border: dashed 6px; border: dashed 6px;
} }
.area-image * {
pointer-events: none;
}
.new-image-dialog .area-image .has-image { .new-image-dialog .area-image .has-image {
position: relative; position: relative;
width: 100%; width: 100%;
@ -281,16 +256,4 @@ 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

@ -17,24 +17,6 @@
<td><label>Sounds: </label></td> <td><label>Sounds: </label></td>
<td><input type="checkbox" v-model="modelValue.soundsEnabled" /></td> <td><input type="checkbox" v-model="modelValue.soundsEnabled" /></td>
</tr> </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>
@ -48,23 +30,7 @@ export default defineComponent({
'update:modelValue': null, 'update:modelValue': null,
}, },
props: { props: {
modelValue: { modelValue: Object,
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
@ -74,7 +40,3 @@ export default defineComponent({
}, },
}) })
</script> </script>
<style scoped>
.sound-volume span { cursor: pointer; user-select: none; }
.sound-volume input { vertical-align: middle; }
</style>

View file

@ -56,14 +56,14 @@ export default defineComponent({
}, },
methods: { methods: {
onKeyUp (ev: KeyboardEvent) { onKeyUp (ev: KeyboardEvent) {
if (ev.code === 'ArrowDown' && this.autocomplete.values.length > 0) { if (ev.key === 'ArrowDown' && this.autocomplete.values.length > 0) {
if (this.autocomplete.idx < this.autocomplete.values.length - 1) { if (this.autocomplete.idx < this.autocomplete.values.length - 1) {
this.autocomplete.idx++ this.autocomplete.idx++
} }
ev.stopPropagation() ev.stopPropagation()
return false return false
} }
if (ev.code === 'ArrowUp' && this.autocomplete.values.length > 0) { if (ev.key === 'ArrowUp' && this.autocomplete.values.length > 0) {
if (this.autocomplete.idx > 0) { if (this.autocomplete.idx > 0) {
this.autocomplete.idx-- this.autocomplete.idx--
} }

View file

@ -6,7 +6,6 @@
</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',
@ -22,7 +21,8 @@ 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 xhr.post('/upload', { const res = await fetch('/upload', {
method: 'post',
body: formData, body: formData,
}) })
const j = await res.json() const j = await res.json()

View file

@ -11,8 +11,6 @@ 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 { import {
FixedLengthArray, FixedLengthArray,
@ -22,9 +20,9 @@ import {
EncodedGame, EncodedGame,
ReplayData, ReplayData,
Timestamp, Timestamp,
GameEvent,
ServerEvent, ServerEvent,
} from '../common/Types' } from '../common/Types'
import EventAdapter from './EventAdapter'
declare global { declare global {
interface Window { interface Window {
DEBUG?: boolean DEBUG?: boolean
@ -46,7 +44,6 @@ 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
@ -56,7 +53,6 @@ interface Hud {
setConnectionState: (v: number) => void setConnectionState: (v: number) => void
togglePreview: () => void togglePreview: () => void
toggleSoundsEnabled: () => void toggleSoundsEnabled: () => void
togglePlayerNames: () => void
setReplaySpeed?: (v: number) => void setReplaySpeed?: (v: number) => void
setReplayPaused?: (v: boolean) => void setReplayPaused?: (v: boolean) => void
} }
@ -73,6 +69,7 @@ interface Replay {
skipNonActionPhases: boolean skipNonActionPhases: boolean
// //
dataOffset: number dataOffset: number
dataSize: number
} }
const shouldDrawPiece = (piece: Piece) => { const shouldDrawPiece = (piece: Piece) => {
@ -96,6 +93,154 @@ function addCanvasToDom(TARGET_EL: HTMLElement, canvas: HTMLCanvasElement) {
return canvas return canvas
} }
function EventAdapter (canvas: HTMLCanvasElement, window: any, viewport: any) {
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.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
}
}
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.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
}
if (ev.key === 'M' || ev.key === 'm') {
addEvent([Protocol.INPUT_EV_TOGGLE_SOUNDS])
}
})
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 async function main( export async function main(
gameId: string, gameId: string,
clientId: string, clientId: string,
@ -156,35 +301,15 @@ export async function main(
lastRealTs: 0, lastRealTs: 0,
lastGameTs: 0, lastGameTs: 0,
gameStartTs: 0, gameStartTs: 0,
skipNonActionPhases: true, skipNonActionPhases: false,
dataOffset: 0, dataOffset: 0,
dataSize: 10000,
} }
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) {
@ -193,16 +318,38 @@ export async function main(
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) {
const replay: ReplayData = await queryNextReplayBatch(gameId) REPLAY.logPointer = 0
if (!replay.game) { REPLAY.dataSize = 10000
REPLAY.speeds = [0.5, 1, 2, 5, 10, 20, 50, 100, 250, 500]
REPLAY.speedIdx = 1
Communication.onReplayData((logLines: any[][]) => {
if (logLines.length === 0) {
console.log('MUHHHHH FINAL!!!!')
REPLAY.final = true
} else {
if (REPLAY.logPointer === 0) {
REPLAY.log.push(...logLines)
} else {
REPLAY.log = REPLAY.log.slice(REPLAY.logPointer - 1)
REPLAY.logPointer = 1
REPLAY.log.push(...logLines)
}
}
})
const game: any = await Communication.connectReplay(wsAddress, gameId, clientId)
if (!game) {
throw '[ 2021-05-29 no game received ]' throw '[ 2021-05-29 no game received ]'
} }
const gameObject: GameType = Util.decodeGame(replay.game) const gameObject: GameType = Util.decodeGame(game)
Game.setGame(gameObject.id, gameObject) Game.setGame(gameObject.id, gameObject)
REPLAY.lastRealTs = Time.timestamp() REPLAY.lastRealTs = Time.timestamp()
REPLAY.gameStartTs = parseInt(replay.log[0][4], 10) REPLAY.gameStartTs = Game.getStartTs(gameId)
REPLAY.lastGameTs = REPLAY.gameStartTs REPLAY.lastGameTs = REPLAY.gameStartTs
REPLAY.paused = false
REPLAY.skipNonActionPhases = false
TIME = () => REPLAY.lastGameTs TIME = () => REPLAY.lastGameTs
} else { } else {
@ -242,40 +389,17 @@ 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
) )
// zoom viewport to fit whole puzzle in const evts = EventAdapter(canvas, window, viewport)
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)
@ -299,42 +423,27 @@ 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 => { const playerSoundEnabled = (): boolean => {
return settings.getBool(SETTINGS.SOUND_ENABLED, false) const enabled = localStorage.getItem('sound_enabled')
if (enabled === null) {
return false
} }
const showPlayerNames = (): boolean => { return enabled === '1'
return settings.getBool(SETTINGS.SHOW_PLAYER_NAMES, true)
} }
const playClick = () => {
const vol = playerSoundVolume()
clickAudio.volume = vol / 100
clickAudio.play()
}
const playerBgColor = () => { const playerBgColor = () => {
if (MODE === MODE_REPLAY) { return (Game.getPlayerBgColor(gameId, clientId)
return settings.getStr(SETTINGS.COLOR_BACKGROUND, '#222222') || localStorage.getItem('bg_color')
} || '#222222')
return Game.getPlayerBgColor(gameId, clientId)
|| settings.getStr(SETTINGS.COLOR_BACKGROUND, '#222222')
} }
const playerColor = () => { const playerColor = () => {
if (MODE === MODE_REPLAY) { return (Game.getPlayerColor(gameId, clientId)
return settings.getStr(SETTINGS.PLAYER_COLOR, '#ffffff') || localStorage.getItem('player_color')
} || '#ffffff')
return Game.getPlayerColor(gameId, clientId)
|| settings.getStr(SETTINGS.PLAYER_COLOR, '#ffffff')
} }
const playerName = () => { const playerName = () => {
if (MODE === MODE_REPLAY) { return (Game.getPlayerName(gameId, clientId)
return settings.getStr(SETTINGS.PLAYER_NAME, 'anon') || localStorage.getItem('player_name')
} || 'anon')
return Game.getPlayerName(gameId, clientId)
|| settings.getStr(SETTINGS.PLAYER_NAME, 'anon')
} }
let cursorDown: string = '' let cursorDown: string = ''
@ -405,6 +514,9 @@ export async function main(
doSetSpeedStatus() doSetSpeedStatus()
} }
// // TODO: remove (make changable via interface)
REPLAY.skipNonActionPhases = true
if (MODE === MODE_PLAY) { if (MODE === MODE_PLAY) {
Communication.onServerChange((msg: ServerEvent) => { Communication.onServerChange((msg: ServerEvent) => {
const msgType = msg[0] const msgType = msg[0]
@ -461,10 +573,11 @@ export async function main(
return false return false
} }
let GAME_TS = REPLAY.lastGameTs
const next = async () => { const next = async () => {
if (REPLAY.logPointer + 1 >= REPLAY.log.length) { if (REPLAY.logPointer >= REPLAY.log.length) {
await queryNextReplayBatch(gameId) Communication.requestMoreReplayData()
to = setTimeout(next, 50)
return
} }
const realTs = Time.timestamp() const realTs = Time.timestamp()
@ -480,36 +593,37 @@ export async function main(
if (REPLAY.paused) { if (REPLAY.paused) {
break break
} }
const nextIdx = REPLAY.logPointer + 1 if (REPLAY.logPointer >= REPLAY.log.length) {
if (nextIdx >= REPLAY.log.length) {
break break
} }
const currLogEntry = REPLAY.log[REPLAY.logPointer] const lastLogEntry = REPLAY.logPointer > 0 ? REPLAY.log[REPLAY.logPointer - 1] : null
const currTs: Timestamp = GAME_TS + currLogEntry[currLogEntry.length - 1] const lastTs: Timestamp = REPLAY.gameStartTs + (lastLogEntry ? lastLogEntry[lastLogEntry.length - 1] : 0)
const nextLogEntry = REPLAY.log[REPLAY.logPointer]
const nextLogEntry = REPLAY.log[nextIdx] const nextTs: Timestamp = REPLAY.gameStartTs + nextLogEntry[nextLogEntry.length - 1]
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 // next log entry is too far into the future
if (REPLAY.skipNonActionPhases && (maxGameTs + 500 * Time.MS < nextTs)) { if (REPLAY.skipNonActionPhases && (maxGameTs + 50 < nextTs)) {
maxGameTs += diffToNext const skipInterval = nextTs - lastTs
// lets skip to the next log entry
// log.info('skipping non-action, from', maxGameTs, skipInterval)
maxGameTs += skipInterval
} }
break break
} }
GAME_TS = currTs
if (handleLogEntry(nextLogEntry, nextTs)) { if (handleLogEntry(nextLogEntry, nextTs)) {
RERENDER = true RERENDER = true
} }
REPLAY.logPointer = nextIdx REPLAY.logPointer++
} while (true) } while (true)
REPLAY.lastRealTs = realTs REPLAY.lastRealTs = realTs
REPLAY.lastGameTs = maxGameTs REPLAY.lastGameTs = maxGameTs
updateTimerElements() updateTimerElements()
if (!REPLAY.final) { if (REPLAY.final && REPLAY.logPointer + 1 >= REPLAY.log.length) {
// done
} else {
to = setTimeout(next, 50) to = setTimeout(next, 50)
} }
} }
@ -568,16 +682,6 @@ export async function main(
HUD.togglePreview() HUD.togglePreview()
} else if (type === Protocol.INPUT_EV_TOGGLE_SOUNDS) { } else if (type === Protocol.INPUT_EV_TOGGLE_SOUNDS) {
HUD.toggleSoundsEnabled() 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
@ -590,7 +694,7 @@ export async function main(
ts, ts,
(playerId: string) => { (playerId: string) => {
if (playerSoundEnabled()) { if (playerSoundEnabled()) {
playClick() clickAudio.play()
} }
} }
) )
@ -602,13 +706,7 @@ export async function main(
// LOCAL ONLY CHANGES // LOCAL ONLY CHANGES
// ------------------------------------------------------------- // -------------------------------------------------------------
const type = evt[0] const type = evt[0]
if (type === Protocol.INPUT_EV_REPLAY_TOGGLE_PAUSE) { if (type === Protocol.INPUT_EV_MOVE) {
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
@ -625,15 +723,11 @@ 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
@ -644,18 +738,6 @@ 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
} }
} }
} }
@ -731,14 +813,12 @@ 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'
@ -774,26 +854,19 @@ export async function main(
evts.setHotkeys(state) evts.setHotkeys(state)
}, },
onBgChange: (value: string) => { onBgChange: (value: string) => {
settings.setStr(SETTINGS.COLOR_BACKGROUND, value) localStorage.setItem('bg_color', value)
evts.addEvent([Protocol.INPUT_EV_BG_COLOR, value]) evts.addEvent([Protocol.INPUT_EV_BG_COLOR, value])
}, },
onColorChange: (value: string) => { onColorChange: (value: string) => {
settings.setStr(SETTINGS.PLAYER_COLOR, value) localStorage.setItem('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) => {
settings.setStr(SETTINGS.PLAYER_NAME, value) localStorage.setItem('player_name', value)
evts.addEvent([Protocol.INPUT_EV_PLAYER_NAME, value]) evts.addEvent([Protocol.INPUT_EV_PLAYER_NAME, value])
}, },
onSoundsEnabledChange: (value: boolean) => { onSoundsEnabledChange: (value: boolean) => {
settings.setBool(SETTINGS.SOUND_ENABLED, value) localStorage.setItem('sound_enabled', value ? '1' : '0')
},
onSoundsVolumeChange: (value: number) => {
settings.setInt(SETTINGS.SOUND_VOLUME, value)
playClick()
},
onShowPlayerNamesChange: (value: boolean) => {
settings.setBool(SETTINGS.SHOW_PLAYER_NAMES, value)
}, },
replayOnSpeedUp, replayOnSpeedUp,
replayOnSpeedDown, replayOnSpeedDown,
@ -804,10 +877,7 @@ export async function main(
color: playerColor(), color: playerColor(),
name: playerName(), name: playerName(),
soundsEnabled: playerSoundEnabled(), soundsEnabled: playerSoundEnabled(),
soundsVolume: playerSoundVolume(),
showPlayerNames: showPlayerNames(),
}, },
game: Game.get(gameId),
disconnect: Communication.disconnect, disconnect: Communication.disconnect,
connect: connect, connect: connect,
unload: unload, unload: unload,

View file

@ -7,36 +7,19 @@ 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 () => {
function initClientSecret() { const res = await fetch(`/api/conf`)
let SECRET = settings.getStr('SECRET', '') const conf = await res.json()
if (!SECRET) {
SECRET = Util.uniqId() function initme() {
settings.setStr('SECRET', SECRET) let ID = localStorage.getItem('ID')
}
return SECRET
}
function initClientId() {
let ID = settings.getStr('ID', '')
if (!ID) { if (!ID) {
ID = Util.uniqId() ID = Util.uniqId()
settings.setStr('ID', ID) localStorage.setItem('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(),
@ -56,9 +39,8 @@ import xhr from './xhr'
}) })
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 = clientId app.config.globalProperties.$clientId = initme()
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
})() })()

View file

@ -1,66 +0,0 @@
/**
* 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

@ -2,15 +2,8 @@
<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"
@ -28,8 +21,7 @@
<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('info', true)"> Info</div> <div class="opener" @click="toggle('help', true)"> Help</div>
<div class="opener" @click="toggle('help', true)"> Hotkeys</div>
</div> </div>
</div> </div>
@ -43,12 +35,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 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 { Game, Player } from '../../common/Types' import { Player } from '../../common/Types'
export default defineComponent({ export default defineComponent({
name: 'game', name: 'game',
@ -57,7 +48,6 @@ export default defineComponent({
Scores, Scores,
SettingsOverlay, SettingsOverlay,
PreviewOverlay, PreviewOverlay,
InfoOverlay,
ConnectionOverlay, ConnectionOverlay,
HelpOverlay, HelpOverlay,
}, },
@ -74,7 +64,6 @@ export default defineComponent({
overlay: '', overlay: '',
connectionState: 0, connectionState: 0,
cuttingPuzzle: true,
g: { g: {
player: { player: {
@ -82,18 +71,13 @@ export default defineComponent({
color: '', color: '',
name: '', name: '',
soundsEnabled: false, 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) => {}, onSoundsEnabledChange: (v: boolean) => {},
onSoundsVolumeChange: (v: number) => {},
onShowPlayerNamesChange: (v: boolean) => {},
connect: () => {}, connect: () => {},
disconnect: () => {}, disconnect: () => {},
unload: () => {}, unload: () => {},
@ -116,12 +100,6 @@ export default defineComponent({
this.$watch(() => this.g.player.soundsEnabled, (value: boolean) => { this.$watch(() => this.g.player.soundsEnabled, (value: boolean) => {
this.g.onSoundsEnabledChange(value) 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
@ -131,17 +109,15 @@ 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 },
togglePreview: () => { this.toggle('preview', false) },
setConnectionState: (v: number) => { this.connectionState = v }, setConnectionState: (v: number) => { this.connectionState = v },
togglePreview: () => { this.toggle('preview', false) },
toggleSoundsEnabled: () => { this.g.player.soundsEnabled = !this.g.player.soundsEnabled }, toggleSoundsEnabled: () => { this.g.player.soundsEnabled = !this.g.player.soundsEnabled },
togglePlayerNames: () => { this.g.player.showPlayerNames = !this.g.player.showPlayerNames },
} }
) )
}, },

View file

@ -13,7 +13,6 @@
</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'
@ -28,7 +27,7 @@ export default defineComponent({
} }
}, },
async created() { async created() {
const res = await xhr.get('/api/index-data', {}) const res = await fetch('/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

@ -44,11 +44,8 @@ in jigsawpuzzles.io
v-if="dialog==='new-image'" v-if="dialog==='new-image'"
:autocompleteTags="autocompleteTags" :autocompleteTags="autocompleteTags"
@bgclick="dialog=''" @bgclick="dialog=''"
:uploadProgress="uploadProgress"
:uploading="uploading"
@postToGalleryClick="postToGalleryClick" @postToGalleryClick="postToGalleryClick"
@setupGameClick="setupGameClick" @setupGameClick="setupGameClick" />
/>
<edit-image-dialog <edit-image-dialog
v-if="dialog==='edit-image'" v-if="dialog==='edit-image'"
:autocompleteTags="autocompleteTags" :autocompleteTags="autocompleteTags"
@ -64,7 +61,7 @@ in jigsawpuzzles.io
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent, PropType } from 'vue'
import ImageLibrary from './../components/ImageLibrary.vue' import ImageLibrary from './../components/ImageLibrary.vue'
import NewImageDialog from './../components/NewImageDialog.vue' import NewImageDialog from './../components/NewImageDialog.vue'
@ -72,7 +69,6 @@ import EditImageDialog from './../components/EditImageDialog.vue'
import NewGameDialog from './../components/NewGameDialog.vue' import NewGameDialog from './../components/NewGameDialog.vue'
import { GameSettings, Image, Tag } from '../../common/Types' 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: {
@ -101,9 +97,6 @@ export default defineComponent({
} as Image, } as Image,
dialog: '', dialog: '',
uploading: '',
uploadProgress: 0,
} }
}, },
async created() { async created() {
@ -133,7 +126,7 @@ export default defineComponent({
this.filtersChanged() this.filtersChanged()
}, },
async loadImages () { async loadImages () {
const res = await xhr.get(`/api/newgame-data${Util.asQueryArgs(this.filters)}`, {}) const res = await fetch(`/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
@ -150,22 +143,20 @@ 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 xhr.post('/api/save-image', { const res = await fetch('/api/save-image', {
method: 'post',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -179,31 +170,24 @@ export default defineComponent({
return await res.json() return await res.json()
}, },
async onSaveImageClick(data: any) { async onSaveImageClick(data: any) {
const res = await this.saveImage(data) 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 xhr.post('/api/newgame', { const res = await fetch('/api/newgame', {
method: 'post',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'

View file

@ -2,15 +2,8 @@
<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"
@ -30,8 +23,7 @@
<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('info', true)"> Info</div> <div class="opener" @click="toggle('help', true)"> Help</div>
<div class="opener" @click="toggle('help', true)"> Hotkeys</div>
</div> </div>
</div> </div>
@ -45,11 +37,9 @@ 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',
@ -58,13 +48,12 @@ export default defineComponent({
Scores, Scores,
SettingsOverlay, SettingsOverlay,
PreviewOverlay, PreviewOverlay,
InfoOverlay,
HelpOverlay, HelpOverlay,
}, },
data() { data() {
return { return {
activePlayers: [] as Array<Player>, activePlayers: [] as Array<any>,
idlePlayers: [] as Array<Player>, idlePlayers: [] as Array<any>,
finished: false, finished: false,
duration: 0, duration: 0,
@ -74,7 +63,6 @@ export default defineComponent({
overlay: '', overlay: '',
connectionState: 0, connectionState: 0,
cuttingPuzzle: true,
g: { g: {
player: { player: {
@ -82,18 +70,13 @@ export default defineComponent({
color: '', color: '',
name: '', name: '',
soundsEnabled: false, 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) => {}, onSoundsEnabledChange: (v: boolean) => {},
onSoundsVolumeChange: (v: number) => {},
onShowPlayerNamesChange: (v: boolean) => {},
replayOnSpeedUp: () => {}, replayOnSpeedUp: () => {},
replayOnSpeedDown: () => {}, replayOnSpeedDown: () => {},
replayOnPauseToggle: () => {}, replayOnPauseToggle: () => {},
@ -124,12 +107,6 @@ export default defineComponent({
this.$watch(() => this.g.player.soundsEnabled, (value: boolean) => { this.$watch(() => this.g.player.soundsEnabled, (value: boolean) => {
this.g.onSoundsEnabledChange(value) 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
@ -139,9 +116,8 @@ export default defineComponent({
MODE_REPLAY, MODE_REPLAY,
this.$el, this.$el,
{ {
setPuzzleCut: () => { this.cuttingPuzzle = false }, setActivePlayers: (v: Array<any>) => { this.activePlayers = v },
setActivePlayers: (v: Array<Player>) => { this.activePlayers = v }, setIdlePlayers: (v: Array<any>) => { 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 },
@ -151,7 +127,6 @@ export default defineComponent({
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 }, toggleSoundsEnabled: () => { this.g.player.soundsEnabled = !this.g.player.soundsEnabled },
togglePlayerNames: () => { this.g.player.showPlayerNames = !this.g.player.showPlayerNames },
} }
) )
}, },

View file

@ -1,68 +0,0 @@
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

@ -1,9 +1,9 @@
import GameCommon from './../common/GameCommon' import GameCommon from './../common/GameCommon'
import { Change, Game, Input, ScoreMode, ShapeMode, SnapMode,ImageInfo, Timestamp, GameSettings } from './../common/Types' import { Change, Game, Input, ScoreMode, ShapeMode, SnapMode, Timestamp } 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 GameLog from './GameLog' import GameLog from './GameLog'
import { createPuzzle } from './Puzzle' import { createPuzzle, PuzzleCreationImageInfo } from './Puzzle'
import Protocol from './../common/Protocol' import Protocol from './../common/Protocol'
import GameStorage from './GameStorage' import GameStorage from './GameStorage'
@ -12,18 +12,16 @@ const log = logger('Game.ts')
async function createGameObject( async function createGameObject(
gameId: string, gameId: string,
targetTiles: number, targetTiles: number,
image: ImageInfo, image: PuzzleCreationImageInfo,
ts: Timestamp, ts: Timestamp,
scoreMode: ScoreMode, scoreMode: ScoreMode,
shapeMode: ShapeMode, shapeMode: ShapeMode,
snapMode: SnapMode, snapMode: SnapMode
creatorUserId: number|null
): Promise<Game> { ): 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, shapeMode), puzzle: await createPuzzle(rng, targetTiles, image, ts, shapeMode),
players: [], players: [],
@ -34,54 +32,50 @@ async function createGameObject(
} }
} }
async function createNewGame( async function createGame(
gameSettings: GameSettings, gameId: string,
targetTiles: number,
image: PuzzleCreationImageInfo,
ts: Timestamp, ts: Timestamp,
creatorUserId: number scoreMode: ScoreMode,
): Promise<string> { shapeMode: ShapeMode,
let gameId; snapMode: SnapMode
do { ): Promise<void> {
gameId = Util.uniqId()
} while (GameCommon.exists(gameId))
const gameObject = await createGameObject( const gameObject = await createGameObject(
gameId, gameId,
gameSettings.tiles, targetTiles,
gameSettings.image, image,
ts, ts,
gameSettings.scoreMode, scoreMode,
gameSettings.shapeMode, shapeMode,
gameSettings.snapMode, snapMode
creatorUserId
) )
GameLog.create(gameId, ts) GameLog.create(gameId)
GameLog.log( GameLog.log(
gameId, gameId,
Protocol.LOG_HEADER, Protocol.LOG_HEADER,
1, 1,
gameSettings.tiles, targetTiles,
gameSettings.image, image,
ts, ts,
gameSettings.scoreMode, scoreMode,
gameSettings.shapeMode, shapeMode,
gameSettings.snapMode, 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: Timestamp): void { function addPlayer(gameId: string, playerId: string, ts: Timestamp): void {
if (GameLog.shouldLog(GameCommon.getFinishTs(gameId), ts)) { 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, ts) GameLog.log(gameId, Protocol.LOG_ADD_PLAYER, playerId, diff)
} else { } else {
GameLog.log(gameId, Protocol.LOG_UPDATE_PLAYER, idx, ts) GameLog.log(gameId, Protocol.LOG_UPDATE_PLAYER, idx, diff)
} }
} }
@ -97,7 +91,8 @@ function handleInput(
): Array<Change> { ): Array<Change> {
if (GameLog.shouldLog(GameCommon.getFinishTs(gameId), ts)) { if (GameLog.shouldLog(GameCommon.getFinishTs(gameId), ts)) {
const idx = GameCommon.getPlayerIndexById(gameId, playerId) const idx = GameCommon.getPlayerIndexById(gameId, playerId)
GameLog.log(gameId, Protocol.LOG_HANDLE_INPUT, idx, input, ts) const diff = ts - GameCommon.getStartTs(gameId)
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)
@ -107,7 +102,7 @@ function handleInput(
export default { export default {
createGameObject, createGameObject,
createNewGame, createGame,
addPlayer, addPlayer,
handleInput, handleInput,
} }

View file

@ -1,13 +1,15 @@
import WebSocket from 'ws'
import fs from 'fs' import fs from 'fs'
import Protocol from '../common/Protocol' import readline from 'readline'
import stream from 'stream'
import Time from '../common/Time' import Time from '../common/Time'
import { DefaultScoreMode, DefaultShapeMode, DefaultSnapMode, Timestamp } from '../common/Types' import { Game as GameType, ScoreMode, ShapeMode, SnapMode, 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'
import Game from './Game'
const log = logger('GameLog.js') const log = logger('GameLog.js')
const LINES_PER_LOG_FILE = 10000
const POST_GAME_LOG_DURATION = 5 * Time.MIN const POST_GAME_LOG_DURATION = 5 * Time.MIN
const shouldLog = (finishTs: Timestamp, currentTs: Timestamp): boolean => { const shouldLog = (finishTs: Timestamp, currentTs: Timestamp): boolean => {
@ -22,85 +24,180 @@ const shouldLog = (finishTs: Timestamp, currentTs: Timestamp): boolean => {
return timeSinceGameEnd <= POST_GAME_LOG_DURATION return timeSinceGameEnd <= POST_GAME_LOG_DURATION
} }
export const filename = (gameId: string, offset: number) => `${DATA_DIR}/log_${gameId}-${offset}.log` const filename = (gameId: string) => `${DATA_DIR}/log_${gameId}.log`
export const idxname = (gameId: string) => `${DATA_DIR}/log_${gameId}.idx.log`
const create = (gameId: string, ts: Timestamp): void => { const create = (gameId: string): void => {
const idxfile = idxname(gameId) const file = filename(gameId)
if (!fs.existsSync(idxfile)) { if (!fs.existsSync(file)) {
fs.appendFileSync(idxfile, JSON.stringify({ fs.appendFileSync(file, '')
gameId: gameId,
total: 0,
lastTs: ts,
currentFile: '',
perFile: LINES_PER_LOG_FILE,
}))
} }
} }
const exists = (gameId: string): boolean => { const exists = (gameId: string): boolean => {
const idxfile = idxname(gameId) const file = filename(gameId)
return fs.existsSync(idxfile) return fs.existsSync(file)
} }
const _log = (gameId: string, type: number, ...args: Array<any>): void => { const _log = (gameId: string, ...args: Array<any>): void => {
const idxfile = idxname(gameId) const file = filename(gameId)
if (!fs.existsSync(idxfile)) { if (!fs.existsSync(file)) {
return return
} }
const str = JSON.stringify(args)
const idxObj = JSON.parse(fs.readFileSync(idxfile, 'utf-8')) fs.appendFileSync(file, str + "\n")
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 = ( const get = async (
gameId: string, gameId: string,
offset: number = 0, offset: number = 0,
): any[] => { size: number = 10000
const idxfile = idxname(gameId) ): Promise<any[]> => {
if (!fs.existsSync(idxfile)) { const file = filename(gameId)
return []
}
const file = filename(gameId, offset)
if (!fs.existsSync(file)) { if (!fs.existsSync(file)) {
return [] return []
} }
return new Promise((resolve) => {
const lines = fs.readFileSync(file, 'utf-8').split("\n") const instream = fs.createReadStream(file)
const log = lines.filter(line => !!line).map(line => { const outstream = new stream.Writable()
return JSON.parse(`[${line}]`) const rl = readline.createInterface(instream, outstream)
}) const lines: any[] = []
if (offset === 0 && log.length > 0) { let i = -1
log[0][5] = DefaultScoreMode(log[0][5]) rl.on('line', (line) => {
log[0][6] = DefaultShapeMode(log[0][6]) if (!line) {
log[0][7] = DefaultSnapMode(log[0][7]) // skip empty
log[0][8] = log[0][8] || null // creatorUserId return
} }
return log i++
if (offset > i) {
return
}
if (offset + size <= i) {
rl.close()
return
}
lines.push(JSON.parse(line))
})
rl.on('close', () => {
resolve(lines)
})
})
}
interface LineReader {
readLine: () => Promise<string>,
}
const createLineReader = async (
gameId: string
): Promise<LineReader|null> => {
const stream: fs.ReadStream = await new Promise(resolve => {
const file = filename(gameId)
if (!fs.existsSync(file)) {
return null
}
const instream = fs.createReadStream(file)
instream.on('readable', () => {
resolve(instream)
})
})
let line = ''
const readLine = async (): Promise<string> => {
return new Promise(resolve => {
let chunk
let resolved = false
while (null !== (chunk = stream.read(1))) {
if (chunk.toString() === "\n") {
resolve(line)
line = ''
resolved = true
break
}
line += chunk
}
if (!resolved) {
resolve('')
}
})
}
return {
readLine,
}
}
interface SocketGameLog {
socket: WebSocket
rl: LineReader
}
const connected: SocketGameLog[] = []
const getSocketGameLog = (
socket: WebSocket,
): SocketGameLog|null => {
for (const entry of connected) {
if (entry.socket === socket) {
return entry
}
}
return null
}
const open = async (socket: WebSocket, gameId: string): Promise<GameType|null> => {
const rl = await createLineReader(gameId)
if (!rl) {
return null
}
const socketGameLog = {
socket: socket,
rl: rl,
}
const line = await rl.readLine()
const log = JSON.parse(line)
connected.push(socketGameLog)
const g: GameType = await Game.createGameObject(
gameId,
log[2],
log[3],
log[4],
log[5] || ScoreMode.FINAL,
log[6] || ShapeMode.NORMAL,
log[7] || SnapMode.NORMAL,
)
return g
}
const getNextBySocket = async (socket: WebSocket): Promise<any[]> => {
const socketGameLog = getSocketGameLog(socket)
if (!socketGameLog) {
return []
}
const lines = []
for (let i = 0; i < 1000; i++) {
const line = await socketGameLog.rl.readLine()
if (line) {
try {
lines.push(JSON.parse(line))
} catch (e) {
log.error(e)
log.error(line)
}
} else {
break
}
}
return lines
} }
export default { export default {
open,
getNextBySocket,
shouldLog, shouldLog,
create, create,
exists, exists,
log: _log, log: _log,
get, get,
filename,
idxname,
} }

View file

@ -1,11 +1,10 @@
import fs from 'fs' import fs from 'fs'
import GameCommon from './../common/GameCommon' import GameCommon from './../common/GameCommon'
import { DefaultScoreMode, DefaultShapeMode, DefaultSnapMode, Game, Piece } from './../common/Types' import { Game, Piece, ScoreMode, ShapeMode, SnapMode } 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')
@ -16,73 +15,8 @@ function setDirty(gameId: string): void {
function setClean(gameId: string): void { function setClean(gameId: string): void {
delete dirtyGames[gameId] delete dirtyGames[gameId]
} }
function loadGamesFromDb(db: Db): void {
const gameRows = db.getMany('games')
for (const gameRow of gameRows) {
loadGameFromDb(db, gameRow.id)
}
}
function loadGameFromDb(db: Db, gameId: string): void { function loadGames(): 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$/)
@ -90,14 +24,11 @@ function loadGamesFromDisk(): void {
continue continue
} }
const gameId = m[1] const gameId = m[1]
loadGameFromDisk(gameId) loadGame(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
@ -118,29 +49,39 @@ function loadGameFromDisk(gameId: string): void {
if (!Array.isArray(game.players)) { if (!Array.isArray(game.players)) {
game.players = Object.values(game.players) game.players = Object.values(game.players)
} }
const gameObject: Game = storeDataToGame(game, null) const gameObject: Game = {
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,
shapeMode: game.shapeMode || ShapeMode.ANY,
snapMode: game.snapMode || SnapMode.NORMAL,
}
GameCommon.setGame(gameObject.id, gameObject) GameCommon.setGame(gameObject.id, gameObject)
} }
function storeDataToGame(storeData: any, creatorUserId: number|null): Game { function persistGames(): void {
return { for (const gameId of Object.keys(dirtyGames)) {
id: storeData.id, persistGame(gameId)
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 gameToStoreData(game: Game): string { function persistGame(gameId: string): void {
return JSON.stringify({ const game = GameCommon.get(gameId)
if (!game) {
log.error(`[ERROR] unable to persist non existing game ${gameId}`)
return
}
if (game.id in dirtyGames) {
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,
@ -151,18 +92,14 @@ function gameToStoreData(game: Game): string {
scoreMode: game.scoreMode, scoreMode: game.scoreMode,
shapeMode: game.shapeMode, shapeMode: game.shapeMode,
snapMode: game.snapMode, snapMode: game.snapMode,
}); }))
log.info(`[INFO] persisted game ${game.id}`)
} }
export default { export default {
// disk functions are deprecated loadGames,
loadGamesFromDisk, loadGame,
loadGameFromDisk, persistGames,
persistGame,
loadGamesFromDb,
loadGameFromDb,
persistGamesToDb,
persistGameToDb,
setDirty, setDirty,
} }

View file

@ -6,11 +6,29 @@ import sharp from 'sharp'
import {UPLOAD_DIR, UPLOAD_URL} from './Dirs' import {UPLOAD_DIR, UPLOAD_URL} from './Dirs'
import Db, { OrderBy, WhereRaw } 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 { logger } from '../common/Util'
import { Tag, ImageInfo } from '../common/Types' import { Timestamp } from '../common/Types'
const log = logger('Images.ts') const log = logger('Images.ts')
interface Tag
{
id: number
slug: string
title: string
}
interface ImageInfo
{
id: number
filename: string
file: string
url: string
title: string
tags: Tag[]
created: Timestamp,
}
const resizeImage = async (filename: string): Promise<void> => { 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
@ -85,14 +103,12 @@ 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), tags: getTags(db, i.id),
created: i.created * 1000, created: i.created * 1000,
width: i.width,
height: i.height,
} }
} }
@ -131,20 +147,15 @@ inner join images i on i.id = ixc.image_id ${where.sql};
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), tags: getTags(db, i.id),
created: i.created * 1000, created: i.created * 1000,
width: i.width,
height: i.height,
})) }))
} }
/**
* @deprecated old function, now database is used
*/
const allImagesFromDisk = ( const allImagesFromDisk = (
tags: string[], tags: string[],
sort: string sort: string
@ -153,26 +164,24 @@ const allImagesFromDisk = (
.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 Tag[], 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.filename > b.filename ? 1 : -1 return a.file > b.file ? 1 : -1
}) })
break; break;
case 'alpha_desc': case 'alpha_desc':
images = images.sort((a, b) => { images = images.sort((a, b) => {
return a.filename < b.filename ? 1 : -1 return a.file < b.file ? 1 : -1
}) })
break; break;
@ -209,20 +218,6 @@ 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,
@ -230,5 +225,4 @@ export default {
getAllTags, getAllTags,
resizeImage, resizeImage,
getDimensions, getDimensions,
setTags,
} }

View file

@ -1,9 +1,13 @@
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, ShapeMode, ImageInfo } from '../common/Types' import { EncodedPiece, EncodedPieceShape, PieceShape, Puzzle, ShapeMode } from '../common/Types'
import { Dim, Point } from '../common/Geometry' import { Dim, Point } from '../common/Geometry'
import { UPLOAD_DIR } from './Dirs'
export interface PuzzleCreationImageInfo {
file: string
url: string
}
export interface PuzzleCreationInfo { export interface PuzzleCreationInfo {
width: number width: number
@ -23,11 +27,11 @@ const TILE_SIZE = 64
async function createPuzzle( async function createPuzzle(
rng: Rng, rng: Rng,
targetTiles: number, targetTiles: number,
image: ImageInfo, image: PuzzleCreationImageInfo,
ts: number, ts: number,
shapeMode: ShapeMode shapeMode: ShapeMode
): Promise<Puzzle> { ): Promise<Puzzle> {
const imagePath = `${UPLOAD_DIR}/${image.filename}` const imagePath = image.file
const imageUrl = image.url const imageUrl = image.url
// determine puzzle information from the image dimensions // determine puzzle information from the image dimensions
@ -135,8 +139,7 @@ async function createPuzzle(
}, },
// information that was used to create the puzzle // information that was used to create the puzzle
targetTiles: targetTiles, targetTiles: targetTiles,
imageUrl, // todo: remove imageUrl,
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)

View file

@ -1,36 +0,0 @@
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

@ -19,10 +19,9 @@ import {
UPLOAD_DIR, UPLOAD_DIR,
} from './Dirs' } from './Dirs'
import GameCommon from '../common/GameCommon' import GameCommon from '../common/GameCommon'
import { ServerEvent, Game as GameType, GameSettings } from '../common/Types' import { ServerEvent, Game as GameType, GameSettings, ScoreMode, ShapeMode, SnapMode } 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()
@ -58,14 +57,6 @@ const storage = multer.diskStorage({
}) })
const upload = multer({storage}).single('file'); const upload = multer({storage}).single('file');
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 => { app.get('/api/conf', (req, res): void => {
res.send({ res.send({
WS_ADDRESS: config.ws.connectstring, WS_ADDRESS: config.ws.connectstring,
@ -89,19 +80,18 @@ app.get('/api/replay-data', async (req, res): Promise<void> => {
res.status(404).send({ reason: 'no log found' }) res.status(404).send({ reason: 'no log found' })
return return
} }
const log = GameLog.get(gameId, offset) const log = await GameLog.get(gameId, offset, size)
let game: GameType|null = null let game: GameType|null = null
if (offset === 0) { if (offset === 0) {
// also need the game // also need the game
game = await Game.createGameObject( game = await Game.createGameObject(
gameId, gameId,
log[0][2], log[0][2],
log[0][3], // must be ImageInfo log[0][3],
log[0][4], log[0][4],
log[0][5], log[0][5] || ScoreMode.FINAL,
log[0][6], log[0][6] || ShapeMode.NORMAL,
log[0][7], log[0][7] || SnapMode.NORMAL,
log[0][8], // creatorUserId
) )
} }
res.send({ log, game: game ? Util.encodeGame(game) : null }) res.send({ log, game: game ? Util.encodeGame(game) : null })
@ -143,27 +133,32 @@ interface SaveImageRequestData {
tags: string[] tags: string[]
} }
const setImageTags = (db: Db, imageId: number, tags: string[]): void => {
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,
})
}
})
}
app.post('/api/save-image', express.json(), (req, res): void => { app.post('/api/save-image', express.json(), (req, res): void => {
const user = Users.getUser(db, req)
if (!user || !user.id) {
res.status(403).send({ ok: false, error: 'forbidden' })
return
}
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,
}) })
Images.setTags(db, data.id, data.tags || []) db.delete('image_x_category', { image_id: data.id })
if (data.tags) {
setImageTags(db, data.id, data.tags)
}
res.send({ ok: true }) res.send({ ok: true })
}) })
@ -171,36 +166,26 @@ app.post('/api/upload', (req, res): void => {
upload(req, res, async (err: any): Promise<void> => { 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) {
const tags = req.body.tags.split(',').filter((tag: string) => !!tag) const tags = req.body.tags.split(',').filter((tag: string) => !!tag)
Images.setTags(db, imageId as number, tags) setImageTags(db, imageId as number, tags)
} }
res.send(Images.imageFromDb(db, imageId as number)) res.send(Images.imageFromDb(db, imageId as number))
@ -208,12 +193,21 @@ app.post('/api/upload', (req, res): void => {
}) })
app.post('/api/newgame', express.json(), async (req, res): Promise<void> => { app.post('/api/newgame', express.json(), async (req, res): Promise<void> => {
const user = Users.getOrCreateUser(db, req) const gameSettings = req.body as GameSettings
const gameId = await Game.createNewGame( log.log(gameSettings)
req.body as GameSettings, const gameId = Util.uniqId()
Time.timestamp(), if (!GameCommon.exists(gameId)) {
user.id const ts = Time.timestamp()
await Game.createGame(
gameId,
gameSettings.tiles,
gameSettings.image,
ts,
gameSettings.scoreMode,
gameSettings.shapeMode,
gameSettings.snapMode,
) )
}
res.send({ id: gameId }) res.send({ id: gameId })
}) })
@ -253,6 +247,37 @@ wss.on('message', async (
const msg = JSON.parse(data as string) 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... ]`
}
// should connect the socket with game log
// pseudo code
const game = await GameLog.open(socket, gameId)
if (!game) {
throw `[game not created :/ ]`
}
notify(
[Protocol.EV_SERVER_INIT_REPLAY, Util.encodeGame(game)],
[socket]
)
} break
case Protocol.EV_CLIENT_REPLAY_EVENT: {
if (!GameLog.exists(gameId)) {
throw `[gamelog ${gameId} does not exist... ]`
}
// should read next some lines from game log, using the game
// log connected with this socket
// pseudo code
const logEntries = await GameLog.getNextBySocket(socket)
notify(
[Protocol.EV_SERVER_REPLAY_EVENT, logEntries],
[socket]
)
} break
case Protocol.EV_CLIENT_INIT: { case Protocol.EV_CLIENT_INIT: {
if (!GameCommon.exists(gameId)) { if (!GameCommon.exists(gameId)) {
throw `[game ${gameId} does not exist... ]` throw `[game ${gameId} does not exist... ]`
@ -311,7 +336,7 @@ wss.on('message', async (
} }
}) })
GameStorage.loadGamesFromDb(db) GameStorage.loadGames()
const server = app.listen( const server = app.listen(
port, port,
hostname, hostname,
@ -334,7 +359,7 @@ 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.persistGamesToDb(db) GameStorage.persistGames()
memoryUsageHuman() memoryUsageHuman()
}, config.persistence.interval) }, config.persistence.interval)
@ -346,7 +371,7 @@ const gracefulShutdown = (signal: string): void => {
clearInterval(persistInterval) clearInterval(persistInterval)
log.log('persisting games...') log.log('persisting games...')
GameStorage.persistGamesToDb(db) GameStorage.persistGames()
log.log('shutting down webserver...') log.log('shutting down webserver...')
server.close() server.close()