feat: add option to disable pixel placement

This commit is contained in:
2026-10-25 10:42:58 +01:00
parent 4f2cf08b27
commit 6cee1135a8
9 changed files with 70 additions and 19 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ from .models import PixelPlacement, PlaceSettings
@admin.register(PlaceSettings) @admin.register(PlaceSettings)
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin): class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ("event", "width", "height") list_display = ("event", "width", "height", "placement_enabled")
list_editable = ("width", "height") list_editable = ("width", "height", "placement_enabled")
list_filter = ("event",) list_filter = ("event",)
change_actions = ("resync_canvas",) change_actions = ("resync_canvas",)
+13 -1
View File
@@ -45,6 +45,10 @@ class PlaceConsumer(AsyncWebsocketConsumer):
await self.send(text_data=encode("r", "a")) await self.send(text_data=encode("r", "a"))
return return
if not place_settings.placement_enabled:
await self.send(text_data=encode("r", "d"))
return
parts = text_data.split("|") parts = text_data.split("|")
if len(parts) != 4: if len(parts) != 4:
await self.send(text_data=encode("r", "i")) await self.send(text_data=encode("r", "i"))
@@ -142,6 +146,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
next_placement_at, next_placement_at,
place_settings.width, place_settings.width,
place_settings.height, place_settings.height,
int(place_settings.placement_enabled),
) )
@database_sync_to_async @database_sync_to_async
@@ -152,4 +157,11 @@ class PlaceConsumer(AsyncWebsocketConsumer):
await self.send(text_data=encode("p", event["x"], event["y"], event["c"])) await self.send(text_data=encode("p", event["x"], event["y"], event["c"]))
async def settings_changed(self, event): async def settings_changed(self, event):
await self.send(text_data=encode("e", event["width"], event["height"])) await self.send(
text_data=encode(
"e",
event["width"],
event["height"],
int(event["placement_enabled"]),
)
)
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-10-25 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("place", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="placesettings",
name="placement_enabled",
field=models.BooleanField(default=True),
),
]
+3
View File
@@ -47,6 +47,9 @@ class PlaceSettings(models.Model):
) )
width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH) width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH)
height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT) 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)
def __str__(self): def __str__(self):
return f"PlaceSettings({self.event}, size={self.width}x{self.height})" return f"PlaceSettings({self.event}, size={self.width}x{self.height})"
+4 -2
View File
@@ -1,11 +1,12 @@
"""Reacts to an admin editing a PlaceSettings row (grid size) by: """Reacts to an admin editing a PlaceSettings row (grid size, placement
toggle) by:
1. Rebuilding that event's Redis canvas from history at the (possibly new) 1. Rebuilding that event's Redis canvas from history at the (possibly new)
dimensions - a resize invalidates the old Redis bytes (wrong length, dimensions - a resize invalidates the old Redis bytes (wrong length,
wrong meaning), so this keeps Redis self-healing instead of relying on wrong meaning), so this keeps Redis self-healing instead of relying on
the admin remembering to click "Resync canvas from DB" after every the admin remembering to click "Resync canvas from DB" after every
resize. resize.
2. Pushing the new size to that event's already-connected clients. Unlike 2. Pushing the new settings to that event's already-connected clients. Unlike
the clock app, place has no periodic heartbeat broadcast to piggyback the clock app, place has no periodic heartbeat broadcast to piggyback
this onto, so it's broadcast explicitly here instead. this onto, so it's broadcast explicitly here instead.
""" """
@@ -33,5 +34,6 @@ def on_settings_saved(sender, instance, **kwargs):
"type": "settings_changed", "type": "settings_changed",
"width": instance.width, "width": instance.width,
"height": instance.height, "height": instance.height,
"placement_enabled": instance.placement_enabled,
}, },
) )
+11 -6
View File
@@ -6,8 +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 '|'-separated fields, in a fixed order the client already knows from this
table: table:
s|<next>|<width>|<height> initial personal state, once per connection s|<next>|<width>|<height>|<on>
(next = epoch ms cooldown ends, or empty if free now) 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)
p|<x>|<y>|<c> a pixel was placed - broadcast to everyone, p|<x>|<y>|<c> a pixel was placed - broadcast to everyone,
including the placer (no separate ack needed) including the placer (no separate ack needed)
k|<next> your own placement was accepted; carries your k|<next> your own placement was accepted; carries your
@@ -15,10 +17,13 @@ table:
r|c|<next> rejected: still on cooldown r|c|<next> rejected: still on cooldown
r|a rejected: not logged in r|a rejected: not logged in
r|i rejected: invalid x/y/color r|i rejected: invalid x/y/color
e|<width>|<height> an admin resized the grid for this event - r|d rejected: placement is disabled by an admin
broadcast to everyone. A client that sees this e|<width>|<height>|<on>
should re-fetch /api/.../place/canvas rather than an admin edited this event's settings (grid size
try to remap its local pixels. and/or placement toggle) - broadcast to everyone.
If the size changed, a client should re-fetch
/api/.../place/canvas rather than try to remap its
local pixels.
Client -> server has exactly one message shape, the same as the broadcast: Client -> server has exactly one message shape, the same as the broadcast:
p|<x>|<y>|<c> p|<x>|<y>|<c>
+8 -5
View File
@@ -43,6 +43,7 @@ function drawPixel(x: number, y: number, colorIndex: number) {
const { const {
connected, connected,
cooldownRemainingMs, cooldownRemainingMs,
placementEnabled,
cooldownDurationSeconds, cooldownDurationSeconds,
gridWidth, gridWidth,
gridHeight, gridHeight,
@@ -55,6 +56,7 @@ const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1)); const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
const onCooldown = computed(() => cooldownRemainingMs.value > 0); const onCooldown = computed(() => cooldownRemainingMs.value > 0);
const canPlace = computed(() => placementEnabled.value && !onCooldown.value);
// The overlay canvas never touches the pixel colors - it only draws grid // 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 // lines plus a preview of the hovered cell, so it can freely redraw on
@@ -86,7 +88,7 @@ function drawOverlay() {
} }
} }
if (hoveredCell.value && !onCooldown.value) { if (hoveredCell.value && canPlace.value) {
const { x, y } = hoveredCell.value; const { x, y } = hoveredCell.value;
gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`; gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`;
gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL); gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL);
@@ -96,7 +98,7 @@ function drawOverlay() {
} }
} }
watch([selectedColorIndex, onCooldown, showGrid, gridWidth, gridHeight], drawOverlay); watch([selectedColorIndex, canPlace, showGrid, gridWidth, gridHeight], drawOverlay);
onMounted(() => { onMounted(() => {
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null; pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
@@ -125,7 +127,7 @@ function handleMouseLeave() {
} }
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
if (onCooldown.value) return; if (!canPlace.value) return;
const { x, y } = cellFromEvent(event); const { x, y } = cellFromEvent(event);
place(x, y, selectedColorIndex.value); place(x, y, selectedColorIndex.value);
} }
@@ -142,7 +144,7 @@ function handleClick(event: MouseEvent) {
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="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="{ class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
imageRendering: 'pixelated', imageRendering: 'pixelated',
cursor: onCooldown ? 'not-allowed' : 'crosshair', cursor: canPlace ? 'crosshair' : 'not-allowed',
}" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" /> }" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" />
<canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight" <canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight"
class="absolute inset-0 w-full h-full rounded-box pointer-events-none" /> class="absolute inset-0 w-full h-full rounded-box pointer-events-none" />
@@ -154,7 +156,8 @@ function handleClick(event: MouseEvent) {
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid"> <button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }} {{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
</button> </button>
<template v-if="onCooldown"> <span v-if="!placementEnabled" class="badge badge-warning">{{ t("place.placementDisabled") }}</span>
<template v-else-if="onCooldown">
<span class="font-bold text-xl tabular-nums">{{ cooldownSecondsDisplay }}s</span> <span class="font-bold text-xl tabular-nums">{{ cooldownSecondsDisplay }}s</span>
<progress class="progress progress-primary w-48" <progress class="progress progress-primary w-48"
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" /> :value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
@@ -29,6 +29,9 @@ const gridHeight = ref(DEFAULT_GRID_SIZE)
export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?: OnResize) { export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?: OnResize) {
const cooldownEndMs = ref<number | null>(null) const cooldownEndMs = ref<number | null>(null)
const cooldownRemainingMs = ref(0) 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)
let animationFrame: number | null = null let animationFrame: number | null = null
@@ -65,9 +68,10 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
switch (parts[0]) { switch (parts[0]) {
case 's': { case 's': {
const [, next, width, height] = parts const [, next, width, height, enabled] = parts
applyCooldown(next !== '' ? Number(next) : null) applyCooldown(next !== '' ? Number(next) : null)
applyGridSize(Number(width), Number(height)) applyGridSize(Number(width), Number(height))
placementEnabled.value = enabled === '1'
break break
} }
case 'p': { case 'p': {
@@ -81,10 +85,12 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
} }
case 'r': { case 'r': {
if (parts[1] === 'c') applyCooldown(Number(parts[2])) if (parts[1] === 'c') applyCooldown(Number(parts[2]))
if (parts[1] === 'd') placementEnabled.value = false
break break
} }
case 'e': { case 'e': {
const [, width, height] = parts const [, width, height, enabled] = parts
placementEnabled.value = enabled === '1'
// The server already rebuilt its canvas at the new size (see // The server already rebuilt its canvas at the new size (see
// place/signals.py) - just re-fetch it rather than guess how to // place/signals.py) - just re-fetch it rather than guess how to
// remap our local pixels. // remap our local pixels.
@@ -151,6 +157,7 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
return { return {
connected, connected,
cooldownRemainingMs, cooldownRemainingMs,
placementEnabled,
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS, cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
gridWidth, gridWidth,
gridHeight, gridHeight,
+2 -1
View File
@@ -97,7 +97,8 @@
"pageTitle": "Place", "pageTitle": "Place",
"header": "Place", "header": "Place",
"showGrid": "Show grid", "showGrid": "Show grid",
"hideGrid": "Hide grid" "hideGrid": "Hide grid",
"placementDisabled": "Pixel placement is paused"
}, },
"market": { "market": {
"pageTitle": "Market", "pageTitle": "Market",