feat: clock + place games

This commit is contained in:
2026-09-17 01:33:54 +02:00
parent d104480188
commit 6866b45f66
46 changed files with 2043 additions and 10 deletions
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { useClockSocket } from "@/composables/useClockSocket";
import { provideEventPath } from "@/composables/useEventPath";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
provideEventPath(props.eventPath);
const { t } = useI18n();
useTitle(computed(() => t("clock.pageTitle")));
const {
connected,
remainingMs,
myPoints,
myRank,
totalPlayers,
pointsAbove,
pointsBelow,
cooldownRemainingMs,
cooldownDurationSeconds,
press,
} = useClockSocket();
const remainingDisplay = computed(() => {
const totalSeconds = Math.floor(remainingMs.value / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
});
const cooldownSeconds = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
const onCooldown = computed(() => cooldownRemainingMs.value > 0);
</script>
<template>
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8">
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
{{ connected ? t("clock.connected") : t("clock.reconnecting") }}
</span>
<div class="font-bold text-7xl tabular-nums">
{{ remainingDisplay }}
</div>
<div class="flex flex-col items-center gap-3">
<button class="btn btn-error btn-xl w-48 h-48 rounded-full text-3xl shadow-lg" :disabled="onCooldown"
@click="press">
<span v-if="onCooldown">{{ cooldownSeconds }}s</span>
<span v-else>{{ t("clock.press") }}</span>
</button>
<progress v-if="onCooldown" class="progress progress-error w-48"
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
</div>
<div class="w-64 flex flex-col gap-1 text-sm">
<div v-if="pointsAbove !== null" class="flex justify-between items-center px-3 py-1.5 opacity-60">
<span>{{ t("clock.nextPlayer") }}</span>
<span class="font-mono">{{ pointsAbove.toFixed(0) }}</span>
</div>
<div class="flex justify-between items-center px-3 py-2 rounded-box bg-base-300 font-bold">
<span>{{ t("clock.yourRank", { rank: myRank ?? "-", total: totalPlayers }) }}</span>
<span class="font-mono">{{ myPoints.toFixed(0) }}</span>
</div>
<div v-if="pointsBelow !== null" class="flex justify-between items-center px-3 py-1.5 opacity-60">
<span>{{ t("clock.chasingYou") }}</span>
<span class="font-mono">{{ pointsBelow.toFixed(0) }}</span>
</div>
</div>
</div>
</template>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { usePlaceSocket } from "@/composables/usePlaceSocket";
import { provideEventPath } from "@/composables/useEventPath";
import { PALETTE } from "@/palette";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
provideEventPath(props.eventPath);
const { t } = useI18n();
useTitle(computed(() => t("place.pageTitle")));
// Internal resolution of the grid overlay canvas - independent of the pixel
// canvas's own resolution, and scaled per grid cell (not to a fixed square)
// so grid lines and the hover highlight stay crisp regardless of the
// admin-configured grid size.
const PX_PER_CELL = 8;
interface Cell {
x: number;
y: number;
}
const pixelCanvasRef = ref<HTMLCanvasElement | null>(null);
const gridCanvasRef = ref<HTMLCanvasElement | null>(null);
const selectedColorIndex = ref(5);
const hoveredCell = ref<Cell | null>(null);
const showGrid = ref(true);
let pixelCtx: CanvasRenderingContext2D | null = null;
let gridCtx: CanvasRenderingContext2D | null = null;
function drawPixel(x: number, y: number, colorIndex: number) {
if (!pixelCtx) return;
pixelCtx.fillStyle = PALETTE[colorIndex];
pixelCtx.fillRect(x, y, 1, 1);
}
const {
connected,
cooldownRemainingMs,
cooldownDurationSeconds,
gridWidth,
gridHeight,
place,
} = usePlaceSocket(drawPixel);
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
const onCooldown = computed(() => cooldownRemainingMs.value > 0);
// The overlay canvas never touches the pixel colors - it only draws grid
// lines plus a preview of the hovered cell, so it can freely redraw on
// every mouse move without ever risking the actual placed pixels. The grid
// lines are cosmetic and can be toggled off, but the hover preview is the
// only way to know which cell a click will hit, so it always stays on.
function drawOverlay() {
if (!gridCtx) return;
const width = overlayWidth.value;
const height = overlayHeight.value;
gridCtx.clearRect(0, 0, width, height);
if (showGrid.value) {
gridCtx.strokeStyle = "rgba(128, 128, 128, 0.35)";
gridCtx.lineWidth = 1;
for (let i = 1; i < gridWidth.value; i++) {
const pos = i * PX_PER_CELL + 0.5;
gridCtx.beginPath();
gridCtx.moveTo(pos, 0);
gridCtx.lineTo(pos, height);
gridCtx.stroke();
}
for (let i = 1; i < gridHeight.value; i++) {
const pos = i * PX_PER_CELL + 0.5;
gridCtx.beginPath();
gridCtx.moveTo(0, pos);
gridCtx.lineTo(width, pos);
gridCtx.stroke();
}
}
if (hoveredCell.value && !onCooldown.value) {
const { x, y } = hoveredCell.value;
gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`;
gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL);
gridCtx.strokeStyle = "rgba(0, 0, 0, 0.6)";
gridCtx.lineWidth = 2;
gridCtx.strokeRect(x * PX_PER_CELL + 1, y * PX_PER_CELL + 1, PX_PER_CELL - 2, PX_PER_CELL - 2);
}
}
watch([selectedColorIndex, onCooldown, showGrid, gridWidth, gridHeight], drawOverlay);
onMounted(() => {
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
gridCtx = gridCanvasRef.value?.getContext("2d") ?? null;
drawOverlay();
});
function cellFromEvent(event: MouseEvent): Cell {
const rect = pixelCanvasRef.value!.getBoundingClientRect();
const x = Math.floor(((event.clientX - rect.left) / rect.width) * gridWidth.value);
const y = Math.floor(((event.clientY - rect.top) / rect.height) * gridHeight.value);
return {
x: Math.min(gridWidth.value - 1, Math.max(0, x)),
y: Math.min(gridHeight.value - 1, Math.max(0, y)),
};
}
function handleMouseMove(event: MouseEvent) {
hoveredCell.value = cellFromEvent(event);
drawOverlay();
}
function handleMouseLeave() {
hoveredCell.value = null;
drawOverlay();
}
function handleClick(event: MouseEvent) {
if (onCooldown.value) return;
const { x, y } = cellFromEvent(event);
place(x, y, selectedColorIndex.value);
}
</script>
<template>
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6">
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
{{ connected ? t("place.connected") : t("place.reconnecting") }}
</span>
<div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }">
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
imageRendering: 'pixelated',
cursor: onCooldown ? 'not-allowed' : 'crosshair',
}" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" />
<canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight"
class="absolute inset-0 w-full h-full rounded-box pointer-events-none" />
</div>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
</button>
</div>
<div class="flex flex-wrap justify-center gap-2 max-w-md">
<button v-for="(color, index) in PALETTE" :key="index" class="w-8 h-8 rounded-btn border-2"
:class="selectedColorIndex === index ? 'border-primary scale-110' : 'border-base-300'"
:style="{ backgroundColor: color }" @click="selectedColorIndex = index" />
</div>
<div class="flex flex-col items-center gap-2">
<span v-if="onCooldown" class="font-bold text-2xl tabular-nums">
{{ cooldownSecondsDisplay }}s
</span>
<progress v-if="onCooldown" class="progress progress-primary w-48"
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
</div>
</div>
</template>
File diff suppressed because one or more lines are too long
+34 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
@@ -272,6 +272,39 @@ export const submissionsApiProcessFileOcr = <ThrowOnError extends boolean = fals
*/
export const submissionsApiProcessCropOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessCropOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessCropOcrResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options });
/**
* Current State
*/
export const clockApiCurrentState = <ThrowOnError extends boolean = false>(options: Options<ClockApiCurrentStateData, ThrowOnError>) => (options.client ?? client).get<ClockApiCurrentStateResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/clock/current', ...options });
/**
* My Stats
*/
export const clockApiMyStats = <ThrowOnError extends boolean = false>(options: Options<ClockApiMyStatsData, ThrowOnError>) => (options.client ?? client).get<ClockApiMyStatsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/clock/me', ...options });
/**
* Canvas
*
* The one-time full-grid load. Every placement after that arrives as a
* single websocket message, never a re-fetch.
*/
export const placeApiCanvas = <ThrowOnError extends boolean = false>(options: Options<PlaceApiCanvasData, ThrowOnError>) => (options.client ?? client).get<PlaceApiCanvasResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/place/canvas', ...options });
/**
* Evolution Gif
*
* Admin-only: replays the full placement history as an animated GIF.
*/
export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiEvolutionGifResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/evolution.gif',
...options
});
/**
* List Markets
*
+136
View File
@@ -1020,6 +1020,62 @@ export type ImageCropIn = {
label?: string | null;
};
/**
* ClockStateOut
*/
export type ClockStateOut = {
/**
* Server Time
*/
server_time: string;
/**
* End Time
*/
end_time: string;
/**
* Initial Duration
*/
initial_duration: number;
/**
* Press Bonus Seconds
*/
press_bonus_seconds: number;
/**
* Next Press At
*/
next_press_at: string | null;
/**
* Cooldown Seconds
*/
cooldown_seconds: number | null;
};
/**
* PlayerStatsOut
*/
export type PlayerStatsOut = {
/**
* Points
*/
points: number;
/**
* Rank
*/
rank: number;
/**
* Total Players
*/
total_players: number;
/**
* Points Above
*/
points_above: number | null;
/**
* Points Below
*/
points_below: number | null;
};
/**
* MarketListSchema
*/
@@ -1991,6 +2047,86 @@ export type SubmissionsApiProcessCropOcrResponses = {
export type SubmissionsApiProcessCropOcrResponse = SubmissionsApiProcessCropOcrResponses[keyof SubmissionsApiProcessCropOcrResponses];
export type ClockApiCurrentStateData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/clock/current';
};
export type ClockApiCurrentStateResponses = {
/**
* OK
*/
200: ClockStateOut;
};
export type ClockApiCurrentStateResponse = ClockApiCurrentStateResponses[keyof ClockApiCurrentStateResponses];
export type ClockApiMyStatsData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/clock/me';
};
export type ClockApiMyStatsResponses = {
/**
* OK
*/
200: PlayerStatsOut;
};
export type ClockApiMyStatsResponse = ClockApiMyStatsResponses[keyof ClockApiMyStatsResponses];
export type PlaceApiCanvasData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/place/canvas';
};
export type PlaceApiCanvasResponses = {
/**
* OK
*/
200: unknown;
};
export type PlaceApiEvolutionGifData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/place/evolution.gif';
};
export type PlaceApiEvolutionGifResponses = {
/**
* OK
*/
200: unknown;
};
export type MarketApiListMarketsData = {
body?: never;
path: {
@@ -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,
}
}
+18
View File
@@ -71,6 +71,24 @@
"submitResults": "Submit results",
"footer": "Select a game above to begin submitting"
},
"clock": {
"pageTitle": "Beat the Clock",
"header": "Beat the Clock",
"connected": "connected",
"reconnecting": "reconnecting…",
"press": "PRESS",
"nextPlayer": "▲ next player",
"chasingYou": "▼ chasing you",
"yourRank": "you · rank #{rank} of {total}"
},
"place": {
"pageTitle": "Place",
"header": "Place",
"connected": "connected",
"reconnecting": "reconnecting…",
"showGrid": "Show grid",
"hideGrid": "Hide grid"
},
"market": {
"pageTitle": "Market",
"header": "Market",
+20
View File
@@ -0,0 +1,20 @@
// Mirrors place/models.py's PALETTE exactly, same order - the color index
// is what's sent over the wire, so the two lists must agree.
export const PALETTE = [
'#FFFFFF', // 0 white
'#E4E4E4', // 1 light grey
'#888888', // 2 grey
'#222222', // 3 black
'#FFA7D1', // 4 pink
'#E50000', // 5 red
'#E59500', // 6 orange
'#A06A42', // 7 brown
'#E5D900', // 8 yellow
'#94E044', // 9 light green
'#02BE01', // 10 green
'#00D3DD', // 11 cyan
'#0083C7', // 12 blue
'#0000EA', // 13 dark blue
'#CF6EE4', // 14 purple
'#820080', // 15 dark purple
]
+16
View File
@@ -1,8 +1,10 @@
import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from "vue-router";
import Clock from "@/Clock.vue";
import Home from "@/Home.vue";
import Market from "@/Market.vue";
import Noita from "@/Noita.vue";
import OpusMagnum from "@/OpusMagnum.vue";
import Place from "@/Place.vue";
import Submissions from "@/Submissions.vue";
import SubmissionsSelect from "@/SubmissionsSelect.vue";
import { usePageHeaderStore } from "@/stores/pageHeader";
@@ -52,6 +54,20 @@ export const router = createRouter({
props: true,
meta: { titleKey: "noita.header", getBackRoute: eventHome },
},
{
path: "/:eventPath/clock",
name: "clock",
component: Clock,
props: true,
meta: { titleKey: "clock.header", getBackRoute: eventHome },
},
{
path: "/:eventPath/place",
name: "place",
component: Place,
props: true,
meta: { titleKey: "place.header", getBackRoute: eventHome },
},
{
path: "/:eventPath/submissions/:gamePath",
name: "submissions-select",