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)
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ("event", "width", "height")
list_editable = ("width", "height")
list_display = ("event", "width", "height", "placement_enabled")
list_editable = ("width", "height", "placement_enabled")
list_filter = ("event",)
change_actions = ("resync_canvas",)
+13 -1
View File
@@ -45,6 +45,10 @@ class PlaceConsumer(AsyncWebsocketConsumer):
await self.send(text_data=encode("r", "a"))
return
if not place_settings.placement_enabled:
await self.send(text_data=encode("r", "d"))
return
parts = text_data.split("|")
if len(parts) != 4:
await self.send(text_data=encode("r", "i"))
@@ -142,6 +146,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
next_placement_at,
place_settings.width,
place_settings.height,
int(place_settings.placement_enabled),
)
@database_sync_to_async
@@ -152,4 +157,11 @@ class PlaceConsumer(AsyncWebsocketConsumer):
await self.send(text_data=encode("p", event["x"], event["y"], event["c"]))
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)
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):
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)
dimensions - a resize invalidates the old Redis bytes (wrong length,
wrong meaning), so this keeps Redis self-healing instead of relying on
the admin remembering to click "Resync canvas from DB" after every
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
this onto, so it's broadcast explicitly here instead.
"""
@@ -33,5 +34,6 @@ def on_settings_saved(sender, instance, **kwargs):
"type": "settings_changed",
"width": instance.width,
"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
table:
s|<next>|<width>|<height> initial personal state, once per connection
(next = epoch ms cooldown ends, or empty if free now)
s|<next>|<width>|<height>|<on>
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,
including the placer (no separate ack needed)
k|<next> your own placement was accepted; carries your
@@ -15,10 +17,13 @@ table:
r|c|<next> rejected: still on cooldown
r|a rejected: not logged in
r|i rejected: invalid x/y/color
e|<width>|<height> an admin resized the grid for this event -
broadcast to everyone. A client that sees this
should re-fetch /api/.../place/canvas rather than
try to remap its local pixels.
r|d rejected: placement is disabled by an admin
e|<width>|<height>|<on>
an admin edited this event's settings (grid size
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:
p|<x>|<y>|<c>
+8 -5
View File
@@ -43,6 +43,7 @@ function drawPixel(x: number, y: number, colorIndex: number) {
const {
connected,
cooldownRemainingMs,
placementEnabled,
cooldownDurationSeconds,
gridWidth,
gridHeight,
@@ -55,6 +56,7 @@ 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);
// 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
@@ -86,7 +88,7 @@ function drawOverlay() {
}
}
if (hoveredCell.value && !onCooldown.value) {
if (hoveredCell.value && canPlace.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);
@@ -96,7 +98,7 @@ function drawOverlay() {
}
}
watch([selectedColorIndex, onCooldown, showGrid, gridWidth, gridHeight], drawOverlay);
watch([selectedColorIndex, canPlace, showGrid, gridWidth, gridHeight], drawOverlay);
onMounted(() => {
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
@@ -125,7 +127,7 @@ function handleMouseLeave() {
}
function handleClick(event: MouseEvent) {
if (onCooldown.value) return;
if (!canPlace.value) return;
const { x, y } = cellFromEvent(event);
place(x, y, selectedColorIndex.value);
}
@@ -142,7 +144,7 @@ function handleClick(event: MouseEvent) {
<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',
cursor: canPlace ? 'crosshair' : 'not-allowed',
}" @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" />
@@ -154,7 +156,8 @@ function handleClick(event: MouseEvent) {
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
</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>
<progress class="progress progress-primary w-48"
: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) {
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)
let animationFrame: number | null = null
@@ -65,9 +68,10 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
switch (parts[0]) {
case 's': {
const [, next, width, height] = parts
const [, next, width, height, enabled] = parts
applyCooldown(next !== '' ? Number(next) : null)
applyGridSize(Number(width), Number(height))
placementEnabled.value = enabled === '1'
break
}
case 'p': {
@@ -81,10 +85,12 @@ 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
break
}
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
// place/signals.py) - just re-fetch it rather than guess how to
// remap our local pixels.
@@ -151,6 +157,7 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
return {
connected,
cooldownRemainingMs,
placementEnabled,
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
gridWidth,
gridHeight,
+2 -1
View File
@@ -97,7 +97,8 @@
"pageTitle": "Place",
"header": "Place",
"showGrid": "Show grid",
"hideGrid": "Hide grid"
"hideGrid": "Hide grid",
"placementDisabled": "Pixel placement is paused"
},
"market": {
"pageTitle": "Market",