change dir stucture
This commit is contained in:
parent
e18b8d3b98
commit
62f8991e11
26 changed files with 8718 additions and 804 deletions
121
public/Camera.js
Normal file
121
public/Camera.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
export default class Camera {
|
||||
constructor() {
|
||||
this.x = 0
|
||||
this.y = 0
|
||||
|
||||
this.zoom = 1
|
||||
this.minZoom = .1
|
||||
this.maxZoom = 6
|
||||
this.zoomStep = .05
|
||||
}
|
||||
|
||||
move(x, y) {
|
||||
this.x += x / this.zoom
|
||||
this.y += y / this.zoom
|
||||
}
|
||||
|
||||
setZoom(newzoom, viewportCoordCenter) {
|
||||
const zoom = Math.min(Math.max(newzoom, this.minZoom), this.maxZoom)
|
||||
if (zoom == this.zoom) {
|
||||
return false
|
||||
}
|
||||
|
||||
const zoomFactor = 1 - (this.zoom / zoom)
|
||||
this.move(
|
||||
-viewportCoordCenter.x * zoomFactor,
|
||||
-viewportCoordCenter.y * zoomFactor,
|
||||
)
|
||||
this.zoom = zoom
|
||||
return true
|
||||
}
|
||||
|
||||
zoomOut(viewportCoordCenter) {
|
||||
return this.setZoom(
|
||||
this.zoom - this.zoomStep * this.zoom,
|
||||
viewportCoordCenter
|
||||
)
|
||||
}
|
||||
|
||||
zoomIn(viewportCoordCenter) {
|
||||
return this.setZoom(
|
||||
this.zoom + this.zoomStep * this.zoom,
|
||||
viewportCoordCenter
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a coordinate in the viewport to a
|
||||
* coordinate in the world, rounded
|
||||
* @param {x, y} viewportCoord
|
||||
*/
|
||||
viewportToWorld(viewportCoord) {
|
||||
const worldCoord = this.viewportToWorldRaw(viewportCoord)
|
||||
return {
|
||||
x: Math.round(worldCoord.x),
|
||||
y: Math.round(worldCoord.y),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a coordinate in the viewport to a
|
||||
* coordinate in the world, not rounded
|
||||
* @param {x, y} viewportCoord
|
||||
*/
|
||||
viewportToWorldRaw(viewportCoord) {
|
||||
return {
|
||||
x: (viewportCoord.x / this.zoom) - this.x,
|
||||
y: (viewportCoord.y / this.zoom) - this.y,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a coordinate in the world to a
|
||||
* coordinate in the viewport, rounded
|
||||
* @param {x, y} worldCoord
|
||||
*/
|
||||
worldToViewport(worldCoord) {
|
||||
const viewportCoord = this.worldToViewportRaw(worldCoord)
|
||||
return {
|
||||
x: Math.round(viewportCoord.x),
|
||||
y: Math.round(viewportCoord.y),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a coordinate in the world to a
|
||||
* coordinate in the viewport, not rounded
|
||||
* @param {x, y} worldCoord
|
||||
*/
|
||||
worldToViewportRaw(worldCoord) {
|
||||
return {
|
||||
x: (worldCoord.x + this.x) * this.zoom,
|
||||
y: (worldCoord.y + this.y) * this.zoom,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a 2d dimension (width/height) in the world to
|
||||
* one in the viewport, rounded
|
||||
* @param {w, h} worldDim
|
||||
*/
|
||||
worldDimToViewport(worldDim) {
|
||||
const viewportDim = this.worldDimToViewportRaw(worldDim)
|
||||
return {
|
||||
w: Math.round(viewportDim.w),
|
||||
h: Math.round(viewportDim.h),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Translate a 2d dimension (width/height) in the world to
|
||||
* one in the viewport, not rounded
|
||||
* @param {w, h} worldDim
|
||||
*/
|
||||
worldDimToViewportRaw(worldDim) {
|
||||
return {
|
||||
w: worldDim.w * this.zoom,
|
||||
h: worldDim.h * this.zoom,
|
||||
}
|
||||
}
|
||||
}
|
||||
77
public/Communication.js
Normal file
77
public/Communication.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import WsClient from './WsClient.js'
|
||||
import Protocol from './../common/Protocol.js'
|
||||
|
||||
/** @type WsClient */
|
||||
let conn
|
||||
let changesCallback = () => {}
|
||||
|
||||
function onServerChange(callback) {
|
||||
changesCallback = callback
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
conn.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
let clientSeq
|
||||
let events
|
||||
function connect(gameId, clientId) {
|
||||
clientSeq = 0
|
||||
events = {}
|
||||
conn = new WsClient(WS_ADDRESS, clientId + '|' + gameId)
|
||||
return new Promise(r => {
|
||||
conn.connect()
|
||||
send([Protocol.EV_CLIENT_INIT])
|
||||
conn.onSocket('message', async ({ data }) => {
|
||||
const msg = JSON.parse(data)
|
||||
const msgType = msg[0]
|
||||
if (msgType === Protocol.EV_SERVER_INIT) {
|
||||
const game = msg[1]
|
||||
r(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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function connectReplay(gameId, clientId) {
|
||||
clientSeq = 0
|
||||
events = {}
|
||||
conn = new WsClient(WS_ADDRESS, clientId + '|' + gameId)
|
||||
return new Promise(r => {
|
||||
conn.connect()
|
||||
send([Protocol.EV_CLIENT_INIT_REPLAY])
|
||||
conn.onSocket('message', async ({ data }) => {
|
||||
const msg = JSON.parse(data)
|
||||
const msgType = msg[0]
|
||||
if (msgType === Protocol.EV_SERVER_INIT_REPLAY) {
|
||||
const game = msg[1]
|
||||
const log = msg[2]
|
||||
r({game, log})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function sendClientEvent(mouse) {
|
||||
// when sending event, increase number of sent events
|
||||
// and add the event locally
|
||||
clientSeq++;
|
||||
events[clientSeq] = mouse
|
||||
send([Protocol.EV_CLIENT_EVENT, clientSeq, events[clientSeq]])
|
||||
}
|
||||
|
||||
export default {
|
||||
connect,
|
||||
connectReplay,
|
||||
onServerChange,
|
||||
sendClientEvent,
|
||||
}
|
||||
25
public/Debug.js
Normal file
25
public/Debug.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { logger } from '../common/Util.js'
|
||||
|
||||
const log = logger('Debug.js')
|
||||
|
||||
let _pt = 0
|
||||
let _mindiff = 0
|
||||
|
||||
const checkpoint_start = (mindiff) => {
|
||||
_pt = performance.now()
|
||||
_mindiff = mindiff
|
||||
}
|
||||
|
||||
const checkpoint = (label) => {
|
||||
const now = performance.now()
|
||||
const diff = now - _pt
|
||||
if (diff > _mindiff) {
|
||||
log.log(label + ': ' + (diff))
|
||||
}
|
||||
_pt = now
|
||||
}
|
||||
|
||||
export default {
|
||||
checkpoint_start,
|
||||
checkpoint,
|
||||
}
|
||||
228
public/Fireworks.js
Normal file
228
public/Fireworks.js
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { Rng } from '../common/Rng.js'
|
||||
import Util from '../common/Util.js'
|
||||
|
||||
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(/** @type 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 {
|
||||
constructor(/** @type 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) {
|
||||
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
|
||||
this.explode(particlesVector)
|
||||
}
|
||||
|
||||
this.px += this.vx
|
||||
this.py += this.vy
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
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) {
|
||||
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 {
|
||||
constructor(parent, angle, speed) {
|
||||
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
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
constructor(canvas, /** @type Rng */ rng) {
|
||||
this.canvas = canvas
|
||||
this.rng = rng
|
||||
this.ctx = this.canvas.getContext('2d')
|
||||
this.resize()
|
||||
|
||||
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()
|
||||
bomb.update()
|
||||
if (bomb.alive) {
|
||||
aliveBombs.push(bomb)
|
||||
}
|
||||
}
|
||||
this.explodedBombs = aliveBombs
|
||||
|
||||
const notExplodedBombs = []
|
||||
while (this.readyBombs.length > 0) {
|
||||
const bomb = this.readyBombs.shift()
|
||||
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()
|
||||
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
|
||||
32
public/Game.js
Normal file
32
public/Game.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import GameCommon from './../common/GameCommon.js'
|
||||
|
||||
export default {
|
||||
setGame: GameCommon.setGame,
|
||||
getRelevantPlayers: GameCommon.getRelevantPlayers,
|
||||
getActivePlayers: GameCommon.getActivePlayers,
|
||||
addPlayer: GameCommon.addPlayer,
|
||||
handleInput: GameCommon.handleInput,
|
||||
getPlayerIdByIndex: GameCommon.getPlayerIdByIndex,
|
||||
getPlayerBgColor: GameCommon.getPlayerBgColor,
|
||||
getPlayerColor: GameCommon.getPlayerColor,
|
||||
getPlayerName: GameCommon.getPlayerName,
|
||||
changePlayer: GameCommon.changePlayer,
|
||||
setPlayer: GameCommon.setPlayer,
|
||||
setTile: GameCommon.setTile,
|
||||
getImageUrl: GameCommon.getImageUrl,
|
||||
setPuzzleData: GameCommon.setPuzzleData,
|
||||
getTableWidth: GameCommon.getTableWidth,
|
||||
getTableHeight: GameCommon.getTableHeight,
|
||||
getPuzzle: GameCommon.getPuzzle,
|
||||
getRng: GameCommon.getRng,
|
||||
getPuzzleWidth: GameCommon.getPuzzleWidth,
|
||||
getPuzzleHeight: GameCommon.getPuzzleHeight,
|
||||
getTilesSortedByZIndex: GameCommon.getTilesSortedByZIndex,
|
||||
getFirstOwnedTile: GameCommon.getFirstOwnedTile,
|
||||
getTileDrawOffset: GameCommon.getTileDrawOffset,
|
||||
getTileDrawSize: GameCommon.getTileDrawSize,
|
||||
getStartTs: GameCommon.getStartTs,
|
||||
getFinishTs: GameCommon.getFinishTs,
|
||||
getFinishedTileCount: GameCommon.getFinishedTileCount,
|
||||
getTileCount: GameCommon.getTileCount,
|
||||
}
|
||||
55
public/Graphics.js
Normal file
55
public/Graphics.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
function createCanvas(width = 0, height = 0) {
|
||||
const c = document.createElement('canvas')
|
||||
c.width = width
|
||||
c.height = height
|
||||
return c
|
||||
}
|
||||
|
||||
async function loadImageToBitmap(imagePath) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
createImageBitmap(img).then(resolve)
|
||||
}
|
||||
img.src = imagePath
|
||||
})
|
||||
}
|
||||
|
||||
async function resizeBitmap (bitmap, width, height) {
|
||||
const c = createCanvas(width, height)
|
||||
const ctx = c.getContext('2d')
|
||||
ctx.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, width, height)
|
||||
return await createImageBitmap(c)
|
||||
}
|
||||
|
||||
async function createBitmap(width, height, color) {
|
||||
const c = createCanvas(width, height)
|
||||
const ctx = c.getContext('2d')
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
return await createImageBitmap(c)
|
||||
}
|
||||
|
||||
async function colorize(image, mask, color) {
|
||||
const c = createCanvas(image.width, image.height)
|
||||
const ctx = c.getContext('2d')
|
||||
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 {
|
||||
createBitmap,
|
||||
createCanvas,
|
||||
loadImageToBitmap,
|
||||
resizeBitmap,
|
||||
colorize,
|
||||
}
|
||||
221
public/PuzzleGraphics.js
Normal file
221
public/PuzzleGraphics.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import Geometry from '../common/Geometry.js'
|
||||
import Graphics from './Graphics.js'
|
||||
import Util, { logger } from './../common/Util.js'
|
||||
|
||||
const log = logger('PuzzleGraphics.js')
|
||||
|
||||
async function createPuzzleTileBitmaps(img, tiles, info) {
|
||||
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 = {}
|
||||
function pathForShape(shape) {
|
||||
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')
|
||||
|
||||
const c2 = Graphics.createCanvas(tileDrawSize, tileDrawSize)
|
||||
const ctx2 = c2.getContext('2d')
|
||||
|
||||
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, idx) {
|
||||
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) {
|
||||
// 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,
|
||||
}
|
||||
63
public/WsClient.js
Normal file
63
public/WsClient.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import WsWrapper from './WsWrapper.js'
|
||||
|
||||
export default class WsClient extends WsWrapper {
|
||||
constructor(addr, protocols) {
|
||||
super(addr, protocols)
|
||||
this._on = {}
|
||||
this.onopen = (e) => {
|
||||
this._dispatch('socket', 'open', e)
|
||||
}
|
||||
this.onmessage = (e) => {
|
||||
this._dispatch('socket', 'message', e)
|
||||
if (!!this._on['message']) {
|
||||
const d = this._parseMessageData(e.data)
|
||||
if (d.event) {
|
||||
this._dispatch('message', d.event, d.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.onclose = (e) => {
|
||||
this._dispatch('socket', 'close', e)
|
||||
}
|
||||
}
|
||||
|
||||
onSocket(tag, callback) {
|
||||
this.addEventListener('socket', tag, callback)
|
||||
}
|
||||
|
||||
onMessage(tag, callback) {
|
||||
this.addEventListener('message', tag, callback)
|
||||
}
|
||||
|
||||
addEventListener(type, tag, callback) {
|
||||
const tags = Array.isArray(tag) ? tag : [tag]
|
||||
this._on[type] = this._on[type] || {}
|
||||
for (const t of tags) {
|
||||
this._on[type][t] = this._on[type][t] || []
|
||||
this._on[type][t].push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
_parseMessageData(data) {
|
||||
try {
|
||||
const d = JSON.parse(data)
|
||||
if (d.event) {
|
||||
return {event: d.event, data: d.data || null}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return {event: null, data: null}
|
||||
}
|
||||
|
||||
_dispatch(type, tag, ...args) {
|
||||
const t = this._on[type] || {}
|
||||
const callbacks = (t[tag] || [])
|
||||
if (callbacks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const callback of callbacks) {
|
||||
callback(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
public/WsWrapper.js
Normal file
62
public/WsWrapper.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Time from '../common/Time.js'
|
||||
|
||||
/**
|
||||
* Wrapper around ws that
|
||||
* - buffers 'send' until a connection is available
|
||||
* - automatically tries to reconnect on close
|
||||
*/
|
||||
export default class WsWrapper {
|
||||
// actual ws handle
|
||||
handle = null
|
||||
|
||||
// timeout for automatic reconnect
|
||||
reconnectTimeout = null
|
||||
|
||||
// buffer for 'send'
|
||||
sendBuffer = []
|
||||
|
||||
constructor(addr, protocols) {
|
||||
this.addr = addr
|
||||
this.protocols = protocols
|
||||
|
||||
this.onopen = () => {}
|
||||
this.onclose = () => {}
|
||||
this.onmessage = () => {}
|
||||
}
|
||||
|
||||
send (txt) {
|
||||
if (this.handle) {
|
||||
this.handle.send(txt)
|
||||
} else {
|
||||
this.sendBuffer.push(txt)
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
let ws = new WebSocket(this.addr, this.protocols)
|
||||
ws.onopen = (e) => {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
}
|
||||
this.handle = ws
|
||||
// should have a queue worker
|
||||
while (this.sendBuffer.length > 0) {
|
||||
this.handle.send(this.sendBuffer.shift())
|
||||
}
|
||||
this.onopen(e)
|
||||
}
|
||||
ws.onmessage = (e) => {
|
||||
this.onmessage(e)
|
||||
}
|
||||
ws.onerror = (e) => {
|
||||
this.handle = null
|
||||
this.reconnectTimeout = setTimeout(() => { this.connect() }, 1 * Time.SEC)
|
||||
this.onclose(e)
|
||||
}
|
||||
ws.onclose = (e) => {
|
||||
this.handle = null
|
||||
this.reconnectTimeout = setTimeout(() => { this.connect() }, 1 * Time.SEC)
|
||||
this.onclose(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
963
public/game.js
Normal file
963
public/game.js
Normal file
|
|
@ -0,0 +1,963 @@
|
|||
"use strict"
|
||||
import {run} from './gameloop.js'
|
||||
import Camera from './Camera.js'
|
||||
import Graphics from './Graphics.js'
|
||||
import Debug from './Debug.js'
|
||||
import Communication from './Communication.js'
|
||||
import Util from './../common/Util.js'
|
||||
import PuzzleGraphics from './PuzzleGraphics.js'
|
||||
import Game from './Game.js'
|
||||
import fireworksController from './Fireworks.js'
|
||||
import Protocol from '../common/Protocol.js'
|
||||
import Time from '../common/Time.js'
|
||||
|
||||
if (typeof GAME_ID === 'undefined') throw '[ GAME_ID not set ]'
|
||||
if (typeof WS_ADDRESS === 'undefined') throw '[ WS_ADDRESS not set ]'
|
||||
if (typeof MODE === 'undefined') throw '[ MODE not set ]'
|
||||
|
||||
if (typeof DEBUG === 'undefined') window.DEBUG = false
|
||||
|
||||
const MODE_PLAY = 'play'
|
||||
const MODE_REPLAY = 'replay'
|
||||
|
||||
let RERENDER = true
|
||||
|
||||
let TIME = () => Time.timestamp()
|
||||
|
||||
function addCanvasToDom(canvas) {
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
document.body.appendChild(canvas)
|
||||
window.addEventListener('resize', () => {
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
RERENDER = true
|
||||
})
|
||||
return canvas
|
||||
}
|
||||
|
||||
const ELEMENTS = {
|
||||
TABLE: document.createElement('table'),
|
||||
TR: document.createElement('tr'),
|
||||
TD: document.createElement('td'),
|
||||
BUTTON: document.createElement('button'),
|
||||
INPUT: document.createElement('input'),
|
||||
LABEL: document.createElement('label'),
|
||||
DIV: document.createElement('div'),
|
||||
A: document.createElement('a'),
|
||||
}
|
||||
|
||||
let KEY_LISTENER_OFF = false
|
||||
|
||||
let PIECE_VIEW_FIXED = true
|
||||
let PIECE_VIEW_LOOSE = true
|
||||
|
||||
function addMenuToDom(gameId) {
|
||||
const previewImageUrl = Game.getImageUrl(gameId)
|
||||
function row (...elements) {
|
||||
const row = ELEMENTS.TR.cloneNode(true)
|
||||
for (let el of elements) {
|
||||
const td = ELEMENTS.TD.cloneNode(true)
|
||||
if (typeof el === 'string') {
|
||||
td.appendChild(document.createTextNode(el))
|
||||
} else {
|
||||
td.appendChild(el)
|
||||
}
|
||||
row.appendChild(td)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function btn(txt) {
|
||||
const btn = ELEMENTS.BUTTON.cloneNode(true)
|
||||
btn.classList.add('btn')
|
||||
btn.innerText = txt
|
||||
return btn
|
||||
}
|
||||
|
||||
function colorinput() {
|
||||
const input = ELEMENTS.INPUT.cloneNode(true)
|
||||
input.type = 'color'
|
||||
return input
|
||||
}
|
||||
|
||||
function textinput(maxLength) {
|
||||
const input = ELEMENTS.INPUT.cloneNode(true)
|
||||
input.type = 'text'
|
||||
input.maxLength = maxLength
|
||||
return input
|
||||
}
|
||||
|
||||
function label(text) {
|
||||
const label = ELEMENTS.LABEL.cloneNode(true)
|
||||
label.innerText = text
|
||||
return label
|
||||
}
|
||||
|
||||
const bgColorPickerEl = colorinput()
|
||||
const bgColorPickerRow = row(
|
||||
label('Background: '),
|
||||
bgColorPickerEl
|
||||
)
|
||||
|
||||
const playerColorPickerEl = colorinput()
|
||||
const playerColorPickerRow = row(
|
||||
label('Color: '),
|
||||
playerColorPickerEl
|
||||
)
|
||||
|
||||
const nameChangeEl = textinput(16)
|
||||
const nameChangeRow = row(
|
||||
label('Name: '),
|
||||
nameChangeEl
|
||||
)
|
||||
|
||||
const kbd = function(txt) {
|
||||
const el = document.createElement('kbd')
|
||||
el.appendChild(document.createTextNode(txt))
|
||||
return el
|
||||
}
|
||||
|
||||
const h = function(...els) {
|
||||
const el = ELEMENTS.DIV.cloneNode(true)
|
||||
for (const other of els) {
|
||||
if (typeof other === 'string') {
|
||||
el.appendChild(document.createTextNode(other))
|
||||
} else {
|
||||
el.appendChild(other)
|
||||
}
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
const helpEl = ELEMENTS.TABLE.cloneNode(true)
|
||||
helpEl.classList.add('help')
|
||||
helpEl.appendChild(row('⬆️ Move up:', h(kbd('W'), '/', kbd('↑'), '/🖱️')))
|
||||
helpEl.appendChild(row('⬇️ Move down:', h(kbd('S'), '/', kbd('↓'), '/🖱️')))
|
||||
helpEl.appendChild(row('⬅️ Move left:', h(kbd('A'), '/', kbd('←'), '/🖱️')))
|
||||
helpEl.appendChild(row('➡️ Move right:', h(kbd('D'), '/', kbd('→'), '/🖱️')))
|
||||
helpEl.appendChild(row('', h('Move faster by holding ', kbd('Shift'))))
|
||||
helpEl.appendChild(row('🔍+ Zoom in:', h(kbd('E'), '/🖱️-Wheel')))
|
||||
helpEl.appendChild(row('🔍- Zoom out:', h(kbd('Q'), '/🖱️-Wheel')))
|
||||
helpEl.appendChild(row('🖼️ Toggle preview:', h(kbd('Space'))))
|
||||
helpEl.appendChild(row('🧩✔️ Toggle fixed pieces:', h(kbd('F'))))
|
||||
helpEl.appendChild(row('🧩❓ Toggle loose pieces:', h(kbd('G'))))
|
||||
helpEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
|
||||
const toggle = (el, disableHotkeys = true) => {
|
||||
el.classList.toggle('closed')
|
||||
if (disableHotkeys) {
|
||||
KEY_LISTENER_OFF = !el.classList.contains('closed')
|
||||
}
|
||||
}
|
||||
|
||||
const helpOverlay = ELEMENTS.DIV.cloneNode(true)
|
||||
helpOverlay.classList.add('overlay', 'transparent', 'closed')
|
||||
helpOverlay.appendChild(helpEl)
|
||||
helpOverlay.addEventListener('click', () => {
|
||||
toggle(helpOverlay)
|
||||
})
|
||||
|
||||
const settingsEl = ELEMENTS.TABLE.cloneNode(true)
|
||||
settingsEl.classList.add('settings')
|
||||
settingsEl.appendChild(bgColorPickerRow)
|
||||
settingsEl.appendChild(playerColorPickerRow)
|
||||
settingsEl.appendChild(nameChangeRow)
|
||||
settingsEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
|
||||
const settingsOverlay = ELEMENTS.DIV.cloneNode(true)
|
||||
settingsOverlay.classList.add('overlay', 'transparent', 'closed')
|
||||
settingsOverlay.appendChild(settingsEl)
|
||||
settingsOverlay.addEventListener('click', () => {
|
||||
toggle(settingsOverlay)
|
||||
})
|
||||
|
||||
const previewEl = ELEMENTS.DIV.cloneNode(true)
|
||||
previewEl.classList.add('preview')
|
||||
|
||||
const imgEl = ELEMENTS.DIV.cloneNode(true)
|
||||
imgEl.classList.add('img')
|
||||
imgEl.style.backgroundImage = `url(${previewImageUrl})`
|
||||
previewEl.appendChild(imgEl)
|
||||
|
||||
const previewOverlay = ELEMENTS.DIV.cloneNode(true)
|
||||
previewOverlay.classList.add('overlay', 'closed')
|
||||
previewOverlay.appendChild(previewEl)
|
||||
const togglePreview = () => {
|
||||
previewOverlay.classList.toggle('closed')
|
||||
}
|
||||
previewOverlay.addEventListener('click', () => {
|
||||
togglePreview()
|
||||
})
|
||||
|
||||
const opener = (txt, overlay, disableHotkeys = true) => {
|
||||
const el = ELEMENTS.DIV.cloneNode(true)
|
||||
el.classList.add('opener')
|
||||
el.appendChild(document.createTextNode(txt))
|
||||
el.addEventListener('click', () => {
|
||||
toggle(overlay, disableHotkeys)
|
||||
})
|
||||
return el
|
||||
}
|
||||
|
||||
const homeEl = ELEMENTS.A.cloneNode(true)
|
||||
homeEl.classList.add('opener')
|
||||
homeEl.appendChild(document.createTextNode('🧩 Puzzles'))
|
||||
homeEl.target = '_blank'
|
||||
homeEl.href = '/'
|
||||
|
||||
const settingsOpenerEl = opener('🛠️ Settings', settingsOverlay)
|
||||
const previewOpenerEl = opener('🖼️ Preview', previewOverlay, false)
|
||||
const helpOpenerEl = opener('ℹ️ Help', helpOverlay)
|
||||
|
||||
const tabsEl = ELEMENTS.DIV.cloneNode(true)
|
||||
tabsEl.classList.add('tabs')
|
||||
tabsEl.appendChild(homeEl)
|
||||
tabsEl.appendChild(previewOpenerEl)
|
||||
tabsEl.appendChild(settingsOpenerEl)
|
||||
tabsEl.appendChild(helpOpenerEl)
|
||||
|
||||
const menuEl = ELEMENTS.DIV.cloneNode(true)
|
||||
menuEl.classList.add('menu')
|
||||
menuEl.appendChild(tabsEl)
|
||||
|
||||
const scoresTitleEl = ELEMENTS.DIV.cloneNode(true)
|
||||
scoresTitleEl.appendChild(document.createTextNode('Scores'))
|
||||
|
||||
const scoresListEl = ELEMENTS.TABLE.cloneNode(true)
|
||||
const updateScoreBoard = (ts) => {
|
||||
const minTs = ts - 30 * Time.SEC
|
||||
|
||||
const players = Game.getRelevantPlayers(gameId, ts)
|
||||
const actives = players.filter(player => player.ts >= minTs)
|
||||
const nonActives = players.filter(player => player.ts < minTs)
|
||||
|
||||
actives.sort((a, b) => b.points - a.points)
|
||||
nonActives.sort((a, b) => b.points - a.points)
|
||||
|
||||
scoresListEl.innerHTML = ''
|
||||
for (let player of actives) {
|
||||
const r = row(
|
||||
document.createTextNode('⚡'),
|
||||
document.createTextNode(player.name),
|
||||
document.createTextNode(player.points)
|
||||
)
|
||||
r.style.color = player.color
|
||||
scoresListEl.appendChild(r)
|
||||
}
|
||||
for (let player of nonActives) {
|
||||
const r = row(
|
||||
document.createTextNode('💤'),
|
||||
document.createTextNode(player.name),
|
||||
document.createTextNode(player.points)
|
||||
)
|
||||
r.style.color = player.color
|
||||
scoresListEl.appendChild(r)
|
||||
}
|
||||
}
|
||||
|
||||
const timerStr = () => {
|
||||
const started = Game.getStartTs(gameId)
|
||||
const ended = Game.getFinishTs(gameId)
|
||||
const icon = ended ? '🏁' : '⏳'
|
||||
const from = started;
|
||||
const to = ended || TIME()
|
||||
const timeDiffStr = Time.timeDiffStr(from, to)
|
||||
return `${icon} ${timeDiffStr}`
|
||||
}
|
||||
|
||||
const timerCountdownEl = ELEMENTS.DIV.cloneNode(true)
|
||||
const updateTimer = () => {
|
||||
timerCountdownEl.innerText = timerStr()
|
||||
}
|
||||
const tilesDoneEl = ELEMENTS.DIV.cloneNode(true)
|
||||
const udateTilesDone = () => {
|
||||
const tilesFinished = Game.getFinishedTileCount(gameId)
|
||||
const tilesTotal = Game.getTileCount(gameId)
|
||||
tilesDoneEl.innerText = `🧩 ${tilesFinished}/${tilesTotal}`
|
||||
}
|
||||
|
||||
const timerEl = ELEMENTS.DIV.cloneNode(true)
|
||||
timerEl.classList.add('timer')
|
||||
timerEl.appendChild(tilesDoneEl)
|
||||
timerEl.appendChild(timerCountdownEl)
|
||||
|
||||
let replayControl = null
|
||||
if (MODE === MODE_REPLAY) {
|
||||
const replayControlEl = ELEMENTS.DIV.cloneNode(true)
|
||||
const speedUp = btn('⏫')
|
||||
const speedDown = btn('⏬')
|
||||
const pause = btn('⏸️')
|
||||
const speed = ELEMENTS.DIV.cloneNode(true)
|
||||
replayControlEl.appendChild(speed)
|
||||
replayControlEl.appendChild(speedUp)
|
||||
replayControlEl.appendChild(speedDown)
|
||||
replayControlEl.appendChild(pause)
|
||||
timerEl.appendChild(replayControlEl)
|
||||
replayControl = { speedUp, speedDown, pause, speed }
|
||||
}
|
||||
|
||||
const scoresEl = ELEMENTS.DIV.cloneNode(true)
|
||||
scoresEl.classList.add('scores')
|
||||
scoresEl.appendChild(scoresTitleEl)
|
||||
scoresEl.appendChild(scoresListEl)
|
||||
|
||||
document.body.appendChild(settingsOverlay)
|
||||
document.body.appendChild(previewOverlay)
|
||||
document.body.appendChild(helpOverlay)
|
||||
document.body.appendChild(timerEl)
|
||||
document.body.appendChild(menuEl)
|
||||
document.body.appendChild(scoresEl)
|
||||
|
||||
return {
|
||||
bgColorPickerEl,
|
||||
playerColorPickerEl,
|
||||
nameChangeEl,
|
||||
updateScoreBoard,
|
||||
updateTimer,
|
||||
udateTilesDone,
|
||||
togglePreview,
|
||||
replayControl,
|
||||
}
|
||||
}
|
||||
|
||||
function initme() {
|
||||
// return uniqId()
|
||||
let ID = localStorage.getItem('ID')
|
||||
if (!ID) {
|
||||
ID = Util.uniqId()
|
||||
localStorage.setItem('ID', ID)
|
||||
}
|
||||
return ID
|
||||
}
|
||||
|
||||
export default class EventAdapter {
|
||||
constructor(canvas, window, viewport) {
|
||||
this.events = []
|
||||
this._viewport = viewport
|
||||
this._canvas = canvas
|
||||
|
||||
this.LEFT = false
|
||||
this.RIGHT = false
|
||||
this.UP = false
|
||||
this.DOWN = false
|
||||
this.ZOOM_IN = false
|
||||
this.ZOOM_OUT = false
|
||||
this.SHIFT = false
|
||||
|
||||
canvas.addEventListener('mousedown', this._mouseDown.bind(this))
|
||||
canvas.addEventListener('mouseup', this._mouseUp.bind(this))
|
||||
canvas.addEventListener('mousemove', this._mouseMove.bind(this))
|
||||
canvas.addEventListener('wheel', this._wheel.bind(this))
|
||||
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
if (KEY_LISTENER_OFF) {
|
||||
return
|
||||
}
|
||||
if (ev.key === 'Shift') {
|
||||
this.SHIFT = true
|
||||
} else if (ev.key === 'ArrowUp' || ev.key === 'w' || ev.key === 'W') {
|
||||
this.UP = true
|
||||
} else if (ev.key === 'ArrowDown' || ev.key === 's' || ev.key === 'S') {
|
||||
this.DOWN = true
|
||||
} else if (ev.key === 'ArrowLeft' || ev.key === 'a' || ev.key === 'A') {
|
||||
this.LEFT = true
|
||||
} else if (ev.key === 'ArrowRight' || ev.key === 'd' || ev.key === 'D') {
|
||||
this.RIGHT = true
|
||||
} else if (ev.key === 'q') {
|
||||
this.ZOOM_OUT = true
|
||||
} else if (ev.key === 'e') {
|
||||
this.ZOOM_IN = true
|
||||
}
|
||||
})
|
||||
window.addEventListener('keyup', (ev) => {
|
||||
if (KEY_LISTENER_OFF) {
|
||||
return
|
||||
}
|
||||
if (ev.key === 'Shift') {
|
||||
this.SHIFT = false
|
||||
} else if (ev.key === 'ArrowUp' || ev.key === 'w' || ev.key === 'W') {
|
||||
this.UP = false
|
||||
} else if (ev.key === 'ArrowDown' || ev.key === 's' || ev.key === 'S') {
|
||||
this.DOWN = false
|
||||
} else if (ev.key === 'ArrowLeft' || ev.key === 'a' || ev.key === 'A') {
|
||||
this.LEFT = false
|
||||
} else if (ev.key === 'ArrowRight' || ev.key === 'd' || ev.key === 'D') {
|
||||
this.RIGHT = false
|
||||
} else if (ev.key === 'q') {
|
||||
this.ZOOM_OUT = false
|
||||
} else if (ev.key === 'e') {
|
||||
this.ZOOM_IN = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addEvent(event) {
|
||||
this.events.push(event)
|
||||
}
|
||||
|
||||
consumeAll() {
|
||||
if (this.events.length === 0) {
|
||||
return []
|
||||
}
|
||||
const all = this.events.slice()
|
||||
this.events = []
|
||||
return all
|
||||
}
|
||||
|
||||
_keydowns() {
|
||||
let amount = this.SHIFT ? 20 : 10
|
||||
let x = 0
|
||||
let y = 0
|
||||
if (this.UP) {
|
||||
y += amount
|
||||
}
|
||||
if (this.DOWN) {
|
||||
y -= amount
|
||||
}
|
||||
if (this.LEFT) {
|
||||
x += amount
|
||||
}
|
||||
if (this.RIGHT) {
|
||||
x -= amount
|
||||
}
|
||||
|
||||
if (x !== 0 || y !== 0) {
|
||||
this.addEvent([Protocol.INPUT_EV_MOVE, x, y])
|
||||
}
|
||||
|
||||
// zoom keys
|
||||
const pos = this._viewport.viewportToWorld({
|
||||
x: this._canvas.width / 2,
|
||||
y: this._canvas.height / 2,
|
||||
})
|
||||
if (this.ZOOM_IN && this.ZOOM_OUT) {
|
||||
// cancel each other out
|
||||
} else if (this.ZOOM_IN) {
|
||||
this.addEvent([Protocol.INPUT_EV_ZOOM_IN, pos.x, pos.y])
|
||||
} else if (this.ZOOM_OUT) {
|
||||
this.addEvent([Protocol.INPUT_EV_ZOOM_OUT, pos.x, pos.y])
|
||||
}
|
||||
}
|
||||
|
||||
_mouseDown(e) {
|
||||
if (e.button === 0) {
|
||||
const pos = this._viewport.viewportToWorld({
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
})
|
||||
this.addEvent([Protocol.INPUT_EV_MOUSE_DOWN, pos.x, pos.y])
|
||||
}
|
||||
}
|
||||
|
||||
_mouseUp(e) {
|
||||
if (e.button === 0) {
|
||||
const pos = this._viewport.viewportToWorld({
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
})
|
||||
this.addEvent([Protocol.INPUT_EV_MOUSE_UP, pos.x, pos.y])
|
||||
}
|
||||
}
|
||||
|
||||
_mouseMove(e) {
|
||||
const pos = this._viewport.viewportToWorld({
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
})
|
||||
this.addEvent([Protocol.INPUT_EV_MOUSE_MOVE, pos.x, pos.y])
|
||||
}
|
||||
|
||||
_wheel(e) {
|
||||
const pos = this._viewport.viewportToWorld({
|
||||
x: e.offsetX,
|
||||
y: e.offsetY,
|
||||
})
|
||||
const evt = e.deltaY < 0 ? Protocol.INPUT_EV_ZOOM_IN : Protocol.INPUT_EV_ZOOM_OUT
|
||||
this.addEvent([evt, pos.x, pos.y])
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let gameId = GAME_ID
|
||||
let CLIENT_ID = initme()
|
||||
|
||||
const cursorGrab = await Graphics.loadImageToBitmap('/grab.png')
|
||||
const cursorHand = await Graphics.loadImageToBitmap('/hand.png')
|
||||
const cursorGrabMask = await Graphics.loadImageToBitmap('/grab_mask.png')
|
||||
const cursorHandMask = await Graphics.loadImageToBitmap('/hand_mask.png')
|
||||
|
||||
// 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 = {}
|
||||
const getPlayerCursor = async (p) => {
|
||||
const key = p.color + ' ' + p.d
|
||||
if (!cursors[key]) {
|
||||
const cursor = p.d ? cursorGrab : cursorHand
|
||||
const mask = p.d ? cursorGrabMask : cursorHandMask
|
||||
cursors[key] = await Graphics.colorize(cursor, mask, p.color)
|
||||
}
|
||||
return cursors[key]
|
||||
}
|
||||
|
||||
// Create a canvas and attach adapters to it so we can work with it
|
||||
const canvas = addCanvasToDom(Graphics.createCanvas())
|
||||
|
||||
|
||||
// stuff only available in replay mode...
|
||||
// TODO: refactor
|
||||
const REPLAY = {
|
||||
log: null,
|
||||
logIdx: 0,
|
||||
speeds: [0.5, 1, 2, 5, 10, 20, 50],
|
||||
speedIdx: 1,
|
||||
paused: false,
|
||||
lastRealTs: null,
|
||||
lastGameTs: null,
|
||||
gameStartTs: null,
|
||||
}
|
||||
|
||||
if (MODE === MODE_PLAY) {
|
||||
const game = await Communication.connect(gameId, CLIENT_ID)
|
||||
const gameObject = Util.decodeGame(game)
|
||||
Game.setGame(gameObject.id, gameObject)
|
||||
} else if (MODE === MODE_REPLAY) {
|
||||
const {game, log} = await Communication.connectReplay(gameId, CLIENT_ID)
|
||||
const gameObject = Util.decodeGame(game)
|
||||
Game.setGame(gameObject.id, gameObject)
|
||||
REPLAY.log = log
|
||||
REPLAY.lastRealTs = Time.timestamp()
|
||||
REPLAY.gameStartTs = REPLAY.log[0][REPLAY.log[0].length - 1]
|
||||
REPLAY.lastGameTs = REPLAY.gameStartTs
|
||||
TIME = () => REPLAY.lastGameTs
|
||||
} else {
|
||||
throw '[ 2020-12-22 MODE invalid, must be play|replay ]'
|
||||
}
|
||||
|
||||
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 bitmaps = await PuzzleGraphics.loadPuzzleBitmaps(Game.getPuzzle(gameId))
|
||||
|
||||
const {
|
||||
bgColorPickerEl,
|
||||
playerColorPickerEl,
|
||||
nameChangeEl,
|
||||
updateScoreBoard,
|
||||
updateTimer,
|
||||
udateTilesDone,
|
||||
togglePreview,
|
||||
replayControl,
|
||||
} = addMenuToDom(gameId)
|
||||
updateTimer()
|
||||
udateTilesDone()
|
||||
updateScoreBoard(TIME())
|
||||
|
||||
const longFinished = !! Game.getFinishTs(gameId)
|
||||
let finished = longFinished
|
||||
const justFinished = () => finished && !longFinished
|
||||
|
||||
const fireworks = new fireworksController(canvas, Game.getRng(gameId))
|
||||
fireworks.init(canvas)
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
canvas.classList.add('loaded')
|
||||
|
||||
// initialize some view data
|
||||
// this global data will change according to input events
|
||||
const viewport = new Camera()
|
||||
// center viewport
|
||||
viewport.move(
|
||||
-(TABLE_WIDTH - canvas.width) /2,
|
||||
-(TABLE_HEIGHT - canvas.height) /2
|
||||
)
|
||||
|
||||
const playerBgColor = () => {
|
||||
return (Game.getPlayerBgColor(gameId, CLIENT_ID)
|
||||
|| localStorage.getItem('bg_color')
|
||||
|| '#222222')
|
||||
}
|
||||
const playerColor = () => {
|
||||
return (Game.getPlayerColor(gameId, CLIENT_ID)
|
||||
|| localStorage.getItem('player_color')
|
||||
|| '#ffffff')
|
||||
}
|
||||
const playerName = () => {
|
||||
return (Game.getPlayerName(gameId, CLIENT_ID)
|
||||
|| localStorage.getItem('player_name')
|
||||
|| 'anon')
|
||||
}
|
||||
|
||||
window.addEventListener('keypress', (ev) => {
|
||||
if (KEY_LISTENER_OFF) {
|
||||
return
|
||||
}
|
||||
if (ev.key === ' ') {
|
||||
togglePreview()
|
||||
}
|
||||
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 evts = new EventAdapter(canvas, window, viewport)
|
||||
|
||||
if (MODE === MODE_PLAY) {
|
||||
bgColorPickerEl.value = playerBgColor()
|
||||
evts.addEvent([Protocol.INPUT_EV_BG_COLOR, bgColorPickerEl.value])
|
||||
bgColorPickerEl.addEventListener('change', () => {
|
||||
localStorage.setItem('bg_color', bgColorPickerEl.value)
|
||||
evts.addEvent([Protocol.INPUT_EV_BG_COLOR, bgColorPickerEl.value])
|
||||
})
|
||||
playerColorPickerEl.value = playerColor()
|
||||
evts.addEvent([Protocol.INPUT_EV_PLAYER_COLOR, playerColorPickerEl.value])
|
||||
playerColorPickerEl.addEventListener('change', () => {
|
||||
localStorage.setItem('player_color', playerColorPickerEl.value)
|
||||
evts.addEvent([Protocol.INPUT_EV_PLAYER_COLOR, playerColorPickerEl.value])
|
||||
})
|
||||
nameChangeEl.value = playerName()
|
||||
evts.addEvent([Protocol.INPUT_EV_PLAYER_NAME, nameChangeEl.value])
|
||||
nameChangeEl.addEventListener('change', () => {
|
||||
localStorage.setItem('player_name', nameChangeEl.value)
|
||||
evts.addEvent([Protocol.INPUT_EV_PLAYER_NAME, nameChangeEl.value])
|
||||
})
|
||||
setInterval(updateTimer, 1000)
|
||||
} else if (MODE === MODE_REPLAY) {
|
||||
const setSpeedStatus = () => {
|
||||
replayControl.speed.innerText = 'Replay-Speed: ' +
|
||||
(REPLAY.speeds[REPLAY.speedIdx] + 'x') +
|
||||
(REPLAY.paused ? ' Paused' : '')
|
||||
}
|
||||
setSpeedStatus()
|
||||
replayControl.speedUp.addEventListener('click', () => {
|
||||
if (REPLAY.speedIdx + 1 < REPLAY.speeds.length) {
|
||||
REPLAY.speedIdx++
|
||||
setSpeedStatus()
|
||||
}
|
||||
})
|
||||
replayControl.speedDown.addEventListener('click', () => {
|
||||
if (REPLAY.speedIdx >= 1) {
|
||||
REPLAY.speedIdx--
|
||||
setSpeedStatus()
|
||||
}
|
||||
})
|
||||
replayControl.pause.addEventListener('click', () => {
|
||||
REPLAY.paused = !REPLAY.paused
|
||||
setSpeedStatus()
|
||||
})
|
||||
}
|
||||
|
||||
if (MODE === MODE_PLAY) {
|
||||
Communication.onServerChange((msg) => {
|
||||
const msgType = msg[0]
|
||||
const evClientId = msg[1]
|
||||
const evClientSeq = msg[2]
|
||||
const evChanges = msg[3]
|
||||
for(let [changeType, changeData] of evChanges) {
|
||||
switch (changeType) {
|
||||
case Protocol.CHANGE_PLAYER: {
|
||||
const p = Util.decodePlayer(changeData)
|
||||
if (p.id !== CLIENT_ID) {
|
||||
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()
|
||||
entryWithTs[entryWithTs.length - 1] = nextTs
|
||||
if (entryWithTs[0] === Protocol.LOG_ADD_PLAYER) {
|
||||
Game.addPlayer(gameId, ...entryWithTs.slice(1))
|
||||
RERENDER = true
|
||||
} else if (entryWithTs[0] === Protocol.LOG_UPDATE_PLAYER) {
|
||||
const playerId = Game.getPlayerIdByIndex(gameId, entryWithTs[1])
|
||||
Game.addPlayer(gameId, playerId, ...entryWithTs.slice(2))
|
||||
RERENDER = true
|
||||
} else if (entryWithTs[0] === Protocol.LOG_HANDLE_INPUT) {
|
||||
const playerId = Game.getPlayerIdByIndex(gameId, entryWithTs[1])
|
||||
Game.handleInput(gameId, playerId, ...entryWithTs.slice(2))
|
||||
RERENDER = true
|
||||
}
|
||||
REPLAY.logIdx = nextIdx
|
||||
} while (true)
|
||||
REPLAY.lastRealTs = realTs
|
||||
REPLAY.lastGameTs = maxGameTs
|
||||
updateTimer()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
let _last_mouse_down = null
|
||||
const onUpdate = () => {
|
||||
// handle key downs once per onUpdate
|
||||
// this will create Protocol.INPUT_EV_MOVE events if something
|
||||
// relevant is pressed
|
||||
evts._keydowns()
|
||||
|
||||
for (let 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, CLIENT_ID)) {
|
||||
// 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] }
|
||||
if (viewport.zoomIn(viewport.worldToViewport(pos))) {
|
||||
RERENDER = true
|
||||
Game.changePlayer(gameId, CLIENT_ID, pos)
|
||||
}
|
||||
} else if (type === Protocol.INPUT_EV_ZOOM_OUT) {
|
||||
const pos = { x: evt[1], y: evt[2] }
|
||||
if (viewport.zoomOut(viewport.worldToViewport(pos))) {
|
||||
RERENDER = true
|
||||
Game.changePlayer(gameId, CLIENT_ID, pos)
|
||||
}
|
||||
}
|
||||
|
||||
// LOCAL + SERVER CHANGES
|
||||
// -------------------------------------------------------------
|
||||
const ts = TIME()
|
||||
const changes = Game.handleInput(GAME_ID, CLIENT_ID, 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_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] }
|
||||
if (viewport.zoomIn(viewport.worldToViewport(pos))) {
|
||||
RERENDER = true
|
||||
}
|
||||
} else if (type === Protocol.INPUT_EV_ZOOM_OUT) {
|
||||
const pos = { x: evt[1], y: evt[2] }
|
||||
if (viewport.zoomOut(viewport.worldToViewport(pos))) {
|
||||
RERENDER = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finished = !! Game.getFinishTs(gameId)
|
||||
if (justFinished()) {
|
||||
fireworks.update()
|
||||
RERENDER = true
|
||||
}
|
||||
}
|
||||
|
||||
const onRender = async () => {
|
||||
if (!RERENDER) {
|
||||
return
|
||||
}
|
||||
|
||||
let pos
|
||||
let dim
|
||||
|
||||
if (DEBUG) Debug.checkpoint_start(0)
|
||||
|
||||
// CLEAR CTX
|
||||
// ---------------------------------------------------------------
|
||||
ctx.fillStyle = playerBgColor()
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
if (DEBUG) Debug.checkpoint('clear done')
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
|
||||
// DRAW BOARD
|
||||
// ---------------------------------------------------------------
|
||||
pos = viewport.worldToViewportRaw({
|
||||
x: (TABLE_WIDTH - PUZZLE_WIDTH) / 2,
|
||||
y: (TABLE_HEIGHT - PUZZLE_HEIGHT) / 2
|
||||
})
|
||||
dim = viewport.worldDimToViewportRaw({
|
||||
w: PUZZLE_WIDTH,
|
||||
h: PUZZLE_HEIGHT,
|
||||
})
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, .3)'
|
||||
ctx.fillRect(pos.x, pos.y, dim.w, dim.h)
|
||||
if (DEBUG) Debug.checkpoint('board done')
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
|
||||
// DRAW TILES
|
||||
// ---------------------------------------------------------------
|
||||
const tiles = Game.getTilesSortedByZIndex(gameId)
|
||||
if (DEBUG) Debug.checkpoint('get tiles done')
|
||||
|
||||
for (let tile of tiles) {
|
||||
if (tile.owner === -1) {
|
||||
if (!PIECE_VIEW_FIXED) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (!PIECE_VIEW_LOOSE) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const bmp = bitmaps[tile.idx]
|
||||
pos = viewport.worldToViewportRaw({
|
||||
x: TILE_DRAW_OFFSET + tile.pos.x,
|
||||
y: TILE_DRAW_OFFSET + tile.pos.y,
|
||||
})
|
||||
dim = viewport.worldDimToViewportRaw({
|
||||
w: TILE_DRAW_SIZE,
|
||||
h: TILE_DRAW_SIZE,
|
||||
})
|
||||
ctx.drawImage(bmp,
|
||||
0, 0, bmp.width, bmp.height,
|
||||
pos.x, pos.y, dim.w, dim.h
|
||||
)
|
||||
}
|
||||
if (DEBUG) Debug.checkpoint('tiles done')
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
|
||||
// DRAW PLAYERS
|
||||
// ---------------------------------------------------------------
|
||||
const ts = TIME()
|
||||
const texts = []
|
||||
// Cursors
|
||||
for (let player of Game.getActivePlayers(gameId, ts)) {
|
||||
const cursor = await getPlayerCursor(player)
|
||||
const pos = viewport.worldToViewport(player)
|
||||
ctx.drawImage(cursor, pos.x - CURSOR_W_2, pos.y - CURSOR_H_2)
|
||||
if (
|
||||
(MODE === MODE_PLAY && player.id !== CLIENT_ID)
|
||||
|| (MODE === MODE_REPLAY)
|
||||
) {
|
||||
// performance:
|
||||
// not drawing text directly here, to have less ctx
|
||||
// switches between drawImage and fillTxt
|
||||
texts.push([
|
||||
`${player.name} (${player.points})`,
|
||||
pos.x,
|
||||
pos.y + CURSOR_H,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// Names
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.textAlign = 'center'
|
||||
for (let [txt, x, y] of texts) {
|
||||
ctx.fillText(txt, x, y)
|
||||
}
|
||||
|
||||
if (DEBUG) Debug.checkpoint('players done')
|
||||
|
||||
// DRAW PLAYERS
|
||||
// ---------------------------------------------------------------
|
||||
updateScoreBoard(ts)
|
||||
udateTilesDone()
|
||||
if (DEBUG) Debug.checkpoint('scores done')
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
if (justFinished()) {
|
||||
fireworks.render()
|
||||
}
|
||||
|
||||
RERENDER = false
|
||||
}
|
||||
|
||||
run({
|
||||
update: onUpdate,
|
||||
render: onRender,
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
31
public/gameloop.js
Normal file
31
public/gameloop.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export const run = options => {
|
||||
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
public/grab.png
Normal file
BIN
public/grab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 B |
BIN
public/grab_mask.png
Normal file
BIN
public/grab_mask.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 B |
BIN
public/hand.png
Normal file
BIN
public/hand.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 B |
BIN
public/hand_mask.png
Normal file
BIN
public/hand_mask.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 B |
190
public/index.js
Normal file
190
public/index.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import GameCommon from '../common/GameCommon.js';
|
||||
import Time from '../common/Time.js'
|
||||
|
||||
const Upload = {
|
||||
name: 'upload',
|
||||
props: {
|
||||
accept: String,
|
||||
label: String,
|
||||
},
|
||||
template: `
|
||||
<label>
|
||||
<input type="file" style="display: none" @change="upload" :accept="accept" />
|
||||
<span class="btn">{{label || 'Upload File'}}</span>
|
||||
</label>
|
||||
`,
|
||||
methods: {
|
||||
async upload(evt) {
|
||||
const file = evt.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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const GameTeaser = {
|
||||
name: 'game-teaser',
|
||||
props: {
|
||||
game: Object,
|
||||
},
|
||||
template: `
|
||||
<div class="game-teaser" :style="style">
|
||||
<a class="game-info" :href="'/g/' + game.id">
|
||||
<span class="game-info-text">
|
||||
🧩 {{game.tilesFinished}}/{{game.tilesTotal}}<br />
|
||||
👥 {{game.players}}<br />
|
||||
{{time(game.started, game.finished)}}<br />
|
||||
</span>
|
||||
</a>
|
||||
<a v-if="false && game.hasReplay" class="game-replay" :href="'/replay/' + game.id">
|
||||
↪️ Watch replay
|
||||
</a>
|
||||
</div>`,
|
||||
computed: {
|
||||
style() {
|
||||
const url = this.game.imageUrl.replace('uploads/', 'uploads/r/') + '-375x210.webp'
|
||||
return {
|
||||
'background-image': `url("${url}")`,
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
time(start, end) {
|
||||
const icon = end ? '🏁' : '⏳'
|
||||
const from = start;
|
||||
const to = end || Time.timestamp()
|
||||
const timeDiffStr = Time.timeDiffStr(from, to)
|
||||
return `${icon} ${timeDiffStr}`
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const ImageTeaser = {
|
||||
name: 'image-teaser',
|
||||
props: {
|
||||
image: Object
|
||||
},
|
||||
template: `<div class="imageteaser" :style="style" @click="onClick"></div>`,
|
||||
computed: {
|
||||
style() {
|
||||
const url = this.image.url.replace('uploads/', 'uploads/r/') + '-150x100.webp'
|
||||
return {
|
||||
'background-image': `url("${url}")`,
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('click')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Upload,
|
||||
GameTeaser,
|
||||
ImageTeaser,
|
||||
},
|
||||
props: {
|
||||
gamesRunning: Array,
|
||||
gamesFinished: Array,
|
||||
images: Array,
|
||||
},
|
||||
template: `
|
||||
<div id="app">
|
||||
<h1>Running games</h1>
|
||||
<div class="game-teaser-wrap" v-for="g in gamesRunning">
|
||||
<game-teaser :game="g" />
|
||||
</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">
|
||||
<span class="btn" :class="" @click="onNewGameClick">Start new game</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h1>Image lib</h1>
|
||||
<div>
|
||||
<image-teaser v-for="i in images" :image="i" @click="image = i" />
|
||||
</div>
|
||||
|
||||
<h1>Finished games</h1>
|
||||
<div class="game-teaser-wrap" v-for="g in gamesFinished">
|
||||
<game-teaser :game="g" />
|
||||
</div>
|
||||
</div>`,
|
||||
data() {
|
||||
return {
|
||||
tiles: 1000,
|
||||
image: '',
|
||||
scoreMode: GameCommon.SCORE_MODE_ANY,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
time(start, end) {
|
||||
const icon = end ? '🏁' : '⏳'
|
||||
const from = start;
|
||||
const to = end || Time.timestamp()
|
||||
const timeDiffStr = Time.timeDiffStr(from, to)
|
||||
return `${icon} ${timeDiffStr}`
|
||||
},
|
||||
mediaImgUploaded(j) {
|
||||
this.image = j.image
|
||||
},
|
||||
async onNewGameClick() {
|
||||
const res = await fetch('/newgame', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tiles: this.tiles,
|
||||
image: this.image,
|
||||
scoreMode: parseInt(this.scoreMode, 10),
|
||||
}),
|
||||
})
|
||||
if (res.status === 200) {
|
||||
const game = await res.json()
|
||||
location.assign(game.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
251
public/style.css
Normal file
251
public/style.css
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
: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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.help {
|
||||
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;
|
||||
}
|
||||
|
||||
.settings {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
.imageteaser {
|
||||
width: 150px;
|
||||
height: 100px;
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue