switch to typescript

This commit is contained in:
Zutatensuppe 2021-05-17 00:27:47 +02:00
parent 031ca31c7e
commit 23559b1a3b
63 changed files with 7943 additions and 1397 deletions

886
src/common/GameCommon.ts Normal file
View file

@ -0,0 +1,886 @@
import Geometry from './Geometry'
import Protocol from './Protocol'
import { Rng } from './Rng'
import Time from './Time'
import Util from './Util'
interface EncodedPlayer extends Array<any> {}
interface EncodedPiece extends Array<any> {}
interface Point {
x: number
y: number
}
interface GameRng {
obj: Rng
type?: string
}
interface Game {
id: string
players: Array<EncodedPlayer>
puzzle: Puzzle
evtInfos: Record<string, EvtInfo>
scoreMode?: number
rng: GameRng
}
export interface Puzzle {
tiles: Array<EncodedPiece>
data: PuzzleData
info: PuzzleInfo
}
interface PuzzleData {
started: number
finished: number
maxGroup: number
maxZ: number
}
interface PuzzleTable {
width: number
height: number
}
export interface PieceShape {
top: 0|1|-1
bottom: 0|1|-1
left: 0|1|-1
right: 0|1|-1
}
export interface Piece {
owner: string|number
}
export interface PuzzleInfo {
table: PuzzleTable
targetTiles: number,
imageUrl: string
width: number
height: number
tileSize: number
tileDrawSize: number
tileMarginWidth: number
tileDrawOffset: number
snapDistance: number
tiles: number
tilesX: number
tilesY: number
// TODO: ts type Array<PieceShape>
shapes: Array<any>
}
export interface Player {
id: string
x: number
y: number
d: 0|1
name: string|null
color: string|null
bgcolor: string|null
points: number
ts: number
}
interface EvtInfo {
_last_mouse: Point|null
_last_mouse_down: Point|null
}
const SCORE_MODE_FINAL = 0
const SCORE_MODE_ANY = 1
const IDLE_TIMEOUT_SEC = 30
// Map<gameId, Game>
const GAMES: Record<string, Game> = {}
function exists(gameId: string) {
return (!!GAMES[gameId]) || false
}
function __createPlayerObject(id: string, ts: number): Player {
return {
id: id,
x: 0,
y: 0,
d: 0, // mouse down
name: null, // 'anon'
color: null, // '#ffffff'
bgcolor: null, // '#222222'
points: 0,
ts: ts,
}
}
function setGame(gameId: string, game: Game) {
GAMES[gameId] = game
}
function getPlayerIndexById(gameId: string, playerId: string): number {
let i = 0;
for (let player of GAMES[gameId].players) {
if (Util.decodePlayer(player).id === playerId) {
return i
}
i++
}
return -1
}
function getPlayerIdByIndex(gameId: string, playerIndex: number) {
if (GAMES[gameId].players.length > playerIndex) {
return Util.decodePlayer(GAMES[gameId].players[playerIndex]).id
}
return null
}
function getPlayer(gameId: string, playerId: string) {
let idx = getPlayerIndexById(gameId, playerId)
return Util.decodePlayer(GAMES[gameId].players[idx])
}
function setPlayer(gameId: string, playerId: string, player: Player) {
let idx = getPlayerIndexById(gameId, playerId)
if (idx === -1) {
GAMES[gameId].players.push(Util.encodePlayer(player))
} else {
GAMES[gameId].players[idx] = Util.encodePlayer(player)
}
}
function setTile(gameId: string, tileIdx: number, tile: Piece) {
GAMES[gameId].puzzle.tiles[tileIdx] = Util.encodeTile(tile)
}
function setPuzzleData(gameId: string, data: PuzzleData) {
GAMES[gameId].puzzle.data = data
}
function playerExists(gameId: string, playerId: string) {
const idx = getPlayerIndexById(gameId, playerId)
return idx !== -1
}
function getActivePlayers(gameId: string, ts: number) {
const minTs = ts - IDLE_TIMEOUT_SEC * Time.SEC
return getAllPlayers(gameId).filter((p: Player) => p.ts >= minTs)
}
function getIdlePlayers(gameId: string, ts: number) {
const minTs = ts - IDLE_TIMEOUT_SEC * Time.SEC
return getAllPlayers(gameId).filter((p: Player) => p.ts < minTs && p.points > 0)
}
function addPlayer(gameId: string, playerId: string, ts: number) {
if (!playerExists(gameId, playerId)) {
setPlayer(gameId, playerId, __createPlayerObject(playerId, ts))
} else {
changePlayer(gameId, playerId, { ts })
}
}
function getEvtInfo(gameId: string, playerId: string) {
if (playerId in GAMES[gameId].evtInfos) {
return GAMES[gameId].evtInfos[playerId]
}
return {
_last_mouse: null,
_last_mouse_down: null,
}
}
function setEvtInfo(gameId: string, playerId: string, evtInfo: EvtInfo) {
GAMES[gameId].evtInfos[playerId] = evtInfo
}
function getAllGames(): Array<Game> {
return Object.values(GAMES).sort((a: Game, b: Game) => {
// when both have same finished state, sort by started
if (isFinished(a.id) === isFinished(b.id)) {
return b.puzzle.data.started - a.puzzle.data.started
}
// otherwise, sort: unfinished, finished
return isFinished(a.id) ? 1 : -1
})
}
function getAllPlayers(gameId: string) {
return GAMES[gameId]
? GAMES[gameId].players.map(Util.decodePlayer)
: []
}
function get(gameId: string) {
return GAMES[gameId]
}
function getTileCount(gameId: string) {
return GAMES[gameId].puzzle.tiles.length
}
function getImageUrl(gameId: string) {
return GAMES[gameId].puzzle.info.imageUrl
}
function setImageUrl(gameId: string, imageUrl: string) {
GAMES[gameId].puzzle.info.imageUrl = imageUrl
}
function getScoreMode(gameId: string) {
return GAMES[gameId].scoreMode || SCORE_MODE_FINAL
}
function isFinished(gameId: string) {
return getFinishedTileCount(gameId) === getTileCount(gameId)
}
function getFinishedTileCount(gameId: string) {
let count = 0
for (let t of GAMES[gameId].puzzle.tiles) {
if (Util.decodeTile(t).owner === -1) {
count++
}
}
return count
}
function getTilesSortedByZIndex(gameId: string) {
const tiles = GAMES[gameId].puzzle.tiles.map(Util.decodeTile)
return tiles.sort((t1, t2) => t1.z - t2.z)
}
function changePlayer(gameId: string, playerId: string, change: any) {
const player = getPlayer(gameId, playerId)
for (let k of Object.keys(change)) {
player[k] = change[k]
}
setPlayer(gameId, playerId, player)
}
function changeData(gameId: string, change: any) {
for (let k of Object.keys(change)) {
// @ts-ignore
GAMES[gameId].puzzle.data[k] = change[k]
}
}
function changeTile(gameId: string, tileIdx: number, change: any) {
for (let k of Object.keys(change)) {
const tile = Util.decodeTile(GAMES[gameId].puzzle.tiles[tileIdx])
tile[k] = change[k]
GAMES[gameId].puzzle.tiles[tileIdx] = Util.encodeTile(tile)
}
}
const getTile = (gameId: string, tileIdx: number) => {
return Util.decodeTile(GAMES[gameId].puzzle.tiles[tileIdx])
}
const getTileGroup = (gameId: string, tileIdx: number) => {
const tile = getTile(gameId, tileIdx)
return tile.group
}
const getFinalTilePos = (gameId: string, tileIdx: number) => {
const info = GAMES[gameId].puzzle.info
const boardPos = {
x: (info.table.width - info.width) / 2,
y: (info.table.height - info.height) / 2
}
const srcPos = srcPosByTileIdx(gameId, tileIdx)
return Geometry.pointAdd(boardPos, srcPos)
}
const getTilePos = (gameId: string, tileIdx: number) => {
const tile = getTile(gameId, tileIdx)
return tile.pos
}
// todo: instead, just make the table bigger and use that :)
const getBounds = (gameId: string) => {
const tw = getTableWidth(gameId)
const th = getTableHeight(gameId)
const overX = Math.round(tw / 4)
const overY = Math.round(th / 4)
return {
x: 0 - overX,
y: 0 - overY,
w: tw + 2 * overX,
h: th + 2 * overY,
}
}
const getTileBounds = (gameId: string, tileIdx: number) => {
const s = getTileSize(gameId)
const tile = getTile(gameId, tileIdx)
return {
x: tile.pos.x,
y: tile.pos.y,
w: s,
h: s,
}
}
const getTileZIndex = (gameId: string, tileIdx: number) => {
const tile = getTile(gameId, tileIdx)
return tile.z
}
const getFirstOwnedTileIdx = (gameId: string, playerId: string) => {
for (let t of GAMES[gameId].puzzle.tiles) {
const tile = Util.decodeTile(t)
if (tile.owner === playerId) {
return tile.idx
}
}
return -1
}
const getFirstOwnedTile = (gameId: string, playerId: string) => {
const idx = getFirstOwnedTileIdx(gameId, playerId)
return idx < 0 ? null : GAMES[gameId].puzzle.tiles[idx]
}
const getTileDrawOffset = (gameId: string) => {
return GAMES[gameId].puzzle.info.tileDrawOffset
}
const getTileDrawSize = (gameId: string) => {
return GAMES[gameId].puzzle.info.tileDrawSize
}
const getTileSize = (gameId: string) => {
return GAMES[gameId].puzzle.info.tileSize
}
const getStartTs = (gameId: string) => {
return GAMES[gameId].puzzle.data.started
}
const getFinishTs = (gameId: string) => {
return GAMES[gameId].puzzle.data.finished
}
const getMaxGroup = (gameId: string) => {
return GAMES[gameId].puzzle.data.maxGroup
}
const getMaxZIndex = (gameId: string) => {
return GAMES[gameId].puzzle.data.maxZ
}
const getMaxZIndexByTileIdxs = (gameId: string, tileIdxs: Array<number>) => {
let maxZ = 0
for (let tileIdx of tileIdxs) {
let tileZIndex = getTileZIndex(gameId, tileIdx)
if (tileZIndex > maxZ) {
maxZ = tileZIndex
}
}
return maxZ
}
function srcPosByTileIdx(gameId: string, tileIdx: number) {
const info = GAMES[gameId].puzzle.info
const c = Util.coordByTileIdx(info, tileIdx)
const cx = c.x * info.tileSize
const cy = c.y * info.tileSize
return { x: cx, y: cy }
}
function getSurroundingTilesByIdx(gameId: string, tileIdx: number) {
const info = GAMES[gameId].puzzle.info
const c = Util.coordByTileIdx(info, tileIdx)
return [
// top
(c.y > 0) ? (tileIdx - info.tilesX) : -1,
// right
(c.x < info.tilesX - 1) ? (tileIdx + 1) : -1,
// bottom
(c.y < info.tilesY - 1) ? (tileIdx + info.tilesX) : -1,
// left
(c.x > 0) ? (tileIdx - 1) : -1,
]
}
const setTilesZIndex = (gameId: string, tileIdxs: Array<number>, zIndex: number) => {
for (let tilesIdx of tileIdxs) {
changeTile(gameId, tilesIdx, { z: zIndex })
}
}
const moveTileDiff = (gameId: string, tileIdx: number, diff: Point) => {
const oldPos = getTilePos(gameId, tileIdx)
const pos = Geometry.pointAdd(oldPos, diff)
changeTile(gameId, tileIdx, { pos })
}
const moveTilesDiff = (gameId: string, tileIdxs: Array<number>, diff: Point) => {
const tileDrawSize = getTileDrawSize(gameId)
const bounds = getBounds(gameId)
const cappedDiff = diff
for (let tileIdx of tileIdxs) {
const t = getTile(gameId, tileIdx)
if (t.pos.x + diff.x < bounds.x) {
cappedDiff.x = Math.max(bounds.x - t.pos.x, cappedDiff.x)
} else if (t.pos.x + tileDrawSize + diff.x > bounds.x + bounds.w) {
cappedDiff.x = Math.min(bounds.x + bounds.w - t.pos.x + tileDrawSize, cappedDiff.x)
}
if (t.pos.y + diff.y < bounds.y) {
cappedDiff.y = Math.max(bounds.y - t.pos.y, cappedDiff.y)
} else if (t.pos.y + tileDrawSize + diff.y > bounds.y + bounds.h) {
cappedDiff.y = Math.min(bounds.y + bounds.h - t.pos.y + tileDrawSize, cappedDiff.y)
}
}
for (let tileIdx of tileIdxs) {
moveTileDiff(gameId, tileIdx, cappedDiff)
}
}
const finishTiles = (gameId: string, tileIdxs: Array<number>) => {
for (let tileIdx of tileIdxs) {
changeTile(gameId, tileIdx, { owner: -1, z: 1 })
}
}
const setTilesOwner = (
gameId: string,
tileIdxs: Array<number>,
owner: string|number
) => {
for (let tileIdx of tileIdxs) {
changeTile(gameId, tileIdx, { owner })
}
}
// get all grouped tiles for a tile
function getGroupedTileIdxs(gameId: string, tileIdx: number) {
const tiles = GAMES[gameId].puzzle.tiles
const tile = Util.decodeTile(tiles[tileIdx])
const grouped = []
if (tile.group) {
for (let other of tiles) {
const otherTile = Util.decodeTile(other)
if (otherTile.group === tile.group) {
grouped.push(otherTile.idx)
}
}
} else {
grouped.push(tile.idx)
}
return grouped
}
// Returns the index of the puzzle tile with the highest z index
// that is not finished yet and that matches the position
const freeTileIdxByPos = (gameId: string, pos: Point) => {
let info = GAMES[gameId].puzzle.info
let tiles = GAMES[gameId].puzzle.tiles
let maxZ = -1
let tileIdx = -1
for (let idx = 0; idx < tiles.length; idx++) {
const tile = Util.decodeTile(tiles[idx])
if (tile.owner !== 0) {
continue
}
const collisionRect = {
x: tile.pos.x,
y: tile.pos.y,
w: info.tileSize,
h: info.tileSize,
}
if (Geometry.pointInBounds(pos, collisionRect)) {
if (maxZ === -1 || tile.z > maxZ) {
maxZ = tile.z
tileIdx = idx
}
}
}
return tileIdx
}
const getPlayerBgColor = (gameId: string, playerId: string) => {
const p = getPlayer(gameId, playerId)
return p ? p.bgcolor : null
}
const getPlayerColor = (gameId: string, playerId: string) => {
const p = getPlayer(gameId, playerId)
return p ? p.color : null
}
const getPlayerName = (gameId: string, playerId: string) => {
const p = getPlayer(gameId, playerId)
return p ? p.name : null
}
const getPlayerPoints = (gameId: string, playerId: string) => {
const p = getPlayer(gameId, playerId)
return p ? p.points : null
}
// determine if two tiles are grouped together
const areGrouped = (gameId: string, tileIdx1: number, tileIdx2: number) => {
const g1 = getTileGroup(gameId, tileIdx1)
const g2 = getTileGroup(gameId, tileIdx2)
return g1 && g1 === g2
}
const getTableWidth = (gameId: string) => {
return GAMES[gameId].puzzle.info.table.width
}
const getTableHeight = (gameId: string) => {
return GAMES[gameId].puzzle.info.table.height
}
const getPuzzle = (gameId: string) => {
return GAMES[gameId].puzzle
}
const getRng = (gameId: string): Rng => {
return GAMES[gameId].rng.obj
}
const getPuzzleWidth = (gameId: string) => {
return GAMES[gameId].puzzle.info.width
}
const getPuzzleHeight = (gameId: string) => {
return GAMES[gameId].puzzle.info.height
}
function handleInput(gameId: string, playerId: string, input: any, ts: number) {
const puzzle = GAMES[gameId].puzzle
const evtInfo = getEvtInfo(gameId, playerId)
const changes = [] as Array<Array<any>>
const _dataChange = () => {
changes.push([Protocol.CHANGE_DATA, puzzle.data])
}
const _tileChange = (tileIdx: number) => {
changes.push([
Protocol.CHANGE_TILE,
Util.encodeTile(getTile(gameId, tileIdx)),
])
}
const _tileChanges = (tileIdxs: Array<number>) => {
for (const tileIdx of tileIdxs) {
_tileChange(tileIdx)
}
}
const _playerChange = () => {
changes.push([
Protocol.CHANGE_PLAYER,
Util.encodePlayer(getPlayer(gameId, playerId)),
])
}
// put both tiles (and their grouped tiles) in the same group
const groupTiles = (gameId: string, tileIdx1: number, tileIdx2: number) => {
const tiles = GAMES[gameId].puzzle.tiles
const group1 = getTileGroup(gameId, tileIdx1)
const group2 = getTileGroup(gameId, tileIdx2)
let group
const searchGroups = []
if (group1) {
searchGroups.push(group1)
}
if (group2) {
searchGroups.push(group2)
}
if (group1) {
group = group1
} else if (group2) {
group = group2
} else {
const maxGroup = getMaxGroup(gameId) + 1
changeData(gameId, { maxGroup })
_dataChange()
group = getMaxGroup(gameId)
}
changeTile(gameId, tileIdx1, { group })
_tileChange(tileIdx1)
changeTile(gameId, tileIdx2, { group })
_tileChange(tileIdx2)
// TODO: strange
if (searchGroups.length > 0) {
for (const t of tiles) {
const tile = Util.decodeTile(t)
if (searchGroups.includes(tile.group)) {
changeTile(gameId, tile.idx, { group })
_tileChange(tile.idx)
}
}
}
}
const type = input[0]
if (type === Protocol.INPUT_EV_BG_COLOR) {
const bgcolor = input[1]
changePlayer(gameId, playerId, { bgcolor, ts })
_playerChange()
} else if (type === Protocol.INPUT_EV_PLAYER_COLOR) {
const color = input[1]
changePlayer(gameId, playerId, { color, ts })
_playerChange()
} else if (type === Protocol.INPUT_EV_PLAYER_NAME) {
const name = `${input[1]}`.substr(0, 16)
changePlayer(gameId, playerId, { name, ts })
_playerChange()
} else if (type === Protocol.INPUT_EV_MOUSE_DOWN) {
const x = input[1]
const y = input[2]
const pos = {x, y}
changePlayer(gameId, playerId, { d: 1, ts })
_playerChange()
evtInfo._last_mouse_down = pos
const tileIdxAtPos = freeTileIdxByPos(gameId, pos)
if (tileIdxAtPos >= 0) {
let maxZ = getMaxZIndex(gameId) + 1
changeData(gameId, { maxZ })
_dataChange()
const tileIdxs = getGroupedTileIdxs(gameId, tileIdxAtPos)
setTilesZIndex(gameId, tileIdxs, getMaxZIndex(gameId))
setTilesOwner(gameId, tileIdxs, playerId)
_tileChanges(tileIdxs)
}
evtInfo._last_mouse = pos
} else if (type === Protocol.INPUT_EV_MOUSE_MOVE) {
const x = input[1]
const y = input[2]
const pos = {x, y}
if (evtInfo._last_mouse_down === null) {
// player is just moving the hand
changePlayer(gameId, playerId, {x, y, ts})
_playerChange()
} else {
let tileIdx = getFirstOwnedTileIdx(gameId, playerId)
if (tileIdx >= 0) {
// player is moving a tile (and hand)
changePlayer(gameId, playerId, {x, y, ts})
_playerChange()
// check if pos is on the tile, otherwise dont move
// (mouse could be out of table, but tile stays on it)
const tileIdxs = getGroupedTileIdxs(gameId, tileIdx)
let anyOk = Geometry.pointInBounds(pos, getBounds(gameId))
&& Geometry.pointInBounds(evtInfo._last_mouse_down, getBounds(gameId))
for (let idx of tileIdxs) {
const bounds = getTileBounds(gameId, idx)
if (Geometry.pointInBounds(pos, bounds)) {
anyOk = true
break
}
}
if (anyOk) {
const diffX = x - evtInfo._last_mouse_down.x
const diffY = y - evtInfo._last_mouse_down.y
const diff = { x: diffX, y: diffY }
moveTilesDiff(gameId, tileIdxs, diff)
_tileChanges(tileIdxs)
}
} else {
// player is just moving map, so no change in position!
changePlayer(gameId, playerId, {ts})
_playerChange()
}
evtInfo._last_mouse_down = pos
}
evtInfo._last_mouse = pos
} else if (type === Protocol.INPUT_EV_MOUSE_UP) {
const x = input[1]
const y = input[2]
const pos = {x, y}
const d = 0
evtInfo._last_mouse_down = null
let tileIdx = getFirstOwnedTileIdx(gameId, playerId)
if (tileIdx >= 0) {
// drop the tile(s)
let tileIdxs = getGroupedTileIdxs(gameId, tileIdx)
setTilesOwner(gameId, tileIdxs, 0)
_tileChanges(tileIdxs)
// Check if the tile was dropped near the final location
let tilePos = getTilePos(gameId, tileIdx)
let finalPos = getFinalTilePos(gameId, tileIdx)
if (Geometry.pointDistance(finalPos, tilePos) < puzzle.info.snapDistance) {
let diff = Geometry.pointSub(finalPos, tilePos)
// Snap the tile to the final destination
moveTilesDiff(gameId, tileIdxs, diff)
finishTiles(gameId, tileIdxs)
_tileChanges(tileIdxs)
let points = getPlayerPoints(gameId, playerId)
if (getScoreMode(gameId) === SCORE_MODE_FINAL) {
points += tileIdxs.length
} else if (getScoreMode(gameId) === SCORE_MODE_ANY) {
points += 1
} else {
// no score mode... should never occur, because there is a
// fallback to SCORE_MODE_FINAL in getScoreMode function
}
changePlayer(gameId, playerId, { d, ts, points })
_playerChange()
// check if the puzzle is finished
if (getFinishedTileCount(gameId) === getTileCount(gameId)) {
changeData(gameId, { finished: ts })
_dataChange()
}
} else {
// Snap to other tiles
const check = (
gameId: string,
tileIdx: number,
otherTileIdx: number,
off: Array<number>
) => {
let info = GAMES[gameId].puzzle.info
if (otherTileIdx < 0) {
return false
}
if (areGrouped(gameId, tileIdx, otherTileIdx)) {
return false
}
const tilePos = getTilePos(gameId, tileIdx)
const dstPos = Geometry.pointAdd(
getTilePos(gameId, otherTileIdx),
{x: off[0] * info.tileSize, y: off[1] * info.tileSize}
)
if (Geometry.pointDistance(tilePos, dstPos) < info.snapDistance) {
let diff = Geometry.pointSub(dstPos, tilePos)
let tileIdxs = getGroupedTileIdxs(gameId, tileIdx)
moveTilesDiff(gameId, tileIdxs, diff)
groupTiles(gameId, tileIdx, otherTileIdx)
tileIdxs = getGroupedTileIdxs(gameId, tileIdx)
const zIndex = getMaxZIndexByTileIdxs(gameId, tileIdxs)
setTilesZIndex(gameId, tileIdxs, zIndex)
_tileChanges(tileIdxs)
return true
}
return false
}
let snapped = false
for (let tileIdxTmp of getGroupedTileIdxs(gameId, tileIdx)) {
let othersIdxs = getSurroundingTilesByIdx(gameId, tileIdxTmp)
if (
check(gameId, tileIdxTmp, othersIdxs[0], [0, 1]) // top
|| check(gameId, tileIdxTmp, othersIdxs[1], [-1, 0]) // right
|| check(gameId, tileIdxTmp, othersIdxs[2], [0, -1]) // bottom
|| check(gameId, tileIdxTmp, othersIdxs[3], [1, 0]) // left
) {
snapped = true
break
}
}
if (snapped && getScoreMode(gameId) === SCORE_MODE_ANY) {
const points = getPlayerPoints(gameId, playerId) + 1
changePlayer(gameId, playerId, { d, ts, points })
_playerChange()
} else {
changePlayer(gameId, playerId, { d, ts })
_playerChange()
}
}
} else {
changePlayer(gameId, playerId, { d, ts })
_playerChange()
}
evtInfo._last_mouse = pos
} else if (type === Protocol.INPUT_EV_ZOOM_IN) {
const x = input[1]
const y = input[2]
changePlayer(gameId, playerId, { x, y, ts })
_playerChange()
evtInfo._last_mouse = { x, y }
} else if (type === Protocol.INPUT_EV_ZOOM_OUT) {
const x = input[1]
const y = input[2]
changePlayer(gameId, playerId, { x, y, ts })
_playerChange()
evtInfo._last_mouse = { x, y }
} else {
changePlayer(gameId, playerId, { ts })
_playerChange()
}
setEvtInfo(gameId, playerId, evtInfo)
return changes
}
export default {
__createPlayerObject,
setGame,
exists,
playerExists,
getActivePlayers,
getIdlePlayers,
addPlayer,
getFinishedTileCount,
getTileCount,
getImageUrl,
setImageUrl,
get,
getAllGames,
getPlayerBgColor,
getPlayerColor,
getPlayerName,
getPlayerIndexById,
getPlayerIdByIndex,
changePlayer,
setPlayer,
setTile,
setPuzzleData,
getTableWidth,
getTableHeight,
getPuzzle,
getRng,
getPuzzleWidth,
getPuzzleHeight,
getTilesSortedByZIndex,
getFirstOwnedTile,
getTileDrawOffset,
getTileDrawSize,
getFinalTilePos,
getStartTs,
getFinishTs,
handleInput,
SCORE_MODE_FINAL,
SCORE_MODE_ANY,
}

88
src/common/Geometry.ts Normal file
View file

@ -0,0 +1,88 @@
interface Point {
x: number
y: number
}
interface Rect {
x: number
y: number
w: number
h: number
}
function pointSub(a: Point, b: Point): Point {
return { x: a.x - b.x, y: a.y - b.y }
}
function pointAdd(a: Point, b: Point): Point {
return { x: a.x + b.x, y: a.y + b.y }
}
function pointDistance(a: Point, b: Point): number {
const diffX = a.x - b.x
const diffY = a.y - b.y
return Math.sqrt(diffX * diffX + diffY * diffY)
}
function pointInBounds(pt: Point, rect: Rect): boolean {
return pt.x >= rect.x
&& pt.x <= rect.x + rect.w
&& pt.y >= rect.y
&& pt.y <= rect.y + rect.h
}
function rectCenter(rect: Rect): Point {
return {
x: rect.x + (rect.w / 2),
y: rect.y + (rect.h / 2),
}
}
/**
* Returns a rectangle with same dimensions as the given one, but
* location (x/y) moved by x and y.
*
* @param {x, y, w,, h} rect
* @param number x
* @param number y
* @returns {x, y, w, h}
*/
function rectMoved(rect: Rect, x: number, y: number): Rect {
return {
x: rect.x + x,
y: rect.y + y,
w: rect.w,
h: rect.h,
}
}
/**
* Returns true if the rectangles overlap, including their borders.
*
* @param {x, y, w, h} rectA
* @param {x, y, w, h} rectB
* @returns bool
*/
function rectsOverlap(rectA: Rect, rectB: Rect): boolean {
return !(
rectB.x > (rectA.x + rectA.w)
|| rectA.x > (rectB.x + rectB.w)
|| rectB.y > (rectA.y + rectA.h)
|| rectA.y > (rectB.y + rectB.h)
)
}
function rectCenterDistance(rectA: Rect, rectB: Rect): number {
return pointDistance(rectCenter(rectA), rectCenter(rectB))
}
export default {
pointSub,
pointAdd,
pointDistance,
pointInBounds,
rectCenter,
rectMoved,
rectCenterDistance,
rectsOverlap,
}

98
src/common/Protocol.ts Normal file
View file

@ -0,0 +1,98 @@
/*
SERVER_CLIENT_MESSAGE_PROTOCOL
NOTE: clients always send game id and their id
when creating sockets (via socket.protocol), so
this doesn't need to be set in each message data
NOTE: first value in the array is always the type of event/message
when describing them below, the value each has is used
instead of writing EVENT_TYPE or something ismilar
EV_CLIENT_EVENT: event triggered by clients and sent to server
[
EV_CLIENT_EVENT, // constant value, type of event
CLIENT_SEQ, // sequence number sent by client.
EV_DATA, // (eg. mouse input info)
]
EV_SERVER_EVENT: event sent to clients after recieving a client
event and processing it
[
EV_SERVER_EVENT, // constant value, type of event
CLIENT_ID, // user who sent the client event
CLIENT_SEQ, // sequence number of the client event msg
CHANGES_TRIGGERED_BY_CLIENT_EVENT,
]
EV_CLIENT_INIT: event sent by client to enter a game
[
EV_CLIENT_INIT, // constant value, type of event
]
EV_SERVER_INIT: event sent to one client after that client
connects to a game
[
EV_SERVER_INIT, // constant value, type of event
GAME, // complete game instance required by
// client to build client side of the game
]
*/
const EV_SERVER_EVENT = 1
const EV_SERVER_INIT = 4
const EV_SERVER_INIT_REPLAY = 5
const EV_CLIENT_EVENT = 2
const EV_CLIENT_INIT = 3
const EV_CLIENT_INIT_REPLAY = 6
const LOG_HEADER = 1
const LOG_ADD_PLAYER = 2
const LOG_UPDATE_PLAYER = 4
const LOG_HANDLE_INPUT = 3
const INPUT_EV_MOUSE_DOWN = 1
const INPUT_EV_MOUSE_UP = 2
const INPUT_EV_MOUSE_MOVE = 3
const INPUT_EV_ZOOM_IN = 4
const INPUT_EV_ZOOM_OUT = 5
const INPUT_EV_BG_COLOR = 6
const INPUT_EV_PLAYER_COLOR = 7
const INPUT_EV_PLAYER_NAME = 8
const INPUT_EV_MOVE = 9
const INPUT_EV_TOGGLE_PREVIEW = 10
const CHANGE_DATA = 1
const CHANGE_TILE = 2
const CHANGE_PLAYER = 3
export default {
EV_SERVER_EVENT,
EV_SERVER_INIT,
EV_SERVER_INIT_REPLAY,
EV_CLIENT_EVENT,
EV_CLIENT_INIT,
EV_CLIENT_INIT_REPLAY,
LOG_HEADER,
LOG_ADD_PLAYER,
LOG_UPDATE_PLAYER,
LOG_HANDLE_INPUT,
INPUT_EV_MOVE, // move by x/y
INPUT_EV_MOUSE_DOWN,
INPUT_EV_MOUSE_UP,
INPUT_EV_MOUSE_MOVE,
INPUT_EV_ZOOM_IN,
INPUT_EV_ZOOM_OUT,
INPUT_EV_BG_COLOR,
INPUT_EV_PLAYER_COLOR,
INPUT_EV_PLAYER_NAME,
INPUT_EV_TOGGLE_PREVIEW,
CHANGE_DATA,
CHANGE_TILE,
CHANGE_PLAYER,
}

35
src/common/Rng.ts Normal file
View file

@ -0,0 +1,35 @@
interface RngSerialized {
rand_high: number,
rand_low: number,
}
export class Rng {
rand_high: number
rand_low: number
constructor(seed: number) {
this.rand_high = seed || 0xDEADC0DE
this.rand_low = seed ^ 0x49616E42
}
random (min: number, max: number) {
this.rand_high = ((this.rand_high << 16) + (this.rand_high >> 16) + this.rand_low) & 0xffffffff;
this.rand_low = (this.rand_low + this.rand_high) & 0xffffffff;
var n = (this.rand_high >>> 0) / 0xffffffff;
return (min + n * (max-min+1))|0;
}
static serialize (rng: Rng): RngSerialized {
return {
rand_high: rng.rand_high,
rand_low: rng.rand_low
}
}
static unserialize (rngSerialized: RngSerialized): Rng {
const rng = new Rng(0)
rng.rand_high = rngSerialized.rand_high
rng.rand_low = rngSerialized.rand_low
return rng
}
}

47
src/common/Time.ts Normal file
View file

@ -0,0 +1,47 @@
const MS = 1
const SEC = MS * 1000
const MIN = SEC * 60
const HOUR = MIN * 60
const DAY = HOUR * 24
export const timestamp = () => {
const d = new Date();
return Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
d.getUTCHours(),
d.getUTCMinutes(),
d.getUTCSeconds(),
d.getUTCMilliseconds(),
)
}
export const durationStr = (duration: number) => {
const d = Math.floor(duration / DAY)
duration = duration % DAY
const h = Math.floor(duration / HOUR)
duration = duration % HOUR
const m = Math.floor(duration / MIN)
duration = duration % MIN
const s = Math.floor(duration / SEC)
return `${d}d ${h}h ${m}m ${s}s`
}
export const timeDiffStr = (from: number, to: number) => durationStr(to - from)
export default {
MS,
SEC,
MIN,
HOUR,
DAY,
timestamp,
timeDiffStr,
durationStr,
}

216
src/common/Util.ts Normal file
View file

@ -0,0 +1,216 @@
import { Rng } from './Rng'
const pad = (x: any, pad: string) => {
const str = `${x}`
if (str.length >= pad.length) {
return str
}
return pad.substr(0, pad.length - str.length) + str
}
export const logger = (...pre: Array<any>) => {
const log = (m: 'log'|'info'|'error') => (...args: Array<any>) => {
const d = new Date()
const hh = pad(d.getHours(), '00')
const mm = pad(d.getMinutes(), '00')
const ss = pad(d.getSeconds(), '00')
console[m](`${hh}:${mm}:${ss}`, ...pre, ...args)
}
return {
log: log('log'),
error: log('error'),
info: log('info'),
}
}
// get a unique id
export const uniqId = () => Date.now().toString(36) + Math.random().toString(36).substring(2)
// get a random int between min and max (inclusive)
export const randomInt = (
rng: Rng,
min: number,
max: number,
) => rng.random(min, max)
// get one random item from the given array
export const choice = (
rng: Rng,
array: Array<any>
) => array[randomInt(rng, 0, array.length - 1)]
// return a shuffled (shallow) copy of the given array
export const shuffle = (
rng: Rng,
array: Array<any>
) => {
const arr = array.slice()
for (let i = 0; i <= arr.length - 2; i++)
{
const j = randomInt(rng, i, arr.length -1);
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
return arr
}
function encodeShape(data: any): number {
if (typeof data === 'number') {
return data
}
/* encoded in 1 byte:
00000000
^^ top
^^ right
^^ bottom
^^ left
*/
return ((data.top + 1) << 0)
| ((data.right + 1) << 2)
| ((data.bottom + 1) << 4)
| ((data.left + 1) << 6)
}
function decodeShape(data: any) {
if (typeof data !== 'number') {
return data
}
return {
top: (data >> 0 & 0b11) - 1,
right: (data >> 2 & 0b11) - 1,
bottom: (data >> 4 & 0b11) - 1,
left: (data >> 6 & 0b11) - 1,
}
}
function encodeTile(data: any): Array<any> {
if (Array.isArray(data)) {
return data
}
return [data.idx, data.pos.x, data.pos.y, data.z, data.owner, data.group]
}
function decodeTile(data: any) {
if (!Array.isArray(data)) {
return data
}
return {
idx: data[0],
pos: {
x: data[1],
y: data[2],
},
z: data[3],
owner: data[4],
group: data[5],
}
}
function encodePlayer(data: any): Array<any> {
if (Array.isArray(data)) {
return data
}
return [
data.id,
data.x,
data.y,
data.d,
data.name,
data.color,
data.bgcolor,
data.points,
data.ts,
]
}
function decodePlayer(data: any) {
if (!Array.isArray(data)) {
return data
}
return {
id: data[0],
x: data[1],
y: data[2],
d: data[3], // mouse down
name: data[4],
color: data[5],
bgcolor: data[6],
points: data[7],
ts: data[8],
}
}
function encodeGame(data: any): Array<any> {
if (Array.isArray(data)) {
return data
}
return [
data.id,
data.rng.type,
Rng.serialize(data.rng.obj),
data.puzzle,
data.players,
data.evtInfos,
data.scoreMode,
]
}
function decodeGame(data: any) {
if (!Array.isArray(data)) {
return data
}
return {
id: data[0],
rng: {
type: data[1],
obj: Rng.unserialize(data[2]),
},
puzzle: data[3],
players: data[4],
evtInfos: data[5],
scoreMode: data[6],
}
}
function coordByTileIdx(info: any, tileIdx: number) {
const wTiles = info.width / info.tileSize
return {
x: tileIdx % wTiles,
y: Math.floor(tileIdx / wTiles),
}
}
const hash = (str: string): number => {
let hash = 0
for (let i = 0; i < str.length; i++) {
let chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
export default {
hash,
uniqId,
randomInt,
choice,
shuffle,
encodeShape,
decodeShape,
encodeTile,
decodeTile,
encodePlayer,
decodePlayer,
encodeGame,
decodeGame,
coordByTileIdx,
}

3
src/common/package.json Normal file
View file

@ -0,0 +1,3 @@
{
"type": "module"
}

23
src/frontend/App.vue Normal file
View file

@ -0,0 +1,23 @@
<template>
<div id="app">
<ul class="nav" v-if="showNav">
<li><router-link class="btn" :to="{name: 'index'}">Index</router-link></li>
<li><router-link class="btn" :to="{name: 'new-game'}">New game</router-link></li>
</ul>
<router-view />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'app',
computed: {
showNav () {
// TODO: add info wether to show nav to route props
return !['game', 'replay'].includes(String(this.$route.name))
},
},
})
</script>

136
src/frontend/Camera.ts Normal file
View file

@ -0,0 +1,136 @@
const MIN_ZOOM = .1
const MAX_ZOOM = 6
const ZOOM_STEP = .05
type ZOOM_DIR = 'in'|'out'
interface Point {
x: number
y: number
}
interface Dim {
w: number
h: number
}
export default function Camera () {
let x = 0
let y = 0
let curZoom = 1
const move = (byX: number, byY: number) => {
x += byX / curZoom
y += byY / curZoom
}
const calculateNewZoom = (inout: ZOOM_DIR): number => {
const factor = inout === 'in' ? 1 : -1
const newzoom = curZoom + ZOOM_STEP * curZoom * factor
const capped = Math.min(Math.max(newzoom, MIN_ZOOM), MAX_ZOOM)
return capped
}
const canZoom = (inout: ZOOM_DIR): boolean => {
return curZoom != calculateNewZoom(inout)
}
const setZoom = (newzoom: number, viewportCoordCenter: Point): boolean => {
if (curZoom == newzoom) {
return false
}
const zoomFactor = 1 - (curZoom / newzoom)
move(
-viewportCoordCenter.x * zoomFactor,
-viewportCoordCenter.y * zoomFactor,
)
curZoom = newzoom
return true
}
/**
* Zooms towards/away from the provided coordinate, if possible.
* If at max or min zoom respectively, no zooming is performed.
*/
const zoom = (inout: ZOOM_DIR, viewportCoordCenter: Point): boolean => {
return setZoom(calculateNewZoom(inout), viewportCoordCenter)
}
/**
* Translate a coordinate in the viewport to a
* coordinate in the world, rounded
* @param {x, y} viewportCoord
*/
const viewportToWorld = (viewportCoord: Point): Point => {
const { x, y } = viewportToWorldRaw(viewportCoord)
return { x: Math.round(x), y: Math.round(y) }
}
/**
* Translate a coordinate in the viewport to a
* coordinate in the world, not rounded
* @param {x, y} viewportCoord
*/
const viewportToWorldRaw = (viewportCoord: Point): Point => {
return {
x: (viewportCoord.x / curZoom) - x,
y: (viewportCoord.y / curZoom) - y,
}
}
/**
* Translate a coordinate in the world to a
* coordinate in the viewport, rounded
* @param {x, y} worldCoord
*/
const worldToViewport = (worldCoord: Point): Point => {
const { x, y } = worldToViewportRaw(worldCoord)
return { x: Math.round(x), y: Math.round(y) }
}
/**
* Translate a coordinate in the world to a
* coordinate in the viewport, not rounded
* @param {x, y} worldCoord
*/
const worldToViewportRaw = (worldCoord: Point): Point => {
return {
x: (worldCoord.x + x) * curZoom,
y: (worldCoord.y + y) * curZoom,
}
}
/**
* Translate a 2d dimension (width/height) in the world to
* one in the viewport, rounded
* @param {w, h} worldDim
*/
const worldDimToViewport = (worldDim: Dim): Dim => {
const { w, h } = worldDimToViewportRaw(worldDim)
return { w: Math.round(w), h: Math.round(h) }
}
/**
* Translate a 2d dimension (width/height) in the world to
* one in the viewport, not rounded
* @param {w, h} worldDim
*/
const worldDimToViewportRaw = (worldDim: Dim): Dim => {
return {
w: worldDim.w * curZoom,
h: worldDim.h * curZoom,
}
}
return {
move,
canZoom,
zoom,
worldToViewport,
worldToViewportRaw,
worldDimToViewport, // not used outside
worldDimToViewportRaw,
viewportToWorld,
viewportToWorldRaw, // not used outside
}
}

View file

@ -0,0 +1,172 @@
"use strict"
import { logger } from '../common/Util'
import Protocol from './../common/Protocol'
const log = logger('Communication.js')
const CODE_GOING_AWAY = 1001
const CODE_CUSTOM_DISCONNECT = 4000
const CONN_STATE_NOT_CONNECTED = 0 // not connected yet
const CONN_STATE_DISCONNECTED = 1 // not connected, but was connected before
const CONN_STATE_CONNECTED = 2 // connected
const CONN_STATE_CONNECTING = 3 // connecting
const CONN_STATE_CLOSED = 4 // not connected (closed on purpose)
let ws: WebSocket
let changesCallback = (msg: Array<any>) => {}
let connectionStateChangeCallback = (state: number) => {}
// TODO: change these to something like on(EVT, cb)
function onServerChange(callback: (msg: Array<any>) => void) {
changesCallback = callback
}
function onConnectionStateChange(callback: (state: number) => void) {
connectionStateChangeCallback = callback
}
let connectionState = CONN_STATE_NOT_CONNECTED
const setConnectionState = (state: number) => {
if (connectionState !== state) {
connectionState = state
connectionStateChangeCallback(state)
}
}
function send(message: Array<any>): void {
if (connectionState === CONN_STATE_CONNECTED) {
try {
ws.send(JSON.stringify(message))
} catch (e) {
log.info('unable to send message.. maybe because ws is invalid?')
}
}
}
let clientSeq: number
let events: Record<number, any>
function connect(
address: string,
gameId: string,
clientId: string
): Promise<any> {
clientSeq = 0
events = {}
setConnectionState(CONN_STATE_CONNECTING)
return new Promise(resolve => {
ws = new WebSocket(address, clientId + '|' + gameId)
ws.onopen = (e) => {
setConnectionState(CONN_STATE_CONNECTED)
send([Protocol.EV_CLIENT_INIT])
}
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
const msgType = msg[0]
if (msgType === Protocol.EV_SERVER_INIT) {
const game = msg[1]
resolve(game)
} else if (msgType === Protocol.EV_SERVER_EVENT) {
const msgClientId = msg[1]
const msgClientSeq = msg[2]
if (msgClientId === clientId && events[msgClientSeq]) {
delete events[msgClientSeq]
// we have already calculated that change locally. probably
return
}
changesCallback(msg)
} else {
throw `[ 2021-05-09 invalid connect msgType ${msgType} ]`
}
}
ws.onerror = (e) => {
setConnectionState(CONN_STATE_DISCONNECTED)
throw `[ 2021-05-15 onerror ]`
}
ws.onclose = (e) => {
if (e.code === CODE_CUSTOM_DISCONNECT || e.code === CODE_GOING_AWAY) {
setConnectionState(CONN_STATE_CLOSED)
} else {
setConnectionState(CONN_STATE_DISCONNECTED)
}
}
})
}
// TOOD: change replay stuff
function connectReplay(
address: string,
gameId: string,
clientId: string
): Promise<{ game: any, log: Array<any> }> {
clientSeq = 0
events = {}
setConnectionState(CONN_STATE_CONNECTING)
return new Promise(resolve => {
ws = new WebSocket(address, clientId + '|' + gameId)
ws.onopen = (e) => {
setConnectionState(CONN_STATE_CONNECTED)
send([Protocol.EV_CLIENT_INIT_REPLAY])
}
ws.onmessage = (e) => {
const msg = JSON.parse(e.data)
const msgType = msg[0]
if (msgType === Protocol.EV_SERVER_INIT_REPLAY) {
const game = msg[1]
const log = msg[2]
const replay: { game: any, log: Array<any> } = { game, log }
resolve(replay)
} else {
throw `[ 2021-05-09 invalid connectReplay msgType ${msgType} ]`
}
}
ws.onerror = (e) => {
setConnectionState(CONN_STATE_DISCONNECTED)
throw `[ 2021-05-15 onerror ]`
}
ws.onclose = (e) => {
if (e.code === CODE_CUSTOM_DISCONNECT || e.code === CODE_GOING_AWAY) {
setConnectionState(CONN_STATE_CLOSED)
} else {
setConnectionState(CONN_STATE_DISCONNECTED)
}
}
})
}
function disconnect(): void {
if (ws) {
ws.close(CODE_CUSTOM_DISCONNECT)
}
clientSeq = 0
events = {}
}
function sendClientEvent(evt: any): void {
// when sending event, increase number of sent events
// and add the event locally
clientSeq++;
events[clientSeq] = evt
send([Protocol.EV_CLIENT_EVENT, clientSeq, events[clientSeq]])
}
export default {
connect,
connectReplay,
disconnect,
sendClientEvent,
onServerChange,
onConnectionStateChange,
CODE_CUSTOM_DISCONNECT,
CONN_STATE_NOT_CONNECTED,
CONN_STATE_DISCONNECTED,
CONN_STATE_CLOSED,
CONN_STATE_CONNECTED,
CONN_STATE_CONNECTING,
}

27
src/frontend/Debug.ts Normal file
View file

@ -0,0 +1,27 @@
"use strict"
import { logger } from '../common/Util'
const log = logger('Debug.js')
let _pt = 0
let _mindiff = 0
const checkpoint_start = (mindiff: number) => {
_pt = performance.now()
_mindiff = mindiff
}
const checkpoint = (label: string) => {
const now = performance.now()
const diff = now - _pt
if (diff > _mindiff) {
log.log(label + ': ' + (diff))
}
_pt = now
}
export default {
checkpoint_start,
checkpoint,
}

272
src/frontend/Fireworks.ts Normal file
View file

@ -0,0 +1,272 @@
"use strict"
import { Rng } from '../common/Rng'
import Util from '../common/Util'
let minVx = -10
let deltaVx = 20
let minVy = 2
let deltaVy = 15
const minParticleV = 5
const deltaParticleV = 5
const gravity = 1
const explosionRadius = 200
const bombRadius = 10
const explodingDuration = 100
const explosionDividerFactor = 10
const nBombs = 1
const percentChanceNewBomb = 5
function color(rng: Rng) {
const r = Util.randomInt(rng, 0, 255)
const g = Util.randomInt(rng, 0, 255)
const b = Util.randomInt(rng, 0, 255)
return 'rgba(' + r + ',' + g + ',' + b + ', 0.8)'
}
// A Bomb. Or firework.
class Bomb {
radius: number
previousRadius: number
explodingDuration: number
hasExploded: boolean
alive: boolean
color: string
px: number
py: number
vx: number
vy: number
duration: number
constructor(rng: Rng) {
this.radius = bombRadius
this.previousRadius = bombRadius
this.explodingDuration = explodingDuration
this.hasExploded = false
this.alive = true
this.color = color(rng)
this.px = (window.innerWidth / 4) + (Math.random() * window.innerWidth / 2)
this.py = window.innerHeight
this.vx = minVx + Math.random() * deltaVx
this.vy = (minVy + Math.random() * deltaVy) * -1 // because y grows downwards
this.duration = 0
}
update(particlesVector?: Array<Particle>) {
if (this.hasExploded) {
const deltaRadius = explosionRadius - this.radius
this.previousRadius = this.radius
this.radius += deltaRadius / explosionDividerFactor
this.explodingDuration--
if (this.explodingDuration == 0) {
this.alive = false
}
}
else {
this.vx += 0
this.vy += gravity
if (this.vy >= 0) { // invertion point
if (particlesVector) {
this.explode(particlesVector)
}
}
this.px += this.vx
this.py += this.vy
}
}
draw(ctx: CanvasRenderingContext2D) {
ctx.beginPath()
ctx.arc(this.px, this.py, this.previousRadius, 0, Math.PI * 2, false)
if (!this.hasExploded) {
ctx.fillStyle = this.color
ctx.lineWidth = 1
ctx.fill()
}
}
explode(particlesVector: Array<Particle>) {
this.hasExploded = true
const e = 3 + Math.floor(Math.random() * 3)
for (let j = 0; j < e; j++) {
const n = 10 + Math.floor(Math.random() * 21) // 10 - 30
const speed = minParticleV + Math.random() * deltaParticleV
const deltaAngle = 2 * Math.PI / n
const initialAngle = Math.random() * deltaAngle
for (let i = 0; i < n; i++) {
particlesVector.push(new Particle(this, i * deltaAngle + initialAngle, speed))
}
}
}
}
class Particle {
px: any
py: any
vx: number
vy: number
color: any
duration: number
alive: boolean
radius: number
constructor(parent: Bomb, angle: number, speed: number) {
this.px = parent.px
this.py = parent.py
this.vx = Math.cos(angle) * speed
this.vy = Math.sin(angle) * speed
this.color = parent.color
this.duration = 40 + Math.floor(Math.random() * 20)
this.alive = true
this.radius = 0
}
update() {
this.vx += 0
this.vy += gravity / 10
this.px += this.vx
this.py += this.vy
this.radius = 3
this.duration--
if (this.duration <= 0) {
this.alive = false
}
}
draw(ctx: CanvasRenderingContext2D) {
ctx.beginPath()
ctx.arc(this.px, this.py, this.radius, 0, Math.PI * 2, false)
ctx.fillStyle = this.color
ctx.lineWidth = 1
ctx.fill()
}
}
class Controller {
canvas: HTMLCanvasElement
rng: Rng
ctx: CanvasRenderingContext2D
readyBombs: Array<Bomb>
explodedBombs: Array<Bomb>
particles: Array<Particle>
constructor(canvas: HTMLCanvasElement, rng: Rng) {
this.canvas = canvas
this.rng = rng
this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D
this.resize()
this.readyBombs = []
this.explodedBombs = []
this.particles = []
window.addEventListener('resize', () => {
this.resize()
})
}
setSpeedParams() {
let heightReached = 0
let vy = 0
while (heightReached < this.canvas.height && vy >= 0) {
vy += gravity
heightReached += vy
}
minVy = vy / 2
deltaVy = vy - minVy
const vx = (1 / 4) * this.canvas.width / (vy / 2)
minVx = -vx
deltaVx = 2 * vx
}
resize() {
this.setSpeedParams()
}
init() {
this.readyBombs = []
this.explodedBombs = []
this.particles = []
for (let i = 0; i < nBombs; i++) {
this.readyBombs.push(new Bomb(this.rng))
}
}
update() {
if (Math.random() * 100 < percentChanceNewBomb) {
this.readyBombs.push(new Bomb(this.rng))
}
const aliveBombs = []
while (this.explodedBombs.length > 0) {
const bomb = this.explodedBombs.shift()
if (!bomb) {
break;
}
bomb.update()
if (bomb.alive) {
aliveBombs.push(bomb)
}
}
this.explodedBombs = aliveBombs
const notExplodedBombs = []
while (this.readyBombs.length > 0) {
const bomb = this.readyBombs.shift()
if (!bomb) {
break
}
bomb.update(this.particles)
if (bomb.hasExploded) {
this.explodedBombs.push(bomb)
}
else {
notExplodedBombs.push(bomb)
}
}
this.readyBombs = notExplodedBombs
const aliveParticles = []
while (this.particles.length > 0) {
const particle = this.particles.shift()
if (!particle) {
break
}
particle.update()
if (particle.alive) {
aliveParticles.push(particle)
}
}
this.particles = aliveParticles
}
render() {
this.ctx.beginPath()
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.1)' // Ghostly effect
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
for (let i = 0; i < this.readyBombs.length; i++) {
this.readyBombs[i].draw(this.ctx)
}
for (let i = 0; i < this.explodedBombs.length; i++) {
this.explodedBombs[i].draw(this.ctx)
}
for (let i = 0; i < this.particles.length; i++) {
this.particles[i].draw(this.ctx)
}
}
}
export default Controller

48
src/frontend/Graphics.ts Normal file
View file

@ -0,0 +1,48 @@
"use strict"
function createCanvas(width:number = 0, height:number = 0): HTMLCanvasElement {
const c = document.createElement('canvas')
c.width = width
c.height = height
return c
}
async function loadImageToBitmap(imagePath: string): Promise<ImageBitmap> {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
createImageBitmap(img).then(resolve)
}
img.src = imagePath
})
}
async function resizeBitmap (bitmap: ImageBitmap, width: number, height: number): Promise<ImageBitmap> {
const c = createCanvas(width, height)
const ctx = c.getContext('2d') as CanvasRenderingContext2D
ctx.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, width, height)
return await createImageBitmap(c)
}
async function colorize(image: ImageBitmap, mask: ImageBitmap, color: string): Promise<ImageBitmap> {
const c = createCanvas(image.width, image.height)
const ctx = c.getContext('2d') as CanvasRenderingContext2D
ctx.save()
ctx.drawImage(mask, 0, 0)
ctx.fillStyle = color
ctx.globalCompositeOperation = "source-in"
ctx.fillRect(0, 0, mask.width, mask.height)
ctx.restore()
ctx.save()
ctx.globalCompositeOperation = "destination-over"
ctx.drawImage(image, 0, 0)
ctx.restore()
return await createImageBitmap(c)
}
export default {
createCanvas,
loadImageToBitmap,
resizeBitmap,
colorize,
}

View file

@ -0,0 +1,224 @@
"use strict"
import Geometry from '../common/Geometry'
import Graphics from './Graphics'
import Util, { logger } from './../common/Util'
import { Puzzle, PuzzleInfo, PieceShape } from './../common/GameCommon'
const log = logger('PuzzleGraphics.js')
async function createPuzzleTileBitmaps(img: ImageBitmap, tiles: Array<any>, info: PuzzleInfo) {
log.log('start createPuzzleTileBitmaps')
var tileSize = info.tileSize
var tileMarginWidth = info.tileMarginWidth
var tileDrawSize = info.tileDrawSize
var tileRatio = tileSize / 100.0
var curvyCoords = [
0, 0, 40, 15, 37, 5,
37, 5, 40, 0, 38, -5,
38, -5, 20, -20, 50, -20,
50, -20, 80, -20, 62, -5,
62, -5, 60, 0, 63, 5,
63, 5, 65, 15, 100, 0
];
const bitmaps = new Array(tiles.length)
const paths: Record<string, Path2D> = {}
function pathForShape(shape: PieceShape) {
const key = `${shape.top}${shape.right}${shape.left}${shape.bottom}`
if (paths[key]) {
return paths[key]
}
const path = new Path2D()
const topLeftEdge = { x: tileMarginWidth, y: tileMarginWidth }
const topRightEdge = Geometry.pointAdd(topLeftEdge, { x: tileSize, y: 0 })
const bottomRightEdge = Geometry.pointAdd(topRightEdge, { x: 0, y: tileSize })
const bottomLeftEdge = Geometry.pointSub(bottomRightEdge, { x: tileSize, y: 0 })
path.moveTo(topLeftEdge.x, topLeftEdge.y)
if (shape.top !== 0) {
for (let i = 0; i < curvyCoords.length / 6; i++) {
const p1 = Geometry.pointAdd(topLeftEdge, { x: curvyCoords[i * 6 + 0] * tileRatio, y: shape.top * curvyCoords[i * 6 + 1] * tileRatio })
const p2 = Geometry.pointAdd(topLeftEdge, { x: curvyCoords[i * 6 + 2] * tileRatio, y: shape.top * curvyCoords[i * 6 + 3] * tileRatio })
const p3 = Geometry.pointAdd(topLeftEdge, { x: curvyCoords[i * 6 + 4] * tileRatio, y: shape.top * curvyCoords[i * 6 + 5] * tileRatio })
path.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
}
} else {
path.lineTo(topRightEdge.x, topRightEdge.y)
}
if (shape.right !== 0) {
for (let i = 0; i < curvyCoords.length / 6; i++) {
const p1 = Geometry.pointAdd(topRightEdge, { x: -shape.right * curvyCoords[i * 6 + 1] * tileRatio, y: curvyCoords[i * 6 + 0] * tileRatio })
const p2 = Geometry.pointAdd(topRightEdge, { x: -shape.right * curvyCoords[i * 6 + 3] * tileRatio, y: curvyCoords[i * 6 + 2] * tileRatio })
const p3 = Geometry.pointAdd(topRightEdge, { x: -shape.right * curvyCoords[i * 6 + 5] * tileRatio, y: curvyCoords[i * 6 + 4] * tileRatio })
path.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
}
} else {
path.lineTo(bottomRightEdge.x, bottomRightEdge.y)
}
if (shape.bottom !== 0) {
for (let i = 0; i < curvyCoords.length / 6; i++) {
let p1 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 0] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 1] * tileRatio })
let p2 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 2] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 3] * tileRatio })
let p3 = Geometry.pointSub(bottomRightEdge, { x: curvyCoords[i * 6 + 4] * tileRatio, y: shape.bottom * curvyCoords[i * 6 + 5] * tileRatio })
path.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
}
} else {
path.lineTo(bottomLeftEdge.x, bottomLeftEdge.y)
}
if (shape.left !== 0) {
for (let i = 0; i < curvyCoords.length / 6; i++) {
let p1 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 1] * tileRatio, y: curvyCoords[i * 6 + 0] * tileRatio })
let p2 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 3] * tileRatio, y: curvyCoords[i * 6 + 2] * tileRatio })
let p3 = Geometry.pointSub(bottomLeftEdge, { x: -shape.left * curvyCoords[i * 6 + 5] * tileRatio, y: curvyCoords[i * 6 + 4] * tileRatio })
path.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
}
} else {
path.lineTo(topLeftEdge.x, topLeftEdge.y)
}
paths[key] = path
return path
}
const c = Graphics.createCanvas(tileDrawSize, tileDrawSize)
const ctx = c.getContext('2d') as CanvasRenderingContext2D
const c2 = Graphics.createCanvas(tileDrawSize, tileDrawSize)
const ctx2 = c2.getContext('2d') as CanvasRenderingContext2D
for (let t of tiles) {
const tile = Util.decodeTile(t)
const srcRect = srcRectByIdx(info, tile.idx)
const path = pathForShape(Util.decodeShape(info.shapes[tile.idx]))
ctx.clearRect(0, 0, tileDrawSize, tileDrawSize)
// stroke (slightly darker version of image)
// -----------------------------------------------------------
// -----------------------------------------------------------
ctx.save()
ctx.lineWidth = 2
ctx.stroke(path)
ctx.globalCompositeOperation = 'source-in'
ctx.drawImage(
img,
srcRect.x - tileMarginWidth,
srcRect.y - tileMarginWidth,
tileDrawSize,
tileDrawSize,
0,
0,
tileDrawSize,
tileDrawSize,
)
ctx.restore()
ctx.save()
ctx.globalCompositeOperation = 'source-in'
ctx.globalAlpha = .2
ctx.fillStyle = 'black'
ctx.fillRect(0,0, c.width, c.height)
ctx.restore()
// main image
// -----------------------------------------------------------
// -----------------------------------------------------------
ctx.save()
ctx.clip(path)
ctx.drawImage(
img,
srcRect.x - tileMarginWidth,
srcRect.y - tileMarginWidth,
tileDrawSize,
tileDrawSize,
0,
0,
tileDrawSize,
tileDrawSize,
)
ctx.restore()
// INSET SHADOW (bottom, right)
// -----------------------------------------------------------
// -----------------------------------------------------------
ctx.save()
ctx.clip(path)
ctx.strokeStyle = 'rgba(0,0,0,.4)'
ctx.lineWidth = 0
ctx.shadowColor = "black";
ctx.shadowBlur = 2;
ctx.shadowOffsetX = -1;
ctx.shadowOffsetY = -1;
ctx.stroke(path)
ctx.restore()
// INSET SHADOW (top, left)
// -----------------------------------------------------------
// -----------------------------------------------------------
ctx.save()
ctx.clip(path)
ctx.strokeStyle = 'rgba(255,255,255,.4)'
ctx.lineWidth = 0
ctx.shadowColor = "white";
ctx.shadowBlur = 2;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.stroke(path)
ctx.restore()
// Redraw the path (border) in the color of the
// tile, this makes the tile look more realistic
// -----------------------------------------------------------
// -----------------------------------------------------------
ctx2.clearRect(0, 0, tileDrawSize, tileDrawSize)
ctx2.save()
ctx2.lineWidth = 1
ctx2.stroke(path)
ctx2.globalCompositeOperation = 'source-in'
ctx2.drawImage(
img,
srcRect.x - tileMarginWidth,
srcRect.y - tileMarginWidth,
tileDrawSize,
tileDrawSize,
0,
0,
tileDrawSize,
tileDrawSize,
)
ctx2.restore()
ctx.drawImage(c2, 0, 0)
bitmaps[tile.idx] = await createImageBitmap(c)
}
log.log('end createPuzzleTileBitmaps')
return bitmaps
}
function srcRectByIdx(puzzleInfo: PuzzleInfo, idx: number) {
const c = Util.coordByTileIdx(puzzleInfo, idx)
return {
x: c.x * puzzleInfo.tileSize,
y: c.y * puzzleInfo.tileSize,
w: puzzleInfo.tileSize,
h: puzzleInfo.tileSize,
}
}
async function loadPuzzleBitmaps(puzzle: Puzzle) {
// load bitmap, to determine the original size of the image
const bmp = await Graphics.loadImageToBitmap(puzzle.info.imageUrl)
// creation of tile bitmaps
// then create the final puzzle bitmap
// NOTE: this can decrease OR increase in size!
const bmpResized = await Graphics.resizeBitmap(bmp, puzzle.info.width, puzzle.info.height)
return await createPuzzleTileBitmaps(bmpResized, puzzle.tiles, puzzle.info)
}
export default {
loadPuzzleBitmaps,
}

View file

@ -0,0 +1,37 @@
<template>
<div class="overlay connection-lost" v-if="show">
<div class="overlay-content" v-if="lostConnection">
<div> LOST CONNECTION </div>
<span class="btn" @click="$emit('reconnect')">Reconnect</span>
</div>
<div class="overlay-content" v-if="connecting">
<div>Connecting...</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Communication from './../Communication'
export default defineComponent({
name: 'connection-overlay',
emits: {
reconnect: null,
},
props: {
connectionState: Number,
},
computed: {
lostConnection (): boolean {
return this.connectionState === Communication.CONN_STATE_DISCONNECTED
},
connecting (): boolean {
return this.connectionState === Communication.CONN_STATE_CONNECTING
},
show (): boolean {
return !!(this.lostConnection || this.connecting)
},
}
})
</script>

View file

@ -0,0 +1,45 @@
<template>
<div class="game-teaser" :style="style">
<router-link class="game-info" :to="{ name: 'game', params: { id: game.id } }">
<span class="game-info-text">
🧩 {{game.tilesFinished}}/{{game.tilesTotal}}<br />
👥 {{game.players}}<br />
{{time(game.started, game.finished)}}<br />
</span>
</router-link>
<router-link v-if="false && game.hasReplay" class="game-replay" :to="{ name: 'replay', params: { id: game.id } }">
Watch replay
</router-link>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Time from './../../common/Time'
export default defineComponent({
name: 'game-teaser',
props: {
game: {
type: Object,
required: true,
},
},
computed: {
style (): object {
const url = this.game.imageUrl.replace('uploads/', 'uploads/r/') + '-375x210.webp'
return {
'background-image': `url("${url}")`,
}
},
},
methods: {
time(start: number, end: number) {
const icon = end ? '🏁' : '⏳'
const from = start;
const to = end || Time.timestamp()
const timeDiffStr = Time.timeDiffStr(from, to)
return `${icon} ${timeDiffStr}`
},
},
})
</script>

View file

@ -0,0 +1,27 @@
<template>
<div class="overlay transparent" @click="$emit('bgclick')">
<table class="overlay-content help" @click.stop="">
<tr><td> Move up:</td><td><div><kbd>W</kbd>/<kbd></kbd>/🖱</div></td></tr>
<tr><td> Move down:</td><td><div><kbd>S</kbd>/<kbd></kbd>/🖱</div></td></tr>
<tr><td> Move left:</td><td><div><kbd>A</kbd>/<kbd></kbd>/🖱</div></td></tr>
<tr><td> Move right:</td><td><div><kbd>D</kbd>/<kbd></kbd>/🖱</div></td></tr>
<tr><td></td><td><div>Move faster by holding <kbd>Shift</kbd></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>🖼 Toggle preview:</td><td><div><kbd>Space</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>
</table>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'help-overlay',
emits: {
bgclick: null,
},
})
</script>

View file

@ -0,0 +1,29 @@
<template>
<div class="imageteaser" :style="style" @click="onClick"></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'image-teaser',
props: {
image: {
type: Object,
required: true,
},
},
computed: {
style (): object {
const url = this.image.url.replace('uploads/', 'uploads/r/') + '-150x100.webp'
return {
'backgroundImage': `url("${url}")`,
}
},
},
methods: {
onClick() {
this.$emit('click')
},
},
})
</script>

View file

@ -0,0 +1,98 @@
<template>
<div>
<h1>New game</h1>
<table>
<tr>
<td><label>Pieces: </label></td>
<td><input type="text" v-model="tiles" /></td>
</tr>
<tr>
<td><label>Scoring: </label></td>
<td>
<label><input type="radio" v-model="scoreMode" value="1" /> Any (Score when pieces are connected to each other or on final location)</label>
<br />
<label><input type="radio" v-model="scoreMode" value="0" /> Final (Score when pieces are put to their final location)</label>
</td>
</tr>
<tr>
<td><label>Image: </label></td>
<td>
<span v-if="image">
<img :src="image.url" style="width: 150px;" />
or
<upload @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" />
</span>
<span v-else>
<upload @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" />
(or select from below)
</span>
</td>
</tr>
<tr>
<td colspan="2">
<button class="btn" :disabled="!canStartNewGame" @click="onNewGameClick">Start new game</button>
</td>
</tr>
</table>
<h1>Image lib</h1>
<div>
<image-teaser v-for="(i,idx) in images" :image="i" @click="image = i" :key="idx" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import GameCommon from './../../common/GameCommon'
import Upload from './../components/Upload.vue'
import ImageTeaser from './../components/ImageTeaser.vue'
export default defineComponent({
name: 'new-game-dialog',
components: {
Upload,
ImageTeaser,
},
props: {
images: Array,
},
emits: {
newGame: null,
},
data() {
return {
tiles: 1000,
image: '',
scoreMode: GameCommon.SCORE_MODE_ANY,
}
},
methods: {
// TODO: ts type UploadedImage
mediaImgUploaded(data: any) {
this.image = data.image
},
canStartNewGame () {
if (!this.tilesInt || !this.image || ![0, 1].includes(this.scoreModeInt)) {
return false
}
return true
},
onNewGameClick() {
this.$emit('newGame', {
tiles: this.tilesInt,
image: this.image,
scoreMode: this.scoreModeInt,
})
},
},
computed: {
scoreModeInt (): number {
return parseInt(`${this.scoreMode}`, 10)
},
tilesInt (): number {
return parseInt(`${this.tiles}`, 10)
},
},
})
</script>

View file

@ -0,0 +1,27 @@
<template>
<div class="overlay" @click="$emit('bgclick')">
<div class="preview">
<div class="img" :style="previewStyle"></div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'preview-overlay',
props: {
img: String,
},
emits: {
bgclick: null,
},
computed: {
previewStyle (): object {
return {
backgroundImage: `url('${this.img}')`,
}
},
},
})
</script>

View file

@ -0,0 +1,45 @@
<template>
<div class="timer">
<div>
🧩 {{piecesDone}}/{{piecesTotal}}
</div>
<div>
{{icon}} {{durationStr}}
</div>
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Time from './../../common/Time'
export default defineComponent({
name: 'puzzle-status',
props: {
finished: {
type: Boolean,
required: true,
},
duration: {
type: Number,
required: true,
},
piecesDone: {
type: Number,
required: true,
},
piecesTotal: {
type: Number,
required: true,
},
},
computed: {
icon (): string {
return this.finished ? '🏁' : '⏳'
},
durationStr (): string {
return Time.durationStr(this.duration)
},
}
})
</script>

View file

@ -0,0 +1,46 @@
<template>
<div class="scores">
<div>Scores</div>
<table>
<tr v-for="(p, idx) in actives" :key="idx" :style="{color: p.color}">
<td></td>
<td>{{p.name}}</td>
<td>{{p.points}}</td>
</tr>
<tr v-for="(p, idx) in idles" :key="idx" :style="{color: p.color}">
<td>💤</td>
<td>{{p.name}}</td>
<td>{{p.points}}</td>
</tr>
</table>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: "scores",
props: {
activePlayers: {
type: Array,
required: true,
},
idlePlayers: {
type: Array,
required: true,
},
},
computed: {
actives (): Array<any> {
// TODO: dont sort in place
this.activePlayers.sort((a: any, b: any) => b.points - a.points)
return this.activePlayers
},
idles (): Array<any> {
// TODO: dont sort in place
this.idlePlayers.sort((a: any, b: any) => b.points - a.points)
return this.idlePlayers
},
},
})
</script>

View file

@ -0,0 +1,38 @@
<template>
<div class="overlay transparent" @click="$emit('bgclick')">
<table class="overlay-content settings" @click.stop="">
<tr>
<td><label>Background: </label></td>
<td><input type="color" v-model="modelValue.background" /></td>
</tr>
<tr>
<td><label>Color: </label></td>
<td><input type="color" v-model="modelValue.color" /></td>
</tr>
<tr>
<td><label>Name: </label></td>
<td><input type="text" maxLength="16" v-model="modelValue.name" /></td>
</tr>
</table>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'settings-overlay',
emits: {
bgclick: null,
'update:modelValue': null,
},
props: {
modelValue: Object,
},
created () {
// TODO: ts type PlayerSettings
this.$watch('modelValue', (val: any) => {
this.$emit('update:modelValue', val)
}, { deep: true })
},
})
</script>

View file

@ -0,0 +1,33 @@
<template>
<label>
<input type="file" style="display: none" @change="upload" :accept="accept" />
<span class="btn">{{label || 'Upload File'}}</span>
</label>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'upload',
props: {
accept: String,
label: String,
},
methods: {
async upload(evt: Event) {
const target = (evt.target as HTMLInputElement)
if (!target.files) return;
const file = target.files[0]
if (!file) return;
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/upload', {
method: 'post',
body: formData,
})
const j = await res.json()
this.$emit('uploaded', j)
},
}
})
</script>

722
src/frontend/game.ts Normal file
View file

@ -0,0 +1,722 @@
"use strict"
import {run} from './gameloop'
import Camera from './Camera'
import Graphics from './Graphics'
import Debug from './Debug'
import Communication from './Communication'
import Util from './../common/Util'
import PuzzleGraphics from './PuzzleGraphics'
import Game, { Player, Piece } from './../common/GameCommon'
import fireworksController from './Fireworks'
import Protocol from '../common/Protocol'
import Time from '../common/Time'
declare global {
interface Window {
DEBUG?: boolean
}
}
// @see https://stackoverflow.com/a/59906630/392905
type ArrayLengthMutationKeys = 'splice' | 'push' | 'pop' | 'shift' | 'unshift' | number
type ArrayItems<T extends Array<any>> = T extends Array<infer TItems> ? TItems : never
type FixedLengthArray<T extends any[]> =
Pick<T, Exclude<keyof T, ArrayLengthMutationKeys>>
& { [Symbol.iterator]: () => IterableIterator< ArrayItems<T> > }
// @ts-ignore
const images = import.meta.globEager('./*.png')
export const MODE_PLAY = 'play'
export const MODE_REPLAY = 'replay'
let PIECE_VIEW_FIXED = true
let PIECE_VIEW_LOOSE = true
interface Point {
x: number
y: number
}
interface Hud {
setActivePlayers: (v: Array<any>) => void
setIdlePlayers: (v: Array<any>) => void
setFinished: (v: boolean) => void
setDuration: (v: number) => void
setPiecesDone: (v: number) => void
setPiecesTotal: (v: number) => void
setConnectionState: (v: number) => void
togglePreview: () => void
setReplaySpeed?: (v: number) => void
setReplayPaused?: (v: boolean) => void
}
interface Replay {
log: Array<any>
logIdx: number
speeds: Array<number>
speedIdx: number
paused: boolean
lastRealTs: number
lastGameTs: number
gameStartTs: number
}
const shouldDrawPiece = (piece: Piece) => {
if (piece.owner === -1) {
return PIECE_VIEW_FIXED
}
return PIECE_VIEW_LOOSE
}
let RERENDER = true
function addCanvasToDom(TARGET_EL: HTMLElement, canvas: HTMLCanvasElement) {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
TARGET_EL.appendChild(canvas)
window.addEventListener('resize', () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
RERENDER = true
})
return canvas
}
function EventAdapter (canvas: HTMLCanvasElement, window: any, viewport: any) {
let events: Array<Array<any>> = []
let KEYS_ON = true
let LEFT = false
let RIGHT = false
let UP = false
let DOWN = false
let ZOOM_IN = false
let ZOOM_OUT = false
let SHIFT = false
const toWorldPoint = (x: number, y: number) => {
const pos = viewport.viewportToWorld({x, y})
return [pos.x, pos.y]
}
const mousePos = (ev: MouseEvent) => toWorldPoint(ev.offsetX, ev.offsetY)
const canvasCenter = () => toWorldPoint(canvas.width / 2, canvas.height / 2)
const key = (state: boolean, ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.key === 'Shift') {
SHIFT = state
} else if (ev.key === 'ArrowUp' || ev.key === 'w' || ev.key === 'W') {
UP = state
} else if (ev.key === 'ArrowDown' || ev.key === 's' || ev.key === 'S') {
DOWN = state
} else if (ev.key === 'ArrowLeft' || ev.key === 'a' || ev.key === 'A') {
LEFT = state
} else if (ev.key === 'ArrowRight' || ev.key === 'd' || ev.key === 'D') {
RIGHT = state
} else if (ev.key === 'q') {
ZOOM_OUT = state
} else if (ev.key === 'e') {
ZOOM_IN = state
}
}
canvas.addEventListener('mousedown', (ev) => {
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_DOWN, ...mousePos(ev)])
}
})
canvas.addEventListener('mouseup', (ev) => {
if (ev.button === 0) {
addEvent([Protocol.INPUT_EV_MOUSE_UP, ...mousePos(ev)])
}
})
canvas.addEventListener('mousemove', (ev) => {
addEvent([Protocol.INPUT_EV_MOUSE_MOVE, ...mousePos(ev)])
})
canvas.addEventListener('wheel', (ev) => {
if (viewport.canZoom(ev.deltaY < 0 ? 'in' : 'out')) {
const evt = ev.deltaY < 0
? Protocol.INPUT_EV_ZOOM_IN
: Protocol.INPUT_EV_ZOOM_OUT
addEvent([evt, ...mousePos(ev)])
}
})
window.addEventListener('keydown', (ev: KeyboardEvent) => key(true, ev))
window.addEventListener('keyup', (ev: KeyboardEvent) => key(false, ev))
window.addEventListener('keypress', (ev: KeyboardEvent) => {
if (!KEYS_ON) {
return
}
if (ev.key === ' ') {
addEvent([Protocol.INPUT_EV_TOGGLE_PREVIEW])
}
if (ev.key === 'F' || ev.key === 'f') {
PIECE_VIEW_FIXED = !PIECE_VIEW_FIXED
RERENDER = true
}
if (ev.key === 'G' || ev.key === 'g') {
PIECE_VIEW_LOOSE = !PIECE_VIEW_LOOSE
RERENDER = true
}
})
const addEvent = (event: Array<any>) => {
events.push(event)
}
const consumeAll = () => {
if (events.length === 0) {
return []
}
const all = events.slice()
events = []
return all
}
const createKeyEvents = () => {
const amount = SHIFT ? 20 : 10
const x = (LEFT ? amount : 0) - (RIGHT ? amount : 0)
const y = (UP ? amount : 0) - (DOWN ? amount : 0)
if (x !== 0 || y !== 0) {
addEvent([Protocol.INPUT_EV_MOVE, x, y])
}
if (ZOOM_IN && ZOOM_OUT) {
// cancel each other out
} else if (ZOOM_IN) {
if (viewport.canZoom('in')) {
addEvent([Protocol.INPUT_EV_ZOOM_IN, ...canvasCenter()])
}
} else if (ZOOM_OUT) {
if (viewport.canZoom('out')) {
addEvent([Protocol.INPUT_EV_ZOOM_OUT, ...canvasCenter()])
}
}
}
const setHotkeys = (state: boolean) => {
KEYS_ON = state
}
return {
addEvent,
consumeAll,
createKeyEvents,
setHotkeys,
}
}
export async function main(
gameId: string,
clientId: string,
wsAddress: string,
MODE: string,
TARGET_EL: HTMLElement,
HUD: Hud
) {
if (typeof window.DEBUG === 'undefined') window.DEBUG = false
const shouldDrawPlayerText = (player: Player) => {
return MODE === MODE_REPLAY || player.id !== clientId
}
const cursorGrab = await Graphics.loadImageToBitmap(images['./grab.png'].default)
const cursorHand = await Graphics.loadImageToBitmap(images['./hand.png'].default)
const cursorGrabMask = await Graphics.loadImageToBitmap(images['./grab_mask.png'].default)
const cursorHandMask = await Graphics.loadImageToBitmap(images['./hand_mask.png'].default)
// all cursors must be of the same dimensions
const CURSOR_W = cursorGrab.width
const CURSOR_W_2 = Math.round(CURSOR_W / 2)
const CURSOR_H = cursorGrab.height
const CURSOR_H_2 = Math.round(CURSOR_H / 2)
const cursors: Record<string, ImageBitmap> = {}
const getPlayerCursor = async (p: Player) => {
const key = p.color + ' ' + p.d
if (!cursors[key]) {
const cursor = p.d ? cursorGrab : cursorHand
if (p.color) {
const mask = p.d ? cursorGrabMask : cursorHandMask
cursors[key] = await Graphics.colorize(cursor, mask, p.color)
} else {
cursors[key] = cursor
}
}
return cursors[key]
}
// Create a canvas and attach adapters to it so we can work with it
const canvas = addCanvasToDom(TARGET_EL, Graphics.createCanvas())
// stuff only available in replay mode...
// TODO: refactor
const REPLAY: Replay = {
log: [],
logIdx: 0,
speeds: [0.5, 1, 2, 5, 10, 20, 50],
speedIdx: 1,
paused: false,
lastRealTs: 0,
lastGameTs: 0,
gameStartTs: 0,
}
Communication.onConnectionStateChange((state) => {
HUD.setConnectionState(state)
})
let TIME: () => number = () => 0
const connect = async () => {
if (MODE === MODE_PLAY) {
const game = await Communication.connect(wsAddress, gameId, clientId)
const gameObject = Util.decodeGame(game)
Game.setGame(gameObject.id, gameObject)
TIME = () => Time.timestamp()
} else if (MODE === MODE_REPLAY) {
// TODO: change how replay connect is done...
const replay: {game: any, log: Array<any>} = await Communication.connectReplay(wsAddress, gameId, clientId)
const gameObject = Util.decodeGame(replay.game)
Game.setGame(gameObject.id, gameObject)
REPLAY.log = replay.log
REPLAY.lastRealTs = Time.timestamp()
REPLAY.gameStartTs = parseInt(REPLAY.log[0][REPLAY.log[0].length - 2], 10)
REPLAY.lastGameTs = REPLAY.gameStartTs
TIME = () => REPLAY.lastGameTs
} else {
throw '[ 2020-12-22 MODE invalid, must be play|replay ]'
}
// rerender after (re-)connect
RERENDER = true
}
await connect()
const TILE_DRAW_OFFSET = Game.getTileDrawOffset(gameId)
const TILE_DRAW_SIZE = Game.getTileDrawSize(gameId)
const PUZZLE_WIDTH = Game.getPuzzleWidth(gameId)
const PUZZLE_HEIGHT = Game.getPuzzleHeight(gameId)
const TABLE_WIDTH = Game.getTableWidth(gameId)
const TABLE_HEIGHT = Game.getTableHeight(gameId)
const BOARD_POS = {
x: (TABLE_WIDTH - PUZZLE_WIDTH) / 2,
y: (TABLE_HEIGHT - PUZZLE_HEIGHT) / 2
}
const BOARD_DIM = {
w: PUZZLE_WIDTH,
h: PUZZLE_HEIGHT,
}
const PIECE_DIM = {
w: TILE_DRAW_SIZE,
h: TILE_DRAW_SIZE,
}
const bitmaps = await PuzzleGraphics.loadPuzzleBitmaps(Game.getPuzzle(gameId))
const fireworks = new fireworksController(canvas, Game.getRng(gameId))
fireworks.init()
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
canvas.classList.add('loaded')
// initialize some view data
// this global data will change according to input events
const viewport = Camera()
// center viewport
viewport.move(
-(TABLE_WIDTH - canvas.width) /2,
-(TABLE_HEIGHT - canvas.height) /2
)
const evts = EventAdapter(canvas, window, viewport)
const previewImageUrl = Game.getImageUrl(gameId)
const updateTimerElements = () => {
const startTs = Game.getStartTs(gameId)
const finishTs = Game.getFinishTs(gameId)
const ts = TIME()
HUD.setFinished(!!(finishTs))
HUD.setDuration((finishTs || ts) - startTs)
}
updateTimerElements()
HUD.setPiecesDone(Game.getFinishedTileCount(gameId))
HUD.setPiecesTotal(Game.getTileCount(gameId))
const ts = TIME()
HUD.setActivePlayers(Game.getActivePlayers(gameId, ts))
HUD.setIdlePlayers(Game.getIdlePlayers(gameId, ts))
const longFinished = !! Game.getFinishTs(gameId)
let finished = longFinished
const justFinished = () => finished && !longFinished
const playerBgColor = () => {
return (Game.getPlayerBgColor(gameId, clientId)
|| localStorage.getItem('bg_color')
|| '#222222')
}
const playerColor = () => {
return (Game.getPlayerColor(gameId, clientId)
|| localStorage.getItem('player_color')
|| '#ffffff')
}
const playerName = () => {
return (Game.getPlayerName(gameId, clientId)
|| localStorage.getItem('player_name')
|| 'anon')
}
const doSetSpeedStatus = () => {
if (HUD.setReplaySpeed) {
HUD.setReplaySpeed(REPLAY.speeds[REPLAY.speedIdx])
}
if (HUD.setReplayPaused) {
HUD.setReplayPaused(REPLAY.paused)
}
}
const replayOnSpeedUp = () => {
if (REPLAY.speedIdx + 1 < REPLAY.speeds.length) {
REPLAY.speedIdx++
doSetSpeedStatus()
}
}
const replayOnSpeedDown = () => {
if (REPLAY.speedIdx >= 1) {
REPLAY.speedIdx--
doSetSpeedStatus()
}
}
const replayOnPauseToggle = () => {
REPLAY.paused = !REPLAY.paused
doSetSpeedStatus()
}
if (MODE === MODE_PLAY) {
setInterval(updateTimerElements, 1000)
} else if (MODE === MODE_REPLAY) {
doSetSpeedStatus()
}
if (MODE === MODE_PLAY) {
Communication.onServerChange((msg) => {
const msgType = msg[0]
const evClientId = msg[1]
const evClientSeq = msg[2]
const evChanges = msg[3]
for (const [changeType, changeData] of evChanges) {
switch (changeType) {
case Protocol.CHANGE_PLAYER: {
const p = Util.decodePlayer(changeData)
if (p.id !== clientId) {
Game.setPlayer(gameId, p.id, p)
RERENDER = true
}
} break;
case Protocol.CHANGE_TILE: {
const t = Util.decodeTile(changeData)
Game.setTile(gameId, t.idx, t)
RERENDER = true
} break;
case Protocol.CHANGE_DATA: {
Game.setPuzzleData(gameId, changeData)
RERENDER = true
} break;
}
}
finished = !! Game.getFinishTs(gameId)
})
} else if (MODE === MODE_REPLAY) {
// no external communication for replay mode,
// only the REPLAY.log is relevant
let inter = setInterval(() => {
const realTs = Time.timestamp()
if (REPLAY.paused) {
REPLAY.lastRealTs = realTs
return
}
const timePassedReal = realTs - REPLAY.lastRealTs
const timePassedGame = timePassedReal * REPLAY.speeds[REPLAY.speedIdx]
const maxGameTs = REPLAY.lastGameTs + timePassedGame
do {
if (REPLAY.paused) {
break
}
const nextIdx = REPLAY.logIdx + 1
if (nextIdx >= REPLAY.log.length) {
clearInterval(inter)
break
}
const logEntry = REPLAY.log[nextIdx]
const nextTs = REPLAY.gameStartTs + logEntry[logEntry.length - 1]
if (nextTs > maxGameTs) {
break
}
const entryWithTs = logEntry.slice()
if (entryWithTs[0] === Protocol.LOG_ADD_PLAYER) {
const playerId = entryWithTs[1]
Game.addPlayer(gameId, playerId, nextTs)
RERENDER = true
} else if (entryWithTs[0] === Protocol.LOG_UPDATE_PLAYER) {
const playerId = Game.getPlayerIdByIndex(gameId, entryWithTs[1])
Game.addPlayer(gameId, playerId, nextTs)
RERENDER = true
} else if (entryWithTs[0] === Protocol.LOG_HANDLE_INPUT) {
const playerId = Game.getPlayerIdByIndex(gameId, entryWithTs[1])
const input = entryWithTs[2]
Game.handleInput(gameId, playerId, input, nextTs)
RERENDER = true
}
REPLAY.logIdx = nextIdx
} while (true)
REPLAY.lastRealTs = realTs
REPLAY.lastGameTs = maxGameTs
updateTimerElements()
}, 50)
}
let _last_mouse_down: Point|null = null
const onUpdate = () => {
// handle key downs once per onUpdate
// this will create Protocol.INPUT_EV_MOVE events if something
// relevant is pressed
evts.createKeyEvents()
for (const evt of evts.consumeAll()) {
if (MODE === MODE_PLAY) {
// LOCAL ONLY CHANGES
// -------------------------------------------------------------
const type = evt[0]
if (type === Protocol.INPUT_EV_MOVE) {
const diffX = evt[1]
const diffY = evt[2]
RERENDER = true
viewport.move(diffX, diffY)
} else if (type === Protocol.INPUT_EV_MOUSE_MOVE) {
if (_last_mouse_down && !Game.getFirstOwnedTile(gameId, clientId)) {
// move the cam
const pos = { x: evt[1], y: evt[2] }
const mouse = viewport.worldToViewport(pos)
const diffX = Math.round(mouse.x - _last_mouse_down.x)
const diffY = Math.round(mouse.y - _last_mouse_down.y)
RERENDER = true
viewport.move(diffX, diffY)
_last_mouse_down = mouse
}
} else if (type === Protocol.INPUT_EV_MOUSE_DOWN) {
const pos = { x: evt[1], y: evt[2] }
_last_mouse_down = viewport.worldToViewport(pos)
} else if (type === Protocol.INPUT_EV_MOUSE_UP) {
_last_mouse_down = null
} else if (type === Protocol.INPUT_EV_ZOOM_IN) {
const pos = { x: evt[1], y: evt[2] }
RERENDER = true
viewport.zoom('in', viewport.worldToViewport(pos))
} else if (type === Protocol.INPUT_EV_ZOOM_OUT) {
const pos = { x: evt[1], y: evt[2] }
RERENDER = true
viewport.zoom('out', viewport.worldToViewport(pos))
} else if (type === Protocol.INPUT_EV_TOGGLE_PREVIEW) {
HUD.togglePreview()
}
// LOCAL + SERVER CHANGES
// -------------------------------------------------------------
const ts = TIME()
const changes = Game.handleInput(gameId, clientId, evt, ts)
if (changes.length > 0) {
RERENDER = true
}
Communication.sendClientEvent(evt)
} else if (MODE === MODE_REPLAY) {
// LOCAL ONLY CHANGES
// -------------------------------------------------------------
const type = evt[0]
if (type === Protocol.INPUT_EV_MOVE) {
const diffX = evt[1]
const diffY = evt[2]
RERENDER = true
viewport.move(diffX, diffY)
} else if (type === Protocol.INPUT_EV_MOUSE_MOVE) {
if (_last_mouse_down) {
// move the cam
const pos = { x: evt[1], y: evt[2] }
const mouse = viewport.worldToViewport(pos)
const diffX = Math.round(mouse.x - _last_mouse_down.x)
const diffY = Math.round(mouse.y - _last_mouse_down.y)
RERENDER = true
viewport.move(diffX, diffY)
_last_mouse_down = mouse
}
} else if (type === Protocol.INPUT_EV_MOUSE_DOWN) {
const pos = { x: evt[1], y: evt[2] }
_last_mouse_down = viewport.worldToViewport(pos)
} else if (type === Protocol.INPUT_EV_MOUSE_UP) {
_last_mouse_down = null
} else if (type === Protocol.INPUT_EV_ZOOM_IN) {
const pos = { x: evt[1], y: evt[2] }
RERENDER = true
viewport.zoom('in', viewport.worldToViewport(pos))
} else if (type === Protocol.INPUT_EV_ZOOM_OUT) {
const pos = { x: evt[1], y: evt[2] }
RERENDER = true
viewport.zoom('out', viewport.worldToViewport(pos))
} else if (type === Protocol.INPUT_EV_TOGGLE_PREVIEW) {
HUD.togglePreview()
}
}
}
finished = !! Game.getFinishTs(gameId)
if (justFinished()) {
fireworks.update()
RERENDER = true
}
}
const onRender = async () => {
if (!RERENDER) {
return
}
const ts = TIME()
let pos
let dim
let bmp
if (window.DEBUG) Debug.checkpoint_start(0)
// CLEAR CTX
// ---------------------------------------------------------------
ctx.fillStyle = playerBgColor()
ctx.fillRect(0, 0, canvas.width, canvas.height)
if (window.DEBUG) Debug.checkpoint('clear done')
// ---------------------------------------------------------------
// DRAW BOARD
// ---------------------------------------------------------------
pos = viewport.worldToViewportRaw(BOARD_POS)
dim = viewport.worldDimToViewportRaw(BOARD_DIM)
ctx.fillStyle = 'rgba(255, 255, 255, .3)'
ctx.fillRect(pos.x, pos.y, dim.w, dim.h)
if (window.DEBUG) Debug.checkpoint('board done')
// ---------------------------------------------------------------
// DRAW TILES
// ---------------------------------------------------------------
const tiles = Game.getTilesSortedByZIndex(gameId)
if (window.DEBUG) Debug.checkpoint('get tiles done')
dim = viewport.worldDimToViewportRaw(PIECE_DIM)
for (const tile of tiles) {
if (!shouldDrawPiece(tile)) {
continue
}
bmp = bitmaps[tile.idx]
pos = viewport.worldToViewportRaw({
x: TILE_DRAW_OFFSET + tile.pos.x,
y: TILE_DRAW_OFFSET + tile.pos.y,
})
ctx.drawImage(bmp,
0, 0, bmp.width, bmp.height,
pos.x, pos.y, dim.w, dim.h
)
}
if (window.DEBUG) Debug.checkpoint('tiles done')
// ---------------------------------------------------------------
// DRAW PLAYERS
// ---------------------------------------------------------------
const texts: Array<FixedLengthArray<[string, number, number]>> = []
// Cursors
for (const p of Game.getActivePlayers(gameId, ts)) {
bmp = await getPlayerCursor(p)
pos = viewport.worldToViewport(p)
ctx.drawImage(bmp, pos.x - CURSOR_W_2, pos.y - CURSOR_H_2)
if (shouldDrawPlayerText(p)) {
// performance:
// not drawing text directly here, to have less ctx
// switches between drawImage and fillTxt
texts.push([`${p.name} (${p.points})`, pos.x, pos.y + CURSOR_H])
}
}
// Names
ctx.fillStyle = 'white'
ctx.textAlign = 'center'
for (const [txt, x, y] of texts) {
ctx.fillText(txt, x, y)
}
if (window.DEBUG) Debug.checkpoint('players done')
// propagate HUD changes
// ---------------------------------------------------------------
HUD.setActivePlayers(Game.getActivePlayers(gameId, ts))
HUD.setIdlePlayers(Game.getIdlePlayers(gameId, ts))
HUD.setPiecesDone(Game.getFinishedTileCount(gameId))
if (window.DEBUG) Debug.checkpoint('HUD done')
// ---------------------------------------------------------------
if (justFinished()) {
fireworks.render()
}
RERENDER = false
}
run({
update: onUpdate,
render: onRender,
})
return {
setHotkeys: (state: boolean) => {
evts.setHotkeys(state)
},
onBgChange: (value: string) => {
localStorage.setItem('bg_color', value)
evts.addEvent([Protocol.INPUT_EV_BG_COLOR, value])
},
onColorChange: (value: string) => {
localStorage.setItem('player_color', value)
evts.addEvent([Protocol.INPUT_EV_PLAYER_COLOR, value])
},
onNameChange: (value: string) => {
localStorage.setItem('player_name', value)
evts.addEvent([Protocol.INPUT_EV_PLAYER_NAME, value])
},
replayOnSpeedUp,
replayOnSpeedDown,
replayOnPauseToggle,
previewImageUrl,
player: {
background: playerBgColor(),
color: playerColor(),
name: playerName(),
},
disconnect: Communication.disconnect,
connect: connect,
}
}

39
src/frontend/gameloop.ts Normal file
View file

@ -0,0 +1,39 @@
"use strict"
interface GameLoopOptions {
fps?: number
slow?: number
update: (step: number) => any
render: (passed: number) => any
}
export const run = (options: GameLoopOptions) => {
const fps = options.fps || 60
const slow = options.slow || 1
const update = options.update
const render = options.render
const raf = window.requestAnimationFrame
const step = 1 / fps
const slowStep = slow * step
let now
let dt = 0
let last = window.performance.now()
const frame = () => {
now = window.performance.now()
dt = dt + Math.min(1, (now - last) / 1000) // duration capped at 1.0 seconds
while (dt > slowStep) {
dt = dt - slowStep
update(step)
}
render(dt / slow)
last = now
raf(frame)
}
raf(frame)
}
export default {
run
}

BIN
src/frontend/grab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

BIN
src/frontend/grab_mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

BIN
src/frontend/hand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

BIN
src/frontend/hand_mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

10
src/frontend/index.html Normal file
View file

@ -0,0 +1,10 @@
<html>
<head>
<link rel="stylesheet" href="/style.css" />
<title>🧩 jigsaw.hyottoko.club</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>

46
src/frontend/main.ts Normal file
View file

@ -0,0 +1,46 @@
import * as VueRouter from 'vue-router'
import * as Vue from 'vue'
import App from './App.vue'
import Index from './views/Index.vue'
import NewGame from './views/NewGame.vue'
import Game from './views/Game.vue'
import Replay from './views/Replay.vue'
import Util from './../common/Util'
(async () => {
const res = await fetch(`/api/conf`)
const conf = await res.json()
function initme() {
let ID = localStorage.getItem('ID')
if (!ID) {
ID = Util.uniqId()
localStorage.setItem('ID', ID)
}
return ID
}
const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(),
routes: [
{ name: 'index', path: '/', component: Index },
{ name: 'new-game', path: '/new-game', component: NewGame },
{ name: 'game', path: '/g/:id', component: Game },
{ name: 'replay', path: '/replay/:id', component: Replay },
],
})
router.beforeEach((to, from) => {
if (from.name) {
document.documentElement.classList.remove(`view-${String(from.name)}`)
}
document.documentElement.classList.add(`view-${String(to.name)}`)
})
const app = Vue.createApp(App)
app.config.globalProperties.$config = conf
app.config.globalProperties.$clientId = initme()
app.use(router)
app.mount('#app')
})()

5
src/frontend/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

280
src/frontend/style.css Normal file
View file

@ -0,0 +1,280 @@
/* TODO: clean up / split ingame vs pregame */
:root {
--main-color: #c1b19f;
--link-color: #808db0;
--link-hover-color: #c5cfeb;
--highlight-color: #dd7e7e;
--input-bg-color: #262523;
--bg-color: rgba(0,0,0,.7);
}
html,
body {
margin: 0;
background: #2b2b2b;
color: var(--main-color);
height: 100%;
}
* {
font-family: monospace;
font-size: 15px;
}
h1, h2, h3, h4 {
font-size: 20px;
}
a {
color: var(--link-color);
text-decoration: none;
}
a:hover {
color: var(--link-hover-color);
}
td, th {
vertical-align: top;
}
.btn {
display: inline-block;
background: var(--input-bg-color);
color: var(--link-color);
border: solid 1px black;
padding: 5px 10px;
box-shadow: 1px 1px 2px rgba(0,0,0,.5), 0 0 1px rgba(150,150,150,.4) inset;
border-radius: 4px;
user-select: none;
}
.btn:hover {
background: #2f2e2c;
color: var(--link-hover-color);
border: solid 1px #111;
box-shadow: 0 0 1px rgba(150,150,150,.4) inset;
cursor: pointer;
}
.btn:disabled {
background: #2f2e2c;
color: #8c4747 !important;
border: solid 1px #111;
box-shadow: 0 0 1px rgba(150,150,150,.4) inset;
cursor: not-allowed;
}
input {
background: #333230;
border-radius: 4px;
color: var(--main-color);
padding: 6px 10px;
border: solid 1px black;
box-shadow: 0 0 3px rgba(0, 0,0,0.3) inset;
}
input:focus {
border: solid 1px #686767;
background: var(--input-bg-color);
}
/* ingame */
.scores {
position: absolute;
right: 0;
top: 0;
background: var(--bg-color);
padding: 5px;
border: solid 1px black;
box-shadow: 0 0 10px 0 rgba(0,0,0,.7);
}
.timer {
position: absolute;
left: 0;
top: 0;
background: var(--bg-color);
padding: 5px;
border: solid 1px black;
box-shadow: 0 0 10px 0 rgba(0,0,0,.7);
}
.menu {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
background: var(--bg-color);
padding: 5px;
border: solid 1px black;
box-shadow: 0 0 10px 0 rgba(0,0,0,.7);
z-index: 2;
}
.closed {
display: none;
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
background: var(--bg-color);
}
.overlay.transparent {
background: transparent;
}
.overlay-content {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
background: var(--bg-color);
padding: 5px;
border: solid 1px black;
box-shadow: 0 0 10px 0 rgba(0,0,0,.7);
z-index: 1;
}
.connection-lost .overlay-content {
padding: 20px;
text-align: center;
}
.preview {
position: absolute;
top: 20px;
left: 20px;
bottom: 20px;
right: 20px;
}
.preview .img {
height: 100%;
width: 100%;
position: absolute;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.menu .opener {
display: inline-block;
margin-right: 10px;
color: var(--link-color);
}
.menu .opener:last-child {
margin-right: 0;
}
.menu .opener:hover {
color: var(--link-hover-color);
cursor: pointer;
}
canvas.loaded {
cursor: none;
}
kbd {
background-color: #eee;
border-radius: 3px;
border: 1px solid #b4b4b4;
box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset;
color: #333;
display: inline-block;
font-size: .85em;
font-weight: 700;
line-height: 1;
padding: 2px 4px;
white-space: nowrap;
}
/* pre-game stuff */
.nav {
list-style: none;
padding: 0;
}
.nav li {
display: inline-block;
margin-right: 1em;
}
.image-list {
overflow: scroll;
}
.image-list-inner {
white-space: nowrap;
}
.imageteaser {
width: 150px;
height: 100px;
display: inline-block;
margin: 5px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-color: #222;
cursor: pointer;
}
.game-teaser-wrap {
display: inline-block;
width: 20%;
padding: 5px;
box-sizing: border-box;
}
.game-teaser {
display: block;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
position: relative;
padding-top: 56.25%;
width: 100%;
background-color: #222222;
}
.game-info {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
}
.game-info-text {
position: absolute;
top: 0;
background: var(--bg-color);
padding: 5px;
}
.game-replay {
position: absolute;
top: 0;
right: 0;
}
html.view-game { overflow: hidden; }
html.view-game body { overflow: hidden; }
html.view-replay { overflow: hidden; }
html.view-replay body { overflow: hidden; }
html.view-replay canvas { cursor: grab; }

140
src/frontend/views/Game.vue Normal file
View file

@ -0,0 +1,140 @@
<template>
<div id="game">
<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" />
<help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" />
<connection-overlay
:connectionState="connectionState"
@reconnect="reconnect"
/>
<puzzle-status
:finished="finished"
:duration="duration"
:piecesDone="piecesDone"
:piecesTotal="piecesTotal"
/>
<div class="menu">
<div class="tabs">
<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('settings', true)">🛠 Settings</div>
<div class="opener" @click="toggle('help', true)"> Help</div>
</div>
</div>
<scores :activePlayers="activePlayers" :idlePlayers="idlePlayers" />
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import Scores from './../components/Scores.vue'
import PuzzleStatus from './../components/PuzzleStatus.vue'
import SettingsOverlay from './../components/SettingsOverlay.vue'
import PreviewOverlay from './../components/PreviewOverlay.vue'
import ConnectionOverlay from './../components/ConnectionOverlay.vue'
import HelpOverlay from './../components/HelpOverlay.vue'
import { main, MODE_PLAY } from './../game'
export default defineComponent({
name: 'game',
components: {
PuzzleStatus,
Scores,
SettingsOverlay,
PreviewOverlay,
ConnectionOverlay,
HelpOverlay,
},
data() {
return {
// TODO: ts Array<Player> type
activePlayers: [] as PropType<Array<any>>,
idlePlayers: [] as PropType<Array<any>>,
finished: false,
duration: 0,
piecesDone: 0,
piecesTotal: 0,
overlay: '',
connectionState: 0,
g: {
player: {
background: '',
color: '',
name: '',
},
previewImageUrl: '',
setHotkeys: (v: boolean) => {},
onBgChange: (v: string) => {},
onColorChange: (v: string) => {},
onNameChange: (v: string) => {},
disconnect: () => {},
connect: () => {},
},
}
},
async mounted() {
if (!this.$route.params.id) {
return
}
this.$watch(() => this.g.player.background, (value: string) => {
this.g.onBgChange(value)
})
this.$watch(() => this.g.player.color, (value: string) => {
this.g.onColorChange(value)
})
this.$watch(() => this.g.player.name, (value: string) => {
this.g.onNameChange(value)
})
this.g = await main(
`${this.$route.params.id}`,
// @ts-ignore
this.$clientId,
// @ts-ignore
this.$config.WS_ADDRESS,
MODE_PLAY,
this.$el,
{
setActivePlayers: (v: Array<any>) => { this.activePlayers = v },
setIdlePlayers: (v: Array<any>) => { this.idlePlayers = v },
setFinished: (v: boolean) => { this.finished = v },
setDuration: (v: number) => { this.duration = v },
setPiecesDone: (v: number) => { this.piecesDone = v },
setPiecesTotal: (v: number) => { this.piecesTotal = v },
setConnectionState: (v: number) => { this.connectionState = v },
togglePreview: () => { this.toggle('preview', false) },
}
)
},
unmounted () {
this.g.disconnect()
},
methods: {
reconnect(): void {
this.g.connect()
},
toggle(overlay: string, affectsHotkeys: boolean): void {
if (this.overlay === '') {
this.overlay = overlay
if (affectsHotkeys) {
this.g.setHotkeys(false)
}
} else {
// could check if overlay was the provided one
this.overlay = ''
if (affectsHotkeys) {
this.g.setHotkeys(true)
}
}
},
},
})
</script>

View file

@ -0,0 +1,36 @@
<template>
<div>
<h1>Running games</h1>
<div class="game-teaser-wrap" v-for="(g, idx) in gamesRunning" :key="idx">
<game-teaser :game="g" />
</div>
<h1>Finished games</h1>
<div class="game-teaser-wrap" v-for="(g, idx) in gamesFinished" :key="idx">
<game-teaser :game="g" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import GameTeaser from './../components/GameTeaser.vue'
export default defineComponent({
components: {
GameTeaser,
},
data() {
return {
gamesRunning: [],
gamesFinished: [],
}
},
async created() {
const res = await fetch('/api/index-data')
const json = await res.json()
this.gamesRunning = json.gamesRunning
this.gamesFinished = json.gamesFinished
},
})
</script>

View file

@ -0,0 +1,45 @@
<template>
<div>
<new-game-dialog :images="images" @newGame="onNewGame" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
// TODO: maybe move dialog back, now that this is a view on its own
import NewGameDialog from './../components/NewGameDialog.vue'
export default defineComponent({
components: {
NewGameDialog,
},
data() {
return {
images: [],
}
},
async created() {
const res = await fetch('/api/newgame-data')
const json = await res.json()
this.images = json.images
},
methods: {
// TODO: ts GameSettings type
async onNewGame(gameSettings: any) {
const res = await fetch('/newgame', {
method: 'post',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(gameSettings),
})
if (res.status === 200) {
const game = await res.json()
this.$router.push({ name: 'game', params: { id: game.id } })
}
}
}
})
</script>

View file

@ -0,0 +1,152 @@
<template>
<div id="replay">
<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" />
<help-overlay v-show="overlay === 'help'" @bgclick="toggle('help', true)" />
<puzzle-status
:finished="finished"
:duration="duration"
:piecesDone="piecesDone"
:piecesTotal="piecesTotal"
>
<div>
<div>{{replayText}}</div>
<button class="btn" @click="g.replayOnSpeedUp()"></button>
<button class="btn" @click="g.replayOnSpeedDown()"></button>
<button class="btn" @click="g.replayOnPauseToggle()"></button>
</div>
</puzzle-status>
<div class="menu">
<div class="tabs">
<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('settings', true)">🛠 Settings</div>
<div class="opener" @click="toggle('help', true)"> Help</div>
</div>
</div>
<scores :activePlayers="activePlayers" :idlePlayers="idlePlayers" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import Scores from './../components/Scores.vue'
import PuzzleStatus from './../components/PuzzleStatus.vue'
import SettingsOverlay from './../components/SettingsOverlay.vue'
import PreviewOverlay from './../components/PreviewOverlay.vue'
import HelpOverlay from './../components/HelpOverlay.vue'
import { main, MODE_REPLAY } from './../game'
export default defineComponent({
name: 'replay',
components: {
PuzzleStatus,
Scores,
SettingsOverlay,
PreviewOverlay,
HelpOverlay,
},
data() {
return {
activePlayers: [] as Array<any>,
idlePlayers: [] as Array<any>,
finished: false,
duration: 0,
piecesDone: 0,
piecesTotal: 0,
overlay: '',
connectionState: 0,
g: {
player: {
background: '',
color: '',
name: '',
},
previewImageUrl: '',
setHotkeys: (v: boolean) => {},
onBgChange: (v: string) => {},
onColorChange: (v: string) => {},
onNameChange: (v: string) => {},
replayOnSpeedUp: () => {},
replayOnSpeedDown: () => {},
replayOnPauseToggle: () => {},
disconnect: () => {},
},
replay: {
speed: 1,
paused: false,
},
}
},
async mounted() {
if (!this.$route.params.id) {
return
}
this.$watch(() => this.g.player.background, (value: string) => {
this.g.onBgChange(value)
})
this.$watch(() => this.g.player.color, (value: string) => {
this.g.onColorChange(value)
})
this.$watch(() => this.g.player.name, (value: string) => {
this.g.onNameChange(value)
})
this.g = await main(
`${this.$route.params.id}`,
// @ts-ignore
this.$clientId,
// @ts-ignore
this.$config.WS_ADDRESS,
MODE_REPLAY,
this.$el,
{
setActivePlayers: (v: Array<any>) => { this.activePlayers = v },
setIdlePlayers: (v: Array<any>) => { this.idlePlayers = v },
setFinished: (v: boolean) => { this.finished = v },
setDuration: (v: number) => { this.duration = v },
setPiecesDone: (v: number) => { this.piecesDone = v },
setPiecesTotal: (v: number) => { this.piecesTotal = v },
togglePreview: () => { this.toggle('preview', false) },
setConnectionState: (v: number) => { this.connectionState = v },
setReplaySpeed: (v: number) => { this.replay.speed = v },
setReplayPaused: (v: boolean) => { this.replay.paused = v },
}
)
},
unmounted () {
this.g.disconnect()
},
methods: {
toggle(overlay: string, affectsHotkeys: boolean): void {
if (this.overlay === '') {
this.overlay = overlay
if (affectsHotkeys) {
this.g.setHotkeys(false)
}
} else {
// could check if overlay was the provided one
this.overlay = ''
if (affectsHotkeys) {
this.g.setHotkeys(true)
}
}
},
},
computed: {
replayText (): string {
return 'Replay-Speed: ' +
(this.replay.speed + 'x') +
(this.replay.paused ? ' Paused' : '')
},
},
})
</script>

12
src/server/Dirs.ts Normal file
View file

@ -0,0 +1,12 @@
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const BASE_DIR = `${__dirname}/../..`
export const DATA_DIR = `${BASE_DIR}/data`
export const UPLOAD_DIR = `${BASE_DIR}/data/uploads`
export const UPLOAD_URL = `/uploads`
export const PUBLIC_DIR = `${BASE_DIR}/build/public/`

70
src/server/Game.ts Normal file
View file

@ -0,0 +1,70 @@
import GameCommon from './../common/GameCommon'
import Util from './../common/Util'
import { Rng } from '../common/Rng'
import GameLog from './GameLog'
import { createPuzzle } from './Puzzle'
import Protocol from '../common/Protocol'
import GameStorage from './GameStorage'
async function createGameObject(gameId: string, targetTiles: number, image: { file: string, url: string }, ts: number, scoreMode: number) {
const seed = Util.hash(gameId + ' ' + ts)
const rng = new Rng(seed)
return {
id: gameId,
rng: { type: 'Rng', obj: rng },
puzzle: await createPuzzle(rng, targetTiles, image, ts),
players: [],
evtInfos: {},
scoreMode,
}
}
async function createGame(gameId: string, targetTiles: number, image: { file: string, url: string }, ts: number, scoreMode: number) {
const gameObject = await createGameObject(gameId, targetTiles, image, ts, scoreMode)
GameLog.create(gameId)
GameLog.log(gameId, Protocol.LOG_HEADER, 1, targetTiles, image, ts, scoreMode)
GameCommon.setGame(gameObject.id, gameObject)
GameStorage.setDirty(gameId)
}
function addPlayer(gameId: string, playerId: string, ts: number) {
const idx = GameCommon.getPlayerIndexById(gameId, playerId)
const diff = ts - GameCommon.getStartTs(gameId)
if (idx === -1) {
GameLog.log(gameId, Protocol.LOG_ADD_PLAYER, playerId, diff)
} else {
GameLog.log(gameId, Protocol.LOG_UPDATE_PLAYER, idx, diff)
}
GameCommon.addPlayer(gameId, playerId, ts)
GameStorage.setDirty(gameId)
}
function handleInput(gameId: string, playerId: string, input: any, ts: number) {
const idx = GameCommon.getPlayerIndexById(gameId, playerId)
const diff = ts - GameCommon.getStartTs(gameId)
GameLog.log(gameId, Protocol.LOG_HANDLE_INPUT, idx, input, diff)
const ret = GameCommon.handleInput(gameId, playerId, input, ts)
GameStorage.setDirty(gameId)
return ret
}
export default {
createGameObject,
createGame,
addPlayer,
handleInput,
getAllGames: GameCommon.getAllGames,
getActivePlayers: GameCommon.getActivePlayers,
getFinishedTileCount: GameCommon.getFinishedTileCount,
getImageUrl: GameCommon.getImageUrl,
getTileCount: GameCommon.getTileCount,
exists: GameCommon.exists,
playerExists: GameCommon.playerExists,
get: GameCommon.get,
getStartTs: GameCommon.getStartTs,
getFinishTs: GameCommon.getFinishTs,
}

51
src/server/GameLog.ts Normal file
View file

@ -0,0 +1,51 @@
import fs from 'fs'
import { logger } from '../common/Util.js'
import { DATA_DIR } from '../server/Dirs.js'
const log = logger('GameLog.js')
const filename = (gameId: string) => `${DATA_DIR}/log_${gameId}.log`
const create = (gameId: string) => {
const file = filename(gameId)
if (!fs.existsSync(file)) {
fs.appendFileSync(file, '')
}
}
const exists = (gameId: string) => {
const file = filename(gameId)
return fs.existsSync(file)
}
const _log = (gameId: string, ...args: Array<any>) => {
const file = filename(gameId)
if (!fs.existsSync(file)) {
return
}
const str = JSON.stringify(args)
fs.appendFileSync(file, str + "\n")
}
const get = (gameId: string) => {
const file = filename(gameId)
if (!fs.existsSync(file)) {
return []
}
const lines = fs.readFileSync(file, 'utf-8').split("\n")
return lines.filter((line: string) => !!line).map((line: string) => {
try {
return JSON.parse(line)
} catch (e) {
log.log(line)
log.log(e)
}
})
}
export default {
create,
exists,
log: _log,
get,
}

48
src/server/GameSockets.ts Normal file
View file

@ -0,0 +1,48 @@
import { logger } from '../common/Util.js'
import WebSocket from 'ws'
const log = logger('GameSocket.js')
// Map<gameId, Socket[]>
const SOCKETS = {} as Record<string, Array<WebSocket>>
function socketExists(gameId: string, socket: WebSocket) {
if (!(gameId in SOCKETS)) {
return false
}
return SOCKETS[gameId].includes(socket)
}
function removeSocket(gameId: string, socket: WebSocket) {
if (!(gameId in SOCKETS)) {
return
}
SOCKETS[gameId] = SOCKETS[gameId].filter((s: WebSocket) => s !== socket)
log.log('removed socket: ', gameId, socket.protocol)
log.log('socket count: ', Object.keys(SOCKETS[gameId]).length)
}
function addSocket(gameId: string, socket: WebSocket) {
if (!(gameId in SOCKETS)) {
SOCKETS[gameId] = []
}
if (!SOCKETS[gameId].includes(socket)) {
SOCKETS[gameId].push(socket)
log.log('added socket: ', gameId, socket.protocol)
log.log('socket count: ', Object.keys(SOCKETS[gameId]).length)
}
}
function getSockets(gameId: string) {
if (!(gameId in SOCKETS)) {
return []
}
return SOCKETS[gameId]
}
export default {
addSocket,
removeSocket,
socketExists,
getSockets,
}

93
src/server/GameStorage.ts Normal file
View file

@ -0,0 +1,93 @@
import fs from 'fs'
import GameCommon from './../common/GameCommon'
import Util, { logger } from './../common/Util'
import { Rng } from '../common/Rng'
import { DATA_DIR } from './Dirs'
import Time from './../common/Time'
const log = logger('GameStorage.js')
const DIRTY_GAMES = {} as any
function setDirty(gameId: string): void {
DIRTY_GAMES[gameId] = true
}
function setClean(gameId: string): void {
delete DIRTY_GAMES[gameId]
}
function loadGames(): void {
const files = fs.readdirSync(DATA_DIR)
for (const f of files) {
const m = f.match(/^([a-z0-9]+)\.json$/)
if (!m) {
continue
}
const gameId = m[1]
loadGame(gameId)
}
}
function loadGame(gameId: string): void {
const file = `${DATA_DIR}/${gameId}.json`
const contents = fs.readFileSync(file, 'utf-8')
let game
try {
game = JSON.parse(contents)
} catch {
log.log(`[ERR] unable to load game from file ${file}`);
}
if (typeof game.puzzle.data.started === 'undefined') {
game.puzzle.data.started = Math.round(fs.statSync(file).ctimeMs)
}
if (typeof game.puzzle.data.finished === 'undefined') {
let unfinished = game.puzzle.tiles.map(Util.decodeTile).find((t: any) => t.owner !== -1)
game.puzzle.data.finished = unfinished ? 0 : Time.timestamp()
}
if (!Array.isArray(game.players)) {
game.players = Object.values(game.players)
}
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 || GameCommon.SCORE_MODE_FINAL,
}
GameCommon.setGame(gameObject.id, gameObject)
}
function persistGames() {
for (const gameId of Object.keys(DIRTY_GAMES)) {
persistGame(gameId)
}
}
function persistGame(gameId: string) {
const game = GameCommon.get(gameId)
if (game.id in DIRTY_GAMES) {
setClean(game.id)
}
fs.writeFileSync(`${DATA_DIR}/${game.id}.json`, JSON.stringify({
id: game.id,
rng: {
type: game.rng.type,
obj: Rng.serialize(game.rng.obj),
},
puzzle: game.puzzle,
players: game.players,
scoreMode: game.scoreMode,
}))
log.info(`[INFO] persisted game ${game.id}`)
}
export default {
loadGames,
loadGame,
persistGames,
persistGame,
setDirty,
}

82
src/server/Images.ts Normal file
View file

@ -0,0 +1,82 @@
import sizeOf from 'image-size'
import fs from 'fs'
import exif from 'exif'
import sharp from 'sharp'
import {UPLOAD_DIR, UPLOAD_URL} from './Dirs.js'
const resizeImage = async (filename: string) => {
if (!filename.toLowerCase().match(/\.(jpe?g|webp|png)$/)) {
return
}
const imagePath = `${UPLOAD_DIR}/${filename}`
const imageOutPath = `${UPLOAD_DIR}/r/${filename}`
const orientation = await getExifOrientation(imagePath)
let sharpImg = sharp(imagePath, { failOnError: false })
// when image is rotated to the left or right, switch width/height
// https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
if (orientation === 6) {
sharpImg = sharpImg.rotate()
} else if (orientation === 3) {
sharpImg = sharpImg.rotate().rotate()
} else if (orientation === 8) {
sharpImg = sharpImg.rotate().rotate().rotate()
}
const sizes = [
[150, 100],
[375, 210],
]
for (let [w,h] of sizes) {
console.log(w, h, imagePath)
await sharpImg.resize(w, h, { fit: 'contain' }).toFile(`${imageOutPath}-${w}x${h}.webp`)
}
}
async function getExifOrientation(imagePath: string) {
return new Promise((resolve, reject) => {
new exif.ExifImage({ image: imagePath }, function (error, exifData) {
if (error) {
resolve(0)
} else {
resolve(exifData.image.Orientation)
}
})
})
}
const allImages = () => {
const images = fs.readdirSync(UPLOAD_DIR)
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
.map(f => ({
filename: f,
file: `${UPLOAD_DIR}/${f}`,
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
}))
.sort((a, b) => {
return fs.statSync(b.file).mtime.getTime() -
fs.statSync(a.file).mtime.getTime()
})
return images
}
async function getDimensions(imagePath: string) {
let dimensions = sizeOf(imagePath)
const orientation = await getExifOrientation(imagePath)
// when image is rotated to the left or right, switch width/height
// https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
if (orientation === 6 || orientation === 8) {
return {
width: dimensions.height,
height: dimensions.width,
}
}
return dimensions
}
export default {
allImages,
resizeImage,
getDimensions,
}

218
src/server/Puzzle.ts Normal file
View file

@ -0,0 +1,218 @@
import Util from '../common/Util'
import { Rng } from '../common/Rng'
import Images from './Images.js'
interface PuzzleInfo {
width: number
height: number
tileSize: number
tileMarginWidth: number
tileDrawSize: number
tiles: number
tilesX: number
tilesY: number
}
// cut size of each puzzle tile in the
// final resized version of the puzzle image
const TILE_SIZE = 64
async function createPuzzle(
rng: Rng,
targetTiles: number,
image: { file: string, url: string },
ts: number
) {
const imagePath = image.file
const imageUrl = image.url
// determine puzzle information from the image dimensions
const dim = await Images.getDimensions(imagePath)
if (!dim || !dim.width || !dim.height) {
throw `[ 2021-05-16 invalid dimension for path ${imagePath} ]`
}
const info = determinePuzzleInfo(dim.width, dim.height, targetTiles)
let tiles = new Array(info.tiles)
for (let i = 0; i < tiles.length; i++) {
tiles[i] = { idx: i }
}
const shapes = determinePuzzleTileShapes(rng, info)
let positions = new Array(info.tiles)
for (let tile of tiles) {
const coord = Util.coordByTileIdx(info, tile.idx)
positions[tile.idx] ={
// instead of info.tileSize, we use info.tileDrawSize
// to spread the tiles a bit
x: coord.x * info.tileSize * 1.5,
y: coord.y * info.tileSize * 1.5,
}
}
const tableWidth = info.width * 3
const tableHeight = info.height * 3
const off = info.tileSize * 1.5
let last = {
x: info.width - (1 * off),
y: info.height - (2 * off),
}
let countX = Math.ceil(info.width / off) + 2
let countY = Math.ceil(info.height / off) + 2
let diffX = off
let diffY = 0
let index = 0
for (let pos of positions) {
pos.x = last.x
pos.y = last.y
last.x+=diffX
last.y+=diffY
index++
// did we move horizontally?
if (diffX !== 0) {
if (index === countX) {
diffY = diffX
countY++
diffX = 0
index = 0
}
} else {
if (index === countY) {
diffX = -diffY
countX++
diffY = 0
index = 0
}
}
}
// then shuffle the positions
positions = Util.shuffle(rng, positions)
tiles = tiles.map(tile => {
return Util.encodeTile({
idx: tile.idx, // index of tile in the array
group: 0, // if grouped with other tiles
z: 0, // z index of the tile
// who owns the tile
// 0 = free for taking
// -1 = finished
// other values: id of player who has the tile
owner: 0,
// physical current position of the tile (x/y in pixels)
// this position is the initial position only and is the
// value that changes when moving a tile
pos: positions[tile.idx],
})
})
// Complete puzzle object
return {
// tiles array
tiles,
// game data for puzzle, data changes during the game
data: {
// TODO: maybe calculate this each time?
maxZ: 0, // max z of all pieces
maxGroup: 0, // max group of all pieces
started: ts, // start timestamp
finished: 0, // finish timestamp
},
// static puzzle information. stays same for complete duration of
// the game
info: {
table: {
width: tableWidth,
height: tableHeight,
},
// information that was used to create the puzzle
targetTiles: targetTiles,
imageUrl,
width: info.width, // actual puzzle width (same as bitmap.width)
height: info.height, // actual puzzle height (same as bitmap.height)
tileSize: info.tileSize, // width/height of each tile (without tabs)
tileDrawSize: info.tileDrawSize, // width/height of each tile (with tabs)
tileMarginWidth: info.tileMarginWidth,
// offset in x and y when drawing tiles, so that they appear to be at pos
tileDrawOffset: (info.tileDrawSize - info.tileSize) / -2,
// max distance between tile and destination that
// makes the tile snap to destination
snapDistance: info.tileSize / 2,
tiles: info.tiles, // the final number of tiles in the puzzle
tilesX: info.tilesX, // number of tiles each row
tilesY: info.tilesY, // number of tiles each col
// ( index => {x, y} )
// this is not the physical coordinate, but
// the tile_coordinate
// this can be used to determine where the
// final destination of a tile is
shapes: shapes, // tile shapes
},
}
}
function determinePuzzleTileShapes(
rng: Rng,
info: PuzzleInfo
) {
const tabs = [-1, 1]
const shapes = new Array(info.tiles)
for (let i = 0; i < info.tiles; i++) {
let coord = Util.coordByTileIdx(info, i)
shapes[i] = {
top: coord.y === 0 ? 0 : shapes[i - info.tilesX].bottom * -1,
right: coord.x === info.tilesX - 1 ? 0 : Util.choice(rng, tabs),
left: coord.x === 0 ? 0 : shapes[i - 1].right * -1,
bottom: coord.y === info.tilesY - 1 ? 0 : Util.choice(rng, tabs),
}
}
return shapes.map(Util.encodeShape)
}
const determineTilesXY = (w: number, h: number, targetTiles: number) => {
const w_ = w < h ? (w * h) : (w * w)
const h_ = w < h ? (h * h) : (w * h)
let size = 0
let tiles = 0
do {
size++
tiles = Math.floor(w_ / size) * Math.floor(h_ / size)
} while (tiles >= targetTiles)
size--
return {
tilesX: Math.round(w_ / size),
tilesY: Math.round(h_ / size),
}
}
const determinePuzzleInfo = (w: number, h: number, targetTiles: number) => {
const {tilesX, tilesY} = determineTilesXY(w, h, targetTiles)
const tiles = tilesX * tilesY
const tileSize = TILE_SIZE
const width = tilesX * tileSize
const height = tilesY * tileSize
const tileMarginWidth = tileSize * .5;
const tileDrawSize = Math.round(tileSize + tileMarginWidth * 2)
return {
width,
height,
tileSize,
tileMarginWidth,
tileDrawSize,
tiles,
tilesX,
tilesY,
}
}
export {
createPuzzle,
}

View file

@ -0,0 +1,80 @@
import WebSocket from 'ws'
import { logger } from '../common/Util.js'
const log = logger('WebSocketServer.js')
/*
Example config
config = {
hostname: 'localhost',
port: 1338,
connectstring: `ws://localhost:1338/ws`,
}
*/
class EvtBus {
private _on: any
constructor() {
this._on = {} as any
}
on(type: string, callback: Function) {
this._on[type] = this._on[type] || []
this._on[type].push(callback)
}
dispatch(type: string, ...args: Array<any>) {
(this._on[type] || []).forEach((cb: Function) => {
cb(...args)
})
}
}
class WebSocketServer {
evt: EvtBus
private _websocketserver: WebSocket.Server|null
config: any
constructor(config: any) {
this.config = config
this._websocketserver = null
this.evt = new EvtBus()
}
on(type: string, callback: Function) {
this.evt.on(type, callback)
}
listen() {
this._websocketserver = new WebSocket.Server(this.config)
this._websocketserver.on('connection', (socket: WebSocket, request: Request) => {
const pathname = new URL(this.config.connectstring).pathname
if (request.url.indexOf(pathname) !== 0) {
log.log('bad request url: ', request.url)
socket.close()
return
}
socket.on('message', (data: any) => {
log.log(`ws`, socket.protocol, data)
this.evt.dispatch('message', {socket, data})
})
socket.on('close', () => {
this.evt.dispatch('close', {socket})
})
})
}
close() {
if (this._websocketserver) {
this._websocketserver.close()
}
}
notifyOne(data: any, socket: WebSocket) {
socket.send(JSON.stringify(data))
}
}
export default WebSocketServer

282
src/server/main.ts Normal file
View file

@ -0,0 +1,282 @@
import WebSocketServer from './WebSocketServer'
import WebSocket from 'ws'
import express from 'express'
import multer from 'multer'
import Protocol from './../common/Protocol'
import Util, { logger } from './../common/Util'
import Game from './Game'
import bodyParser from 'body-parser'
import v8 from 'v8'
import fs from 'fs'
import GameLog from './GameLog'
import GameSockets from './GameSockets'
import Time from '../common/Time'
import Images from './Images'
import {
UPLOAD_DIR,
UPLOAD_URL,
PUBLIC_DIR,
} from './Dirs'
import GameCommon from '../common/GameCommon'
import GameStorage from './GameStorage'
let configFile = ''
let last = ''
for (const val of process.argv) {
if (last === '-c') {
configFile = val
}
last = val
}
if (configFile === '') {
process.exit(2)
}
const config = JSON.parse(String(fs.readFileSync(configFile)))
const log = logger('main.js')
const port = config.http.port
const hostname = config.http.hostname
const app = express()
const storage = multer.diskStorage({
destination: UPLOAD_DIR,
filename: function (req, file, cb) {
cb(null , file.originalname);
}
})
const upload = multer({storage}).single('file');
app.get('/api/conf', (req, res) => {
res.send({
WS_ADDRESS: config.ws.connectstring,
})
})
app.get('/api/newgame-data', (req, res) => {
res.send({
images: Images.allImages(),
})
})
app.get('/api/index-data', (req, res) => {
const ts = Time.timestamp()
const games = [
...Game.getAllGames().map((game: any) => ({
id: game.id,
hasReplay: GameLog.exists(game.id),
started: Game.getStartTs(game.id),
finished: Game.getFinishTs(game.id),
tilesFinished: Game.getFinishedTileCount(game.id),
tilesTotal: Game.getTileCount(game.id),
players: Game.getActivePlayers(game.id, ts).length,
imageUrl: Game.getImageUrl(game.id),
})),
]
res.send({
gamesRunning: games.filter(g => !g.finished),
gamesFinished: games.filter(g => !!g.finished),
})
})
app.post('/upload', (req, res) => {
upload(req, res, async (err: any) => {
if (err) {
log.log(err)
res.status(400).send("Something went wrong!");
}
try {
await Images.resizeImage(req.file.filename)
} catch (err) {
log.log(err)
res.status(400).send("Something went wrong!");
}
res.send({
image: {
file: `${UPLOAD_DIR}/${req.file.filename}`,
url: `${UPLOAD_URL}/${req.file.filename}`,
},
})
})
})
app.post('/newgame', bodyParser.json(), async (req, res) => {
log.log(req.body.tiles, req.body.image)
const gameId = Util.uniqId()
if (!Game.exists(gameId)) {
const ts = Time.timestamp()
await Game.createGame(
gameId,
req.body.tiles,
req.body.image,
ts,
req.body.scoreMode
)
}
res.send({ id: gameId })
})
app.use('/uploads/', express.static(UPLOAD_DIR))
app.use('/', express.static(PUBLIC_DIR))
const wss = new WebSocketServer(config.ws);
const notify = (data: any, sockets: Array<WebSocket>) => {
// TODO: throttle?
for (let socket of sockets) {
wss.notifyOne(data, socket)
}
}
wss.on('close', async ({socket} : {socket: WebSocket}) => {
try {
const proto = socket.protocol.split('|')
const clientId = proto[0]
const gameId = proto[1]
GameSockets.removeSocket(gameId, socket)
} catch (e) {
log.error(e)
}
})
wss.on('message', async ({socket, data} : { socket: WebSocket, data: any }) => {
try {
const proto = socket.protocol.split('|')
const clientId = proto[0]
const gameId = proto[1]
const msg = JSON.parse(data)
const msgType = msg[0]
switch (msgType) {
case Protocol.EV_CLIENT_INIT_REPLAY: {
if (!GameLog.exists(gameId)) {
throw `[gamelog ${gameId} does not exist... ]`
}
const log = GameLog.get(gameId)
const game = await Game.createGameObject(
gameId,
log[0][2],
log[0][3],
log[0][4],
log[0][5] || GameCommon.SCORE_MODE_FINAL
)
notify(
[Protocol.EV_SERVER_INIT_REPLAY, Util.encodeGame(game), log],
[socket]
)
} break
case Protocol.EV_CLIENT_INIT: {
if (!Game.exists(gameId)) {
throw `[game ${gameId} does not exist... ]`
}
const ts = Time.timestamp()
Game.addPlayer(gameId, clientId, ts)
GameSockets.addSocket(gameId, socket)
const game = Game.get(gameId)
notify(
[Protocol.EV_SERVER_INIT, Util.encodeGame(game)],
[socket]
)
} break
case Protocol.EV_CLIENT_EVENT: {
if (!Game.exists(gameId)) {
throw `[game ${gameId} does not exist... ]`
}
const clientSeq = msg[1]
const clientEvtData = msg[2]
const ts = Time.timestamp()
let sendGame = false
if (!Game.playerExists(gameId, clientId)) {
Game.addPlayer(gameId, clientId, ts)
sendGame = true
}
if (!GameSockets.socketExists(gameId, socket)) {
GameSockets.addSocket(gameId, socket)
sendGame = true
}
if (sendGame) {
const game = Game.get(gameId)
notify(
[Protocol.EV_SERVER_INIT, Util.encodeGame(game)],
[socket]
)
}
const changes = Game.handleInput(gameId, clientId, clientEvtData, ts)
notify(
[Protocol.EV_SERVER_EVENT, clientId, clientSeq, changes],
GameSockets.getSockets(gameId)
)
} break
}
} catch (e) {
log.error(e)
}
})
GameStorage.loadGames()
const server = app.listen(
port,
hostname,
() => log.log(`server running on http://${hostname}:${port}`)
)
wss.listen()
const memoryUsageHuman = () => {
const totalHeapSize = v8.getHeapStatistics().total_available_size
let totalHeapSizeInGB = (totalHeapSize / 1024 / 1024 / 1024).toFixed(2)
log.log(`Total heap size (bytes) ${totalHeapSize}, (GB ~${totalHeapSizeInGB})`)
const used = process.memoryUsage().heapUsed / 1024 / 1024
log.log(`Mem: ${Math.round(used * 100) / 100}M`)
}
memoryUsageHuman()
// persist games in fixed interval
const persistInterval = setInterval(() => {
log.log('Persisting games...')
GameStorage.persistGames()
memoryUsageHuman()
}, config.persistence.interval)
const gracefulShutdown = (signal: any) => {
log.log(`${signal} received...`)
log.log('clearing persist interval...')
clearInterval(persistInterval)
log.log('persisting games...')
GameStorage.persistGames()
log.log('shutting down webserver...')
server.close()
log.log('shutting down websocketserver...')
wss.close()
log.log('shutting down...')
process.exit()
}
// used by nodemon
process.once('SIGUSR2', function () {
gracefulShutdown('SIGUSR2')
})
process.once('SIGINT', function (code) {
gracefulShutdown('SIGINT')
})
process.once('SIGTERM', function (code) {
gracefulShutdown('SIGTERM')
})