feat: clock + place games
This commit is contained in:
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user