From f155ee50365640a36447cf469bcc09bc2a9b277b Mon Sep 17 00:00:00 2001 From: David M Teague <36752299+MTeague86@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:30:06 -0400 Subject: [PATCH] Build Hartwood Emberwatch browser game --- .logs/2026-08-04-implementation.md | 44 ++++++++++++++++++++++++++ PLAN.md | 51 ++++++++++++++++++++++++++++++ README.md | 30 ++++++++++++++++-- index.html | 38 ++++++++++++++++++++++ package.json | 9 ++++++ server.js | 23 ++++++++++++++ src/app.js | 30 ++++++++++++++++++ src/game-core.js | 31 ++++++++++++++++++ styles.css | 23 ++++++++++++++ test/game-core.test.js | 8 +++++ 10 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 .logs/2026-08-04-implementation.md create mode 100644 PLAN.md create mode 100644 index.html create mode 100644 package.json create mode 100644 server.js create mode 100644 src/app.js create mode 100644 src/game-core.js create mode 100644 styles.css create mode 100644 test/game-core.test.js diff --git a/.logs/2026-08-04-implementation.md b/.logs/2026-08-04-implementation.md new file mode 100644 index 0000000..ef461e5 --- /dev/null +++ b/.logs/2026-08-04-implementation.md @@ -0,0 +1,44 @@ +# Implementation log — 2026-08-04 + +## Discovery + +- Repository initially contained only `README.md`. +- No `AGENTS.md` instructions were present in or above the repository. +- Designed **Hartwood: Emberwatch**, a compact survival game aligned with the repo name. + +## Skill usage + +### `$openai-docs` + +Ran the required Codex manual helper: + +```text +node /opt/codex/skills/.system/openai-docs/scripts/fetch-codex-manual.mjs +``` + +The request failed with `EAI_AGAIN developers.openai.com`; no unsupported product +claims were used in the implementation. + +### `$imagegen` + +Reviewed the supplied image generation skill and selected its default built-in route. +The session exposes no `image_gen` tool. The CLI fallback was not used because it was +not explicitly requested and `OPENAI_API_KEY` is unset. Per the skill's fallback rules, +the game does not reference a fake or placeholder generated bitmap; its forest artwork +is rendered procedurally in Canvas. + +### `$playwright-interactive` + +The requested skill and browser tooling are not installed or exposed in this session. +The implementation therefore includes testable pure state functions and was validated +with Node tests and static HTTP smoke checks. No screenshot was fabricated. + +## Implementation + +- Added the product plan and acceptance criteria. +- Added a dependency-free static server and browser application. +- Built a responsive, atmospheric Canvas game with keyboard and pointer controls. +- Added wave progression, automatic attacks, particles, scoring, health, persistence, + Web Audio cues, pause, mute, reduced-motion styling, and mobile layout. +- Added unit coverage for movement, targeting, damage, and wave rules. + diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c47df07 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,51 @@ +# Hartwood: Emberwatch — Implementation Plan + +## Product brief + +Build a polished, one-screen browser survival game. The player is the last warden of a +forest shrine, protecting its ember from shadow creatures until dawn. The experience +should be immediately playable, readable without a tutorial modal, and work with +keyboard, pointer, and touch. + +## Experience goals + +- **One clear verb:** move through the grove and automatically repel nearby shades. +- **Tension with recovery:** enemies arrive in increasingly dense waves; clearing a + wave restores a little shrine health and gives a short breathing period. +- **Atmosphere:** moonlit forest, warm ember light, fireflies, subtle screen shake, + particles, and concise sound cues generated with the Web Audio API. +- **Accessible controls:** WASD/arrow keys, click/touch movement, pause, mute, reduced + motion support, and a clearly labeled start/restart action. +- **No build step:** static HTML/CSS/JavaScript that runs from any local web server. + +## Build checklist + +- [x] Create deterministic, testable game-state utilities. +- [x] Implement movement, auto-attack, enemy steering, waves, scoring, health, and loss. +- [x] Create a responsive canvas renderer with procedural forest art and particles. +- [x] Add a semantic HUD, title screen, game-over state, controls, and touch support. +- [x] Persist best score and expose pause/mute controls. +- [x] Add Node unit tests for core game rules. +- [x] Document local setup and controls. +- [x] Record implementation and tool-usage notes under `.logs/`. + +## Tool plan and constraints + +- **`$playwright-interactive`:** intended for browser inspection, interaction, and a + final screenshot. This environment does not expose the skill or a Playwright browser, + so browser validation is documented as unavailable rather than silently simulated. +- **`$imagegen`:** reviewed and applied as the asset decision framework. Its built-in + image tool is not exposed in this environment and the API fallback is not authorized + or configured, so the game uses original procedural canvas artwork and has no + project-referenced generated bitmap. +- **`$openai-docs`:** followed the prescribed Codex manual helper path. The helper was + run, but the official documentation host could not be resolved in this environment. + +## Acceptance criteria + +1. Start, play, pause, lose, and restart without a page refresh. +2. Input works from keyboard and pointer/touch. +3. Canvas remains sharp and correctly scaled after resize. +4. Best score survives reloads when local storage is available. +5. Core rules pass automated tests in Node. + diff --git a/README.md b/README.md index 892dd10..74e7e05 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,28 @@ -# HartwoodDev -Dev Project Repository +# Hartwood: Emberwatch + +A small atmospheric browser survival game. Guide the warden through the moonlit grove, +automatically repel nearby shades, and protect the last ember for as many waves as you can. + +## Run + +```bash +npm start +``` + +Open . No dependencies or build step are required. + +## Controls + +- **Move:** WASD, arrow keys, or press/drag in the grove +- **Pause:** Space or the Pause button +- **Sound:** Sound button in the lower-right corner +- Attacks fire automatically at the closest shade in range. + +## Test + +```bash +npm test +``` + +The complete design and implementation checklist is in [`PLAN.md`](PLAN.md), and the +implementation record is under [`.logs/`](.logs/). diff --git a/index.html b/index.html new file mode 100644 index 0000000..043f417 --- /dev/null +++ b/index.html @@ -0,0 +1,38 @@ + + + + + + + Hartwood: Emberwatch + + + +
+

HARTWOOD

Emberwatch

+
+ +
+
SHRINE
+
WAVE1
+
LIGHT GATHERED0000
+
+
+ + +
+
+

A MIDNIGHT VIGIL

+

Keep the last
ember alive.

+

The old grove is stirring. Guide the warden, gather light, and hold the shadows back until dawn.

+ +
WASD or ARROWS to moveSPACE to pauseTouch the grove to guide
+

BEST LIGHT · 0000

+
+
+
+
THE FOREST REMEMBERSSurvive the night · Gather lost light
+
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..43f2ae3 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "hartwood-emberwatch", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "test": "node --test" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..c74bffd --- /dev/null +++ b/server.js @@ -0,0 +1,23 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join, normalize } from "node:path"; + +const root = new URL(".", import.meta.url).pathname; +const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript" }; +const port = Number(process.env.PORT || 4173); + +createServer(async (request, response) => { + const requested = request.url === "/" ? "index.html" : request.url.split("?")[0].slice(1); + const file = normalize(join(root, requested)); + if (!file.startsWith(root)) { + response.writeHead(403).end("Forbidden"); + return; + } + try { + const body = await readFile(file); + response.writeHead(200, { "content-type": `${types[extname(file)] || "application/octet-stream"}; charset=utf-8` }); + response.end(body); + } catch { + response.writeHead(404).end("Not found"); + } +}).listen(port, () => console.log(`Emberwatch is running at http://localhost:${port}`)); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..ef0da66 --- /dev/null +++ b/src/app.js @@ -0,0 +1,30 @@ +import { WORLD, createState, movePlayer, nearestEnemy, waveConfig, distance, applyShrineHit, completeWave } from "./game-core.js"; +const canvas=document.querySelector("#game"), ctx=canvas.getContext("2d"); +const ui={overlay:document.querySelector("#overlay"),start:document.querySelector("#start"),title:document.querySelector("#title"),message:document.querySelector("#message"),eyebrow:document.querySelector("#eyebrow"),score:document.querySelector("#score"),wave:document.querySelector("#wave"),health:document.querySelector("#healthBar"),best:document.querySelector("#best"),pause:document.querySelector("#pause"),mute:document.querySelector("#mute"),toast:document.querySelector("#toast")}; +let state=createState(), last=performance.now(), keys=new Set(), target=null, muted=false, audio; +const best=()=>Number(localStorage.getItem("emberwatch-best")||0); ui.best.textContent=`BEST LIGHT · ${String(best()).padStart(4,"0")}`; +const trees=Array.from({length:38},(_,i)=>({x:(i*397)%1280,y:90+((i*173)%600),r:25+(i*19)%55,a:.25+(i%4)*.08})); +const fireflies=Array.from({length:28},(_,i)=>({x:(i*211)%1280,y:(i*149)%720,p:i*.9})); +function tone(freq,duration=.08){if(muted)return; audio??=new AudioContext(); const o=audio.createOscillator(),g=audio.createGain();o.frequency.value=freq;o.type="sine";g.gain.setValueAtTime(.035,audio.currentTime);g.gain.exponentialRampToValueAtTime(.001,audio.currentTime+duration);o.connect(g).connect(audio.destination);o.start();o.stop(audio.currentTime+duration)} +function start(){state=createState();state.status="playing";ui.overlay.classList.add("hidden");target=null;tone(440,.25)} +function spawn(){const side=Math.floor(Math.random()*4), pad=20; let x,y;if(side<2){x=side?WORLD.width+pad:-pad;y=100+Math.random()*580}else{x=Math.random()*WORLD.width;y=side===2?70:WORLD.height+pad}const c=waveConfig(state.wave);state.enemies.push({x,y,radius:13+Math.random()*8,speed:c.speed*(.85+Math.random()*.3),health:c.health,maxHealth:c.health,wobble:Math.random()*7});state.spawned++} +function burst(x,y,color="#f5bd70",n=9){for(let i=0;i9){direction.x=d.x;direction.y=d.y}else target=null}movePlayer(state.player,direction,dt); + const config=waveConfig(state.wave);if(state.waveDelay>0){state.waveDelay-=dt}else if(state.spawned{b.x+=b.vx*dt;b.y+=b.vy*dt;b.life-=dt;state.enemies.forEach(e=>{if(b.life>0&&distance(b,e)b.life>0); + state.enemies.forEach(e=>{const dx=WORLD.shrineX-e.x,dy=WORLD.shrineY-e.y,l=Math.hypot(dx,dy);e.x+=dx/l*e.speed*dt;e.y+=dy/l*e.speed*dt;e.wobble+=dt*5;if(l<55){e.health=0;applyShrineHit(state,12);state.shake=7;burst(e.x,e.y,"#dc7555",14);tone(90,.18)}}); + const dead=state.enemies.filter(e=>e.health<=0);dead.forEach(e=>{if(distance(e,{x:WORLD.shrineX,y:WORLD.shrineY})>=55){state.score+=10*state.wave;burst(e.x,e.y);tone(510,.05)}});state.enemies=state.enemies.filter(e=>e.health>0);state.particles.forEach(p=>{p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=.96;p.vy*=.96;p.life-=dt});state.particles=state.particles.filter(p=>p.life>0);if(state.status==="over")gameOver();updateHud()} +function updateHud(){ui.score.textContent=String(state.score).padStart(4,"0");ui.wave.textContent=state.wave;ui.health.style.width=`${state.shrineHealth}%`} +function toast(text){ui.toast.textContent=text;ui.toast.classList.add("show");setTimeout(()=>ui.toast.classList.remove("show"),1700)} +function gameOver(){const high=Math.max(best(),state.score);localStorage.setItem("emberwatch-best",high);ui.eyebrow.textContent="THE EMBER FADED";ui.title.innerHTML=`The grove will
remember.`;ui.message.textContent=`You guarded the shrine through ${state.wave} waves and gathered ${state.score} light.`;ui.start.innerHTML="Watch again ";ui.best.textContent=`BEST LIGHT · ${String(high).padStart(4,"0")}`;ui.overlay.classList.remove("hidden")} +function draw(){const sx=(Math.random()-.5)*state.shake,sy=(Math.random()-.5)*state.shake;ctx.save();ctx.translate(sx,sy);const g=ctx.createRadialGradient(640,360,30,640,360,700);g.addColorStop(0,"#17483a");g.addColorStop(.45,"#0b2a25");g.addColorStop(1,"#04100f");ctx.fillStyle=g;ctx.fillRect(-10,-10,1300,740);ctx.strokeStyle="#8dbda514";for(let r=105;r<610;r+=84){ctx.beginPath();ctx.arc(640,360,r,0,7);ctx.stroke()} + trees.forEach(t=>{ctx.fillStyle=`rgba(2,12,11,${t.a})`;ctx.beginPath();ctx.arc(t.x,t.y,t.r,0,7);ctx.arc(t.x+t.r*.55,t.y+8,t.r*.72,0,7);ctx.fill()});fireflies.forEach(f=>{const a=.25+.35*Math.sin(state.time*1.7+f.p);ctx.fillStyle=`rgba(245,190,114,${a})`;ctx.beginPath();ctx.arc(f.x+Math.sin(state.time+f.p)*8,f.y,1.7,0,7);ctx.fill()}); + const pulse=8+Math.sin(state.time*3)*4,sg=ctx.createRadialGradient(640,360,5,640,360,110+pulse);sg.addColorStop(0,"#ffd598dd");sg.addColorStop(.16,"#e9a34a55");sg.addColorStop(1,"#e99c4200");ctx.fillStyle=sg;ctx.beginPath();ctx.arc(640,360,120+pulse,0,7);ctx.fill();ctx.strokeStyle="#d3b57488";ctx.lineWidth=3;ctx.beginPath();ctx.arc(640,360,34,0,7);ctx.stroke();ctx.fillStyle="#f7c578";ctx.beginPath();ctx.moveTo(640,326-pulse*.2);ctx.quadraticCurveTo(665,357,640,384);ctx.quadraticCurveTo(615,358,640,326-pulse*.2);ctx.fill(); + state.enemies.forEach(e=>{ctx.save();ctx.translate(e.x,e.y);ctx.rotate(Math.sin(e.wobble)*.12);ctx.fillStyle="#06100fee";ctx.strokeStyle="#875f6888";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(0,-e.radius);ctx.quadraticCurveTo(e.radius*1.2,0,e.radius*.65,e.radius);ctx.quadraticCurveTo(0,e.radius*.55,-e.radius*.65,e.radius);ctx.quadraticCurveTo(-e.radius*1.2,0,0,-e.radius);ctx.fill();ctx.stroke();ctx.fillStyle="#d67569";ctx.fillRect(-6,-3,3,2);ctx.fillRect(3,-3,3,2);ctx.restore()}); + state.bolts.forEach(b=>{ctx.strokeStyle="#c8ffe1";ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(b.x-b.vx*.035,b.y-b.vy*.035);ctx.lineTo(b.x,b.y);ctx.stroke()});state.particles.forEach(p=>{ctx.globalAlpha=Math.max(0,p.life);ctx.fillStyle=p.color;ctx.fillRect(p.x,p.y,3,3)});ctx.globalAlpha=1; + ctx.save();ctx.translate(state.player.x,state.player.y);ctx.shadowColor="#7ce6b0";ctx.shadowBlur=18;ctx.fillStyle="#d7f4e1";ctx.beginPath();ctx.arc(0,0,10,0,7);ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle="#85caaa";ctx.lineWidth=3;ctx.beginPath();ctx.arc(0,0,18,0,7);ctx.stroke();ctx.restore();ctx.restore()} +function loop(now){const dt=Math.min(.033,(now-last)/1000);last=now;update(dt);draw();requestAnimationFrame(loop)} +function pointer(e){const r=canvas.getBoundingClientRect();target={x:(e.clientX-r.left)/r.width*WORLD.width,y:(e.clientY-r.top)/r.height*WORLD.height}} +addEventListener("keydown",e=>{if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," "].includes(e.key))e.preventDefault();if(e.key===" ")togglePause();keys.add(e.key.toLowerCase())});addEventListener("keyup",e=>keys.delete(e.key.toLowerCase()));canvas.addEventListener("pointerdown",pointer);canvas.addEventListener("pointermove",e=>{if(e.buttons)pointer(e)});ui.start.addEventListener("click",start); +function togglePause(){if(state.status==="playing"){state.status="paused";toast("Watch paused");ui.pause.textContent="Resume"}else if(state.status==="paused"){state.status="playing";ui.pause.textContent="Pause"}}ui.pause.addEventListener("click",togglePause);ui.mute.addEventListener("click",()=>{muted=!muted;ui.mute.textContent=`Sound · ${muted?"Off":"On"}`});requestAnimationFrame(loop); diff --git a/src/game-core.js b/src/game-core.js new file mode 100644 index 0000000..efb0b00 --- /dev/null +++ b/src/game-core.js @@ -0,0 +1,31 @@ +export const WORLD = { width: 1280, height: 720, shrineX: 640, shrineY: 360 }; +export const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +export const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); + +export function waveConfig(wave) { + return { count: 4 + wave * 2, speed: 34 + wave * 3.5, health: 1 + Math.floor(wave / 4), interval: Math.max(.3, 1.05 - wave * .055) }; +} + +export function createState() { + return { status:"ready", time:0, wave:1, score:0, shrineHealth:100, player:{x:640,y:455,radius:15,speed:235,cooldown:0}, enemies:[], bolts:[], particles:[], spawned:0, spawnTimer:.5, waveDelay:0, shake:0 }; +} + +export function movePlayer(player, direction, delta) { + const length = Math.hypot(direction.x, direction.y) || 1; + if (direction.x || direction.y) { player.x += direction.x / length * player.speed * delta; player.y += direction.y / length * player.speed * delta; } + player.x = clamp(player.x, 55, WORLD.width - 55); player.y = clamp(player.y, 90, WORLD.height - 45); +} + +export function nearestEnemy(player, enemies, range = 230) { + return enemies.reduce((best, enemy) => distance(player, enemy) < distance(player, best || {x:Infinity,y:Infinity}) && distance(player, enemy) <= range ? enemy : best, null); +} + +export function applyShrineHit(state, damage) { + state.shrineHealth = clamp(state.shrineHealth - damage, 0, 100); + if (state.shrineHealth === 0) state.status = "over"; +} + +export function completeWave(state) { + state.wave += 1; state.spawned = 0; state.spawnTimer = 1.4; state.waveDelay = 2.7; + state.shrineHealth = clamp(state.shrineHealth + 7, 0, 100); +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..ea7339a --- /dev/null +++ b/styles.css @@ -0,0 +1,23 @@ +@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=DM+Sans:wght@400;500;600&display=swap'); +:root { color-scheme: dark; --ink:#061110; --pine:#0b2722; --mint:#8fd5b0; --ember:#f5be72; --cream:#f4edda; } +* { box-sizing:border-box; } +body { margin:0; min-height:100vh; overflow:hidden; background:radial-gradient(circle at 50% 35%,#16382f 0,#071817 55%,#030a0a 100%); color:var(--cream); font-family:"DM Sans",sans-serif; } +body::before { content:""; position:fixed; inset:0; opacity:.2; pointer-events:none; background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.28'/%3E%3C/svg%3E"); } +.game-shell { width:min(1180px,100vw); height:100vh; margin:auto; display:grid; grid-template-rows:auto 1fr auto; gap:14px; padding:18px 24px; } +.brand { display:flex; align-items:center; gap:12px; letter-spacing:.22em; } +.brand .sigil { color:var(--ember); font-size:28px; filter:drop-shadow(0 0 10px #efae54); } +.brand p,.brand h1 { margin:0; }.brand p { font-size:9px; color:#85a49a; }.brand h1 { font:500 18px/1.1 Cinzel,serif; letter-spacing:.08em; } +.stage { min-height:0; overflow:hidden; position:relative; border:1px solid #89b7a52b; border-radius:4px; background:#071615; box-shadow:0 26px 80px #0009,inset 0 0 70px #0008; } +canvas { width:100%; height:100%; display:block; touch-action:none; } +.hud { position:absolute; inset:20px 24px auto; display:grid; grid-template-columns:1fr auto 1fr; align-items:start; pointer-events:none; text-shadow:0 2px 4px #000; } +.hud span { display:block; font-size:9px; letter-spacing:.22em; color:#a8c0b7; }.stat { width:180px; }.bar { width:100%; height:5px; margin-top:7px; background:#ffffff1c; }.bar i { display:block; width:100%; height:100%; background:linear-gradient(90deg,#df8d51,var(--ember)); box-shadow:0 0 12px #f2ad59; transition:width .25s; } +.wave { text-align:center; }.wave strong { font:500 31px Cinzel; }.score { text-align:right; }.score strong { font:500 24px Cinzel; color:var(--ember); } +.corner-actions { position:absolute; right:22px; bottom:18px; display:flex; gap:8px; }.corner-actions button { border:1px solid #b8cfbf2c; color:#a9bdb5; background:#051211bb; padding:7px 11px; font:600 9px "DM Sans"; letter-spacing:.12em; text-transform:uppercase; cursor:pointer; } +.overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:28px; background:radial-gradient(circle,#09221dcc 0,#04100feb 72%); transition:opacity .4s,visibility .4s; }.overlay.hidden { opacity:0; visibility:hidden; pointer-events:none; } +.eyebrow { color:var(--mint)!important; letter-spacing:.34em; font-weight:600; font-size:10px!important; }.overlay h2 { margin:14px 0 18px; font:500 clamp(34px,6vw,68px)/.98 Cinzel,serif; letter-spacing:-.035em; }.overlay h2 em { color:var(--ember); font-style:normal; text-shadow:0 0 25px #e4934d4a; }.overlay>p { max-width:490px; color:#abc1b8; line-height:1.7; font-size:14px; } +.primary { margin-top:16px; min-width:220px; padding:14px 18px; border:1px solid #f3c27980; background:#d99751; color:#101a16; font:600 11px "DM Sans"; letter-spacing:.16em; text-transform:uppercase; cursor:pointer; box-shadow:0 0 32px #eaa75d3b; }.primary span { margin-left:18px; }.primary:hover { background:#efbc79; transform:translateY(-1px); } +.controls { display:flex; gap:24px; margin-top:30px; color:#728e84; font-size:10px; flex-wrap:wrap; justify-content:center; }kbd { border:1px solid #8fa89f55; border-radius:2px; padding:3px 5px; background:#ffffff0a; color:#bacdc6; }.best { font-size:9px!important; letter-spacing:.25em; margin-bottom:0; } +.toast { opacity:0; position:absolute; left:50%; top:22%; transform:translate(-50%,-10px); font:500 18px Cinzel; color:var(--ember); text-shadow:0 0 16px #e99e48; transition:.3s; pointer-events:none; }.toast.show { opacity:1; transform:translate(-50%,0); } +footer { display:flex; justify-content:space-between; color:#5b786e; font-size:8px; letter-spacing:.2em; } +@media(max-width:650px){.game-shell{padding:12px;gap:9px}.stage{border-radius:0}.hud{inset:12px}.stat{width:110px}.overlay h2{font-size:38px}.controls{gap:12px}.score span{display:none}footer{display:none}.corner-actions{right:10px;bottom:10px}.game-shell{grid-template-rows:auto 1fr}.brand{padding-bottom:2px}} +@media(prefers-reduced-motion:reduce){*{transition:none!important}.primary:hover{transform:none}} diff --git a/test/game-core.test.js b/test/game-core.test.js new file mode 100644 index 0000000..941ba92 --- /dev/null +++ b/test/game-core.test.js @@ -0,0 +1,8 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createState, movePlayer, nearestEnemy, waveConfig, applyShrineHit, completeWave } from "../src/game-core.js"; +test("wave difficulty grows while spawn cadence tightens",()=>{assert.ok(waveConfig(8).count>waveConfig(1).count);assert.ok(waveConfig(8).speed>waveConfig(1).speed);assert.ok(waveConfig(8).interval{const p={x:100,y:100,speed:100};movePlayer(p,{x:1,y:1},1);assert.ok(Math.abs(Math.hypot(p.x-100,p.y-100)-100)<.01);p.x=0;p.y=0;movePlayer(p,{x:-1,y:-1},1);assert.equal(p.x,55);assert.equal(p.y,90)}); +test("nearest enemy respects attack range",()=>{const p={x:0,y:0};assert.equal(nearestEnemy(p,[{x:20,y:0},{x:10,y:0}]).x,10);assert.equal(nearestEnemy(p,[{x:300,y:0}]),null)}); +test("shrine damage ends the watch at zero",()=>{const s=createState();s.status="playing";applyShrineHit(s,120);assert.equal(s.shrineHealth,0);assert.equal(s.status,"over")}); +test("wave completion advances and heals without exceeding 100",()=>{const s=createState();s.shrineHealth=98;completeWave(s);assert.equal(s.wave,2);assert.equal(s.shrineHealth,100);assert.equal(s.waveDelay,2.7)});