feat: clock + place games
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useWebSocket } from '@vueuse/core'
|
||||
import { clockApiMyStats } from '@/api'
|
||||
import { useEventPath } from './useEventPath'
|
||||
|
||||
// Mirrors clock/wire.py: a one-letter type tag then '|'-separated fields, in a
|
||||
// fixed order the server always uses. See that file for the message table.
|
||||
// Everyone reaching this page is already authenticated (the Django view is
|
||||
// behind @login_required), so unlike pplace's original there's no
|
||||
// "not logged in" branch to handle here.
|
||||
const RECONNECT_DELAY_MS = 1000
|
||||
|
||||
export function useClockSocket() {
|
||||
const eventPath = useEventPath()
|
||||
|
||||
const endTimeMs = ref<number | null>(null)
|
||||
const offsetMs = ref(0)
|
||||
const remainingMs = ref(0)
|
||||
const myPoints = ref(0)
|
||||
const myRank = ref<number | null>(null)
|
||||
const totalPlayers = ref(0)
|
||||
const pointsAbove = ref<number | null>(null)
|
||||
const pointsBelow = ref<number | null>(null)
|
||||
const cooldownEndMs = ref<number | null>(null)
|
||||
const cooldownRemainingMs = ref(0)
|
||||
const cooldownDurationSeconds = ref(10)
|
||||
|
||||
let animationFrame: number | null = null
|
||||
|
||||
// Every server message carries its own send-time (epoch ms) so the client
|
||||
// can keep recalibrating its clock offset, regardless of message type.
|
||||
function calibrateOffset(nowMs: number) {
|
||||
offsetMs.value = nowMs - Date.now()
|
||||
}
|
||||
|
||||
function applyClock(endMs: number, nowMs: number) {
|
||||
endTimeMs.value = endMs
|
||||
calibrateOffset(nowMs)
|
||||
}
|
||||
|
||||
function applyCooldown(nextMs: number | null) {
|
||||
cooldownEndMs.value = nextMs ?? null
|
||||
}
|
||||
|
||||
function tickClock() {
|
||||
remainingMs.value =
|
||||
endTimeMs.value !== null ? Math.max(0, endTimeMs.value - (Date.now() + offsetMs.value)) : 0
|
||||
|
||||
cooldownRemainingMs.value =
|
||||
cooldownEndMs.value !== null
|
||||
? Math.max(0, cooldownEndMs.value - (Date.now() + offsetMs.value))
|
||||
: 0
|
||||
|
||||
animationFrame = requestAnimationFrame(tickClock)
|
||||
}
|
||||
|
||||
async function refreshStats() {
|
||||
const response = await clockApiMyStats({ path: { event_path: eventPath } })
|
||||
const stats = response.data
|
||||
if (!stats) return
|
||||
myPoints.value = stats.points
|
||||
myRank.value = stats.rank
|
||||
totalPlayers.value = stats.total_players
|
||||
pointsAbove.value = stats.points_above
|
||||
pointsBelow.value = stats.points_below
|
||||
}
|
||||
|
||||
function handleMessage(_ws: WebSocket, event: MessageEvent<string>) {
|
||||
const parts = event.data.split('|')
|
||||
|
||||
switch (parts[0]) {
|
||||
case 's': {
|
||||
const [, end, now, , bonus, next] = parts
|
||||
cooldownDurationSeconds.value = Number(bonus) * 2
|
||||
applyClock(Number(end), Number(now))
|
||||
applyCooldown(next !== '' ? Number(next) : null)
|
||||
break
|
||||
}
|
||||
case 't': {
|
||||
const [, end, now, pressed] = parts
|
||||
applyClock(Number(end), Number(now))
|
||||
// Anyone's press can shift my rank relative to them, so refresh on
|
||||
// every press-triggered tick, not just my own presses.
|
||||
if (pressed === '1') refreshStats()
|
||||
break
|
||||
}
|
||||
case 'k': {
|
||||
const [, next, now] = parts
|
||||
calibrateOffset(Number(now))
|
||||
applyCooldown(Number(next))
|
||||
break
|
||||
}
|
||||
case 'r': {
|
||||
if (parts[1] === 'c') {
|
||||
const [, , next, now] = parts
|
||||
calibrateOffset(Number(now))
|
||||
applyCooldown(Number(next))
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const wsUrl = computed(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}/ws/clock/${eventPath}/`
|
||||
})
|
||||
|
||||
const { status, send } = useWebSocket(wsUrl, {
|
||||
onConnected: () => refreshStats(),
|
||||
onMessage: handleMessage,
|
||||
autoReconnect: { retries: -1, delay: RECONNECT_DELAY_MS },
|
||||
})
|
||||
|
||||
const connected = computed(() => status.value === 'OPEN')
|
||||
|
||||
function press() {
|
||||
send('press')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animationFrame = requestAnimationFrame(tickClock)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame !== null) cancelAnimationFrame(animationFrame)
|
||||
})
|
||||
|
||||
return {
|
||||
connected,
|
||||
remainingMs,
|
||||
myPoints,
|
||||
myRank,
|
||||
totalPlayers,
|
||||
pointsAbove,
|
||||
pointsBelow,
|
||||
cooldownRemainingMs,
|
||||
cooldownDurationSeconds,
|
||||
press,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useWebSocket } from '@vueuse/core'
|
||||
import { placeApiCanvas } from '@/api'
|
||||
import { useEventPath } from './useEventPath'
|
||||
|
||||
// Mirrors place/wire.py: a one-letter type tag then '|'-separated fields, in
|
||||
// a fixed order the server always uses. See that file for the message table.
|
||||
// Everyone reaching this page is already authenticated (the Django view is
|
||||
// behind @login_required), so unlike pplace's original there's no
|
||||
// "not logged in" branch to handle here.
|
||||
const RECONNECT_DELAY_MS = 1000
|
||||
const DEFAULT_GRID_SIZE = 64
|
||||
// Mirrors place/models.py's PLACE_COOLDOWN_SECONDS - the server is the
|
||||
// source of truth for enforcement, this is only used to size the cooldown
|
||||
// progress bar.
|
||||
const PLACE_COOLDOWN_SECONDS = 30
|
||||
|
||||
export type OnPixel = (x: number, y: number, colorIndex: number) => void
|
||||
export type OnResize = (width: number, height: number) => void
|
||||
|
||||
// Plain (non-reactive) - a several-thousand-cell reactive array is
|
||||
// pointless overhead. Kept module-level, alongside its dimensions, so the
|
||||
// grid survives across component remounts. The grid isn't a fixed size -
|
||||
// an admin can resize it - so it's reallocated whenever the dimensions
|
||||
// change (see applyGridSize below).
|
||||
let grid = new Uint8Array(DEFAULT_GRID_SIZE * DEFAULT_GRID_SIZE)
|
||||
const gridWidth = ref(DEFAULT_GRID_SIZE)
|
||||
const gridHeight = ref(DEFAULT_GRID_SIZE)
|
||||
|
||||
export function usePlaceSocket(onPixel?: OnPixel, onResize?: OnResize) {
|
||||
const eventPath = useEventPath()
|
||||
|
||||
const cooldownEndMs = ref<number | null>(null)
|
||||
const cooldownRemainingMs = ref(0)
|
||||
|
||||
let animationFrame: number | null = null
|
||||
|
||||
function applyCooldown(nextMs: number | null) {
|
||||
cooldownEndMs.value = nextMs ?? null
|
||||
}
|
||||
|
||||
function tickCooldown() {
|
||||
cooldownRemainingMs.value =
|
||||
cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - Date.now()) : 0
|
||||
|
||||
animationFrame = requestAnimationFrame(tickCooldown)
|
||||
}
|
||||
|
||||
// Returns true if the size actually changed, so callers can decide
|
||||
// whether to re-fetch the canvas (the old pixel buffer is meaningless at
|
||||
// the new dimensions).
|
||||
function applyGridSize(width: number, height: number): boolean {
|
||||
if (width === gridWidth.value && height === gridHeight.value) return false
|
||||
gridWidth.value = width
|
||||
gridHeight.value = height
|
||||
grid = new Uint8Array(width * height)
|
||||
onResize?.(width, height)
|
||||
return true
|
||||
}
|
||||
|
||||
function setPixel(x: number, y: number, colorIndex: number) {
|
||||
grid[y * gridWidth.value + x] = colorIndex
|
||||
onPixel?.(x, y, colorIndex)
|
||||
}
|
||||
|
||||
function handleMessage(_ws: WebSocket, event: MessageEvent<string>) {
|
||||
const parts = event.data.split('|')
|
||||
|
||||
switch (parts[0]) {
|
||||
case 's': {
|
||||
const [, next, width, height] = parts
|
||||
applyCooldown(next !== '' ? Number(next) : null)
|
||||
applyGridSize(Number(width), Number(height))
|
||||
break
|
||||
}
|
||||
case 'p': {
|
||||
const [, x, y, c] = parts
|
||||
setPixel(Number(x), Number(y), Number(c))
|
||||
break
|
||||
}
|
||||
case 'k': {
|
||||
applyCooldown(Number(parts[1]))
|
||||
break
|
||||
}
|
||||
case 'r': {
|
||||
if (parts[1] === 'c') applyCooldown(Number(parts[2]))
|
||||
break
|
||||
}
|
||||
case 'e': {
|
||||
const [, width, height] = parts
|
||||
// The server already rebuilt its canvas at the new size (see
|
||||
// place/signals.py) - just re-fetch it rather than guess how to
|
||||
// remap our local pixels.
|
||||
if (applyGridSize(Number(width), Number(height))) loadCanvas()
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const wsUrl = computed(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}/ws/place/${eventPath}/`
|
||||
})
|
||||
|
||||
const { status, send } = useWebSocket(wsUrl, {
|
||||
onMessage: handleMessage,
|
||||
autoReconnect: { retries: -1, delay: RECONNECT_DELAY_MS },
|
||||
})
|
||||
|
||||
const connected = computed(() => status.value === 'OPEN')
|
||||
|
||||
async function loadCanvas() {
|
||||
const response = await placeApiCanvas({ path: { event_path: eventPath } })
|
||||
if (!response.data) return
|
||||
const bytes = new Uint8Array(await (response.data as Blob).arrayBuffer())
|
||||
// 4-byte little-endian header (uint16 width, uint16 height) - see
|
||||
// place/api.py - then width*height pixel bytes.
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const width = view.getUint16(0, true)
|
||||
const height = view.getUint16(2, true)
|
||||
applyGridSize(width, height)
|
||||
grid.set(bytes.subarray(4))
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
onPixel?.(x, y, grid[y * width + x])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function place(x: number, y: number, colorIndex: number) {
|
||||
send(`p|${x}|${y}|${colorIndex}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCanvas()
|
||||
animationFrame = requestAnimationFrame(tickCooldown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame !== null) cancelAnimationFrame(animationFrame)
|
||||
})
|
||||
|
||||
return {
|
||||
connected,
|
||||
cooldownRemainingMs,
|
||||
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
place,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user