feat: add option to disable pixel placement
This commit is contained in:
@@ -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",)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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})"
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user