switch to typescript

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

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

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

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

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

View file

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,27 @@
<template>
<div class="overlay transparent" @click="$emit('bgclick')">
<table class="overlay-content help" @click.stop="">
<tr><td> Move up:</td><td><div><kbd>W</kbd>/<kbd></kbd>/🖱</div></td></tr>
<tr><td> Move down:</td><td><div><kbd>S</kbd>/<kbd></kbd>/🖱</div></td></tr>
<tr><td> Move left:</td><td><div><kbd>A</kbd>/<kbd></kbd>/🖱</div></td></tr>
<tr><td> Move right:</td><td><div><kbd>D</kbd>/<kbd></kbd>/🖱</div></td></tr>
<tr><td></td><td><div>Move faster by holding <kbd>Shift</kbd></div></td></tr>
<tr><td>🔍+ Zoom in:</td><td><div><kbd>E</kbd>/🖱-Wheel</div></td></tr>
<tr><td>🔍- Zoom out:</td><td><div><kbd>Q</kbd>/🖱-Wheel</div></td></tr>
<tr><td>🖼 Toggle preview:</td><td><div><kbd>Space</kbd></div></td></tr>
<tr><td>🧩 Toggle fixed pieces:</td><td><div><kbd>F</kbd></div></td></tr>
<tr><td>🧩 Toggle loose pieces:</td><td><div><kbd>G</kbd></div></td></tr>
</table>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'help-overlay',
emits: {
bgclick: null,
},
})
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

BIN
src/frontend/grab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

BIN
src/frontend/grab_mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

BIN
src/frontend/hand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

BIN
src/frontend/hand_mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

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

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

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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