switch to typescript
This commit is contained in:
parent
031ca31c7e
commit
23559b1a3b
63 changed files with 7943 additions and 1397 deletions
886
src/common/GameCommon.ts
Normal file
886
src/common/GameCommon.ts
Normal 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
88
src/common/Geometry.ts
Normal 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
98
src/common/Protocol.ts
Normal 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
35
src/common/Rng.ts
Normal 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
47
src/common/Time.ts
Normal 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
216
src/common/Util.ts
Normal 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
3
src/common/package.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"type": "module"
|
||||
}
|
||||
23
src/frontend/App.vue
Normal file
23
src/frontend/App.vue
Normal 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
136
src/frontend/Camera.ts
Normal 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
|
||||
}
|
||||
}
|
||||
172
src/frontend/Communication.ts
Normal file
172
src/frontend/Communication.ts
Normal 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
27
src/frontend/Debug.ts
Normal 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
272
src/frontend/Fireworks.ts
Normal 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
48
src/frontend/Graphics.ts
Normal 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,
|
||||
}
|
||||
224
src/frontend/PuzzleGraphics.ts
Normal file
224
src/frontend/PuzzleGraphics.ts
Normal 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,
|
||||
}
|
||||
37
src/frontend/components/ConnectionOverlay.vue
Normal file
37
src/frontend/components/ConnectionOverlay.vue
Normal 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>
|
||||
45
src/frontend/components/GameTeaser.vue
Normal file
45
src/frontend/components/GameTeaser.vue
Normal 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>
|
||||
27
src/frontend/components/HelpOverlay.vue
Normal file
27
src/frontend/components/HelpOverlay.vue
Normal 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>
|
||||
29
src/frontend/components/ImageTeaser.vue
Normal file
29
src/frontend/components/ImageTeaser.vue
Normal 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>
|
||||
98
src/frontend/components/NewGameDialog.vue
Normal file
98
src/frontend/components/NewGameDialog.vue
Normal 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>
|
||||
27
src/frontend/components/PreviewOverlay.vue
Normal file
27
src/frontend/components/PreviewOverlay.vue
Normal 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>
|
||||
45
src/frontend/components/PuzzleStatus.vue
Normal file
45
src/frontend/components/PuzzleStatus.vue
Normal 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>
|
||||
46
src/frontend/components/Scores.vue
Normal file
46
src/frontend/components/Scores.vue
Normal 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>
|
||||
38
src/frontend/components/SettingsOverlay.vue
Normal file
38
src/frontend/components/SettingsOverlay.vue
Normal 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>
|
||||
33
src/frontend/components/Upload.vue
Normal file
33
src/frontend/components/Upload.vue
Normal 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
722
src/frontend/game.ts
Normal 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
39
src/frontend/gameloop.ts
Normal 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
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
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
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
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
10
src/frontend/index.html
Normal 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
46
src/frontend/main.ts
Normal 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
5
src/frontend/shims-vue.d.ts
vendored
Normal 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
280
src/frontend/style.css
Normal 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
140
src/frontend/views/Game.vue
Normal 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>
|
||||
36
src/frontend/views/Index.vue
Normal file
36
src/frontend/views/Index.vue
Normal 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>
|
||||
45
src/frontend/views/NewGame.vue
Normal file
45
src/frontend/views/NewGame.vue
Normal 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>
|
||||
152
src/frontend/views/Replay.vue
Normal file
152
src/frontend/views/Replay.vue
Normal 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
12
src/server/Dirs.ts
Normal 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
70
src/server/Game.ts
Normal 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
51
src/server/GameLog.ts
Normal 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
48
src/server/GameSockets.ts
Normal 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
93
src/server/GameStorage.ts
Normal 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
82
src/server/Images.ts
Normal 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
218
src/server/Puzzle.ts
Normal 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,
|
||||
}
|
||||
80
src/server/WebSocketServer.ts
Normal file
80
src/server/WebSocketServer.ts
Normal 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
282
src/server/main.ts
Normal 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')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue