feat(place): final end time + destruction wave

This commit is contained in:
2026-10-25 12:55:24 +01:00
parent 1844b973e9
commit 73b279d7fe
14 changed files with 372 additions and 39 deletions
+2 -2
View File
@@ -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",)
+6 -2
View File
@@ -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")
+10 -3
View File
@@ -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),
),
]
+13 -3
View File
@@ -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)
+10 -3
View File
@@ -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)
),
},
)
+65
View File
@@ -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"}
)
+82 -1
View File
@@ -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()
+8 -5
View File
@@ -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>
+25 -5
View File
@@ -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">
+18 -2
View File
@@ -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,
+2 -1
View File
@@ -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",
+68
View File
@@ -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)
}
}