quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Make the in-world cue a shape, and let the dead lie down
Colour alone fails at twenty pixels across a dim corridor, and fails entirely for a colour-blind player. An enemy now wears a red chevron over their head and a hard dark outline around body and head; a team mate wears their name tag with a health pip in it. The silhouette is what carries. The dead lie flat on the floor, desaturated, with no tag, no chevron and no agent band — if something is upright with a marker over it, it is alive. On the map your own team is always there, initial in the dot and crossed out when down, and an enemy is drawn only while alive and in sight, so the map stays honest about vision while your own five stay legible. The how-to card and the README say all of this, since it is exactly what a first-time player needs told. Co-Authored-By: Claude Opus 5 <[email protected]>
5 files
changed +176
−31
README.md +5 −1
| @@ -32,6 +32,10 @@ server: | ||
| 32 | 32 | - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a |
| 33 | 33 | plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is |
| 34 | 34 | refused server-side. |
| 35 | - **Who is alive.** The roster under the view lists your five with their | |
| 36 | health from the snapshot's own team data, and the kill feed is derived in | |
| 37 | `src/arena.ts` by comparing the players either side of a tick — it carries | |
| 38 | names, the gun and an age, and never a position. | |
| 35 | 39 | - **Bots** fill every empty seat and use their kit: smokes on the way in, |
| 36 | 40 | flashes before the entry, a recon dart, a molly on the planted spike. |
| 37 | 41 | |
| @@ -91,6 +95,6 @@ no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one | ||
| 91 | 95 | live match and its sockets · `src/server.ts` the door · `static/` the browser client — |
| 92 | 96 | `val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray |
| 93 | 97 | maths, `scene.js` the first-person view, `topdown.js` the minimap, |
| 94 | `gun.js`, `beep.js`, `steer.js` and `prompt.js` the rest; the server bundles | |
| 98 | `gun.js`, `beep.js`, `steer.js`, `roster.js` and `prompt.js` the rest; the server bundles | |
| 95 | 99 | them into one content-named file at start-up. The rules are pure functions and the tests play whole matches |
| 96 | 100 | headlessly. |
src/pages/game.ts +7 −1
| @@ -91,7 +91,8 @@ export function gamePage(opts: { | ||
| 91 | 91 | .slot { background: #171a21; border: 1px solid #262b35; border-left: 3px solid #4a5162; |
| 92 | 92 | border-radius: .35rem; padding: .2rem .35rem; font-size: .72rem; overflow: hidden; } |
| 93 | 93 | .slot .who { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| 94 | .slot .agent { opacity: .6; font-size: .66rem; } | |
| 94 | .slot .agent { display: block; opacity: .6; font-size: .66rem; white-space: nowrap; | |
| 95 | overflow: hidden; text-overflow: ellipsis; } | |
| 95 | 96 | .slot .hp { display: block; height: 5px; margin-top: .2rem; border-radius: 3px; background: #2a2f3a; } |
| 96 | 97 | .slot .hp i { display: block; height: 100%; border-radius: 3px; background: #6fcf6f; } |
| 97 | 98 | .slot.mine { border-color: #6f8ecf; background: #1b2130; } |
| @@ -172,6 +173,11 @@ export function gamePage(opts: { | ||
| 172 | 173 | <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and |
| 173 | 174 | last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is |
| 174 | 175 | the ultimate, paid for in ult points you earn by playing.</p> |
| 176 | <p><strong>Telling people apart.</strong> Your five are always listed under the view with their | |
| 177 | health — struck through when they are down — and the last few kills scroll in the top right. | |
| 178 | In the world an enemy wears a red chevron over their head and a hard dark outline, a team mate | |
| 179 | wears their name and a health pip, and anybody lying flat on the floor is dead. On the map your | |
| 180 | team is always shown (crossed out when down) and enemies appear only while you can see them.</p> | |
| 175 | 181 | <p><strong>On a phone</strong> the view stays top-down: the left thumb walks, the right |
| 176 | 182 | thumb aims and fires, and the buttons under the map buy and cast.</p> |
| 177 | 183 | <ul id="helpagents"></ul> |
src/scene.test.ts +57
| @@ -117,6 +117,33 @@ test("a team mate gets a name tag and an enemy never does", () => { | ||
| 117 | 117 | expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false); |
| 118 | 118 | }); |
| 119 | 119 | |
| 120 | test("the dead lie on the floor and wear no markers at all", () => { | |
| 121 | const ctx = fakeContext(); | |
| 122 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view); | |
| 123 | // Flat on the ground: an ellipse, not an upright body with a head. | |
| 124 | expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true); | |
| 125 | // No name tag, and no chevron — the chevron is the only closed path here. | |
| 126 | expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false); | |
| 127 | expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 128 | }); | |
| 129 | ||
| 130 | test("an enemy wears a chevron and a team mate does not", () => { | |
| 131 | const enemy = fakeContext(); | |
| 132 | drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 133 | expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true); | |
| 134 | ||
| 135 | const mate = fakeContext(); | |
| 136 | drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view); | |
| 137 | expect(mate.ops.some((o) => o.op === "closePath")).toBe(false); | |
| 138 | expect(mate.ops.some((o) => o.op === "fillText")).toBe(true); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a body has a hard dark edge, whichever side it is on", () => { | |
| 142 | const ctx = fakeContext(); | |
| 143 | drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view); | |
| 144 | expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true); | |
| 145 | }); | |
| 146 | ||
| 120 | 147 | test("a smoke between the eye and a body hides it", () => { |
| 121 | 148 | const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }]; |
| 122 | 149 | const ctx = fakeContext(); |
| @@ -161,6 +188,36 @@ test("the top-down view draws the real map, small or large", () => { | ||
| 161 | 188 | expect(small.ops.some((o) => o.op === "translate")).toBe(true); |
| 162 | 189 | }); |
| 163 | 190 | |
| 191 | test("on the map a dead team mate is crossed out and a dead enemy is not there", () => { | |
| 192 | const map = { rows: gridRows(), tile: TILE }; | |
| 193 | const box = { x: 0, y: 0, w: 900, h: 570 }; | |
| 194 | const look = (p: never) => p; | |
| 195 | ||
| 196 | const mate = fakeContext(); | |
| 197 | drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box); | |
| 198 | // Two strokes through the dot, and no dot: a cross is not a live player. | |
| 199 | expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2); | |
| 200 | expect(mate.ops.some((o) => o.op === "arc")).toBe(false); | |
| 201 | ||
| 202 | const enemy = fakeContext(); | |
| 203 | drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box); | |
| 204 | const empty = fakeContext(); | |
| 205 | drawTopdown(empty as never, map, snapshot([]), look, box); | |
| 206 | expect(enemy.ops.length).toBe(empty.ops.length); | |
| 207 | }); | |
| 208 | ||
| 209 | test("a living team mate carries their initial on the big map", () => { | |
| 210 | const map = { rows: gridRows(), tile: TILE }; | |
| 211 | const big = fakeContext(); | |
| 212 | drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, { | |
| 213 | x: 0, | |
| 214 | y: 0, | |
| 215 | w: 900, | |
| 216 | h: 570, | |
| 217 | }); | |
| 218 | expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true); | |
| 219 | }); | |
| 220 | ||
| 164 | 221 | test("the gun and the crosshair draw, and the hit marker only when there is one", () => { |
| 165 | 222 | const gun = makeGun(); |
| 166 | 223 | const ctx = fakeContext(); |
static/scene.js +64 −19
| @@ -233,23 +233,44 @@ function drawBody(ctx, scene, thing, floorY, step, columns) { | ||
| 233 | 233 | const cx = thing.column; |
| 234 | 234 | const top = floorY - h; |
| 235 | 235 | const mate = p.mate; |
| 236 | const skin = !p.alive ? "#555a63" : mate ? "#3f9fd6" : "#d8483f"; | |
| 237 | const trim = AGENT_COLOUR[p.agent] || "#999"; | |
| 238 | 236 | |
| 239 | withRuns(ctx, scene, cx - w, cx + w * 1.4, thing.perp, step, columns, () => { | |
| 240 | // Body, head, and a shoulder band in the agent's colour. | |
| 237 | // The dead lie down. No tag, no chevron, no agent colour: if something is | |
| 238 | // upright with a marker over it, it is alive and it can shoot you. | |
| 239 | if (!p.alive) { | |
| 240 | const lie = h * 0.16; | |
| 241 | withRuns(ctx, scene, cx - w * 1.1, cx + w * 1.1, thing.perp, step, columns, () => { | |
| 242 | ctx.fillStyle = "#4a4e57"; | |
| 243 | ctx.beginPath(); | |
| 244 | ctx.ellipse(cx, floorY - lie * 0.4, w * 1.05, lie, 0, 0, 7); | |
| 245 | ctx.fill(); | |
| 246 | ctx.strokeStyle = "#23262d"; | |
| 247 | ctx.lineWidth = Math.max(1, h * 0.02); | |
| 248 | ctx.stroke(); | |
| 249 | }); | |
| 250 | return; | |
| 251 | } | |
| 252 | ||
| 253 | const skin = mate ? "#3f9fd6" : "#d8483f"; | |
| 254 | const trim = AGENT_COLOUR[p.agent] || "#999"; | |
| 255 | const edge = Math.max(1.2, h * 0.05); | |
| 256 | ||
| 257 | withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.5, thing.perp, step, columns, () => { | |
| 258 | // Body and head, each with a hard dark edge: the silhouette is what | |
| 259 | // carries across a dim room, where the colour does not. | |
| 241 | 260 | ctx.fillStyle = skin; |
| 242 | 261 | ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72); |
| 262 | ctx.strokeStyle = "#0b0d11"; | |
| 263 | ctx.lineWidth = edge; | |
| 264 | ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72); | |
| 243 | 265 | ctx.fillStyle = trim; |
| 244 | 266 | ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1); |
| 245 | 267 | ctx.beginPath(); |
| 246 | 268 | ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7); |
| 247 | 269 | ctx.fillStyle = skin; |
| 248 | 270 | ctx.fill(); |
| 249 | ctx.lineWidth = Math.max(1, h * 0.03); | |
| 250 | ctx.strokeStyle = trim; | |
| 271 | ctx.strokeStyle = "#0b0d11"; | |
| 251 | 272 | ctx.stroke(); |
| 252 | if (!p.alive) return; | |
| 273 | ||
| 253 | 274 | if (p.spike) { |
| 254 | 275 | ctx.fillStyle = "#f0d060"; |
| 255 | 276 | ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3); |
| @@ -260,21 +281,45 @@ function drawBody(ctx, scene, thing, floorY, step, columns) { | ||
| 260 | 281 | ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a"; |
| 261 | 282 | ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06); |
| 262 | 283 | } |
| 263 | // A name tag over team mates only: an enemy is a shape, never a label. | |
| 264 | if (mate) { | |
| 265 | const size = Math.max(9, Math.min(18, h * 0.16)); | |
| 266 | ctx.font = `${size}px system-ui, sans-serif`; | |
| 267 | ctx.textAlign = "center"; | |
| 268 | ctx.fillStyle = "rgba(10,12,16,0.65)"; | |
| 269 | const tw = ctx.measureText(p.name).width + 8; | |
| 270 | ctx.fillRect(cx - tw / 2, top - h * 0.34, tw, size * 1.4); | |
| 271 | ctx.fillStyle = "#cfe8ff"; | |
| 272 | ctx.fillText(p.name, cx, top - h * 0.34 + size * 1.05); | |
| 273 | ctx.textAlign = "left"; | |
| 274 | } | |
| 284 | ||
| 285 | if (mate) tag(ctx, p, cx, top, h); | |
| 286 | else chevron(ctx, cx, top, h); | |
| 275 | 287 | }); |
| 276 | 288 | } |
| 277 | 289 | |
| 290 | /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read | |
| 291 | * red and blue the same way. */ | |
| 292 | function chevron(ctx, cx, top, h) { | |
| 293 | const size = Math.max(4, h * 0.16); | |
| 294 | const y = top - h * 0.16; | |
| 295 | ctx.beginPath(); | |
| 296 | ctx.moveTo(cx - size, y - size); | |
| 297 | ctx.lineTo(cx + size, y - size); | |
| 298 | ctx.lineTo(cx, y); | |
| 299 | ctx.closePath(); | |
| 300 | ctx.fillStyle = "#ff6f5e"; | |
| 301 | ctx.fill(); | |
| 302 | ctx.lineWidth = Math.max(1, h * 0.02); | |
| 303 | ctx.strokeStyle = "#140a08"; | |
| 304 | ctx.stroke(); | |
| 305 | } | |
| 306 | ||
| 307 | /** A team mate wears their name and a pip of their health — never an enemy. */ | |
| 308 | function tag(ctx, p, cx, top, h) { | |
| 309 | const size = Math.max(9, Math.min(18, h * 0.16)); | |
| 310 | const y = top - h * 0.34; | |
| 311 | ctx.font = `${size}px system-ui, sans-serif`; | |
| 312 | ctx.textAlign = "center"; | |
| 313 | const width = ctx.measureText(p.name).width + size * 1.4; | |
| 314 | ctx.fillStyle = "rgba(10,12,16,0.65)"; | |
| 315 | ctx.fillRect(cx - width / 2, y, width, size * 1.4); | |
| 316 | ctx.fillStyle = p.hp > 60 ? "#6fcf6f" : p.hp > 25 ? "#e0a040" : "#e05a4a"; | |
| 317 | ctx.fillRect(cx - width / 2 + size * 0.25, y + size * 0.35, size * 0.45, size * 0.7); | |
| 318 | ctx.fillStyle = "#cfe8ff"; | |
| 319 | ctx.fillText(p.name, cx + size * 0.4, y + size * 1.05); | |
| 320 | ctx.textAlign = "left"; | |
| 321 | } | |
| 322 | ||
| 278 | 323 | function drawSpike(ctx, scene, thing, floorY, snap, step, columns) { |
| 279 | 324 | const h = thing.size * 0.42; |
| 280 | 325 | const cx = thing.column; |
static/topdown.js +43 −10
| @@ -122,15 +122,21 @@ function disc(ctx, x, y, r, fill, stroke) { | ||
| 122 | 122 | if (stroke) ctx.stroke(); |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | /** Everybody is a dot; you are an arrow, so you can tell at a glance where you face. */ | |
| 125 | /** | |
| 126 | * Everybody is a dot and you are an arrow — but a team mate is on the map | |
| 127 | * whether you can see them or not, with their initial in the dot and a cross | |
| 128 | * when they are down, and an enemy is only ever drawn while they are alive | |
| 129 | * and in sight. The map stays honest about vision and legible about your own. | |
| 130 | */ | |
| 126 | 131 | function drawPlayer(ctx, p, pos, s, me, labels) { |
| 132 | const mine = p.id === me.id; | |
| 133 | if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out | |
| 127 | 134 | const x = pos.x * s; |
| 128 | 135 | const y = pos.y * s; |
| 129 | 136 | const r = Math.max(2.5, 16 * s); |
| 130 | const mine = p.id === me.id; | |
| 131 | 137 | const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b"; |
| 132 | 138 | |
| 133 | if (mine) { | |
| 139 | if (mine && p.alive) { | |
| 134 | 140 | ctx.save(); |
| 135 | 141 | ctx.translate(x, y); |
| 136 | 142 | ctx.rotate(pos.angle); |
| @@ -146,6 +152,26 @@ function drawPlayer(ctx, p, pos, s, me, labels) { | ||
| 146 | 152 | return; |
| 147 | 153 | } |
| 148 | 154 | |
| 155 | // The dead of your own team keep their place on the map, crossed out. | |
| 156 | if (!p.alive) { | |
| 157 | ctx.strokeStyle = "#6a707c"; | |
| 158 | ctx.lineWidth = Math.max(1, 2 * s); | |
| 159 | ctx.beginPath(); | |
| 160 | ctx.moveTo(x - r * 0.8, y - r * 0.8); | |
| 161 | ctx.lineTo(x + r * 0.8, y + r * 0.8); | |
| 162 | ctx.moveTo(x + r * 0.8, y - r * 0.8); | |
| 163 | ctx.lineTo(x - r * 0.8, y + r * 0.8); | |
| 164 | ctx.stroke(); | |
| 165 | if (labels) { | |
| 166 | ctx.fillStyle = "rgba(180,185,195,0.6)"; | |
| 167 | ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`; | |
| 168 | ctx.textAlign = "center"; | |
| 169 | ctx.fillText(p.name, x, y - r * 1.9); | |
| 170 | ctx.textAlign = "left"; | |
| 171 | } | |
| 172 | return; | |
| 173 | } | |
| 174 | ||
| 149 | 175 | ctx.fillStyle = body; |
| 150 | 176 | ctx.beginPath(); |
| 151 | 177 | ctx.arc(x, y, r, 0, 7); |
| @@ -153,12 +179,19 @@ function drawPlayer(ctx, p, pos, s, me, labels) { | ||
| 153 | 179 | ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888"; |
| 154 | 180 | ctx.lineWidth = Math.max(1, 3 * s); |
| 155 | 181 | ctx.stroke(); |
| 156 | if (p.alive) { | |
| 157 | ctx.strokeStyle = body; | |
| 158 | ctx.beginPath(); | |
| 159 | ctx.moveTo(x, y); | |
| 160 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 161 | ctx.stroke(); | |
| 182 | ctx.strokeStyle = body; | |
| 183 | ctx.beginPath(); | |
| 184 | ctx.moveTo(x, y); | |
| 185 | ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2); | |
| 186 | ctx.stroke(); | |
| 187 | ||
| 188 | // The initial inside the dot, when the dot is big enough to hold one. | |
| 189 | if (p.mate && r >= 6) { | |
| 190 | ctx.fillStyle = "#0c1016"; | |
| 191 | ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`; | |
| 192 | ctx.textAlign = "center"; | |
| 193 | ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45); | |
| 194 | ctx.textAlign = "left"; | |
| 162 | 195 | } |
| 163 | 196 | if (p.spike) { |
| 164 | 197 | ctx.fillStyle = "#f0d060"; |
| @@ -175,7 +208,7 @@ function drawPlayer(ctx, p, pos, s, me, labels) { | ||
| 175 | 208 | ctx.fillText(p.name, x, y - r * 1.9); |
| 176 | 209 | ctx.textAlign = "left"; |
| 177 | 210 | } |
| 178 | if (p.alive && p.hp < 100) { | |
| 211 | if (p.hp < 100) { | |
| 179 | 212 | ctx.fillStyle = "#0008"; |
| 180 | 213 | ctx.fillRect(x - r, y + r * 1.2, r * 2, 3); |
| 181 | 214 | ctx.fillStyle = "#6fcf6f"; |