feat: add option to disable pixel placement
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")
|
||||
list_editable = ("width", "height")
|
||||
list_display = ("event", "width", "height", "placement_enabled")
|
||||
list_editable = ("width", "height", "placement_enabled")
|
||||
list_filter = ("event",)
|
||||
change_actions = ("resync_canvas",)
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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})"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user