160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
import { useWebSocket } from '@vueuse/core'
|
|
import { placeApiCanvas } from '@/api'
|
|
|
|
// 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(eventPath: string, onPixel?: OnPixel, onResize?: OnResize) {
|
|
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')
|
|
|
|
// Pixels placed while disconnected never reach us: catch up by reloading
|
|
// the whole canvas on every reconnect (the first load is done on mount).
|
|
let hasConnected = false
|
|
watch(connected, (isConnected) => {
|
|
if (!isConnected) return
|
|
if (hasConnected) loadCanvas()
|
|
hasConnected = true
|
|
})
|
|
|
|
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,
|
|
}
|
|
}
|