switch to typescript
This commit is contained in:
parent
031ca31c7e
commit
23559b1a3b
63 changed files with 7943 additions and 1397 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue