diff --git a/README.md b/README.md index 1e9c9c7..86cdd85 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Play at [arena.littledill.app](https://arena.littledill.app) or enter from the i - Gadget sounds follow the same synthesis and timing on both platforms. Four shared pickup clips rotate at a quieter, limited cadence; losing a piece or being eaten uses a crunch. - Brine jar drops: 90 seconds after a garden starts, then every 210 seconds, the server marks a spot away from edges and gadgets with a 10-second warning on the minimap and in the garden. The jar then spills a ring of 40 bonus pellets at a 260 radius. The spill moves existing pellets, so the food count and older clients stay unchanged. - Leader bounty: the heaviest live player with at least 500 mass, bots included, wears a gold crown and is highlighted on the leaderboard. Eating one of their pieces adds 10% of the absorbed mass, counted as earned mass. -- Run goals: each human spawn rolls three goals (crunch pickles, reach a mass, survive, eat pellets, or touch a jar spill). Reconnects keep the same goals. The death and leave screens show a recap with peak mass, best rank, kills, time alive, and goals done. On iOS each completed goal pays 5 coins within the same 1,000-coin daily cap. +- Run goals: each human spawn rolls three goals (crunch pickles, reach a mass, survive, eat pellets, or touch a jar spill). Reconnects keep the same goals. A short toast names each goal as it completes. The death and leave screens show a recap with peak mass, best rank, kills, time alive, and goals done. On iOS each completed goal pays 5 coins within the same 1,000-coin daily cap. - Reconnect within 30 seconds to recover the same run, pieces, mass, and paused timers. One-second Durable Object checkpoints support server restart recovery. Explicitly leaving ends recovery; there is no fixed session-length limit. The rule source is `server/arena-engine.mjs`. Web/native parity checks share `tests/fixtures/arena-parity.json`; run them with the test commands below. The arena requires a connection; pet care and native solo games work offline. diff --git a/arena-web/arena-core.mjs b/arena-web/arena-core.mjs index 94afabd..a11b775 100644 --- a/arena-web/arena-core.mjs +++ b/arena-web/arena-core.mjs @@ -2,7 +2,7 @@ import { bodyProfile, bodyRadius, bodyPoints } from './pickle-body.mjs'; import { WARDROBE_IDS, drawWardrobe } from './arena-wardrobe.mjs'; import { EAT_OVERLAP, drawSmile, tearPose } from './arena-expression.mjs'; export { EAT_OVERLAP, SMILES, skinSmile, pickupCue } from './arena-expression.mjs'; -export { JAR, EVENT_COLORS, goalLabel, goalDone, goalProgress, eventState, jarSpilled, recapStats, recapRows, clockLabel, drawJar, drawBountyCrown, bountyCrownWidth, drawMapJar } from './arena-events.mjs'; +export { JAR, EVENT_COLORS, goalLabel, goalDone, goalProgress, eventState, jarSpilled, goalsCompleted, recapStats, recapRows, clockLabel, drawJar, drawBountyCrown, bountyCrownWidth, drawMapJar } from './arena-events.mjs'; export const BRINES = ['classic', 'garlic', 'spicy']; export const OUTFITS = ['original', 'sprout', 'bow', 'shades', 'crown', 'party', ...WARDROBE_IDS]; diff --git a/arena-web/arena-events.mjs b/arena-web/arena-events.mjs index e1be08c..d788642 100644 --- a/arena-web/arena-events.mjs +++ b/arena-web/arena-events.mjs @@ -30,6 +30,7 @@ export function eventState(snapshot, ownID) { }; } export const jarSpilled = (previous, next) => Boolean(previous && next && previous.warn > 0 && next.warn === 0); +export const goalsCompleted = (previous = [], next = []) => next.filter(goal => goalDone(goal) && previous.some(([kind, target, progress]) => kind === goal[0] && target === goal[1] && progress < target)); export function recapStats(own) { const { run, goals } = eventState({ players: own ? [own] : [] }, own?.id); diff --git a/arena-web/arena.css b/arena-web/arena.css index 5e33730..0c6905f 100644 --- a/arena-web/arena.css +++ b/arena-web/arena.css @@ -81,14 +81,9 @@ kbd { font-family: inherit; font-size: 9px; border: 1px solid #cdd5bd; border-ra /* Container queries follow the visible game width, even while Safari is zoomed. */ .leaderboard .bounty { background: #f2c14e2e; border-radius: 6px; margin: -2px -4px; padding: 2px 4px; } .leaderboard .bounty-mark { color: #c9971f; font-size: 11px; flex-shrink: 0; } -.goals { justify-self: start; width: min(100%, 260px); padding: 9px 12px; border: 1px solid #d8dfc9; border-radius: 14px; background: #fffdf4dc; } -.goals h2 { font-size: 8px; letter-spacing: 1.3px; margin: 0 0 6px; color: var(--muted); } -.goals ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 4px; } -.goals li { display: flex; gap: 8px; font-size: 10px; white-space: nowrap; min-width: 0; } -.goals li span { overflow: hidden; text-overflow: ellipsis; min-width: 0; } -.goals li b { margin-left: auto; font-variant-numeric: tabular-nums; flex-shrink: 0; } -.goals li.done { color: #5c7d2d; font-weight: 750; } -.run-recap { display: grid; grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); gap: 10px; margin: 0; padding: 5px 0 18px; } +.goal-toast { position: absolute; top: calc(var(--hud-bottom, 140px) - 6px); left: 0; right: 0; margin: 0 auto; width: max-content; max-width: min(320px, calc(100% - 32px)); padding: 7px 13px; border: 1px solid #d8dfc9; border-radius: 20px; background: #fffdf4f0; color: #4f6d27; font-size: 11px; font-weight: 750; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 5px 18px #263e310d; pointer-events: none; opacity: 0; transform: translateY(-6px); transition: opacity .35s, transform .35s; } +.goal-toast.shown { opacity: 1; transform: none; } +.run-recap { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0; padding: 5px 0 18px; } .run-recap div { min-width: 0; } .run-recap dt { font-size: 8px; letter-spacing: 1px; color: var(--muted); } .run-recap dd { margin: 4px 0 0; font: 24px var(--serif); color: var(--ink); overflow-wrap: anywhere; } @@ -171,8 +166,7 @@ kbd { font-family: inherit; font-size: 9px; border: 1px solid #cdd5bd; border-ra .game[data-short=true] .overlay-card p { margin-bottom: 8px; } .game[data-short=true] .run-result { padding-bottom: 5px; } .game[data-short=true] .run-result strong { font-size: 25px; } -.game[data-short=true] .goals li { font-size: 9px; } .game[data-short=true] .run-recap dd { font-size: 18px; } -.game[data-short=true] .goals { padding: 6px 9px; } .game[data-short=true] .goals h2 { display: none; } +.game[data-short=true] .run-recap dd { font-size: 18px; } .game[data-short=true] .run-recap { padding-bottom: 5px; } .game[data-short=true] .overlay-card .button { min-height: 40px; margin-top: 6px; } @media (pointer: coarse) { .joystick { display: block; } .desktop-hint { display: none; } .game > canvas { cursor: default; } } @@ -194,4 +188,4 @@ kbd { font-family: inherit; font-size: 9px; border: 1px solid #cdd5bd; border-ra .intro h1 { font-size: 48px; } .hero-art { width: 245px; } .join-card { padding: 21px; } .join-card h2 { font-size: 26px; } .brine { font-size: 10px; gap: 5px; padding: 5px; } } -@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none!important; animation: none!important; scroll-behavior: auto!important; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none!important; animation: none!important; scroll-behavior: auto!important; } .goal-toast { transform: none; transition: opacity .35s!important; } } diff --git a/arena-web/arena.js b/arena-web/arena.js index e925f92..71e2530 100644 --- a/arena-web/arena.js +++ b/arena-web/arena.js @@ -1,4 +1,4 @@ -import { BRINES, OUTFITS, VARIETIES, varietyOf, clamp, radius, viewportBounds, cameraFrame, playerCells, flattenCells, cellKey, threatenedCellIDs, splitState, massLabel, roomCode, direction, stickInput, playerName, socketURL, applyFoodUpdate, readResumeRecord, refreshResumeRecord, resumeFromWelcome, resumeRetryDelay, shareURL, interpolate, drawPickle, easeZoom, killRings, bodyShape, membraneSize, resizeMembrane, stepMembrane, membraneOutline, hazardFor, drainDroplets, spitFlight, drawDroplet, drawHazard, hazardKind, scheduleStrokes, pickupCue, JAR, goalLabel, goalDone, goalProgress, eventState, jarSpilled, recapStats, recapRows, drawJar, drawBountyCrown, bountyCrownWidth, drawMapJar } from './arena-core.mjs'; +import { BRINES, OUTFITS, VARIETIES, varietyOf, clamp, radius, viewportBounds, cameraFrame, playerCells, flattenCells, cellKey, threatenedCellIDs, splitState, massLabel, roomCode, direction, stickInput, playerName, socketURL, applyFoodUpdate, readResumeRecord, refreshResumeRecord, resumeFromWelcome, resumeRetryDelay, shareURL, interpolate, drawPickle, easeZoom, killRings, bodyShape, membraneSize, resizeMembrane, stepMembrane, membraneOutline, hazardFor, drainDroplets, spitFlight, drawDroplet, drawHazard, hazardKind, scheduleStrokes, pickupCue, JAR, goalLabel, eventState, jarSpilled, goalsCompleted, recapStats, recapRows, drawJar, drawBountyCrown, bountyCrownWidth, drawMapJar } from './arena-core.mjs'; const $ = id => document.getElementById(id); const canvas = $('arena'), ctx = canvas.getContext('2d'), map = $('minimap').getContext('2d'); @@ -25,8 +25,18 @@ let threatened = new Set(), membranes = new Map(), membraneClock = 0, spits = ne let width = innerWidth, height = innerHeight, camera = { x: 1200, y: 900 }, zoom = null, lastFrame = performance.now(); let viewCenter = { x: width / 2, y: height / 2 }, cameraInsets = {}; let audio = null, audioOut = null, noiseBuffer = null, drainLoop = null, pickupState = null, pickupBuffers = [], pickupLoading = null, leaderboardSignature = '', lastSavedBest = settings.best; -let events = eventState(null, null), lastAliveOwn = null, deathRecap = null, jarSpillAt = null, goalsSignature = ''; +let events = eventState(null, null), lastAliveOwn = null, deathRecap = null, jarSpillAt = null, goalToasts = [], goalToastTimer = null; +function showGoalToast() { + const toast = $('goal-toast'); + if (goalToastTimer !== null || !goalToasts.length) return; + toast.textContent = `✓ ${goalLabel(goalToasts.shift())}`; toast.classList.toggle('shown', true); + goalToastTimer = setTimeout(() => { + toast.classList.toggle('shown', false); + goalToastTimer = setTimeout(() => { toast.textContent = ''; goalToastTimer = null; showGoalToast(); }, 450); + }, 2500); +} +function clearGoalToasts() { goalToasts = []; clearTimeout(goalToastTimer); goalToastTimer = null; $('goal-toast').classList.toggle('shown', false); $('goal-toast').textContent = ''; } function notify(message) { $('toast').textContent = message; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { $('toast').hidden = true; }, 3500); } function audioOutput() { if (!audioOut) { @@ -176,6 +186,13 @@ function overlay(mode, title, message) { for (const element of [$('arena'), ...document.querySelectorAll('.game-header button'), $('dash'), $('split')]) element.inert = true; (mode === 'joining' ? $('overlay-back') : $('overlay-action')).focus({ preventScroll: true }); } +// A disabled button loses focus, so Share holds it during the respawn countdown. +function waitForRespawn(waiting) { + const action = $('overlay-action'), share = $('share-run'); + if (waiting && document.activeElement === action) { share.focus({ preventScroll: true }); share.dataset.heldFocus = 'true'; } + action.disabled = waiting; + if (!waiting && share.dataset.heldFocus === 'true') { delete share.dataset.heldFocus; if (document.activeElement === share) action.focus({ preventScroll: true }); } +} function hideOverlay() { $('overlay').hidden = true; overlayMode = ''; for (const element of [$('arena'), ...document.querySelectorAll('.game-header button'), $('dash'), $('split')]) element.inert = false; } function join(room) { settings.name = playerName($('name').value); settings.outfit = $('outfit').value; save(); unlockAudio(); @@ -183,7 +200,7 @@ function join(room) { // keep following it while the keyboard and browser chrome finish animating. document.activeElement?.blur(); releaseRun(); requestedRoom = roomCode(room); ownID = null; state = null; previous.clear(); threatened.clear(); membranes.clear(); spits.clear(); ownPrevious = null; sequence = 0; leaderboardSignature = ''; zoom = null; - events = eventState(null, null); lastAliveOwn = null; deathRecap = null; jarSpillAt = null; goalsSignature = ''; $('goals').hidden = true; $('recap-card').hidden = true; + events = eventState(null, null); lastAliveOwn = null; deathRecap = null; jarSpillAt = null; clearGoalToasts(); $('recap-card').hidden = true; phase = 'joining'; $('lobby').hidden = true; $('game').hidden = false; document.body.style.overflow = 'hidden'; $('room-badge').hidden = !requestedRoom; $('room-badge').textContent = requestedRoom ? `FRIEND ROOM · ${requestedRoom}` : ''; $('population').textContent = 'Finding your garden…'; $('mass').textContent = '25'; $('rank').textContent = '—'; $('best').textContent = '25'; $('leaders').replaceChildren(); $('protection').hidden = true; @@ -220,8 +237,9 @@ function connectSocket(resume = null) { packet.food = applyFoodUpdate(state?.food || [], packet); packet.hazards = Array.isArray(packet.hazards) ? packet.hazards : []; state = packet; arrived = performance.now(); - const jarBefore = events.jar; events = eventState(packet, ownID); + const jarBefore = events.jar, goalsBefore = events.goals; events = eventState(packet, ownID); if (!entering && jarSpilled(jarBefore, events.jar)) { jarSpillAt = arrived; sound('jar'); } + if (!entering) { goalToasts.push(...goalsCompleted(goalsBefore, events.goals)); showGoalToast(); } if (own.alive) lastAliveOwn = own; trackSpit(packet, entering); pruneMembranes(packet); threatened = threatenedCellIDs(packet.players); @@ -333,7 +351,7 @@ function fillRecap(list, stats) { } function leave() { const recap = recapStats(ownPrevious?.run ? ownPrevious : lastAliveOwn); - releaseRun(); phase = 'lobby'; save(); $('game').hidden = true; $('lobby').hidden = false; document.body.style.overflow = ''; hideOverlay(); state = null; ownPrevious = null; lastAliveOwn = null; deathRecap = null; refreshLobby(); + releaseRun(); phase = 'lobby'; save(); $('game').hidden = true; $('lobby').hidden = false; document.body.style.overflow = ''; hideOverlay(); state = null; ownPrevious = null; lastAliveOwn = null; deathRecap = null; clearGoalToasts(); refreshLobby(); fillRecap($('recap-stats'), recap); $('recap-card').hidden = !recap; (recap ? $('recap-dismiss') : $('play')).focus({ preventScroll: true }); } @@ -362,7 +380,7 @@ function updateHUD(own) { $('split').setAttribute('aria-label', splitting.enabled ? 'Split forward into pickle slices; regroup automatically after 12 seconds' : `Split unavailable: ${splitting.reason}`); $('cell-count').textContent = `${splitting.count} / 8 slices`; $('merge-note').textContent = splitting.count > 1 ? splitting.merge > 0 ? `Regroup in ${Math.ceil(splitting.merge)}s` : 'Regrouping automatically…' : '60 mass per slice to split'; - if (!own.alive && overlayMode === 'dead') { $('run-best').textContent = massLabel(own.best); $('run-best').title = own.best.toLocaleString(); $('run-kills').textContent = massLabel(own.kills); $('overlay-action').disabled = own.respawn > 0; $('overlay-action').textContent = own.respawn > 0 ? `Fresh pickle in ${Math.ceil(own.respawn)}…` : 'One more crunch'; } + if (!own.alive && overlayMode === 'dead') { $('run-best').textContent = massLabel(own.best); $('run-best').title = own.best.toLocaleString(); $('run-kills').textContent = massLabel(own.kills); waitForRespawn(own.respawn > 0); $('overlay-action').textContent = own.respawn > 0 ? `Fresh pickle in ${Math.ceil(own.respawn)}…` : 'One more crunch'; } const leaders = ranked.slice(0, 5), signature = `${events.bounty}|${leaders.map(p => `${p.id}:${Math.floor(p.mass)}`).join('|')}`; if (signature !== leaderboardSignature) { leaderboardSignature = signature; $('leaders').replaceChildren(...leaders.map(p => { @@ -372,14 +390,6 @@ function updateHUD(own) { return row; })); } - const goalSignature = JSON.stringify(events.goals); - if (goalSignature !== goalsSignature) { - goalsSignature = goalSignature; $('goals').hidden = events.goals.length === 0; - $('goal-list').replaceChildren(...events.goals.map(goal => { - const item = document.createElement('li'), label = document.createElement('span'), progress = document.createElement('b'), done = goalDone(goal); - item.className = done ? 'done' : ''; label.textContent = `${done ? '✓ ' : ''}${goalLabel(goal)}`; progress.textContent = goalProgress(goal); item.append(label, progress); return item; - })); - } } function steer() { if (phase !== 'playing' || overlayMode || !ownPrevious?.alive) { movement = { x: 0, y: 0 }; return; } @@ -451,6 +461,7 @@ function measurePlayArea() { if (phase === 'lobby') return; const bounds = $('game').getBoundingClientRect(), top = document.querySelector('.game-top').getBoundingClientRect(), bottom = document.querySelector('.game-controls').getBoundingClientRect(); cameraInsets = { top: top.bottom - bounds.top, bottom: bounds.bottom - bottom.top, left: 12, right: 12 }; + $('game').style.setProperty('--hud-bottom', `${cameraInsets.top}px`); } new ResizeObserver(measurePlayArea).observe(document.querySelector('.game-controls')); new ResizeObserver(measurePlayArea).observe(document.querySelector('.game-top')); diff --git a/arena-web/index.html b/arena-web/index.html index ba887ee..3836b10 100644 --- a/arena-web/index.html +++ b/arena-web/index.html @@ -53,9 +53,9 @@