feat(place): final end time + destruction wave
This commit is contained in:
@@ -7,8 +7,8 @@ from .models import PixelPlacement, PlaceSettings
|
||||
|
||||
@admin.register(PlaceSettings)
|
||||
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = ("event", "width", "height", "placement_enabled")
|
||||
list_editable = ("width", "height", "placement_enabled")
|
||||
list_display = ("event", "width", "height", "placement_ends_at")
|
||||
list_editable = ("width", "height", "placement_ends_at")
|
||||
list_filter = ("event",)
|
||||
change_actions = ("resync_canvas",)
|
||||
|
||||
|
||||
@@ -35,10 +35,14 @@ HEADER_FORMAT = "<HH"
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def canvas(request: HttpRequest, event_path: str):
|
||||
"""The one-time full-grid load. Every placement after that arrives as a
|
||||
single websocket message, never a re-fetch."""
|
||||
single websocket message, never a re-fetch. Once placement is over the
|
||||
board is shown wiped (Redis keeps the real final board, see tasks.py)."""
|
||||
place_settings = PlaceSettings.current(request.event)
|
||||
header = struct.pack(HEADER_FORMAT, place_settings.width, place_settings.height)
|
||||
pixels = read_canvas(event_path, place_settings.width, place_settings.height)
|
||||
if place_settings.placement_ended:
|
||||
pixels = bytes(place_settings.width * place_settings.height)
|
||||
else:
|
||||
pixels = read_canvas(event_path, place_settings.width, place_settings.height)
|
||||
return HttpResponse(header + pixels, content_type="application/octet-stream")
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ from .models import PALETTE, PLACE_COOLDOWN_SECONDS, PixelPlacement, PlaceSettin
|
||||
from .wire import encode, to_epoch_ms
|
||||
|
||||
|
||||
def _epoch_ms_or_none(dt):
|
||||
return None if dt is None else to_epoch_ms(dt)
|
||||
|
||||
|
||||
class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
|
||||
@@ -45,7 +49,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
await self.send(text_data=encode("r", "a"))
|
||||
return
|
||||
|
||||
if not place_settings.placement_enabled:
|
||||
if place_settings.placement_ended:
|
||||
await self.send(text_data=encode("r", "d"))
|
||||
return
|
||||
|
||||
@@ -146,7 +150,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
next_placement_at,
|
||||
place_settings.width,
|
||||
place_settings.height,
|
||||
int(place_settings.placement_enabled),
|
||||
_epoch_ms_or_none(place_settings.placement_ends_at),
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
@@ -162,6 +166,9 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
"e",
|
||||
event["width"],
|
||||
event["height"],
|
||||
int(event["placement_enabled"]),
|
||||
event["ends_at"],
|
||||
)
|
||||
)
|
||||
|
||||
async def wipe(self, event):
|
||||
await self.send(text_data=encode("w"))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-25 09:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("place", "0002_placesettings_placement_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="placesettings",
|
||||
name="placement_enabled",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="placesettings",
|
||||
name="placement_ends_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from events.models import Event
|
||||
from events.scoping import EventScopedManager
|
||||
@@ -47,13 +48,22 @@ class PlaceSettings(models.Model):
|
||||
)
|
||||
width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH)
|
||||
height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT)
|
||||
# Freezes the board without taking the game down: clients can still
|
||||
# connect and watch, but every placement is rejected.
|
||||
placement_enabled = models.BooleanField(default=True)
|
||||
# Past this, the game is over without taking it down: every placement
|
||||
# is rejected and the board is shown wiped (see tasks.py). Only visual -
|
||||
# the PixelPlacement history and the Redis canvas are kept as-is, so
|
||||
# admin stats/heatmaps still see the final board. Empty = no end.
|
||||
placement_ends_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"PlaceSettings({self.event}, size={self.width}x{self.height})"
|
||||
|
||||
@property
|
||||
def placement_ended(self) -> bool:
|
||||
return (
|
||||
self.placement_ends_at is not None
|
||||
and self.placement_ends_at <= timezone.now()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def current(cls, event: Event) -> "PlaceSettings":
|
||||
obj, _ = cls.objects.get_or_create(event=event)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Reacts to an admin editing a PlaceSettings row (grid size, placement
|
||||
toggle) by:
|
||||
"""Reacts to an admin editing a PlaceSettings row (grid size, end time) by:
|
||||
|
||||
1. Rebuilding that event's Redis canvas from history at the (possibly new)
|
||||
dimensions - a resize invalidates the old Redis bytes (wrong length,
|
||||
@@ -9,6 +8,7 @@ toggle) by:
|
||||
2. Pushing the new settings to that event's already-connected clients. Unlike
|
||||
the clock app, place has no periodic heartbeat broadcast to piggyback
|
||||
this onto, so it's broadcast explicitly here instead.
|
||||
3. Queueing the wipe for the end time, if any (see tasks.py).
|
||||
"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -19,11 +19,14 @@ from django.dispatch import receiver
|
||||
from .broadcast import place_group
|
||||
from .canvas import rebuild_canvas_from_history
|
||||
from .models import PlaceSettings
|
||||
from .tasks import schedule_wipe
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
|
||||
@receiver(post_save, sender=PlaceSettings)
|
||||
def on_settings_saved(sender, instance, **kwargs):
|
||||
rebuild_canvas_from_history(instance.event, instance.width, instance.height)
|
||||
schedule_wipe(instance)
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
@@ -34,6 +37,10 @@ def on_settings_saved(sender, instance, **kwargs):
|
||||
"type": "settings_changed",
|
||||
"width": instance.width,
|
||||
"height": instance.height,
|
||||
"placement_enabled": instance.placement_enabled,
|
||||
"ends_at": (
|
||||
None
|
||||
if instance.placement_ends_at is None
|
||||
else to_epoch_ms(instance.placement_ends_at)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Ends the game at PlaceSettings.placement_ends_at: a task queued to run at
|
||||
that time broadcasts the wipe to everyone connected, so they all see the
|
||||
board cleared at the same moment (no client-side timer to drift).
|
||||
|
||||
The wipe is only visual. Nothing is deleted, the Redis canvas still holds the
|
||||
final board (admin stats, heatmaps, resync keep working), it's just that the
|
||||
canvas endpoint serves a blank board once placement is over (see api.py).
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.cache import cache
|
||||
from django.tasks import task
|
||||
from django.utils import timezone
|
||||
|
||||
from .broadcast import place_group
|
||||
from .models import PlaceSettings
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
# Kept a bit past the end time, so re-saving the settings with the same end
|
||||
# time (or with only the size changed) doesn't queue the same wipe twice.
|
||||
WIPE_GUARD_MARGIN = timedelta(hours=1)
|
||||
|
||||
|
||||
def _wipe_guard_key(settings_id: int, ends_at_ms: int) -> str:
|
||||
return f"place:wipe:{settings_id}:{ends_at_ms}"
|
||||
|
||||
|
||||
def schedule_wipe(place_settings: PlaceSettings) -> bool:
|
||||
"""Queue the wipe for this end time, unless it's already queued or the end
|
||||
time is already past. Safe to call on every save."""
|
||||
ends_at = place_settings.placement_ends_at
|
||||
if ends_at is None or ends_at <= timezone.now():
|
||||
return False
|
||||
|
||||
ends_at_ms = to_epoch_ms(ends_at)
|
||||
timeout = int((ends_at - timezone.now() + WIPE_GUARD_MARGIN).total_seconds())
|
||||
if not cache.add(_wipe_guard_key(place_settings.id, ends_at_ms), True, timeout):
|
||||
return False
|
||||
|
||||
wipe_canvas_task.using(run_after=ends_at).enqueue(place_settings.id, ends_at_ms)
|
||||
return True
|
||||
|
||||
|
||||
@task
|
||||
def wipe_canvas_task(settings_id: int, ends_at_ms: int):
|
||||
"""Broadcast the wipe wave, if `ends_at_ms` is still the event's end time -
|
||||
an admin moving or clearing it leaves the old job queued, it just stops
|
||||
here."""
|
||||
place_settings = (
|
||||
PlaceSettings.objects.filter(id=settings_id).select_related("event").first()
|
||||
)
|
||||
if place_settings is None or place_settings.placement_ends_at is None:
|
||||
return
|
||||
if to_epoch_ms(place_settings.placement_ends_at) != ends_at_ms:
|
||||
return
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
return
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
place_group(place_settings.event.path), {"type": "wipe"}
|
||||
)
|
||||
@@ -1,14 +1,21 @@
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from accounts.models import CustomUser
|
||||
from events.models import Event
|
||||
from games.models import EventGame, Game
|
||||
|
||||
from .models import PixelPlacement, PlaceSettings
|
||||
from .tasks import schedule_wipe, wipe_canvas_task
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
URL = "/api/events/event/place/stats"
|
||||
CANVAS_URL = "/api/events/event/place/canvas"
|
||||
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
|
||||
|
||||
@mock.patch("place.stats.read_canvas")
|
||||
@@ -85,3 +92,77 @@ class PlaceStatsTests(TestCase):
|
||||
self.assertEqual(colors[5]["on_canvas"], 1)
|
||||
self.assertEqual(colors[0]["on_canvas"], 6)
|
||||
self.assertEqual(sum(row["placements"] for row in data["per_hour"]), 5)
|
||||
|
||||
|
||||
@override_settings(CACHES=LOCMEM_CACHE)
|
||||
@mock.patch("place.signals.rebuild_canvas_from_history")
|
||||
class PlacementEndTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.event = Event.objects.create(name="Event", path="event")
|
||||
place = Game.objects.create(name="Place", path="place", custom_path=True)
|
||||
EventGame.objects.create(event=self.event, game=place)
|
||||
self.alice = CustomUser.objects.create_user(username="alice", password="x")
|
||||
|
||||
def settings(self, ends_at):
|
||||
with mock.patch("place.signals.schedule_wipe"):
|
||||
return PlaceSettings.objects.create(
|
||||
event=self.event, width=2, height=1, placement_ends_at=ends_at
|
||||
)
|
||||
|
||||
@mock.patch("place.api.read_canvas")
|
||||
def test_canvas_is_blank_once_ended(self, read_canvas, _):
|
||||
read_canvas.return_value = bytes([5, 3])
|
||||
place_settings = self.settings(timezone.now() + timedelta(hours=1))
|
||||
self.client.force_login(self.alice)
|
||||
|
||||
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes([5, 3]))
|
||||
|
||||
place_settings.placement_ends_at = timezone.now() - timedelta(seconds=1)
|
||||
with mock.patch("place.signals.schedule_wipe"):
|
||||
place_settings.save()
|
||||
|
||||
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes(2))
|
||||
|
||||
@mock.patch("place.tasks.wipe_canvas_task")
|
||||
def test_wipe_is_scheduled_once_per_end_time(self, task, _):
|
||||
ends_at = timezone.now() + timedelta(hours=1)
|
||||
place_settings = self.settings(ends_at)
|
||||
|
||||
self.assertTrue(schedule_wipe(place_settings))
|
||||
self.assertFalse(schedule_wipe(place_settings))
|
||||
|
||||
task.using.assert_called_once_with(run_after=ends_at)
|
||||
task.using.return_value.enqueue.assert_called_once_with(
|
||||
place_settings.id, to_epoch_ms(ends_at)
|
||||
)
|
||||
|
||||
@mock.patch("place.tasks.wipe_canvas_task")
|
||||
def test_no_wipe_without_future_end(self, task, _):
|
||||
self.assertFalse(schedule_wipe(self.settings(None)))
|
||||
past = PlaceSettings(placement_ends_at=timezone.now() - timedelta(seconds=1))
|
||||
self.assertFalse(schedule_wipe(past))
|
||||
|
||||
task.using.assert_not_called()
|
||||
|
||||
@mock.patch("place.tasks.get_channel_layer")
|
||||
def test_task_broadcasts_wipe(self, get_channel_layer, _):
|
||||
ends_at = timezone.now()
|
||||
place_settings = self.settings(ends_at)
|
||||
layer = get_channel_layer.return_value
|
||||
layer.group_send = mock.AsyncMock()
|
||||
|
||||
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
|
||||
|
||||
layer.group_send.assert_awaited_once_with("place_room_event", {"type": "wipe"})
|
||||
|
||||
@mock.patch("place.tasks.get_channel_layer")
|
||||
def test_task_skips_moved_end_time(self, get_channel_layer, _):
|
||||
ends_at = timezone.now()
|
||||
place_settings = self.settings(ends_at + timedelta(hours=1))
|
||||
layer = get_channel_layer.return_value
|
||||
layer.group_send = mock.AsyncMock()
|
||||
|
||||
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
|
||||
|
||||
layer.group_send.assert_not_awaited()
|
||||
|
||||
@@ -6,10 +6,10 @@ either). Each message is a short string: a one-letter type tag followed by
|
||||
'|'-separated fields, in a fixed order the client already knows from this
|
||||
table:
|
||||
|
||||
s|<next>|<width>|<height>|<on>
|
||||
s|<next>|<width>|<height>|<end>
|
||||
initial personal state, once per connection
|
||||
(next = epoch ms cooldown ends, or empty if free now;
|
||||
on = 1 if placing pixels is allowed, 0 if frozen)
|
||||
end = epoch ms placement ends, or empty if never)
|
||||
p|<x>|<y>|<c> a pixel was placed - broadcast to everyone,
|
||||
including the placer (no separate ack needed)
|
||||
k|<next> your own placement was accepted; carries your
|
||||
@@ -17,13 +17,16 @@ table:
|
||||
r|c|<next> rejected: still on cooldown
|
||||
r|a rejected: not logged in
|
||||
r|i rejected: invalid x/y/color
|
||||
r|d rejected: placement is disabled by an admin
|
||||
e|<width>|<height>|<on>
|
||||
r|d rejected: placement is over (past <end>)
|
||||
e|<width>|<height>|<end>
|
||||
an admin edited this event's settings (grid size
|
||||
and/or placement toggle) - broadcast to everyone.
|
||||
and/or end time) - broadcast to everyone.
|
||||
If the size changed, a client should re-fetch
|
||||
/api/.../place/canvas rather than try to remap its
|
||||
local pixels.
|
||||
w placement just ended - broadcast to everyone at
|
||||
<end> (see tasks.py). Clients play the wipe wave
|
||||
and blank their board.
|
||||
|
||||
Client -> server has exactly one message shape, the same as the broadcast:
|
||||
p|<x>|<y>|<c>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
||||
import { useLiveBadge } from "@/composables/useLiveBadge";
|
||||
import { PALETTE } from "@/palette";
|
||||
import { playWipeWave } from "@/placeWipe";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
@@ -40,15 +41,26 @@ function drawPixel(x: number, y: number, colorIndex: number) {
|
||||
pixelCtx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
let cancelWipe: (() => void) | null = null;
|
||||
|
||||
function wipe() {
|
||||
if (!pixelCtx) return;
|
||||
cancelWipe?.();
|
||||
cancelWipe = playWipeWave(pixelCtx, gridWidth.value, gridHeight.value);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => cancelWipe?.());
|
||||
|
||||
const {
|
||||
connected,
|
||||
cooldownRemainingMs,
|
||||
placementEnabled,
|
||||
placementEndMs,
|
||||
placementEnded,
|
||||
cooldownDurationSeconds,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
place,
|
||||
} = usePlaceSocket(props.eventPath, drawPixel);
|
||||
} = usePlaceSocket(props.eventPath, drawPixel, undefined, wipe);
|
||||
useLiveBadge("place", connected);
|
||||
|
||||
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
|
||||
@@ -56,7 +68,12 @@ const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
|
||||
|
||||
const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
|
||||
const onCooldown = computed(() => cooldownRemainingMs.value > 0);
|
||||
const canPlace = computed(() => placementEnabled.value && !onCooldown.value);
|
||||
const canPlace = computed(() => !placementEnded.value && !onCooldown.value);
|
||||
const placementEndDisplay = computed(() =>
|
||||
placementEndMs.value !== null
|
||||
? new Date(placementEndMs.value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: null,
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -156,12 +173,15 @@ function handleClick(event: MouseEvent) {
|
||||
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
|
||||
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
|
||||
</button>
|
||||
<span v-if="!placementEnabled" class="badge badge-warning">{{ t("place.placementDisabled") }}</span>
|
||||
<span v-if="placementEnded" class="badge badge-warning">{{ t("place.placementEnded") }}</span>
|
||||
<template v-else-if="onCooldown">
|
||||
<span class="font-bold text-xl tabular-nums">{{ cooldownSecondsDisplay }}s</span>
|
||||
<progress class="progress progress-primary w-48"
|
||||
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
||||
</template>
|
||||
<span v-if="placementEndDisplay && !placementEnded" class="text-sm opacity-70">
|
||||
{{ t("place.endsAt", { time: placementEndDisplay }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2 max-w-md">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
||||
import { PALETTE } from "@/palette";
|
||||
import { playWipeWave } from "@/placeWipe";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
@@ -22,7 +23,22 @@ function drawPixel(x: number, y: number, colorIndex: number) {
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
const { connected, gridWidth, gridHeight } = usePlaceSocket(props.eventPath, drawPixel);
|
||||
let cancelWipe: (() => void) | null = null;
|
||||
|
||||
function wipe() {
|
||||
if (!ctx) return;
|
||||
cancelWipe?.();
|
||||
cancelWipe = playWipeWave(ctx, gridWidth.value, gridHeight.value);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => cancelWipe?.());
|
||||
|
||||
const { connected, gridWidth, gridHeight } = usePlaceSocket(
|
||||
props.eventPath,
|
||||
drawPixel,
|
||||
undefined,
|
||||
wipe,
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
ctx = canvasRef.value?.getContext("2d") ?? null;
|
||||
|
||||
@@ -16,6 +16,7 @@ const PLACE_COOLDOWN_SECONDS = 30
|
||||
|
||||
export type OnPixel = (x: number, y: number, colorIndex: number) => void
|
||||
export type OnResize = (width: number, height: number) => void
|
||||
export type OnWipe = () => void
|
||||
|
||||
// Plain (non-reactive) - a several-thousand-cell reactive array is
|
||||
// pointless overhead. Kept module-level, alongside its dimensions, so the
|
||||
@@ -26,12 +27,18 @@ 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) {
|
||||
export function usePlaceSocket(
|
||||
eventPath: string,
|
||||
onPixel?: OnPixel,
|
||||
onResize?: OnResize,
|
||||
onWipe?: OnWipe,
|
||||
) {
|
||||
const cooldownEndMs = ref<number | null>(null)
|
||||
const cooldownRemainingMs = ref(0)
|
||||
// An admin can freeze the board (PlaceSettings.placement_enabled) - the
|
||||
// server rejects placements anyway, this just lets the UI say so.
|
||||
const placementEnabled = ref(true)
|
||||
// PlaceSettings.placement_ends_at - the server rejects placements past it
|
||||
// anyway, this just lets the UI say so.
|
||||
const placementEndMs = ref<number | null>(null)
|
||||
const placementEnded = ref(false)
|
||||
|
||||
let animationFrame: number | null = null
|
||||
|
||||
@@ -39,9 +46,23 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
||||
cooldownEndMs.value = nextMs ?? null
|
||||
}
|
||||
|
||||
// Returns true if placement went from over to open again (an admin moved
|
||||
// the end time later or cleared it), so the caller can bring the real
|
||||
// board back - the server serves it again.
|
||||
function applyPlacementEnd(endMs: string): boolean {
|
||||
const wasEnded = placementEnded.value
|
||||
placementEndMs.value = endMs !== '' ? Number(endMs) : null
|
||||
placementEnded.value = placementEndMs.value !== null && placementEndMs.value <= Date.now()
|
||||
return wasEnded && !placementEnded.value
|
||||
}
|
||||
|
||||
function tickCooldown() {
|
||||
const now = Date.now()
|
||||
cooldownRemainingMs.value =
|
||||
cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - Date.now()) : 0
|
||||
cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - now) : 0
|
||||
// Only ever flips it on: the server's own 'w'/'r|d' may beat a skewed
|
||||
// local clock, and reopening goes through applyPlacementEnd.
|
||||
if (placementEndMs.value !== null && placementEndMs.value <= now) placementEnded.value = true
|
||||
|
||||
animationFrame = requestAnimationFrame(tickCooldown)
|
||||
}
|
||||
@@ -68,10 +89,10 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
||||
|
||||
switch (parts[0]) {
|
||||
case 's': {
|
||||
const [, next, width, height, enabled] = parts
|
||||
const [, next, width, height, end] = parts
|
||||
applyCooldown(next !== '' ? Number(next) : null)
|
||||
applyGridSize(Number(width), Number(height))
|
||||
placementEnabled.value = enabled === '1'
|
||||
applyPlacementEnd(end)
|
||||
break
|
||||
}
|
||||
case 'p': {
|
||||
@@ -85,16 +106,24 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
||||
}
|
||||
case 'r': {
|
||||
if (parts[1] === 'c') applyCooldown(Number(parts[2]))
|
||||
if (parts[1] === 'd') placementEnabled.value = false
|
||||
if (parts[1] === 'd') placementEnded.value = true
|
||||
break
|
||||
}
|
||||
case 'e': {
|
||||
const [, width, height, enabled] = parts
|
||||
placementEnabled.value = enabled === '1'
|
||||
const [, width, height, end] = parts
|
||||
const reopened = applyPlacementEnd(end)
|
||||
// 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()
|
||||
if (applyGridSize(Number(width), Number(height)) || reopened) loadCanvas()
|
||||
break
|
||||
}
|
||||
case 'w': {
|
||||
// Placement is over: the server now serves a blank canvas, match it
|
||||
// locally and let the page animate the wipe instead of redrawing.
|
||||
placementEnded.value = true
|
||||
grid.fill(0)
|
||||
onWipe?.()
|
||||
break
|
||||
}
|
||||
default:
|
||||
@@ -157,7 +186,8 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
||||
return {
|
||||
connected,
|
||||
cooldownRemainingMs,
|
||||
placementEnabled,
|
||||
placementEndMs,
|
||||
placementEnded,
|
||||
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
"header": "Place",
|
||||
"showGrid": "Show grid",
|
||||
"hideGrid": "Hide grid",
|
||||
"placementDisabled": "Pixel placement is paused"
|
||||
"placementEnded": "Pixel placement is over",
|
||||
"endsAt": "Ends at {time}"
|
||||
},
|
||||
"market": {
|
||||
"pageTitle": "Market",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// The end-of-game wipe (see place/tasks.py): a wavy front sweeps the board
|
||||
// diagonally from the top-left, a cyan crest leaving plain white (palette
|
||||
// index 0, what the server's blank canvas holds) behind it.
|
||||
const WIPE_DURATION_MS = 2200
|
||||
// Crest thickness and front wobble, in cells
|
||||
const CREST_WIDTH = 6
|
||||
const WOBBLE = 2.5
|
||||
const CREST_RGB = [0x00, 0xd3, 0xdd]
|
||||
|
||||
function easeInOut(t: number) {
|
||||
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
|
||||
}
|
||||
|
||||
// Works on a snapshot of the canvas through ImageData rather than per-cell
|
||||
// fillRect, so it stays smooth on a big grid. Returns a cancel function.
|
||||
export function playWipeWave(ctx: CanvasRenderingContext2D, width: number, height: number) {
|
||||
const before = ctx.getImageData(0, 0, width, height)
|
||||
const frame = ctx.createImageData(width, height)
|
||||
const out = frame.data
|
||||
const src = before.data
|
||||
|
||||
// Distance of each cell from the start corner, wobbled so the front reads
|
||||
// as a wave rather than a straight line.
|
||||
const distance = new Float32Array(width * height)
|
||||
const wobblePeriod = Math.max(width, height) / 5
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
distance[y * width + x] = x + y + WOBBLE * Math.sin((x - y) / wobblePeriod)
|
||||
}
|
||||
}
|
||||
const travel = width + height + 2 * WOBBLE + CREST_WIDTH
|
||||
|
||||
let start: number | null = null
|
||||
let animationFrame: number | null = null
|
||||
|
||||
function step(now: number) {
|
||||
start ??= now
|
||||
const progress = Math.min(1, (now - start) / WIPE_DURATION_MS)
|
||||
const front = easeInOut(progress) * travel - WOBBLE
|
||||
|
||||
for (let i = 0; i < distance.length; i++) {
|
||||
const o = i * 4
|
||||
const behind = front - distance[i]
|
||||
if (behind <= 0) {
|
||||
out[o] = src[o]
|
||||
out[o + 1] = src[o + 1]
|
||||
out[o + 2] = src[o + 2]
|
||||
} else if (behind < CREST_WIDTH) {
|
||||
// Cyan at the leading edge, fading to white at the back of the crest
|
||||
const t = behind / CREST_WIDTH
|
||||
out[o] = CREST_RGB[0] + (255 - CREST_RGB[0]) * t
|
||||
out[o + 1] = CREST_RGB[1] + (255 - CREST_RGB[1]) * t
|
||||
out[o + 2] = CREST_RGB[2] + (255 - CREST_RGB[2]) * t
|
||||
} else {
|
||||
out[o] = out[o + 1] = out[o + 2] = 255
|
||||
}
|
||||
out[o + 3] = 255
|
||||
}
|
||||
ctx.putImageData(frame, 0, 0)
|
||||
|
||||
animationFrame = progress < 1 ? requestAnimationFrame(step) : null
|
||||
}
|
||||
animationFrame = requestAnimationFrame(step)
|
||||
|
||||
return () => {
|
||||
if (animationFrame !== null) cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user