diff --git a/README.md b/README.md index 92c5778..bae5740 100644 --- a/README.md +++ b/README.md @@ -481,3 +481,7 @@ eller borttagning av äldre filer och bloggdemon. Kör `npm run build` efter ändringar, eller `npm start` för lokal förhandsvisning. Om utvecklingsservern får en annan port än 8080 används adressen den skriver ut. + +## Hemligt rymdspel + +Kontroller, multiplayer, arkitektur och tester finns i [spelets README](docs/space-egg/README.md). diff --git a/_includes/layout.html b/_includes/layout.html index 256dca3..de30d83 100644 --- a/_includes/layout.html +++ b/_includes/layout.html @@ -30,5 +30,6 @@ {% include "header.html" %} {{ content }} {% include "footer.html" %} + diff --git a/assets/space-egg/arena.js b/assets/space-egg/arena.js new file mode 100644 index 0000000..ebdd491 --- /dev/null +++ b/assets/space-egg/arena.js @@ -0,0 +1,76 @@ +export const WORLD_WIDTH = 1280; +export const WORLD_HEIGHT = 800; + +// Every pilot uses this layout viewport. Only the camera changes with the screen. +export function cameraFor(viewWidth, viewHeight, ship) { + const scale = Math.min(1, Math.max(viewWidth / WORLD_WIDTH, viewHeight / WORLD_HEIGHT)); + const visibleWidth = viewWidth / scale; + const visibleHeight = viewHeight / scale; + const x = Math.max(0, Math.min(WORLD_WIDTH - visibleWidth, ship.x - visibleWidth / 2)); + const y = Math.max(0, Math.min(WORLD_HEIGHT - visibleHeight, ship.y - visibleHeight / 2)); + return { + x, y, scale, + left: Math.max(0, (viewWidth - WORLD_WIDTH * scale) / 2) - x * scale, + top: Math.max(0, (viewHeight - WORLD_HEIGHT * scale) / 2) - y * scale, + visibleWidth: Math.min(WORLD_WIDTH, visibleWidth), + visibleHeight: Math.min(WORLD_HEIGHT, visibleHeight), + }; +} + +export async function createArena(url, { signal } = {}) { + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error('Spelplanen kunde inte laddas.'); + const page = new DOMParser().parseFromString(await response.text(), 'text/html'); + page.querySelectorAll('script, base, meta[http-equiv="refresh"]').forEach(node => node.remove()); + const base = page.createElement('base'); + base.href = response.url; + page.head.prepend(base); + const style = page.createElement('style'); + style.textContent = 'html { scrollbar-width: none; scroll-behavior: auto !important; } ::-webkit-scrollbar { display: none; }'; + page.head.append(style); + const container = document.createElement('div'); + container.id = 'athega-space-arena'; + container.inert = true; + container.setAttribute('aria-hidden', 'true'); + container.style.cssText = 'visibility:hidden;position:fixed;inset:0;overflow:hidden;background:#050b14;z-index:2147483646;pointer-events:none'; + const iframe = document.createElement('iframe'); + iframe.title = 'Gemensam spelplan'; + iframe.setAttribute('sandbox', 'allow-same-origin'); + iframe.style.cssText = `position:absolute;width:${WORLD_WIDTH}px;height:${WORLD_HEIGHT}px;border:0;transform-origin:0 0`; + container.append(iframe); + iframe.srcdoc = '' + page.documentElement.outerHTML; + document.body.append(container); + try { + // A sandboxed document may eagerly fetch archive images. The arena only needs + // its DOM, styles and fonts; waiting for window.load stalls large blog pages. + await new Promise((resolve, reject) => { + const started = performance.now(); + const poll = setInterval(() => { + if (signal?.aborted) { clearInterval(poll); reject(signal.reason); return; } + const doc = iframe.contentDocument; + // Force layout so web fonts are requested before checking their status. + doc?.documentElement?.getBoundingClientRect(); + if (doc?.querySelector('main') && doc.readyState !== 'loading' + && [...doc.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && doc.fonts.status === 'loaded') { + clearInterval(poll); + resolve(); + } else if (performance.now() - started > 15000) { + clearInterval(poll); + reject(new Error('Spelplanen tog för lång tid att ladda.')); + } + }, 50); + }); + } catch (error) { + container.remove(); + throw error; + } + return { + container, + document: iframe.contentDocument, + window: iframe.contentWindow, + position(camera) { + iframe.style.transform = `translate(${camera.left}px, ${camera.top}px) scale(${camera.scale})`; + }, + close() { container.remove(); }, + }; +} diff --git a/assets/space-egg/combat.js b/assets/space-egg/combat.js new file mode 100644 index 0000000..2dba179 --- /dev/null +++ b/assets/space-egg/combat.js @@ -0,0 +1,44 @@ +export const POWERUPS = { + triple: { label: '3X', name: 'Trippelskott', duration: 12 }, + mine: { label: 'MIN', name: 'Mina · placera med M', duration: 0 }, + shield: { label: 'SK', name: 'Sköld', duration: 10 }, + bomb: { label: 'BOM', name: 'Bomb · släpp med B', duration: 0 }, + rapid: { label: 'RF', name: 'Snabbeld', duration: 12 }, + repair: { label: '+', name: 'Reparation +40', duration: 0 }, + turbo: { label: 'T', name: 'Turbo', duration: 10 }, + scroll: { label: 'S↕', name: 'Snabbscroll för alla', duration: 8 }, + gravity: { label: 'G↓', name: 'Gravitation för alla', duration: 8 }, +}; + +export function damageHull(hull, amount, protectedShip) { + if (protectedShip || hull <= 0) return hull; + return Math.max(0, hull - amount); +} + +export function expandedRect(rect, radius) { + return { left: rect.left - radius, right: rect.right + radius, top: rect.top - radius, bottom: rect.bottom + radius }; +} + +export function effectSeconds(deadline, now) { + return Math.max(0, Math.min(POWERUPS.gravity.duration, (deadline - now) / 1000)); +} + +// Reflect at the document edges; keep fractional pixels between animation frames. +export function advanceScroll(position, direction, distance, maximum) { + if (maximum <= 0) return { position: 0, direction: 1 }; + let next = Math.max(0, Math.min(maximum, position)) + direction * distance; + while (next < 0 || next > maximum) { + if (next > maximum) { next = maximum * 2 - next; direction = -1; } + if (next < 0) { next = -next; direction = 1; } + } + return { position: next, direction }; +} + +export function shipContact(ship, other, fallbackSide = 1) { + const dx = ship.x - other.x; + const dy = ship.y - other.y; + const distance = Math.hypot(dx, dy); + const separation = 30; + if (distance >= separation) return null; + return { nx: distance ? dx / distance : fallbackSide, ny: distance ? dy / distance : 0, overlap: separation - distance }; +} diff --git a/assets/space-egg/game.css b/assets/space-egg/game.css new file mode 100644 index 0000000..e423820 --- /dev/null +++ b/assets/space-egg/game.css @@ -0,0 +1,120 @@ +:host { all: initial; position: fixed; inset: 0; z-index: 2147483647; color: #fff; font: 14px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; } +* { box-sizing: border-box; } +.world-canvas { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; } +button { font: inherit; cursor: pointer; color: inherit; border: 1px solid #5d6267; background: #151a20; border-radius: 8px; min-height: 44px; padding: 9px 13px; touch-action: manipulation; } +button:hover { background: #29313a; border-color: #ff6600; } +button:focus-visible { outline: 2px solid #ff8c40; outline-offset: 3px; } +[hidden] { display: none !important; } +.hud { position: absolute; z-index: 2; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); top: 12px; display: flex; justify-content: space-between; align-items: start; gap: 12px; pointer-events: none; } +.hud > * { pointer-events: auto; } +.scoreboard { background: #111820f2; border: 1px solid #4b535d; border-left: 3px solid #ff6600; border-radius: 6px; padding: 10px 16px; box-shadow: 0 8px 32px #0003; } +.pace { display: block; font-size: 11px; color: #ffb480; font-variant-numeric: tabular-nums; } +.power-status { display: block; color: #8ee7d1; font-size: 10px; margin-top: 4px; } +.kicker { display: block; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; color: #ff9b58; } +.score { display: block; font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1.2; } +.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; max-width: min(65%, 380px); gap: 8px; } +.briefing { position: absolute; inset: 0; display: grid; place-items: center; padding: 20px; background: #08101aa8; backdrop-filter: blur(5px); } +.panel { width: min(100%, 440px); background: #101820; border: 1px solid #49525c; border-top: 3px solid #ff6600; border-radius: 16px; padding: 32px; box-shadow: 0 30px 100px #0007; } +h1 { font: 500 clamp(30px, 7vw, 42px)/1.1 system-ui, sans-serif; letter-spacing: -.04em; margin: 12px 0 20px; } +p { color: #c5cdd5; margin: 12px 0; } +.primary { background: #ff6600; color: #111315; border-color: #ff6600; font-weight: 700; width: 100%; margin-top: 16px; } +.primary:hover { background: #ff8b3d; } +.instructions { font-size: 12px; border-block: 1px solid #36414c; padding: 14px 0; } +.bottom { position: absolute; bottom: max(20px, env(safe-area-inset-bottom)); left: 16px; right: 16px; display: flex; justify-content: center; pointer-events: none; } +.bottom button { pointer-events: auto; background: #101820ed; } +.hint { position: absolute; bottom: 78px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 32px); text-align: center; padding: 6px 12px; border-radius: 6px; background: #101820ed; color: #dce2e7; font-size: 11px; pointer-events: none; } +.touch { display: none; } +@media (pointer: coarse), (max-width: 640px) { + .touch { display: flex; position: absolute; bottom: max(22px, env(safe-area-inset-bottom)); align-items: center; justify-content: center; } + .stick { left: 20px; width: 104px; height: 104px; border: 1px solid #74808b; background: #101820df; border-radius: 50%; touch-action: none; } + .stick::before { content: ''; position: absolute; inset: 34px; border: 1px solid #ff9b58; border-radius: 50%; background: #ff660033; transform: translate(var(--stick-x, 0px), var(--stick-y, 0px)); } + .stick span { position: absolute; bottom: 9px; font-size: 9px; letter-spacing: .1em; color: #c5cdd5; } + .navigate { right: 110px; width: 60px; height: 60px; bottom: max(32px, env(safe-area-inset-bottom)); border-radius: 50%; border: 2px solid #52d8f5; background: #122934; color: #a5edff; font-weight: bold; touch-action: none; } + .fire { right: 20px; width: 80px; height: 80px; border-radius: 50%; background: #ff6600; border: 3px solid #ffae77; color: #111315; font-weight: bold; touch-action: none; box-shadow: 0 0 24px #ff660033; } + .bottom { bottom: 148px; } + .hint { bottom: 204px; } + .scoreboard { padding: 8px 10px; } + .score { font-size: 21px; } + .actions { gap: 5px; } + .actions button { padding: 8px; font-size: 12px; } + .panel { padding: 24px; } +} + +@media (max-height: 600px) { + .briefing { display: block; overflow-y: auto; padding-top: 160px; } + .panel { margin: 0 auto; } +} + +@media (max-width: 360px) { + .actions { max-width: 170px; flex-wrap: wrap; justify-content: flex-end; } + .stick { left: 12px; width: 88px; height: 88px; } + .fire { right: 12px; } + .navigate { right: 102px; } +} + +.connection-status { display: block; font-size: 9px; color: #a6bacb; max-width: 135px; } +.lobby, .missions { position: absolute; inset: 0; z-index: 5; overflow-y: auto; padding: 24px; background: #07101de8; display: flex; } +.lobby .panel, .missions .panel { margin: auto; } +h2 { font: 500 28px/1.2 system-ui, sans-serif; margin: 12px 0; } +.lobby p, .missions p { font-size: 12px; } +.lobby-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +label { display: block; font-size: 12px; color: #c5d4e1; margin: 12px 0; } +input, select { display: block; width: 100%; margin-top: 6px; padding: 10px; border: 1px solid #536b80; border-radius: 6px; background: #08121d; color: #e5f3ff; font: 12px/1.4 ui-monospace, monospace; } +button:disabled { opacity: .45; cursor: default; } +.bottom { gap: 8px; } +@media (max-width: 640px) { + .hud { gap: 6px; } + .scoreboard { max-width: 128px; } + .actions { max-width: 62%; } + .actions button { min-height: 36px; font-size: 11px; padding: 6px 8px; } + .lobby, .missions { padding: 12px; } + .lobby .panel, .missions .panel { padding: 20px; } +} + +.local-invite { border-left: 2px solid #ff6600; padding-left: 10px; } + +.pilot-list { list-style: none; margin: 6px 0 0; padding: 0; font-size: 10px; max-width: 140px; overflow-wrap: anywhere; } + +.hull-status { display: block; color: #e5f3ff; font-size: 11px; margin-top: 6px; } +.gravity-status { display: block; color: #ffd16c; font-size: 11px; margin-top: 6px; } +.rules, .join-manually { border-top: 1px solid #36414c; margin-top: 16px; padding-top: 12px; } +summary { cursor: pointer; font-size: 12px; padding: 5px 0; } +summary span, .name-label > span { color: #a6b6c5; font-size: 10px; } +.rule { display: flex; align-items: center; gap: 12px; cursor: pointer; } +.rule input { width: 20px; height: 20px; margin: 0; accent-color: #ff6600; } +.rule small { display: block; color: #a6b6c5; font-size: 11px; } +.rules p { font-size: 11px; } +.lobby-status:empty { display: none; } +.lobby-actions { justify-content: space-between; } +.disconnect { border-color: transparent; color: #a6b6c5; } +input:focus-visible, summary:focus-visible { outline: 2px solid #ff8c40; outline-offset: 3px; } +.crew { margin: 18px 0; padding: 12px 14px; border: 1px solid #36414c; border-radius: 8px; background: #08121d; } +.crew h3 { display: flex; justify-content: space-between; margin: 0 0 8px; font: 12px ui-monospace, monospace; color: #c5d4e1; } +.crew-list { list-style: none; margin: 0; padding: 0; font-size: 13px; overflow-wrap: anywhere; } +.crew-list li { padding: 4px 0; } +.crew-list li::before { content: '●'; margin-right: 10px; } + +.radar { position: absolute; right: 16px; bottom: 84px; width: 132px; padding: 2px; border: 1px solid #4b657a; background: #08121ded; pointer-events: none; } +.radar canvas { display: block; width: 128px; height: 80px; } +.radar span { display: block; font-size: 8px; text-align: center; letter-spacing: .2em; color: #aec2d3; } +.offscreen-pilots { position: absolute; inset: 0; pointer-events: none; } +.offscreen-pilot { position: absolute; transform: translate(-50%, -50%); background: #08121deb; border: 1px solid currentColor; padding: 4px 7px; border-radius: 6px; font-size: 10px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (pointer: coarse), (max-width: 640px) { + .radar { top: 110px; bottom: auto; right: 12px; transform: scale(.8); transform-origin: top right; } +} + +.round-clock { display: block; font-size: 22px; font-variant-numeric: tabular-nums; color: #ffd18e; } +.kill-count { display: block; font-size: 10px; margin-bottom: 4px; } +.results { position: absolute; inset: 0; z-index: 6; display: flex; overflow-y: auto; padding: 24px; background: #07101df2; } +.results .panel { margin: auto; } +.result-list { padding: 0; list-style: none; font-size: 13px; } +.result-list li { padding: 12px 0; border-bottom: 1px solid #36414c; } +.result-list .winner { color: #ffb26f; } +.result-exit { margin-top: 12px; } + +.ordnance { position: absolute; right: 16px; bottom: 20px; display: flex; gap: 8px; } +.ordnance button { min-width: 80px; border-color: #ba853f; } +@media (pointer: coarse), (max-width: 900px) { + .ordnance { left: 16px; right: auto; bottom: 196px; } + .hint { display: none; } +} diff --git a/assets/space-egg/game.js b/assets/space-egg/game.js new file mode 100644 index 0000000..01123ca --- /dev/null +++ b/assets/space-egg/game.js @@ -0,0 +1,1634 @@ +import { createRenderer } from './rendering.js?v=14'; +import { gameMarkup, gameTheme, waitForStyles } from './view.js?v=14'; +import { matchesWorld } from './protocol.js?v=14'; +import { createOrdnance, ORDNANCE, blastTouches } from './ordnance.js?v=14'; +import { targetStrength, damageEdge } from './targets.js?v=14'; +import { ROUND_SECONDS, roundWinners, clockLabel, validRoundSnapshot } from './round.js?v=14'; +import { createArena, cameraFor, WORLD_WIDTH, WORLD_HEIGHT } from './arena.js?v=14'; +import { POWERUPS, damageHull, expandedRect, effectSeconds, advanceScroll, shipContact } from './combat.js?v=14'; +import { hitsRect, firstHit } from './physics.js?v=14'; +import { createSound } from './sound.js?v=14'; +import { internalDestination } from './navigation.js?v=14'; +import { createMultiplayer, MAX_PLAYERS, pilotName } from './multiplayer.js?v=14'; +import { invitationLink, invitationPeer } from './invitation.js?v=14'; +import { hitReward, pageReward } from './scoring.js?v=14'; + +export async function openGame(onClose, invitedPeerId = null, signal) { + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; + const previousFocus = document.activeElement; + const originalUrl = location.href; + const arena = await createArena(originalUrl, { signal }); + const cleanup = [() => arena.close()]; + function dispose() { for (const release of cleanup.splice(0).reverse()) release(); } + try { + const arenaDocument = arena.document; + const arenaWindow = arena.window; + let camera = cameraFor(innerWidth, innerHeight, { x: WORLD_WIDTH / 2, y: WORLD_HEIGHT * 0.62 }); + let currentUrl = originalUrl; + let loadingPage = false; + let pageChanged = false; + let network = null; + const ghosts = new Map(); + let networkTime = 0; + let messageQueue = Promise.resolve(); + let connectionAttempt = 0; + let sessionVersion = 0; + let pageRevision = 0; + function sendGame(message) { + network?.send({ ...message, round: roundSerial, revision: pageRevision, page: currentUrl }); + } + const pendingHits = new Set(); + const initialScroll = { x: scrollX, y: scrollY }; + const controller = new AbortController(); + cleanup.push(() => controller.abort()); + const listen = (node, event, handler, options = {}) => node.addEventListener(event, handler, { ...options, signal: controller.signal }); + const sound = createSound(); + cleanup.push(() => sound.close()); + const host = document.createElement('div'); + host.id = 'athega-space-game'; + cleanup.push(() => host.remove()); + // Keep the overlay out of document flow even while its lazy CSS is loading. + host.style.cssText = 'position:fixed;inset:0;overflow:hidden;z-index:2147483647'; + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = gameMarkup(); + let dark = true; + const themeStyle = document.createElement('style'); + themeStyle.textContent = gameTheme; + arenaDocument.head.append(themeStyle); + arenaDocument.body.toggleAttribute('data-space-dark', dark); + host.style.visibility = 'hidden'; + const styled = waitForStyles(shadow.querySelector('link'), signal); + document.body.append(host); + await styled; + signal?.throwIfAborted(); + const inertStates = [...document.body.children].filter(node => node !== arena.container && node !== host).map(node => [node, node.inert]); + cleanup.push(() => inertStates.forEach(([node, wasInert]) => { node.inert = wasInert; })); + inertStates.forEach(([node]) => { node.inert = true; }); + host.style.visibility = arena.container.style.visibility = 'visible'; + const find = selector => shadow.querySelector(selector); + function setText(selector, text) { + const node = find(selector); + if (node.textContent !== text) node.textContent = text; + } + const nameInput = find('.pilot-name'); + const nameStorageKey = 'athega-space-pilot-name'; + try { nameInput.value = pilotName(localStorage.getItem(nameStorageKey)); } catch { /* Storage may be disabled. */ } + listen(nameInput, 'input', () => { + try { + const name = pilotName(nameInput.value); + if (name) localStorage.setItem(nameStorageKey, name); + else localStorage.removeItem(nameStorageKey); + } catch { /* The game also works without persistent storage. */ } + }); + const canvas = find('.world-canvas'); + const radar = find('.radar canvas'); + const radarContext = radar.getContext('2d'); + const markers = new Map(); + const context = canvas.getContext('2d'); + if (!context || !radarContext) throw new Error('Webbläsaren saknar stöd för spelgrafiken.'); + const { drawShip, drawPilotName, drawOrdnance } = createRenderer(context, reducedMotion); + const launchButton = find('.launch'); + const scoreOutput = find('.score'); + const colors = ['#ff6600', '#56dfff', '#ee91ff', '#b2ef5e']; + function playerColor(id = network?.playerId) { + const index = network?.playerIds.indexOf(id) ?? -1; + return colors[Math.max(0, index) % colors.length]; + } + const hint = find('.hint'); + const stick = find('.stick'); + let mobileMenu = arenaDocument.querySelector('.mobile-menu'); + const destroyed = new Map(); + const targetHealth = new Map(); + const damageStyles = new Map(); + const keys = new Set(); + const shots = []; + let shotSequence = 0; + const flightShots = new Map(); + const impacts = []; + const blasts = []; + let hitFlash = 0; + const particles = []; + const rings = []; + const stars = Array.from({ length: reducedMotion ? 60 : 130 }, () => ({ x: Math.random(), y: Math.random(), depth: 0.3 + Math.random() * 0.7, phase: Math.random() * Math.PI * 2 })); + const pickups = []; + const animations = new Set(); + const powers = { triple: 0, rapid: 0, shield: 0, turbo: 0 }; + const rules = { friendlyFire: false, collisions: false }; + let lobbyMode = invitedPeerId ? 'invited' : 'idle'; + let roomStarted = false; + let roundSerial = 0; + let roundDeadline = 0; + let roundEnded = false; + let roundRemaining = ROUND_SECONDS; + let lastRoundSecond = -1; + let deathSequence = 0; + const roundStats = new Map(); + const pendingDamage = new Map(); + const collisionTimes = new Map(); + let hull = 100; + let invulnerable = 3; + let respawn = 0; + let pilotUpdatedAt = performance.now(); + const roomEffects = { gravity: { until: 0, revision: 0 }, scroll: { until: 0, revision: 0 } }; + const effectClaims = new Set(); + const powerupDrops = new Map(); + let scrollPaused = false; + let scrollDirection = 1; + let scrollPosition = arenaWindow.scrollY; + let lastWrittenScroll = arenaWindow.scrollY; + let remoteScroll = null; + let scrollNetworkTime = 0; + let pageCleared = false; + let checkMissionClear = true; + let missionElapsed = 0; + let missionStartedAt = 0; + let lastHitTime = -Infinity; + let combo = 1; + const labels = []; + let lastPowerLabel = ''; + const joystick = { active: false, x: 0, y: 0 }; + const ship = { x: WORLD_WIDTH / 2, y: WORLD_HEIGHT * 0.62, vx: 0, vy: 0, angle: -Math.PI / 2 }; + const ordnance = createOrdnance({ + ownId: ownPilotId, + authoritative: () => !network?.connected || network.isHost, + send: sendGame, + onBlast: explodeOrdnance, + onChange: ammo => { + setText('.lay-mine', `Mina · ${ammo.mine}`); + setText('.drop-bomb', `Bomb · ${ammo.bomb}`); + }, + }); + const width = WORLD_WIDTH; + const height = WORLD_HEIGHT; + let running = false; + let closed = false; + let frame; + let lastTime = 0; + let cooldown = 0; + let score = 0; + let soundEnabled = true; + scoreOutput.textContent = String(score).padStart(5, '0'); + let holdingFire = false; + let holdingNavigation = false; + let linkRects = []; + let targetRects = []; + let targetsDirty = true; + + // Keep the page geometry intact: hide only hit elements, never remove DOM nodes. + const targetSelector = 'h1, h2, h3, h4, p, li, img, a, summary'; + function collectTargets() { + return [...arenaDocument.querySelectorAll('header.site-header, main, footer')].flatMap(root => [...root.querySelectorAll(targetSelector)]) + .filter(node => !node.querySelector('h1, h2, h3, h4, p, li, img') + && !(node.matches('a, summary') && node.closest('p, h1, h2, h3, h4, li')) + && !node.closest('pre, code, [hidden]')); + } + let targets = collectTargets(); + + + function isDestroyed(node) { + for (let current = node; current; current = current.parentElement) { + if (destroyed.has(current)) return true; + } + return false; + } + + function resize() { + find('.menu-toggle').hidden = !mobileMenu?.checkVisibility(); + const scale = Math.min(devicePixelRatio || 1, 2); + canvas.width = width * scale; + canvas.height = height * scale; + context.setTransform(scale, 0, 0, scale, 0, 0); + updateCamera(); + ship.x = Math.min(ship.x, width - 20); + ship.y = Math.min(ship.y, height - 20); + targetsDirty = true; + } + + function updateTargets() { + targetRects = targets.filter(node => !isDestroyed(node) && node.checkVisibility({ visibilityProperty: true, opacityProperty: true })).flatMap(node => { + // Text ranges follow actual lines, so shots can pass through empty margins. + const range = arenaDocument.createRange(); + range.selectNodeContents(node); + let rects = node.tagName === 'IMG' ? [node.getBoundingClientRect()] : [...range.getClientRects()]; + if (targetHealth.has(node)) { + const bounds = node.getBoundingClientRect(); + const edge = bounds.left + bounds.width * damageEdge(targetHealth.get(node), targetStrength(node.tagName)) / 100; + rects = rects.map(rect => ({ left: rect.left, right: Math.min(rect.right, edge), top: rect.top, bottom: rect.bottom, width: Math.min(rect.right, edge) - rect.left, height: rect.height })); + } + return rects.filter(rect => rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < height) + .map(rect => ({ node, rect })); + }); + linkRects = [...arenaDocument.querySelectorAll('header.site-header a[href], main a[href], footer a[href]')] + .filter(node => !node.hasAttribute('download') && !isDestroyed(node) && node.checkVisibility({ visibilityProperty: true, opacityProperty: true })) + .flatMap(node => { + const destination = internalDestination(node.href, currentUrl); + if (!destination) return []; + return [...node.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < height) + .map(rect => ({ node, rect, destination })); + }); + targetsDirty = false; + } + + function close(reloadPage = true) { + if (closed) return; + closed = true; + cancelAnimationFrame(frame); + dispose(); + animations.forEach(animation => animation.cancel()); + window.scrollTo({ left: initialScroll.x, top: initialScroll.y, behavior: 'instant' }); + previousFocus?.focus({ preventScroll: true }); + network?.close(); + onClose(); + if (pageChanged && reloadPage) location.reload(); + } + + function burst(x, y, rect) { + const count = reducedMotion ? 8 : 38; + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = 45 + Math.random() * 240; + particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 0.4 + Math.random() * 0.45, max: 0.85, size: 2 + Math.random() * 4, color: i % 3 ? '#ff6600' : '#151e27' }); + } + if (!reducedMotion) rings.push({ x, y, life: 0.4, rect }); + // Bound memory and draw work even when the fire button is held down. + if (particles.length > 300) particles.splice(0, particles.length - 300); + } + + function rememberTarget(node) { + destroyed.set(node, { value: node.style.getPropertyValue('visibility'), priority: node.style.getPropertyPriority('visibility') }); + } + + function applyTargetDamage(node, remaining, x, y, animate = true) { + const maximum = targetStrength(node.tagName); + if (!Number.isInteger(remaining) || remaining <= 0 || remaining >= maximum) return; + if (!damageStyles.has(node)) { + damageStyles.set(node, ['clip-path', 'opacity'].map(property => [property, node.style.getPropertyValue(property), node.style.getPropertyPriority(property)])); + } + targetHealth.set(node, remaining); + const edge = damageEdge(remaining, maximum); + node.style.setProperty('clip-path', `polygon(0 0, ${edge}% 0, ${edge - 5}% 18%, ${edge + 1}% 31%, ${edge - 8}% 44%, ${edge}% 60%, ${edge - 4}% 77%, ${edge}% 100%, 0 100%)`); + node.style.setProperty('opacity', String(0.65 + 0.35 * remaining / maximum)); + pendingHits.delete(node); + targetsDirty = true; + if (!animate) return; + labels.push({ x, y, text: `${remaining}/${maximum}`, life: 0.65 }); + for (let i = 0; i < (reducedMotion ? 3 : 12); i++) { + particles.push({ x, y, vx: (Math.random() - 0.5) * 220, vy: -50 - Math.random() * 150, life: 0.45, max: 0.45, size: 2 + Math.random() * 3, color: '#ffae77' }); + } + sound.chip(); + } + + function restoreTargetDamage() { + for (const [node, styles] of damageStyles) { + for (const [property, value, priority] of styles) { + if (value) node.style.setProperty(property, value, priority); + else node.style.removeProperty(property); + } + } + damageStyles.clear(); + targetHealth.clear(); + } + + function destroy(target, x, y, remote = null) { + const node = target.node; + if (destroyed.has(node)) return; + if (network?.connected && !network.isHost && !remote) { + if (!pendingHits.has(node)) sendGame({ type: 'hit', id: targets.indexOf(node), page: currentUrl }); + pendingHits.add(node); + return; + } + pendingHits.delete(node); + const remaining = (targetHealth.get(node) ?? targetStrength(node.tagName)) - 1; + if (!remote && remaining > 0) { + applyTargetDamage(node, remaining, x, y); + sendGame({ type: 'target-damage', page: currentUrl, id: targets.indexOf(node), remaining }); + return; + } + rememberTarget(node); + checkMissionClear = true; + const animation = node.animate(reducedMotion ? [ + { opacity: arenaWindow.getComputedStyle(node).opacity }, { opacity: 0 }, + ] : [ + { opacity: arenaWindow.getComputedStyle(node).opacity, transform: 'translate(0, 0) rotate(0) scale(1)', filter: 'blur(0)' }, + { opacity: 0.8, transform: 'translate(0, -8px) rotate(-2deg) scale(1.025)', offset: 0.2 }, + { opacity: 0, transform: `translate(${(Math.random() - 0.5) * 80}px, 35px) rotate(${(Math.random() - 0.5) * 24}deg) scale(.15)`, filter: 'blur(3px)' }, + ], { duration: reducedMotion ? 120 : 420, easing: 'cubic-bezier(.2,.7,.3,1)', fill: 'forwards' }); + animations.add(animation); + animation.finished.then(() => { + if (!closed) node.style.setProperty('visibility', 'hidden', 'important'); + animation.cancel(); + animations.delete(animation); + }).catch(() => {}); + const hitTime = performance.now() / 1000; + const reward = remote || hitReward(node.tagName === 'IMG' ? 250 : 100, lastHitTime, hitTime, combo); + combo = reward.combo; + lastHitTime = hitTime; + score = remote ? remote.score : score + reward.points; + labels.push({ x, y, text: `+${reward.points}${combo > 1 ? ` ×${combo}` : ''}`, life: 1 }); + scoreOutput.textContent = String(score).padStart(5, '0'); + burst(x, y, target.rect); + sound.explode(); + targetsDirty = true; + if (network?.connected && network.isHost) sendGame({ type: 'destroy', page: currentUrl, id: targets.indexOf(node), score, points: reward.points, combo }); + if (destroyed.size % 3 === 0) { + const kinds = Object.keys(POWERUPS); + const kind = kinds[(destroyed.size / 3 - 1) % kinds.length]; + powerupDrops.set(targets.indexOf(node), kind); + ordnance.recordDrop(targets.indexOf(node), kind); + pickups.push({ x: Math.max(24, Math.min(width - 24, x)), y: Math.max(100, Math.min(height - 100, y)), kind, id: targets.indexOf(node), life: 18 }); + if (pickups.length > 6) pickups.shift(); + } + } + + async function navigateTo(target, fromPeer = false) { + const destination = target.destination; + if (loadingPage) return; + if (network?.connected && !network.isHost && !fromPeer) { + sendGame({ type: 'navigate', url: destination.href, page: currentUrl }); + return; + } + if (destination.pathname === new URL(currentUrl).pathname && destination.search === new URL(currentUrl).search) { + let anchor; + try { anchor = arenaDocument.getElementById(decodeURIComponent(destination.hash.slice(1))); } catch { /* Ignore invalid fragments. */ } + if (anchor) anchor.scrollIntoView({ behavior: 'instant', block: 'start' }); + else if (!destination.hash) arenaWindow.scrollTo({ top: 0, behavior: 'instant' }); + targetsDirty = true; + return; + } + const navigationSession = sessionVersion; + loadingPage = true; + resetControls(); + hint.textContent = 'Hoppar till nästa uppdrag…'; + try { + const response = await fetch(destination.href, { signal: controller.signal }); + if (!response.ok || !internalDestination(response.url, currentUrl)) throw new Error('Sidan gick inte att hämta.'); + const nextDocument = new DOMParser().parseFromString(await response.text(), 'text/html'); + if (!nextDocument.querySelector('main')) throw new Error('Den sidan saknar ett spelområde.'); + if (closed || navigationSession !== sessionVersion) return; + animations.forEach(animation => animation.cancel()); + animations.clear(); + arenaDocument.body.replaceChildren(); + currentUrl = response.url; + if (!fromPeer) pageRevision++; + history.pushState(null, '', currentUrl); + pageChanged = true; + const pageNodes = [...nextDocument.body.children].filter(node => !['SCRIPT', 'STYLE'].includes(node.tagName)); + for (const node of pageNodes) { + const imported = arenaDocument.importNode(node, true); + imported.querySelectorAll('script').forEach(script => script.remove()); + arenaDocument.body.append(imported); + } + arenaDocument.querySelector('base')?.setAttribute('href', currentUrl); + flightShots.clear(); + ghosts.clear(); + pendingDamage.clear(); + collisionTimes.clear(); + targetHealth.clear(); + damageStyles.clear(); + ordnance.clearField(); + blasts.length = 0; + impacts.length = 0; + destroyed.clear(); + pendingHits.clear(); + shots.length = particles.length = rings.length = pickups.length = labels.length = 0; + mobileMenu = arenaDocument.querySelector('.mobile-menu'); + targets = collectTargets(); + arenaWindow.scrollTo({ top: 0, behavior: 'instant' }); + resize(); + pageCleared = false; + checkMissionClear = true; + missionElapsed = 0; + missionStartedAt = performance.now(); + scrollDirection = 1; + scrollPosition = lastWrittenScroll = arenaWindow.scrollY; + remoteScroll = null; + ship.x = Math.min(width - 24, width / 2 + (network?.connected ? 48 * network.playerIds.indexOf(network.playerId) : 0)); + ship.y = height * 0.62; + ship.vx = ship.vy = 0; + advancePilotTimers(); + invulnerable = 3; + effectClaims.clear(); + powerupDrops.clear(); + hint.textContent = 'Nytt uppdrag! Fortsätt röja tillsammans.'; + if (!fromPeer) broadcastState(); + } catch (error) { + if (!closed) hint.textContent = 'Kunde inte öppna sidan. Du kan fortsätta här och prova igen.'; + } finally { + loadingPage = false; + } + } + + function fire(kind = 'destroy') { + if (respawn > 0 || roundEnded || !roomStarted || loadingPage || !find('.lobby').hidden || !find('.missions').hidden) return; + const dx = Math.cos(ship.angle); + const dy = Math.sin(ship.angle); + const salvo = ++shotSequence; + const owner = network?.playerId || 'solo'; + const angles = kind === 'destroy' && powers.triple > 0 ? [-0.17, 0, 0.17] : [0]; + for (const [index, offset] of angles.entries()) { + const angle = ship.angle + offset; + const id = `${owner}:${salvo}:${index}`; + flightShots.set(id, { owner, expires: performance.now() + 2000, kind }); + shots.push({ id, kind, x: ship.x + dx * 19, y: ship.y + dy * 19, vx: Math.cos(angle) * 750 + ship.vx, vy: Math.sin(angle) * 750 + ship.vy, life: 1.3 }); + } + sound.shoot(); + sendGame({ type: 'shot', id: salvo, page: currentUrl, x: ship.x / width, y: ship.y / height, angle: ship.angle, vx: ship.vx, vy: ship.vy, kind, triple: powers.triple > 0 }); + cooldown = kind === 'destroy' && powers.rapid > 0 ? 0.075 : 0.16; + } + + function resetControls() { + keys.clear(); + holdingFire = false; + holdingNavigation = false; + joystick.active = false; + stick.style.removeProperty('--stick-x'); + stick.style.removeProperty('--stick-y'); + sound.thrust(false); + } + + function tick(now) { + if (closed || !running) return; + frame = requestAnimationFrame(tick); + const dt = Math.min((now - lastTime) / 1000 || 0, 0.035); + lastTime = now; + for (const [id, flight] of flightShots) if (flight.expires < now) flightShots.delete(id); + if (!document.hidden) updateCamera(); + for (const effect of Object.values(roomEffects)) { + if (effect.announced && performance.now() >= effect.until) { effect.announced = false; sound.effectEnded(); } + } + if (roundEnded || (roundDeadline > 0 && performance.now() >= roundDeadline) || document.hidden || loadingPage) return; + if (!roomStarted) return; + missionElapsed = (performance.now() - missionStartedAt) / 1000; + updateScroll(dt); + if (performance.now() / 1000 - lastHitTime > 2) combo = 1; + find('.pace').textContent = `×${combo} · ${missionElapsed.toFixed(1)} s`; + if (targetsDirty) updateTargets(); + advancePilotTimers(); + hitFlash = Math.max(0, hitFlash - dt); + setText('.hull-status', respawn > 0 ? `Nytt skepp om ${Math.ceil(respawn)}s` : `Skrov ${hull}${powers.shield > 0 ? ' · skyddat' : ''}`); + const gravity = effectTime('gravity'); + find('.gravity-status').hidden = gravity <= 0; + setText('.gravity-status', `↓ GRAVITATION ${Math.ceil(gravity)}s`); + const powerLabel = Object.entries(powers).filter(([, time]) => time > 0).map(([kind, time]) => `${POWERUPS[kind].label} ${Math.ceil(time)}s`).join(' · ') || 'Powerups —'; + if (powerLabel !== lastPowerLabel) { + find('.power-status').textContent = powerLabel; + lastPowerLabel = powerLabel; + } + if (checkMissionClear && !pageCleared && (!network?.connected || network.isHost)) { + checkMissionClear = false; + const remaining = targets.filter(node => !node.closest('.site-header') && !isDestroyed(node) + && node.checkVisibility({ visibilityProperty: true, opacityProperty: true })); + if (!remaining.length && destroyed.size > 0) { + pageCleared = true; + const bonus = pageReward(destroyed.size, missionElapsed); + score += bonus; + scoreOutput.textContent = String(score).padStart(5, '0'); + hint.textContent = `SIDAN RENSAD +${bonus} · Välj nästa uppdrag!`; + sound.launch(); + broadcastState(); + showMissions(); + } + } + let thrust = keys.has('ArrowUp') || keys.has('KeyW'); + if (joystick.active) { + const amount = Math.hypot(joystick.x, joystick.y); + if (amount > 8) ship.angle = Math.atan2(joystick.y, joystick.x); + thrust = amount > 12; + } else { + const turn = Number(keys.has('ArrowRight') || keys.has('KeyD')) - Number(keys.has('ArrowLeft') || keys.has('KeyA')); + ship.angle += turn * 4.2 * dt; + } + if (respawn > 0) thrust = false; + if (thrust) { + const acceleration = powers.turbo > 0 ? 650 : 380; + ship.vx += Math.cos(ship.angle) * acceleration * dt; + ship.vy += Math.sin(ship.angle) * acceleration * dt; + if (!reducedMotion) particles.push({ x: ship.x - Math.cos(ship.angle) * 12, y: ship.y - Math.sin(ship.angle) * 12, vx: -Math.cos(ship.angle) * 90 + (Math.random() - 0.5) * 40, vy: -Math.sin(ship.angle) * 90 + (Math.random() - 0.5) * 40, life: 0.23, max: 0.23, size: 3, color: '#ff6600' }); + } + sound.thrust(thrust); + const brake = keys.has('ArrowDown') || keys.has('KeyS'); + const friction = Math.exp(-(brake ? 5 : 0.7) * dt); + ship.vx *= friction; + ship.vy *= friction; + if (gravity > 0 && respawn <= 0) ship.vy += 260 * dt; + const speed = Math.hypot(ship.vx, ship.vy); + const maxSpeed = powers.turbo > 0 ? 550 : 360; + if (speed > maxSpeed) { ship.vx *= maxSpeed / speed; ship.vy *= maxSpeed / speed; } + const oldX = ship.x; + const oldY = ship.y; + ship.x = (ship.x + ship.vx * dt + width) % width; + ship.y = (ship.y + ship.vy * dt + height) % height; + if (rules.collisions && respawn <= 0 && invulnerable <= 0) { + const wrapped = Math.abs(ship.x - oldX) > width / 2 || Math.abs(ship.y - oldY) > height / 2; + const obstacle = targetRects.find(({ node, rect }) => !isDestroyed(node) + && hitsRect(wrapped ? ship.x : oldX, wrapped ? ship.y : oldY, ship.x, ship.y, expandedRect(rect, 10))); + if (obstacle) { + takeDamage(25); + ship.x = oldX; + ship.y = oldY; + ship.vx *= -0.65; + ship.vy *= -0.65; + invulnerable = Math.max(invulnerable, 0.8); + } + } + if (rules.collisions && respawn <= 0) { + for (const [id, other] of ghosts) { + if (other.hull === 0) continue; + const contact = shipContact(ship, { x: other.targetX, y: other.targetY }, network.playerId < id ? -1 : 1); + if (!contact) continue; + ship.x = Math.max(16, Math.min(width - 16, ship.x + contact.nx * (contact.overlap + 2))); + ship.y = Math.max(16, Math.min(height - 16, ship.y + contact.ny * (contact.overlap + 2))); + const approaching = ship.vx * contact.nx + ship.vy * contact.ny; + if (approaching < 80) { + ship.vx += contact.nx * (100 - approaching); + ship.vy += contact.ny * (100 - approaching); + } + if (network.isHost) routeCollision(network.playerId, id); + else sendGame({ type: 'collision-request', page: currentUrl, other: id, round: roundSerial }); + } + } + updateCamera(); + cooldown -= dt; + if (cooldown <= 0 && respawn <= 0) { + if (keys.has('KeyN') || holdingNavigation) fire('navigate'); + else if (keys.has('Space') || holdingFire) fire(); + } + context.clearRect(0, 0, width, height); + if (dark) { + for (const star of stars) { + if (!reducedMotion) { + star.x = (star.x - ship.vx * dt * star.depth / width * 0.025 + 1) % 1; + star.y = (star.y - ship.vy * dt * star.depth / height * 0.025 + 1) % 1; + } + context.globalAlpha = reducedMotion ? 0.4 : 0.25 + (Math.sin(now / 1800 + star.phase) + 1) * 0.15; + context.fillStyle = '#cbe8ff'; + context.fillRect(star.x * width, star.y * height, star.depth * 1.8, star.depth * 1.8); + } + context.globalAlpha = 1; + } + for (let i = shots.length - 1; i >= 0; i--) { + const shot = shots[i]; + const x = shot.x + shot.vx * dt; + const y = shot.y + shot.vy * dt; + const targetsInFlight = shot.kind === 'navigate' ? (shot.remote ? [] : linkRects) : targetRects; + const candidates = targetsInFlight.filter(target => !isDestroyed(target.node)); + if (!shot.remote && shot.kind === 'destroy' && rules.friendlyFire && network?.connected) { + for (const [pilot, craft] of ghosts) { + if (craft.hull !== 0) candidates.push({ pilot, rect: { left: craft.targetX - 13, right: craft.targetX + 13, top: craft.targetY - 13, bottom: craft.targetY + 13 } }); + } + } + const hit = firstHit(shot.x, shot.y, x, y, candidates); + if (hit?.pilot) { + if (network.isHost) routePilotHit(hit.pilot, shot.id, network.playerId); + else sendGame({ type: 'pilot-hit', target: hit.pilot, shotId: shot.id }); + shots.splice(i, 1); + continue; + } + shot.life -= dt; + if (hit) { + if (shot.remote) { shots.splice(i, 1); continue; } + if (shot.kind === 'navigate') { + shots.splice(i, 1); + navigateTo(hit); + if (loadingPage) return; + continue; + } + destroy(hit, Math.max(hit.rect.left, Math.min(x, hit.rect.right)), Math.max(hit.rect.top, Math.min(y, hit.rect.bottom))); + shots.splice(i, 1); + continue; + } + if (shot.life <= 0 || x < 0 || x > width || y < 0 || y > height) { shots.splice(i, 1); continue; } + context.beginPath(); + context.moveTo(shot.x, shot.y); + context.lineTo(x, y); + context.strokeStyle = shot.kind === 'navigate' ? '#00b7dd' : shot.color || playerColor(); + context.lineWidth = shot.kind === 'navigate' ? 5 : 3; + context.shadowColor = shot.kind === 'navigate' ? '#00d4ff' : '#ff6600'; + context.shadowBlur = reducedMotion ? 0 : 10; + context.stroke(); + context.shadowBlur = 0; + shot.x = x; + shot.y = y; + } + for (let i = impacts.length - 1; i >= 0; i--) { + const impact = impacts[i]; + impact.life -= dt; + if (impact.life <= 0) { impacts.splice(i, 1); continue; } + context.save(); + context.translate(impact.x, impact.y); + context.strokeStyle = impact.blocked ? '#64e9ce' : '#ffdf8e'; + context.globalAlpha = Math.min(1, impact.life * 3); + context.lineWidth = 3; + context.beginPath(); + const radius = reducedMotion ? 24 : 18 + (0.45 - impact.life) * 65; + context.arc(0, 0, radius, 0, Math.PI * 2); + context.stroke(); + for (let ray = 0; ray < 6; ray++) { + const angle = ray * Math.PI / 3; + context.beginPath(); + context.moveTo(Math.cos(angle) * radius, Math.sin(angle) * radius); + context.lineTo(Math.cos(angle) * (radius + 10), Math.sin(angle) * (radius + 10)); + context.stroke(); + } + context.restore(); + } + for (let i = particles.length - 1; i >= 0; i--) { + const particle = particles[i]; + particle.life -= dt; + if (particle.life <= 0) { particles.splice(i, 1); continue; } + particle.x += particle.vx * dt; + particle.y += particle.vy * dt; + particle.vy += 70 * dt; + context.globalAlpha = Math.min(1, particle.life / particle.max); + context.fillStyle = particle.color; + context.fillRect(particle.x, particle.y, particle.size, particle.size); + } + context.globalAlpha = 1; + drawOrdnance(ordnance.hazards, blasts, ownPilotId(), now, dt); + for (let i = rings.length - 1; i >= 0; i--) { + const ring = rings[i]; + ring.life -= dt; + if (ring.life <= 0) { rings.splice(i, 1); continue; } + context.strokeStyle = `rgba(255,102,0,${ring.life / 0.4})`; + context.lineWidth = 1; + context.beginPath(); + context.arc(ring.x, ring.y, (0.4 - ring.life) * 130, 0, Math.PI * 2); + context.stroke(); + context.strokeRect(ring.rect.left, ring.rect.top, ring.rect.width, ring.rect.height); + } + if (keys.has('KeyN') || holdingNavigation) { + context.strokeStyle = '#007b9b'; + context.lineWidth = 1; + context.setLineDash([4, 4]); + linkRects.forEach(({ rect }) => context.strokeRect(rect.left, rect.top, rect.width, rect.height)); + context.setLineDash([]); + } + for (let i = pickups.length - 1; i >= 0; i--) { + const pickup = pickups[i]; + pickup.life -= dt; + const dx = ship.x - pickup.x; + const dy = ship.y - pickup.y; + const distance = Math.hypot(dx, dy); + if (respawn <= 0 && distance < 130) { pickup.x += dx * dt * 2.8; pickup.y += dy * dt * 2.8; } + if (respawn <= 0 && distance < 28) { + collectPowerup(pickup); + pickups.splice(i, 1); + continue; + } + if (pickup.life <= 0) { pickups.splice(i, 1); continue; } + context.save(); + context.translate(pickup.x, pickup.y); + context.rotate(reducedMotion ? Math.PI / 4 : Math.PI / 4 + Math.sin(now / 400) * 0.1); + context.fillStyle = '#122934'; + context.strokeStyle = '#64e9ce'; + context.lineWidth = 2; + context.shadowColor = '#64e9ce'; + context.shadowBlur = reducedMotion ? 0 : 14; + context.fillRect(-16, -16, 32, 32); + context.strokeRect(-16, -16, 32, 32); + context.restore(); + context.fillStyle = '#fff'; + context.font = 'bold 12px monospace'; + context.textAlign = 'center'; + context.fillText(POWERUPS[pickup.kind].label, pickup.x, pickup.y + 4); + } + for (let i = labels.length - 1; i >= 0; i--) { + const label = labels[i]; + label.life -= dt; + if (label.life <= 0) { labels.splice(i, 1); continue; } + if (!reducedMotion) label.y -= 26 * dt; + context.globalAlpha = Math.min(1, label.life * 2); + context.font = 'bold 15px monospace'; + context.textAlign = 'center'; + const textWidth = context.measureText(label.text).width; + context.fillStyle = '#101820'; + context.fillRect(label.x - textWidth / 2 - 8, label.y - 18, textWidth + 16, 26); + context.fillStyle = '#91efd4'; + context.fillText(label.text, label.x, label.y); + } + context.globalAlpha = 1; + networkTime += dt; + if (networkTime >= 0.05 && network?.connected) { + networkTime = 0; + sendGame({ type: 'position', page: currentUrl, x: ship.x / width, y: ship.y / height, angle: ship.angle, thrust, hull, hit: hitFlash > 0, shield: powers.shield > 0 || invulnerable > 0 }); + } + for (const [id, ghost] of ghosts) { + ghost.x += (ghost.targetX - ghost.x) * Math.min(1, dt * 14); + ghost.y += (ghost.targetY - ghost.y) * Math.min(1, dt * 14); + if (ghost.hull === 0) continue; + drawShip(ghost.thrust, now, ghost, playerColor(id)); + drawPilotName(network.playerName(id), ghost, playerColor(id)); + } + if (respawn <= 0) drawShip(thrust, now, { ...ship, hitFlash, shield: powers.shield > 0 || invulnerable > 0 }, playerColor()); + if (network?.connected && respawn <= 0) drawPilotName(network.playerName(network.playerId), ship, playerColor()); + } + + function updateCamera() { + camera = cameraFor(innerWidth, innerHeight, ship); + arena.position(camera); + canvas.style.width = `${width * camera.scale}px`; + canvas.style.height = `${height * camera.scale}px`; + canvas.style.left = `${camera.left}px`; + canvas.style.top = `${camera.top}px`; + find('.radar').hidden = !running; + radarContext.clearRect(0, 0, 128, 80); + radarContext.strokeStyle = '#6e879b'; + radarContext.strokeRect(camera.x / 10, camera.y / 10, camera.visibleWidth / 10, camera.visibleHeight / 10); + const pilots = [[network?.playerId, ship], ...ghosts.entries()]; + for (const [id, craft] of pilots) { + if (craft.hull === 0) continue; + radarContext.fillStyle = playerColor(id); + radarContext.beginPath(); + radarContext.arc(craft.x / 10, craft.y / 10, 2.5, 0, Math.PI * 2); + radarContext.fill(); + } + for (const [id, marker] of markers) { + if (!ghosts.has(id)) { marker.remove(); markers.delete(id); } + } + for (const [id, ghost] of ghosts) { + let marker = markers.get(id); + if (!marker) { + marker = document.createElement('span'); + marker.className = 'offscreen-pilot'; + find('.offscreen-pilots').append(marker); + markers.set(id, marker); + } + const x = ghost.x * camera.scale + camera.left; + const y = ghost.y * camera.scale + camera.top; + marker.hidden = ghost.hull === 0 || (x >= 15 && x <= innerWidth - 15 && y >= 15 && y <= innerHeight - 15); + marker.style.left = `${Math.max(55, Math.min(innerWidth - 55, x))}px`; + marker.style.top = `${Math.max(55, Math.min(innerHeight - 145, y))}px`; + marker.style.color = playerColor(id); + const arrow = x < 15 ? '←' : x > innerWidth - 15 ? '→' : y < 15 ? '↑' : '↓'; + marker.textContent = `${arrow} ${network.playerName(id)}`; + } + } + + function start() { + if (running) return; + find('.briefing').hidden = true; + find('.play-ui').hidden = false; + if (matchMedia('(pointer: coarse)').matches || innerWidth <= 640) hint.textContent = 'ELD: förstör · NAV: träffa en länk för att resa'; + running = true; + lastTime = performance.now(); + find('.exit').focus({ preventScroll: true }); + frame = requestAnimationFrame(tick); + } + listen(launchButton, 'click', () => { + sound.init(); + sound.launch(); + beginRound(); + start(); + }); + listen(find('.exit'), 'click', () => close()); + function updateSoundButton() { + const button = find('.sound'); + button.textContent = soundEnabled ? 'Ljud på' : 'Ljud av'; + button.setAttribute('aria-pressed', String(soundEnabled)); + button.setAttribute('aria-label', button.textContent); + } + updateSoundButton(); + listen(find('.sound'), 'click', () => { + sound.init(); + soundEnabled = sound.toggle(); + updateSoundButton(); + }); + function updateThemeButton() { + find('.theme').textContent = dark ? 'Mörkt' : 'Ljust'; + find('.theme').setAttribute('aria-pressed', String(dark)); + } + updateThemeButton(); + listen(find('.theme'), 'click', () => { + dark = !dark; + arenaDocument.body.toggleAttribute('data-space-dark', dark); + updateThemeButton(); + }); + listen(find('.menu-toggle'), 'click', event => { + mobileMenu.open = !mobileMenu.open; + event.currentTarget.setAttribute('aria-expanded', String(mobileMenu.open)); + targetsDirty = true; + }); + function updateScrollButton() { + const button = find('.scroll-toggle'); + const guest = network?.connected && !network.isHost; + button.disabled = guest; + button.setAttribute('aria-pressed', String(scrollPaused)); + let label = scrollPaused ? 'Fortsätt scroll' : 'Pausa scroll'; + if (guest) label = scrollPaused ? 'Scroll pausad' : 'Värden styr scroll'; + if (effectTime('scroll') > 0) label += ` · S↕ ${Math.ceil(effectTime('scroll'))}s`; + setText('.scroll-toggle', label); + } + + function updateScroll(dt) { + updateScrollButton(); + const maximum = Math.max(0, arenaDocument.documentElement.scrollHeight - height); + if (network?.connected && !network.isHost) { + if (remoteScroll !== null) { + scrollPosition += (remoteScroll * maximum - scrollPosition) * Math.min(1, dt * 18); + arenaWindow.scrollTo({ top: scrollPosition, behavior: 'instant' }); + targetsDirty = true; + } + return; + } + if (Math.abs(arenaWindow.scrollY - lastWrittenScroll) > 1) scrollPosition = arenaWindow.scrollY; + if (!scrollPaused) { + const next = advanceScroll(scrollPosition, scrollDirection, dt * (effectTime('scroll') > 0 ? 72 : 18), maximum); + scrollPosition = next.position; + scrollDirection = next.direction; + arenaWindow.scrollTo({ top: scrollPosition, behavior: 'instant' }); + lastWrittenScroll = arenaWindow.scrollY; + targetsDirty = true; + } + scrollNetworkTime += dt; + if (scrollNetworkTime >= 0.1 && network?.connected) { + scrollNetworkTime = 0; + sendGame({ type: 'scroll', page: currentUrl, scroll: maximum ? arenaWindow.scrollY / maximum : 0, paused: scrollPaused }); + } + } + listen(find('.scroll-toggle'), 'click', () => { + if (network?.connected && !network.isHost) return; + scrollPaused = !scrollPaused; + updateScrollButton(); + broadcastState(); + }); + const movementKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD', 'Space', 'KeyN']; + listen(window, 'keydown', event => { + if (event.code === 'Escape') { event.preventDefault(); close(); return; } + if (roundEnded || !find('.lobby').hidden || !find('.missions').hidden) return; + if (!running || event.ctrlKey || event.metaKey || event.altKey) return; + if (['KeyM', 'KeyB'].includes(event.code)) { + event.preventDefault(); + if (!event.repeat) placeOrdnance(event.code === 'KeyM' ? 'mine' : 'bomb'); + return; + } + // Space still activates a focused control when navigating the HUD with Tab. + if (event.code === 'Space' && keyboardFocus) return; + if (movementKeys.includes(event.code) && event.code !== 'Space') keyboardFocus = false; + if (movementKeys.includes(event.code)) { event.preventDefault(); sound.init(); keys.add(event.code); } + }); + listen(window, 'keyup', event => keys.delete(event.code)); + let keyboardFocus = false; + listen(shadow, 'keydown', event => { + if (event.key !== 'Tab') return; + keyboardFocus = true; + const scope = find('.results:not([hidden])') || find('.lobby:not([hidden])') || find('.missions:not([hidden])') || shadow; + const buttons = [...scope.querySelectorAll('button, input, select, summary')].filter(button => !button.disabled && button.checkVisibility()); + const index = buttons.indexOf(shadow.activeElement); + const next = event.shiftKey ? (index - 1 + buttons.length) % buttons.length : (index + 1) % buttons.length; + event.preventDefault(); + buttons[next].focus({ preventScroll: true }); + }); + listen(canvas, 'pointerdown', () => { keyboardFocus = false; }); + listen(window, 'blur', resetControls); + listen(document, 'visibilitychange', resetControls); + listen(window, 'pagehide', () => close(false)); + listen(window, 'popstate', () => { close(false); location.reload(); }); + listen(window, 'resize', resize); + listen(arenaWindow, 'scroll', () => { targetsDirty = true; }, { passive: true }); + listen(arenaDocument, 'load', () => { targetsDirty = true; }, { capture: true }); + listen(stick, 'pointerdown', event => { + sound.init(); + joystick.active = true; + stick.setPointerCapture(event.pointerId); + steer(event); + }); + function steer(event) { + if (!joystick.active) return; + const rect = stick.getBoundingClientRect(); + joystick.x = event.clientX - rect.left - rect.width / 2; + joystick.y = event.clientY - rect.top - rect.height / 2; + const length = Math.max(1, Math.hypot(joystick.x, joystick.y) / 30); + stick.style.setProperty('--stick-x', `${joystick.x / length}px`); + stick.style.setProperty('--stick-y', `${joystick.y / length}px`); + } + listen(stick, 'pointermove', steer); + listen(stick, 'lostpointercapture', () => { + joystick.active = false; + stick.style.removeProperty('--stick-x'); + stick.style.removeProperty('--stick-y'); + }); + listen(find('.fire'), 'pointerdown', event => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + holdingFire = true; + sound.init(); + }); + listen(find('.fire'), 'lostpointercapture', () => { holdingFire = false; }); + listen(find('.fire'), 'click', event => { + if (event.detail === 0 && cooldown <= 0) fire(); + }); + listen(find('.navigate'), 'pointerdown', event => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + holdingNavigation = true; + sound.init(); + }); + listen(find('.navigate'), 'lostpointercapture', () => { holdingNavigation = false; }); + listen(find('.navigate'), 'click', event => { + if (event.detail === 0 && cooldown <= 0) fire('navigate'); + }); + + const missionLinks = new Map([['/', 'Startsidan']]); + for (const link of arenaDocument.querySelectorAll('.nav a[href], .service-card[href]')) { + const url = internalDestination(link.href, currentUrl); + if (url) missionLinks.set(url.pathname, link.querySelector('h2')?.textContent.trim() || link.textContent.trim()); + } + function showMissions() { + resetControls(); + const select = find('.mission-select'); + select.replaceChildren(); + for (const [href, label] of missionLinks) { + if (new URL(currentUrl).pathname === href) continue; + const option = document.createElement('option'); + option.value = href; + option.textContent = label; + select.append(option); + } + find('.missions').hidden = false; + select.focus({ preventScroll: true }); + } + listen(find('.missions-open'), 'click', showMissions); + listen(find('.missions-close'), 'click', () => { find('.missions').hidden = true; }); + listen(find('.mission-go'), 'click', () => { + const destination = internalDestination(find('.mission-select').value, currentUrl); + find('.missions').hidden = true; + if (destination) navigateTo({ destination }); + }); + + function targetSignature() { + let hash = 2166136261; + for (const node of targets) { + for (const char of node.tagName + node.textContent + (node.getAttribute('src') || '')) { + hash = Math.imul(hash ^ char.charCodeAt(0), 16777619); + } + } + return hash >>> 0; + } + + // Local protection and respawn use elapsed time too, including hidden tabs. + function advancePilotTimers() { + const now = performance.now(); + const elapsed = (now - pilotUpdatedAt) / 1000; + pilotUpdatedAt = now; + if (!roomStarted || roundEnded) return; + for (const kind of Object.keys(powers)) powers[kind] = Math.max(0, powers[kind] - elapsed); + invulnerable = Math.max(0, invulnerable - elapsed); + if (respawn <= 0) return; + const remaining = respawn - elapsed; + respawn = Math.max(0, remaining); + if (respawn === 0) { + hull = 100; + invulnerable = Math.max(0, 3 + remaining); + ship.x = width / 2; + ship.y = height * 0.62; + sound.launch(); + } + } + + function takeDamage(amount) { + advancePilotTimers(); + const next = damageHull(hull, amount, invulnerable > 0 || powers.shield > 0 || respawn > 0); + if (next === hull) return; + hull = next; + hitFlash = 0.25; + invulnerable = 0.9; + sound.damage(); + labels.push({ x: ship.x, y: ship.y - 24, text: `−${amount} SKROV`, life: 1 }); + if (hull === 0) { + burst(ship.x, ship.y, { left: ship.x - 15, top: ship.y - 15, width: 30, height: 30 }); + sound.crash(); + deathSequence++; + const death = { type: 'pilot-death', page: currentUrl, round: roundSerial, sequence: deathSequence, x: ship.x / width, y: ship.y / height }; + if (!network?.connected || network.isHost) recordDeath(death, ownPilotId()); + sendGame(death); + respawn = 3; + ship.vx = ship.vy = 0; + for (const kind of Object.keys(powers)) powers[kind] = 0; + resetControls(); + } + } + + function ownPilotId() { return network?.playerId || 'solo'; } + + function ensurePilot(id) { + if (!roundStats.has(id)) roundStats.set(id, { id, name: network?.playerName(id) || pilotName(nameInput.value) || 'Pilot', kills: 0, deaths: 0 }); + return roundStats.get(id); + } + + function roundSnapshot() { + return { serial: roundSerial, started: roomStarted, remaining: roundRemaining, ended: roundEnded, players: [...roundStats.values()] }; + } + + function publishRound() { + if (network?.isHost) sendGame({ type: 'round', data: roundSnapshot() }); + } + + function renderRound() { + setText('.round-clock', clockLabel(roundRemaining)); + const me = roundStats.get(ownPilotId()); + setText('.kill-count', `${me?.kills || 0} ${me?.kills === 1 ? 'kill' : 'kills'} · ${me?.deaths || 0} dödsfall`); + if (!roundEnded) return; + resetControls(); + const players = [...roundStats.values()].sort((a, b) => b.kills - a.kills); + const winners = roundWinners(players); + const names = players.filter(player => winners.includes(player.id)).map(player => player.name); + setText('.result-title', players.length === 1 ? 'Ronden är klar' : winners.length === 1 ? `${names[0]} vinner!` : 'Delad seger!'); + const list = find('.result-list'); + list.replaceChildren(); + for (const player of players) { + const item = document.createElement('li'); + item.textContent = `${player.name} · ${player.kills} ${player.kills === 1 ? 'kill' : 'kills'} · ${player.deaths} dödsfall`; + if (winners.includes(player.id)) item.className = 'winner'; + list.append(item); + } + find('.lobby').hidden = find('.missions').hidden = true; + find('.results').hidden = false; + const guest = network?.connected && !network.isHost; + find('.rematch').hidden = Boolean(guest); + find('.result-wait').hidden = !guest; + } + + function resetRound() { + roomStarted = false; + roundDeadline = 0; + roundEnded = false; + roundRemaining = ROUND_SECONDS; + lastRoundSecond = -1; + deathSequence = 0; + roundStats.clear(); + pendingDamage.clear(); + collisionTimes.clear(); + ghosts.clear(); + ordnance.reset(); + blasts.length = 0; + animations.forEach(animation => animation.cancel()); + animations.clear(); + for (const [node, original] of destroyed) { + if (original.value) node.style.setProperty('visibility', original.value, original.priority); + else node.style.removeProperty('visibility'); + } + restoreTargetDamage(); + destroyed.clear(); + pendingHits.clear(); + flightShots.clear(); + shots.length = pickups.length = particles.length = rings.length = labels.length = impacts.length = 0; + score = 0; + scoreOutput.textContent = '00000'; + hull = 100; + respawn = 0; + pilotUpdatedAt = performance.now(); + invulnerable = 3; + hitFlash = 0; + combo = 1; + lastHitTime = -Infinity; + missionElapsed = 0; + missionStartedAt = 0; + pageCleared = false; + checkMissionClear = targetsDirty = true; + for (const kind of Object.keys(powers)) powers[kind] = 0; + for (const effect of Object.values(roomEffects)) { effect.until = 0; effect.revision = 0; effect.announced = false; } + effectClaims.clear(); + powerupDrops.clear(); + scrollPaused = false; + scrollDirection = 1; + scrollPosition = lastWrittenScroll = 0; + remoteScroll = null; + arenaWindow.scrollTo({ top: 0, behavior: 'instant' }); + ship.x = width / 2 + (network?.connected ? 48 * network.playerIds.indexOf(ownPilotId()) : 0); + ship.y = height * 0.62; + ship.vx = ship.vy = 0; + find('.results').hidden = true; + resetControls(); + renderRound(); + } + + function beginRound() { + pilotUpdatedAt = performance.now(); + missionStartedAt = performance.now(); + roomStarted = true; + roundDeadline = performance.now() + ROUND_SECONDS * 1000; + roundRemaining = ROUND_SECONDS; + roundStats.clear(); + const players = network?.playerIds || []; + for (const id of players.length ? players : [ownPilotId()]) ensurePilot(id); + renderRound(); + } + + function receiveRound(data) { + if (!validRoundSnapshot(data) || data.serial < roundSerial || (data.serial === roundSerial && roundEnded && !data.ended)) return; + if (data.serial > roundSerial) { resetRound(); roundSerial = data.serial; } + const wasStarted = roomStarted; + roundRemaining = data.remaining; + if (data.ended === true && !roundEnded) sound.roundEnd(); + roundEnded = data.ended === true; + roomStarted = data.started === true; + roundDeadline = roomStarted ? performance.now() + data.remaining * 1000 : 0; + roundStats.clear(); + for (const player of data.players) { + roundStats.set(player.id, { id: player.id, name: pilotName(player.name) || 'Pilot', kills: player.kills, deaths: player.deaths }); + } + renderRound(); + if (roomStarted && !wasStarted && !roundEnded) { + pilotUpdatedAt = performance.now(); + missionStartedAt = performance.now(); + find('.lobby').hidden = true; + sound.launch(); + start(); + } else if (!roomStarted && !roundEnded) find('.lobby').hidden = false; + } + + function updateRound() { + if (closed || !roundDeadline || roundEnded) return; + roundRemaining = Math.max(0, (roundDeadline - performance.now()) / 1000); + const second = Math.ceil(roundRemaining); + if (roundRemaining === 0 && (!network?.connected || network.isHost)) { + roundEnded = true; + sound.roundEnd(); + renderRound(); + publishRound(); + } else if (second !== lastRoundSecond) { + lastRoundSecond = second; + renderRound(); + if (!network?.connected || network.isHost) publishRound(); + } + if (!roundEnded && roundRemaining > 0) { + advancePilotTimers(); + const pilots = [{ id: ownPilotId(), x: ship.x, y: ship.y, alive: hull > 0 }, ...[...ghosts].map(([id, ghost]) => ({ id, x: ghost.targetX, y: ghost.targetY, alive: ghost.hull !== 0 }))]; + ordnance.tick(pilots, rules.friendlyFire); + } + for (const [id, damage] of pendingDamage) if (damage.expires < performance.now()) pendingDamage.delete(id); + } + const roundTimer = setInterval(updateRound, 100); + cleanup.push(() => clearInterval(roundTimer)); + + function recordDeath(message, pilot) { + if (roundEnded || message.round !== roundSerial || !Number.isInteger(message.sequence)) return; + const entry = ensurePilot(pilot); + if (message.sequence !== entry.deaths + 1) return; + entry.deaths++; + ordnance.clearAmmo(pilot); + renderRound(); + publishRound(); + } + + function recordKill(message, victim) { + const damage = pendingDamage.get(message.shotId); + if (!damage || damage.victim !== victim || damage.expires < performance.now()) return; + pendingDamage.delete(message.shotId); + if (roundEnded || message.round !== roundSerial || message.killed !== true || message.damage <= 0 || damage.shooter === victim) return; + ensurePilot(damage.shooter).kills++; + renderRound(); + publishRound(); + } + + function routeCollision(pilot, other) { + if (!rules.collisions || !network.playerIds.includes(other) || pilot === other) return; + const pair = [pilot, other].sort().join(':'); + if (performance.now() - (collisionTimes.get(pair) ?? -Infinity) < 1000) return; + collisionTimes.set(pair, performance.now()); + const packet = { type: 'collision-damage', page: currentUrl, round: roundSerial, pilots: [pilot, other] }; + sendGame(packet); + if (packet.pilots.includes(ownPilotId())) takeDamage(25); + } + + listen(find('.result-exit'), 'click', () => close()); + listen(find('.rematch'), 'click', () => { + if (network?.connected && !network.isHost) return; + if (network && !network.connected && !network.isHost) { network.close(); network = null; } + resetRound(); + roundSerial++; + roomStarted = false; + showLobby(); + broadcastState(); + if (!network) { find('.lobby').hidden = true; find('.briefing').hidden = false; running = false; cancelAnimationFrame(frame); } + }); + + function routePilotHit(target, shotId, sender) { + const flight = flightShots.get(shotId); + if (!rules.friendlyFire || !flight || flight.owner !== sender || flight.kind !== 'destroy' + || flight.expires < performance.now() || target === sender || !network.playerIds.includes(target)) return; + flightShots.delete(shotId); + pendingDamage.set(shotId, { shooter: sender, victim: target, expires: performance.now() + 3000 }); + const packet = { type: 'pilot-damage', page: currentUrl, target, shotId }; + sendGame(packet); + if (target === network.playerId) resolvePilotHit(packet); + } + + function resolvePilotHit(message) { + const before = hull; + takeDamage(message.amount || 20); + const impact = { type: 'impact', page: currentUrl, shotId: message.shotId, x: ship.x / width, y: ship.y / height, damage: before - hull, killed: before > 0 && hull === 0, round: roundSerial }; + if (!network?.connected || network.isHost) recordKill(impact, ownPilotId()); + showImpact(impact, ownPilotId()); + sendGame(impact); + } + + function showImpact(message, pilot) { + if (!Number.isFinite(message.x) || !Number.isFinite(message.y) || message.x < 0 || message.x > 1 || message.y < 0 || message.y > 1 + || !Number.isInteger(message.damage) || message.damage < 0 || message.damage > 80 || typeof message.shotId !== 'string') return; + for (let i = shots.length - 1; i >= 0; i--) if (shots[i].id === message.shotId) shots.splice(i, 1); + const x = message.x * width; + const y = message.y * height; + impacts.push({ x, y, life: 0.45, blocked: message.damage === 0 }); + if (impacts.length > 24) impacts.shift(); + if (ghosts.has(pilot)) ghosts.get(pilot).flashUntil = performance.now() + 250; + if (pilot !== ownPilotId() || message.damage === 0) { + labels.push({ x, y: y - 25, text: message.damage ? `TRÄFF −${message.damage}` : 'SKYDD', life: 0.8 }); + sound.damage(); + } + } + + function effectTime(kind) { + return effectSeconds(roomEffects[kind].until, performance.now()); + } + + function setRoomEffect(kind, seconds, revision) { + if (!Object.hasOwn(roomEffects, kind) || !Number.isFinite(seconds) || seconds < 0 || seconds > 8 || !Number.isSafeInteger(revision)) return; + const effect = roomEffects[kind]; + if (revision < effect.revision) return; + const fresh = revision > effect.revision; + effect.revision = revision; + effect.until = performance.now() + seconds * 1000; + if (seconds > 0 && fresh) { sound.roomEffect(kind); effect.announced = true; } + } + + function activateRoomEffect(kind, id) { + if (!Object.hasOwn(roomEffects, kind) || !Number.isInteger(id) || !targets[id] || !destroyed.has(targets[id]) || powerupDrops.get(id) !== kind) return; + const claim = `${kind}:${id}`; + if (effectClaims.has(claim)) return; + effectClaims.add(claim); + setRoomEffect(kind, POWERUPS[kind].duration, roomEffects[kind].revision + 1); + sendGame({ type: 'effect', kind, page: currentUrl, seconds: POWERUPS[kind].duration, revision: roomEffects[kind].revision }); + } + + function placeOrdnance(kind) { + if (!running || roundEnded || respawn > 0 || !find('.lobby').hidden || !find('.missions').hidden || (roundDeadline && performance.now() >= roundDeadline)) return; + if (ordnance.ammo[kind] <= 0) { hint.textContent = 'Plocka upp MIN eller BOM först.'; return; } + sound.init(); + sound.deploy(kind); + ordnance.deploy(kind, ship.x, ship.y); + } + listen(find('.lay-mine'), 'click', () => placeOrdnance('mine')); + listen(find('.drop-bomb'), 'click', () => placeOrdnance('bomb')); + + function explodeOrdnance(hazard, victims, authoritative) { + const definition = ORDNANCE[hazard.kind]; + blasts.push({ ...hazard, radius: definition.radius, life: 0.65 }); + sound.crash(); + burst(hazard.x, hazard.y, { left: hazard.x - 20, top: hazard.y - 20, width: 40, height: 40 }); + if (authoritative) { + for (const victim of victims) pendingDamage.set(`${hazard.id}:${victim.id}`, { shooter: hazard.owner, victim: victim.id, expires: performance.now() + 3000 }); + if (targetsDirty) updateTargets(); + const hitNodes = new Set(); + for (const target of targetRects) { + if (hitNodes.has(target.node) || isDestroyed(target.node) || !blastTouches(target.rect, hazard)) continue; + hitNodes.add(target.node); + for (let hit = 0; hit < definition.objectHits && !isDestroyed(target.node); hit++) destroy(target, hazard.x, hazard.y); + } + } + const victim = victims.find(pilot => pilot.id === ownPilotId()); + if (victim) resolvePilotHit({ shotId: `${hazard.id}:${victim.id}`, amount: victim.damage }); + } + + function collectPowerup(pickup) { + const power = POWERUPS[pickup.kind]; + if (Object.hasOwn(ORDNANCE, pickup.kind)) ordnance.collect(pickup.kind, pickup.id); + else if (pickup.kind === 'repair') hull = Math.min(100, hull + 40); + else if (Object.hasOwn(roomEffects, pickup.kind)) { + if (network?.connected && !network.isHost) sendGame({ type: 'effect-request', kind: pickup.kind, page: currentUrl, id: pickup.id }); + else activateRoomEffect(pickup.kind, pickup.id); + } else powers[pickup.kind] = power.duration; + sound.powerup(); + hint.textContent = `${power.name.toUpperCase()}${power.duration ? ` · ${power.duration} sekunder` : ''}`; + } + + function updateRules() { + find('.friendly-fire').checked = rules.friendlyFire; + find('.collisions').checked = rules.collisions; + const guest = lobbyMode === 'guest' || lobbyMode === 'invited'; + find('.friendly-fire').disabled = find('.collisions').disabled = guest; + } + for (const [selector, key] of [['.friendly-fire', 'friendlyFire'], ['.collisions', 'collisions']]) { + listen(find(selector), 'change', event => { + if (network?.connected && !network.isHost) { updateRules(); return; } + rules[key] = event.target.checked; + invulnerable = 3; + broadcastState(); + }); + } + + function broadcastState() { + if (!network?.connected || !network.isHost) return; + sendGame({ + type: 'state', + roundState: roundSnapshot(), + ordnance: ordnance.snapshot(), + damage: [...targetHealth].filter(([node]) => !destroyed.has(node)) + .map(([node, remaining]) => [targets.indexOf(node), remaining]), + rules: { ...rules }, + effects: Object.fromEntries(Object.entries(roomEffects).map(([kind, effect]) => + [kind, { seconds: effectTime(kind), revision: effect.revision }])), + scrollPaused, + targetCount: targets.length, + signature: targetSignature(), + destroyed: [...destroyed.keys()].map(node => targets.indexOf(node)), + score, + scroll: arenaWindow.scrollY / Math.max(1, arenaDocument.documentElement.scrollHeight - height), + }); + } + + function validPoint(message) { + return Number.isFinite(message.x) && message.x >= 0 && message.x <= 1 + && Number.isFinite(message.y) && message.y >= 0 && message.y <= 1 + && Number.isFinite(message.angle) && Math.abs(message.angle) < 1e9; + } + + async function receive(message, sender) { + if (closed) return; + const receivingSession = sessionVersion; + if (message.type === 'round' && !network.isHost) { receiveRound(message.data); return; } + if (message.type === 'state' && !network.isHost) { + if (!validRoundSnapshot(message.roundState) || message.roundState.serial !== message.round || message.round < roundSerial || !Number.isSafeInteger(message.revision) || message.revision < pageRevision) return; + const destination = internalDestination(message.page, currentUrl); + if (!destination || !Array.isArray(message.destroyed) || message.destroyed.length > 5000 + || !Number.isSafeInteger(message.score) || message.score < 0 + || !Number.isFinite(message.scroll) || message.scroll < 0 || message.scroll > 1) return; + if (destination.href !== currentUrl) await navigateTo({ destination }, true); + if (closed || receivingSession !== sessionVersion || destination.href !== currentUrl) return; + pageRevision = message.revision; + if (message.targetCount !== targets.length || message.signature !== targetSignature()) { find('.connection-status').textContent = 'Olika sidversioner – ladda om båda sidorna.'; return; } + if (message.roundState?.serial > roundSerial) { resetRound(); roundSerial = message.roundState.serial; } + if (Array.isArray(message.damage) && message.damage.length <= 5000) { + for (const entry of message.damage) { + if (!Array.isArray(entry)) continue; + const [id, remaining] = entry; + if (Number.isInteger(id) && targets[id]) applyTargetDamage(targets[id], remaining, 0, 0, false); + } + } + for (const id of message.destroyed) { + if (!Number.isInteger(id) || !targets[id] || destroyed.has(targets[id])) continue; + rememberTarget(targets[id]); + targets[id].style.setProperty('visibility', 'hidden', 'important'); + } + if (typeof message.rules?.friendlyFire === 'boolean' && typeof message.rules?.collisions === 'boolean') { + if (rules.collisions !== message.rules.collisions) invulnerable = 3; + Object.assign(rules, { friendlyFire: message.rules.friendlyFire, collisions: message.rules.collisions }); + updateRules(); + } + for (const kind of Object.keys(roomEffects)) { + const effect = message.effects?.[kind]; + if (effect) setRoomEffect(kind, effect.seconds, effect.revision); + } + scrollPaused = message.scrollPaused === true; + remoteScroll = message.scroll; + scrollPosition = message.scroll * Math.max(0, arenaDocument.documentElement.scrollHeight - height); + score = message.score; + scoreOutput.textContent = String(score).padStart(5, '0'); + arenaWindow.scrollTo({ top: message.scroll * Math.max(0, arenaDocument.documentElement.scrollHeight - height), behavior: 'instant' }); + targetsDirty = true; + if (message.roundState) receiveRound(message.roundState); + if (message.ordnance) ordnance.restore(message.ordnance); + updateLobby(); + return; + } + if (roundEnded || (roundDeadline && performance.now() >= roundDeadline) || (network?.connected && !roomStarted)) return; + if (!matchesWorld(message, roundSerial, pageRevision, currentUrl) || loadingPage) return; + if (message.type.startsWith('ordnance-') && message.round === roundSerial) { ordnance.handle(message, sender); return; } + if (message.type === 'target-damage' && !network.isHost && Number.isInteger(message.id) && targets[message.id] && !destroyed.has(targets[message.id])) { + const node = targets[message.id]; + const rect = node.getBoundingClientRect(); + applyTargetDamage(node, message.remaining, rect.left + rect.width / 2, rect.top + rect.height / 2); + return; + } + if (message.type === 'pilot-death' && message.round === roundSerial && Number.isFinite(message.x) && Number.isFinite(message.y)) { + if (network.isHost) recordDeath(message, sender); + const x = message.x * width, y = message.y * height; + burst(x, y, { left: x - 15, top: y - 15, width: 30, height: 30 }); + sound.crash(); + if (ghosts.has(sender)) ghosts.get(sender).hull = 0; + return; + } + if (message.type === 'collision-request' && network.isHost && message.round === roundSerial) { routeCollision(sender, message.other); return; } + if (message.type === 'collision-damage' && !network.isHost && message.round === roundSerial && Array.isArray(message.pilots)) { + if (message.pilots.includes(ownPilotId())) takeDamage(25); + return; + } + if (message.type === 'pilot-hit' && network.isHost) { routePilotHit(message.target, message.shotId, sender); return; } + if (message.type === 'pilot-damage' && !network.isHost) { + for (let i = shots.length - 1; i >= 0; i--) if (shots[i].id === message.shotId) shots.splice(i, 1); + if (message.target === network.playerId) resolvePilotHit(message); + return; + } + if (message.type === 'impact') { if (network.isHost) recordKill(message, sender); showImpact(message, sender); return; } + if (message.type === 'scroll' && !network.isHost && Number.isFinite(message.scroll) && message.scroll >= 0 && message.scroll <= 1) { + remoteScroll = message.scroll; + scrollPaused = message.paused === true; + return; + } + if (message.type === 'effect-request' && network.isHost) { activateRoomEffect(message.kind, message.id); return; } + if (message.type === 'effect' && !network.isHost) { setRoomEffect(message.kind, message.seconds, message.revision); return; } + if (message.type === 'hit' && network.isHost && Number.isInteger(message.id) && targets[message.id]) { + const node = targets[message.id]; + const rect = node.getBoundingClientRect(); + destroy({ node, rect }, rect.left + rect.width / 2, rect.top + rect.height / 2); + } else if (message.type === 'destroy' && !network.isHost && Number.isInteger(message.id) && targets[message.id] + && Number.isSafeInteger(message.score) && message.score >= 0 && Number.isSafeInteger(message.points) && message.points > 0 + && Number.isInteger(message.combo) && message.combo >= 1 && message.combo <= 5) { + const node = targets[message.id]; + const rect = node.getBoundingClientRect(); + destroy({ node, rect }, rect.left + rect.width / 2, rect.top + rect.height / 2, message); + } else if (message.type === 'shot' && validPoint(message) && Number.isSafeInteger(message.id) && message.id > 0 && ['destroy', 'navigate'].includes(message.kind) && [message.vx, message.vy].every(value => Number.isFinite(value) && Math.abs(value) <= 600)) { + const offsets = message.triple && message.kind === 'destroy' ? [-0.17, 0, 0.17] : [0]; + for (const [index, offset] of offsets.entries()) { + const id = `${sender}:${message.id}:${index}`; + flightShots.set(id, { owner: sender, expires: performance.now() + 2000, kind: message.kind }); + if (shots.length >= 200) shots.shift(); + shots.push({ id, remote: true, color: playerColor(sender), kind: message.kind, x: message.x * width + Math.cos(message.angle) * 19, y: message.y * height + Math.sin(message.angle) * 19, vx: Math.cos(message.angle + offset) * 750 + message.vx, vy: Math.sin(message.angle + offset) * 750 + message.vy, life: 1.3 }); + } + } else if (message.type === 'navigate' && network.isHost) { + const destination = internalDestination(message.url, currentUrl); + if (destination) await navigateTo({ destination }); + } + } + + function makeNetwork() { + sessionVersion++; + messageQueue = Promise.resolve(); + network?.close(); + roomStarted = false; + resetRound(); + roundSerial = 1; + pageRevision = 0; + ghosts.clear(); + const source = createMultiplayer({ + onMessage(message, sender) { + if (closed || network !== source) return; + if (message.type === 'position') { + if (!roomStarted || loadingPage || !matchesWorld(message, roundSerial, pageRevision, currentUrl) || !validPoint(message)) return; + let ghost = ghosts.get(sender); + if (!ghost) { ghost = { x: message.x * width, y: message.y * height }; ghosts.set(sender, ghost); } + Object.assign(ghost, { targetX: message.x * width, targetY: message.y * height, angle: message.angle, thrust: Boolean(message.thrust), hull: Number.isFinite(message.hull) ? message.hull : 100, shield: Boolean(message.shield), flashUntil: message.hit ? performance.now() + 100 : 0 }); + return; + } + messageQueue = messageQueue.then(() => { if (!closed && network === source) return receive(message, sender); }).catch(() => { hint.textContent = 'Ett spelmeddelande kunde inte läsas.'; }); + }, + onConnected() { + if (!network.isHost) ship.x = Math.min(width - 24, width / 2 + 48 * network.playerIds.indexOf(network.playerId)); + lobbyMode = network.isHost ? 'hosting' : 'guest'; + updateLobby(); + if (!roomStarted) find('.lobby').hidden = false; + invulnerable = 3; + broadcastState(); + }, + onPlayers(players) { + if (network.isHost && !roundEnded) for (const id of players) ensurePilot(id); + for (const id of ghosts.keys()) if (!players.includes(id)) ghosts.delete(id); + for (const selector of ['.pilot-list', '.crew-list']) { + const list = find(selector); + list.replaceChildren(); + for (const [index, id] of players.entries()) { + const item = document.createElement('li'); + item.style.color = playerColor(id); + item.textContent = network.playerName(id) + (id === network.playerId ? ' (du)' : '') + (index === 0 && selector === '.crew-list' && (network.connected || network.isHost) ? ' · värd' : ''); + list.append(item); + } + } + find('.crew').hidden = false; + find('.crew-count').textContent = `${players.length} / ${MAX_PLAYERS}`; + }, + onStatus(status) { + if (closed) return; + find('.lobby-status').textContent = status; + find('.connection-status').textContent = network?.connected ? `${network.playerIds.length} / ${MAX_PLAYERS} piloter` : status; + if (!network?.connected) { + ghosts.clear(); pendingHits.clear(); + if (lobbyMode === 'guest') { + lobbyMode = 'idle'; + updateLobby(); + if (!roomStarted) { find('.briefing').hidden = false; running = false; cancelAnimationFrame(frame); } + if (roundEnded) renderRound(); + } + } + }, + }); + network = source; + return network; + } + + function updateLobby() { + const invited = lobbyMode === 'invited'; + const connected = lobbyMode === 'hosting' || lobbyMode === 'guest'; + find('.host-game').hidden = lobbyMode !== 'idle'; + find('.join-game').hidden = !invited; + find('.start-room').hidden = lobbyMode !== 'hosting' || roomStarted; + find('.lobby-close').hidden = connected && !roomStarted; + find('.join-manually').hidden = lobbyMode !== 'idle'; + find('.invitation').hidden = lobbyMode !== 'hosting'; + find('.disconnect').hidden = lobbyMode === 'idle'; + find('.disconnect').textContent = invited ? 'Avbryt inbjudan' : 'Lämna rummet'; + find('.pilot-name').disabled = connected; + let intro = 'Skapa ett rum och bjud in med en länk.'; + if (invited) intro = 'Du är inbjuden. Välj namn och anslut.'; + else if (connected && roomStarted) intro = 'Rummet är öppet. Flyg tillsammans!'; + else if (lobbyMode === 'hosting') intro = 'Starta när besättningen är redo.'; + else if (lobbyMode === 'guest') intro = 'Väntar på att värden startar spelet.'; + find('.lobby-intro').textContent = intro; + updateRules(); + } + function showLobby(peerId = null) { + resetControls(); + find('.lobby').hidden = false; + if (peerId && !network?.connected) { + lobbyMode = 'invited'; + find('.incoming-link').value = invitationLink(peerId, currentUrl); + find('.lobby-status').textContent = ''; + } + updateLobby(); + const focus = find('.pilot-name').disabled ? (find('.start-room').hidden ? find('.disconnect') : find('.start-room')) : find('.pilot-name'); + focus.focus({ preventScroll: true }); + } + listen(find('.use-invitation'), 'click', () => { + const id = invitationPeer(find('.incoming-link').value, currentUrl); + if (id) showLobby(id); + else find('.lobby-status').textContent = 'Klistra in en giltig inbjudningslänk.'; + }); + listen(find('.multiplayer'), 'click', () => showLobby()); + listen(window, 'athega-space-invite', event => showLobby(event.detail)); + listen(find('.lobby-close'), 'click', () => { + if ((lobbyMode === 'hosting' || lobbyMode === 'guest') && !roomStarted) return; + find('.lobby').hidden = true; + }); + listen(find('.start-room'), 'click', () => { + if (!network?.isHost || roomStarted) return; + roomStarted = true; + beginRound(); + updateLobby(); + find('.lobby').hidden = true; + sound.init(); + sound.launch(); + start(); + broadcastState(); + }); + async function connectionAction(action) { + const attempt = ++connectionAttempt; + const status = find('.lobby-status'); + find('.host-game').disabled = find('.join-game').disabled = true; + find('.invitation').hidden = true; + try { + await action(); + } catch (error) { + if (!closed && attempt === connectionAttempt) status.textContent = error.message || 'Anslutningen gick inte att skapa.'; + } finally { + if (!closed && attempt === connectionAttempt) find('.host-game').disabled = find('.join-game').disabled = false; + } + } + listen(find('.host-game'), 'click', () => connectionAction(async () => { + sound.init(); + const id = await makeNetwork().host(find('.pilot-name').value); + if (closed) return; + find('.outgoing-link').value = invitationLink(id, currentUrl); + lobbyMode = 'hosting'; + updateLobby(); + find('.local-invite').hidden = !['localhost', '127.0.0.1', '[::1]'].includes(location.hostname); + find('.lobby-status').textContent = 'Skicka länken till kompisen och låt spelet vara öppet. Ni kopplas ihop när kompisen väljer Anslut.'; + })); + listen(find('.join-game'), 'click', () => { + sound.init(); + const id = invitationPeer(find('.incoming-link').value, currentUrl); + if (!id) { find('.lobby-status').textContent = 'Klistra in en giltig inbjudningslänk från den här sajten.'; return; } + connectionAction(() => makeNetwork().join(id, find('.pilot-name').value)); + }); + listen(find('.copy-link'), 'click', async () => { + try { await navigator.clipboard.writeText(find('.outgoing-link').value); find('.lobby-status').textContent = 'Länken är kopierad. Skicka den till kompisen och vänta kvar här.'; } + catch { find('.outgoing-link').select(); find('.lobby-status').textContent = 'Markeringen är klar. Kopiera länken manuellt.'; } + }); + listen(find('.disconnect'), 'click', () => { + connectionAttempt++; + sessionVersion++; + messageQueue = Promise.resolve(); + network?.close(); + network = null; + ghosts.clear(); + pendingHits.clear(); + find('.incoming-link').value = find('.outgoing-link').value = ''; + find('.host-game').disabled = find('.join-game').disabled = false; + lobbyMode = 'idle'; + resetRound(); + running = false; + cancelAnimationFrame(frame); + find('.briefing').hidden = false; + updateLobby(); + find('.lobby-status').textContent = 'Skapa ett nytt spel eller anslut med en inbjudningslänk.'; + find('.connection-status').textContent = 'Soloflygning'; + find('.pilot-list').replaceChildren(); + find('.crew-list').replaceChildren(); + find('.crew').hidden = true; + }); + resize(); + launchButton.focus({ preventScroll: true }); + if (invitedPeerId) showLobby(invitedPeerId); + } catch (error) { + dispose(); + throw error; + } +} diff --git a/assets/space-egg/invitation.js b/assets/space-egg/invitation.js new file mode 100644 index 0000000..1d02c1b --- /dev/null +++ b/assets/space-egg/invitation.js @@ -0,0 +1,23 @@ +const peerIdPattern = /^athega-space-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +export function isPeerId(value) { + return typeof value === 'string' && peerIdPattern.test(value); +} + +export function invitationLink(peerId, pageUrl) { + if (!isPeerId(peerId)) throw new Error('Ogiltig spelinbjudan.'); + const url = new URL('/', pageUrl); + url.hash = new URLSearchParams({ space: peerId }).toString(); + return url.href; +} + +export function invitationPeer(value, pageUrl) { + try { + const url = new URL(value.trim(), pageUrl); + if (url.origin !== new URL(pageUrl).origin) return null; + const id = new URLSearchParams(url.hash.slice(1)).get('space'); + return isPeerId(id) ? id : null; + } catch { + return null; + } +} diff --git a/assets/space-egg/multiplayer.js b/assets/space-egg/multiplayer.js new file mode 100644 index 0000000..84d806a --- /dev/null +++ b/assets/space-egg/multiplayer.js @@ -0,0 +1,256 @@ +import { acceptsMessage, relayMessage, replaceableMessage } from './protocol.js?v=14'; +import { isPeerId } from './invitation.js?v=14'; + +let libraryPromise; + +function loadPeerLibrary() { + if (libraryPromise) return libraryPromise; + libraryPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = new URL('./vendor/peerjs-1.5.5.min.js', import.meta.url).href; + const timer = setTimeout(() => fail(), 15000); + function fail() { + clearTimeout(timer); + script.remove(); + reject(new Error('Kunde inte ladda multiplayer. Prova igen.')); + } + script.onload = () => { + clearTimeout(timer); + const Peer = window.peerjs?.Peer || window.Peer; + if (Peer) resolve(Peer); + else fail(); + }; + script.onerror = fail; + document.head.append(script); + }).catch(error => { + libraryPromise = null; + throw error; + }); + return libraryPromise; +} + +export const MAX_PLAYERS = 4; + +export function pilotName(value) { + if (typeof value !== 'string') return ''; + return Array.from(value.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '').replace(/\s+/g, ' ').trim()).slice(0, 20).join(''); +} +const protocol = 9; + +// Star topology: the host owns scoring and relays guests' visual updates. +export function createMultiplayer({ onMessage, onConnected, onStatus, onPlayers = () => {}, loadPeer = loadPeerLibrary }) { + let peer; + let host = false; + let ready = false; + let closed = false; + let timer; + let pendingResolve; + let pendingReject; + let roster = []; + let name = ''; + const names = new Map(); + const connections = new Map(); + const admitted = new Set(); + const rejectedConnections = new Set(); + + function settle(error, value) { + clearTimeout(timer); + const resolve = pendingResolve; + const reject = pendingReject; + pendingResolve = pendingReject = null; + if (error) reject?.(error); + else resolve?.(value); + } + + function close() { + if (closed) return; + closed = true; + ready = false; + settle(new Error('Anslutningen stängdes.')); + for (const connection of [...connections.values(), ...rejectedConnections]) connection.close(); + connections.clear(); + admitted.clear(); + rejectedConnections.clear(); + peer?.destroy(); + } + + function fail(message) { + if (closed) return; + settle(new Error(message)); + close(); + onStatus(message); + } + + function write(connection, value) { + if (!connection.open) return; + const buffered = connection.dataChannel?.bufferedAmount || 0; + // Positions can be replaced by the next frame. Never silently lose damage, + // round transitions or snapshots: disconnect instead of diverging forever. + if (buffered > 65536 && replaceableMessage(value.message?.type)) return; + if (buffered > 1000000) { connection.close(); return; } + const encoded = JSON.stringify(value); + if (encoded.length > 50000) { connection.close(); return; } + try { connection.send(encoded); } catch { connection.close(); } + } + + function broadcast(value, exceptId = null) { + for (const [id, connection] of connections) { + if (admitted.has(id) && id !== exceptId) write(connection, value); + } + } + + function updateRoster(players, receivedNames = {}) { + roster = players; + for (const id of players) names.set(id, pilotName(receivedNames[id]) || names.get(id) || `Pilot ${players.indexOf(id) + 1}`); + onPlayers([...roster]); + onStatus(roster.length > 1 ? `${roster.length} piloter anslutna` : 'Väntar på fler piloter. Du kan fortsätta spela själv.'); + } + + function rosterNames(players) { + return Object.fromEntries(players.map(id => [id, names.get(id)])); + } + + function publishRoster() { + updateRoster([peer.id, ...admitted]); + broadcast({ control: 'roster', players: roster, names: rosterNames(roster) }); + } + + function validRoster(players) { + return Array.isArray(players) && players.length >= 2 && players.length <= MAX_PLAYERS + && players.every(isPeerId) && new Set(players).size === players.length && players.includes(peer.id); + } + + function attach(connection) { + connections.set(connection.peer, connection); + const openingTimer = setTimeout(() => connection.close(), 15000); + connection.on('close', () => clearTimeout(openingTimer)); + connection.on('open', () => { + clearTimeout(openingTimer); + if (closed) { connection.close(); return; } + if (host) { + admitted.add(connection.peer); + ready = true; + const players = [peer.id, ...admitted]; + write(connection, { control: 'welcome', protocol, players, names: rosterNames(players) }); + publishRoster(); + onConnected(); + } + }); + connection.on('data', value => { + if (closed || typeof value !== 'string' || value.length > 50000) return; + let packet; + try { packet = JSON.parse(value); } catch { return; } + if (!packet || typeof packet !== 'object') return; + if (!host && packet.control === 'incompatible') { fail('Ni har olika spelversioner. Ladda om båda sidorna och skapa en ny inbjudan.'); return; } + if (!host && packet.control === 'full') { fail(`Spelet är fullt (${MAX_PLAYERS} spelare). Be om en ny inbjudan.`); return; } + if (!host && packet.control === 'welcome' && packet.protocol === protocol && validRoster(packet.players)) { + ready = true; + admitted.add(connection.peer); + settle(null); + updateRoster(packet.players, packet.names || {}); + onConnected(); + return; + } + if (!host && packet.control === 'roster' && ready && validRoster(packet.players)) { updateRoster(packet.players, packet.names || {}); return; } + const message = packet.message; + if (!ready || !admitted.has(connection.peer) || !message || typeof message.type !== 'string') return; + const sender = host ? connection.peer : packet.sender; + if (!roster.includes(sender) || sender === peer.id || !acceptsMessage(message.type, host, sender === connection.peer)) return; + onMessage(message, sender); + if (host && relayMessage(message.type)) broadcast({ sender, message }, sender); + }); + connection.on('close', () => { + if (closed || connections.get(connection.peer) !== connection) return; + connections.delete(connection.peer); + admitted.delete(connection.peer); + if (host) { + ready = admitted.size > 0; + publishRoster(); + } else { + ready = false; + const message = 'Värden lämnade spelet. Du kan fortsätta själv.'; + settle(new Error(message)); + updateRoster([peer.id]); + onStatus(message); + } + }); + connection.on('error', () => { + if (host) connection.close(); + else fail('Kunde inte koppla ihop skeppen. Prova igen eller byt nätverk.'); + }); + } + + async function createPeer() { + if (peer || closed) throw new Error('Skapa en ny anslutning för att försöka igen.'); + const Peer = await loadPeer(); + if (closed) throw new Error('Anslutningen stängdes.'); + return new Promise((resolve, reject) => { + pendingResolve = resolve; + pendingReject = reject; + timer = setTimeout(() => fail('Anslutningstjänsten svarar inte. Du kan fortsätta spela själv och prova igen senare.'), 15000); + peer = new Peer(`athega-space-${crypto.randomUUID()}`, { + secure: true, + config: { iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }] }, + }); + peer.on('open', id => { if (!closed) { roster = [id]; names.set(id, name || 'Pilot 1'); if (host) updateRoster([id]); settle(null, id); } }); + peer.on('error', error => { + if (error.type === 'peer-unavailable') fail('Inbjudan har gått ut eller värden har lämnat. Be om en ny länk.'); + else if (!ready) fail('Kunde inte nå multiplayer. Kontrollera anslutningen och prova igen.'); + else onStatus('Kontakten med inbjudningstjänsten bröts. Det pågående spelet kan fortsätta.'); + }); + peer.on('disconnected', () => { + if (!closed) onStatus(ready ? 'Spelet fortsätter, men nya inbjudningar är tillfälligt otillgängliga.' : 'Kontakten med inbjudningstjänsten bröts. Skapa en ny anslutning.'); + }); + peer.on('connection', connection => { + if (closed || !host || connection.metadata?.app !== 'athega-space' || !isPeerId(connection.peer)) { connection.close(); return; } + const incompatible = connection.metadata?.protocol !== protocol; + if (incompatible || connections.size >= MAX_PLAYERS - 1 || connections.has(connection.peer)) { + rejectedConnections.add(connection); + const rejectionTimer = setTimeout(() => { rejectedConnections.delete(connection); connection.close(); }, 2000); + connection.on('open', () => write(connection, { control: incompatible ? 'incompatible' : 'full' })); + connection.on('close', () => clearTimeout(rejectionTimer)); + connection.on('close', () => rejectedConnections.delete(connection)); + connection.on('error', () => { rejectedConnections.delete(connection); connection.close(); }); + return; + } + names.set(connection.peer, pilotName(connection.metadata.name) || `Pilot ${connections.size + 2}`); + attach(connection); + }); + }); + } + + return { + get connected() { return ready && admitted.size > 0; }, + get isHost() { return host; }, + get playerId() { return peer?.id; }, + get playerIds() { return [...roster]; }, + playerName(id) { return names.get(id) || 'Pilot'; }, + async host(displayName = '') { + name = pilotName(displayName); + host = true; + onStatus('Skapar en inbjudan…'); + return createPeer(); + }, + async join(id, displayName = '') { + name = pilotName(displayName); + if (!isPeerId(id)) throw new Error('Klistra in en giltig inbjudningslänk.'); + host = false; + onStatus('Ansluter till spelet…'); + await createPeer(); + if (closed) throw new Error('Anslutningen stängdes.'); + return new Promise((resolve, reject) => { + pendingResolve = resolve; + pendingReject = reject; + timer = setTimeout(() => fail('Ingen kontakt med värden. Värden behöver ha spelet öppet. Prova igen eller byt nätverk.'), 25000); + attach(peer.connect(id, { label: 'athega-space', serialization: 'raw', reliable: true, metadata: { app: 'athega-space', protocol, name } })); + }); + }, + send(message) { + if (!ready) return; + const packet = { sender: peer.id, message }; + if (host) broadcast(packet); + else for (const connection of connections.values()) write(connection, packet); + }, + close, + }; +} diff --git a/assets/space-egg/navigation.js b/assets/space-egg/navigation.js new file mode 100644 index 0000000..c0a5da0 --- /dev/null +++ b/assets/space-egg/navigation.js @@ -0,0 +1,10 @@ +export function internalDestination(href, currentUrl) { + if (typeof href !== 'string' || !href) return null; + let destination; + try { destination = new URL(href, currentUrl); } catch { return null; } + if (destination.origin !== new URL(currentUrl).origin || !['https:', 'http:'].includes(destination.protocol)) return null; + // Only navigate to site pages, never downloads or standalone blog demos. + if (destination.pathname.startsWith('/assets/') || /\.[^/]+$/.test(destination.pathname) && !/\.html?$/.test(destination.pathname)) return null; + return destination; +} + diff --git a/assets/space-egg/ordnance.js b/assets/space-egg/ordnance.js new file mode 100644 index 0000000..3fe2c63 --- /dev/null +++ b/assets/space-egg/ordnance.js @@ -0,0 +1,118 @@ +import { WORLD_WIDTH, WORLD_HEIGHT } from './arena.js?v=14'; + +export const ORDNANCE = { + mine: { label: 'MIN', radius: 95, damage: 60, objectHits: 2 }, + bomb: { label: 'BOM', radius: 150, damage: 80, objectHits: 3 }, +}; +const capacity = 3; + +export function mineTriggered(mine, pilots, now) { + return now >= mine.armedAt && pilots.some(pilot => pilot.id !== mine.owner && pilot.alive + && Math.hypot(pilot.x - mine.x, pilot.y - mine.y) <= 60); +} + +export function blastTouches(rect, blast) { + const x = Math.max(rect.left, Math.min(blast.x, rect.right)); + const y = Math.max(rect.top, Math.min(blast.y, rect.bottom)); + return Math.hypot(x - blast.x, y - blast.y) <= ORDNANCE[blast.kind].radius; +} + +export function blastVictims(blast, pilots, friendlyFire) { + return pilots.filter(pilot => pilot.alive + && (pilot.id === blast.owner ? blast.kind === 'bomb' : friendlyFire) + && Math.hypot(pilot.x - blast.x, pilot.y - blast.y) <= ORDNANCE[blast.kind].radius) + .map(pilot => ({ id: pilot.id, damage: ORDNANCE[blast.kind].damage })); +} + +// Authority owns ammunition and fuses. Guests request actions and only draw them. +export function createOrdnance({ ownId, authoritative, send, onBlast, onChange, now = () => performance.now() }) { + const ammunition = new Map(); + const drops = new Map(); + const claimed = new Set(); + const hazards = new Map(); + let sequence = 0; + function stock(id) { + if (!ammunition.has(id)) ammunition.set(id, { mine: 0, bomb: 0 }); + return ammunition.get(id); + } + function snapshot() { + return { + ammo: [...ammunition].map(([id, value]) => ({ id, ...value })), + hazards: [...hazards.values()].map(hazard => ({ ...hazard, fuse: Math.max(0, hazard.detonatesAt - now()), arm: Math.max(0, hazard.armedAt - now()) })), + }; + } + function publish() { onChange(stock(ownId())); send({ type: 'ordnance-state', data: snapshot() }); } + function collectFor(pilot, kind, target) { + if (!Object.hasOwn(ORDNANCE, kind) || drops.get(target) !== kind) return; + const key = `${pilot}:${target}`; + if (claimed.has(key)) return; + claimed.add(key); + stock(pilot)[kind] = Math.min(capacity, stock(pilot)[kind] + 1); + publish(); + } + function deployFor(pilot, kind, x, y) { + if (!Object.hasOwn(ORDNANCE, kind) || !Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > WORLD_WIDTH || y < 0 || y > WORLD_HEIGHT || stock(pilot)[kind] <= 0) return; + if ([...hazards.values()].filter(hazard => hazard.owner === pilot && hazard.kind === kind).length >= capacity) return; + stock(pilot)[kind]--; + const hazard = { id: `${pilot}:${++sequence}`, kind, owner: pilot, x, y, detonatesAt: now() + 1000, armedAt: now() + 600 }; + hazards.set(hazard.id, hazard); + publish(); + } + function restore(data) { + if (!data || !Array.isArray(data.ammo) || !Array.isArray(data.hazards) || data.ammo.length > 100 || data.hazards.length > 100) return; + ammunition.clear(); + for (const item of data.ammo) { + if (item && typeof item.id === 'string' && [item.mine, item.bomb].every(value => Number.isInteger(value) && value >= 0 && value <= capacity)) ammunition.set(item.id, { mine: item.mine, bomb: item.bomb }); + } + hazards.clear(); + for (const hazard of data.hazards) { + if (hazard && Object.hasOwn(ORDNANCE, hazard.kind) && typeof hazard.id === 'string' && typeof hazard.owner === 'string' + && [hazard.x, hazard.y, hazard.fuse, hazard.arm].every(Number.isFinite) + && hazard.x >= 0 && hazard.x <= WORLD_WIDTH && hazard.y >= 0 && hazard.y <= WORLD_HEIGHT && hazard.fuse >= 0 && hazard.fuse <= 1000 && hazard.arm >= 0 && hazard.arm <= 600) { + hazards.set(hazard.id, { ...hazard, detonatesAt: now() + hazard.fuse, armedAt: now() + hazard.arm }); + } + } + onChange(stock(ownId())); + } + return { + snapshot, restore, + get hazards() { return [...hazards.values()]; }, + get ammo() { return { ...stock(ownId()) }; }, + recordDrop(target, kind) { if (Object.hasOwn(ORDNANCE, kind)) drops.set(target, kind); }, + collect(kind, target) { + if (authoritative()) collectFor(ownId(), kind, target); + else send({ type: 'ordnance-pickup', kind, target }); + }, + deploy(kind, x, y) { + if (authoritative()) deployFor(ownId(), kind, x, y); + else send({ type: 'ordnance-deploy', kind, x, y }); + }, + handle(message, sender) { + if (authoritative()) { + if (message.type === 'ordnance-pickup') collectFor(sender, message.kind, message.target); + if (message.type === 'ordnance-deploy') deployFor(sender, message.kind, message.x, message.y); + } else if (message.type === 'ordnance-state') restore(message.data); + else if (message.type === 'ordnance-blast') { + const hazard = hazards.get(message.id); + if (!hazard || !Array.isArray(message.victims) || message.victims.length > 4) return; + hazards.delete(message.id); + onBlast(hazard, message.victims.filter(victim => victim && typeof victim.id === 'string' && victim.damage === ORDNANCE[hazard.kind].damage), false); + } + }, + tick(pilots, friendlyFire) { + if (!authoritative()) return; + for (const hazard of [...hazards.values()]) { + const triggered = hazard.kind === 'bomb' ? now() >= hazard.detonatesAt : mineTriggered(hazard, pilots, now()); + if (!triggered) continue; + hazards.delete(hazard.id); + const victims = blastVictims(hazard, pilots, friendlyFire); + // Deliver the explosion before damage callbacks publish respawn/ammo state. + send({ type: 'ordnance-blast', id: hazard.id, victims }); + onBlast(hazard, victims, true); + } + }, + clearAmmo(pilot) { ammunition.set(pilot, { mine: 0, bomb: 0 }); if (authoritative()) publish(); }, + clearField() { hazards.clear(); drops.clear(); claimed.clear(); }, + reset() { ammunition.clear(); hazards.clear(); drops.clear(); claimed.clear(); onChange({ mine: 0, bomb: 0 }); }, + }; +} diff --git a/assets/space-egg/physics.js b/assets/space-egg/physics.js new file mode 100644 index 0000000..41506ba --- /dev/null +++ b/assets/space-egg/physics.js @@ -0,0 +1,34 @@ +// Swept collision: fast shots must not jump over a thin line of text. +export function rectEntry(x1, y1, x2, y2, rect) { + let enter = 0; + let leave = 1; + for (const [start, delta, min, max] of [ + [x1, x2 - x1, rect.left, rect.right], + [y1, y2 - y1, rect.top, rect.bottom], + ]) { + if (delta === 0) { + if (start < min || start > max) return null; + continue; + } + const a = (min - start) / delta; + const b = (max - start) / delta; + enter = Math.max(enter, Math.min(a, b)); + leave = Math.min(leave, Math.max(a, b)); + if (enter > leave) return null; + } + return enter; +} + +export function hitsRect(x1, y1, x2, y2, rect) { + return rectEntry(x1, y1, x2, y2, rect) !== null; +} + +export function firstHit(x1, y1, x2, y2, candidates) { + let nearest = null; + let distance = Infinity; + for (const candidate of candidates) { + const entry = rectEntry(x1, y1, x2, y2, candidate.rect); + if (entry !== null && entry < distance) { nearest = candidate; distance = entry; } + } + return nearest; +} diff --git a/assets/space-egg/protocol.js b/assets/space-egg/protocol.js new file mode 100644 index 0000000..305fded --- /dev/null +++ b/assets/space-egg/protocol.js @@ -0,0 +1,16 @@ +// The transport authenticates the sender; the game checks round/page revisions. +const pilotMessages = new Set(['position', 'shot', 'impact', 'pilot-death']); +const requests = new Set(['hit', 'navigate', 'pilot-hit', 'collision-request', 'effect-request', 'ordnance-pickup', 'ordnance-deploy']); +const hostMessages = new Set(['state', 'round', 'target-damage', 'destroy', 'pilot-damage', 'collision-damage', 'scroll', 'effect', 'ordnance-state', 'ordnance-blast']); + +export function acceptsMessage(type, receivingHost, senderIsHost) { + if (receivingHost) return pilotMessages.has(type) || requests.has(type); + return pilotMessages.has(type) || (senderIsHost && hostMessages.has(type)); +} + +export function relayMessage(type) { return pilotMessages.has(type); } +export function replaceableMessage(type) { return type === 'position' || type === 'scroll'; } + +export function matchesWorld(message, round, revision, page) { + return message.round === round && message.revision === revision && message.page === page; +} diff --git a/assets/space-egg/rendering.js b/assets/space-egg/rendering.js new file mode 100644 index 0000000..78cc2d6 --- /dev/null +++ b/assets/space-egg/rendering.js @@ -0,0 +1,92 @@ +export function createRenderer(context, reducedMotion) { + function drawPilotName(name, craft, color) { + context.save(); + context.font = '12px ui-monospace, monospace'; + context.textAlign = 'center'; + const textWidth = context.measureText(name).width; + context.fillStyle = '#08121de0'; + context.fillRect(craft.x - textWidth / 2 - 5, craft.y + 20, textWidth + 10, 19); + context.fillStyle = color; + context.fillText(name, craft.x, craft.y + 33); + context.restore(); + } + + function drawShip(thrust, now, craft, color = '#ff6600') { + context.save(); + context.translate(craft.x, craft.y); + if (craft.hitFlash > 0 || craft.flashUntil > performance.now()) color = '#fff0ce'; + if (craft.shield) { + context.beginPath(); + context.arc(0, 0, 26, 0, Math.PI * 2); + context.strokeStyle = '#64e9ce'; + context.lineWidth = 2; + context.stroke(); + } + context.rotate(craft.angle); + context.shadowColor = color; + context.shadowBlur = reducedMotion ? 0 : 12; + if (thrust) { + const flame = reducedMotion ? 19 : 18 + Math.sin(now * 0.035) * 6; + context.beginPath(); + context.moveTo(-10, -5); + context.lineTo(-flame - 8, 0); + context.lineTo(-10, 5); + context.fillStyle = color; + context.fill(); + } + context.beginPath(); + context.moveTo(19, 0); + context.lineTo(-12, -12); + context.lineTo(-6, 0); + context.lineTo(-12, 12); + context.closePath(); + context.fillStyle = '#111820'; + context.fill(); + context.strokeStyle = color; + context.lineWidth = 2; + context.stroke(); + context.shadowBlur = 0; + context.fillStyle = '#d8f4ff'; + context.fillRect(0, -2, 6, 4); + context.restore(); + } + + function drawOrdnance(hazards, blasts, owner, now, dt) { + for (const hazard of hazards) { + context.save(); + context.translate(hazard.x, hazard.y); + const friendly = hazard.kind === 'mine' && hazard.owner === owner; + context.strokeStyle = friendly ? '#64e9ce' : '#ffb24d'; + context.fillStyle = '#142332'; + context.lineWidth = 2; + context.beginPath(); + context.arc(0, 0, hazard.kind === 'mine' ? 12 : 15, 0, Math.PI * 2); + context.fill(); context.stroke(); + if (hazard.kind === 'mine') { + for (let ray = 0; ray < 8; ray++) { + const angle = ray * Math.PI / 4; + context.beginPath(); context.moveTo(Math.cos(angle) * 12, Math.sin(angle) * 12); context.lineTo(Math.cos(angle) * 18, Math.sin(angle) * 18); context.stroke(); + } + context.fillStyle = friendly ? '#64e9ce' : '#ff6600'; + context.globalAlpha = reducedMotion ? 1 : 0.5 + Math.sin(now / 140) * 0.4; + context.fillRect(-3, -3, 6, 6); + } else { + context.fillStyle = '#ffd18e'; context.font = 'bold 12px monospace'; context.textAlign = 'center'; + context.fillText(Math.max(0, (hazard.detonatesAt - now) / 1000).toFixed(1), 0, 4); + } + context.restore(); + } + for (let i = blasts.length - 1; i >= 0; i--) { + const blast = blasts[i]; + blast.life -= dt; + if (blast.life <= 0) { blasts.splice(i, 1); continue; } + context.save(); + context.globalAlpha = blast.life / 0.65; + context.fillStyle = '#ff8a2433'; context.strokeStyle = '#ffc273'; context.lineWidth = 4; + context.beginPath(); context.arc(blast.x, blast.y, reducedMotion ? blast.radius : blast.radius * Math.min(1, (0.65 - blast.life) * 5), 0, Math.PI * 2); + context.fill(); context.stroke(); context.restore(); + } + } + + return { drawShip, drawPilotName, drawOrdnance }; +} diff --git a/assets/space-egg/round.js b/assets/space-egg/round.js new file mode 100644 index 0000000..3cf05d5 --- /dev/null +++ b/assets/space-egg/round.js @@ -0,0 +1,22 @@ +export const ROUND_SECONDS = 120; + +export function roundWinners(players) { + if (!players.length) return []; + const best = Math.max(...players.map(player => player.kills)); + return players.filter(player => player.kills === best).map(player => player.id); +} + +export function clockLabel(seconds) { + const whole = Math.max(0, Math.ceil(seconds)); + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`; +} + +export function validRoundSnapshot(data) { + return Boolean(data && Number.isSafeInteger(data.serial) && data.serial >= 0 + && Number.isFinite(data.remaining) && data.remaining >= 0 && data.remaining <= ROUND_SECONDS + && typeof data.started === 'boolean' && typeof data.ended === 'boolean' + && Array.isArray(data.players) && data.players.length <= 100 + && data.players.every(player => player && typeof player.id === 'string' + && Number.isSafeInteger(player.kills) && player.kills >= 0 + && Number.isSafeInteger(player.deaths) && player.deaths >= 0)); +} diff --git a/assets/space-egg/scoring.js b/assets/space-egg/scoring.js new file mode 100644 index 0000000..5b0ffd2 --- /dev/null +++ b/assets/space-egg/scoring.js @@ -0,0 +1,8 @@ +export function hitReward(base, previousHit, now, previousCombo) { + const combo = now - previousHit <= 2 ? Math.min(5, previousCombo + 1) : 1; + return { combo, points: base * combo }; +} + +export function pageReward(targetCount, seconds) { + return Math.max(0, Math.round(targetCount * 75 - seconds * 15)); +} diff --git a/assets/space-egg/sound.js b/assets/space-egg/sound.js new file mode 100644 index 0000000..fec1ded --- /dev/null +++ b/assets/space-egg/sound.js @@ -0,0 +1,103 @@ +export function createSound() { + let context; + let master; + let engine; + let engineGain; + let noise; + let enabled = true; + + function init() { + if (context) { + context.resume().catch(() => {}); + return; + } + try { + context = new AudioContext(); + master = context.createGain(); + master.gain.value = enabled ? 0.22 : 0; + master.connect(context.destination); + engine = context.createOscillator(); + engine.type = 'triangle'; + engine.frequency.value = 65; + engineGain = context.createGain(); + engineGain.gain.value = 0; + engine.connect(engineGain).connect(master); + engine.start(); + noise = context.createBuffer(1, context.sampleRate * 0.3, context.sampleRate); + const samples = noise.getChannelData(0); + for (let i = 0; i < samples.length; i++) samples[i] = Math.random() * 2 - 1; + context.resume().catch(() => {}); + } catch { + // The game remains playable when audio is unavailable. + context?.close().catch(() => {}); + context = null; + } + } + + function tone(from, to, duration, type = 'sine', delay = 0) { + if (!context || !enabled) return; + const time = context.currentTime + delay; + const osc = context.createOscillator(); + const gain = context.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(from, time); + osc.frequency.exponentialRampToValueAtTime(to, time + duration); + gain.gain.setValueAtTime(0.0001, time); + gain.gain.exponentialRampToValueAtTime(0.28, time + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, time + duration); + osc.connect(gain).connect(master); + osc.start(time); + osc.stop(time + duration); + osc.onended = () => { osc.disconnect(); gain.disconnect(); }; + } + + return { + init, + launch() { + [220, 330, 440, 660].forEach((note, i) => tone(note, note * 1.01, 0.22, 'triangle', i * 0.09)); + }, + powerup() { [440, 660, 880].forEach((note, i) => tone(note, note * 1.1, 0.18, 'sine', i * 0.07)); }, + deploy(kind) { tone(kind === 'mine' ? 600 : 180, kind === 'mine' ? 300 : 600, 0.15, 'triangle'); }, + chip() { tone(260, 90, 0.08, 'triangle'); }, + damage() { tone(160, 55, 0.18, 'sawtooth'); }, + roomEffect(kind) { + if (kind === 'gravity') { + tone(520, 45, 0.8, 'sine'); + tone(180, 55, 0.6, 'triangle', 0.12); + } else { + tone(180, 1100, 0.5, 'triangle'); + tone(350, 1600, 0.35, 'sine', 0.1); + } + }, + crash() { this.explode(); tone(80, 24, 0.7, 'sawtooth'); tone(160, 35, 0.5, 'triangle', 0.08); }, + roundEnd() { [660, 550, 440, 880].forEach((note, i) => tone(note, note, 0.3, 'triangle', i * 0.16)); }, + effectEnded() { tone(440, 330, 0.18, 'sine'); tone(330, 220, 0.18, 'sine', 0.16); }, + shoot() { tone(950, 140, 0.12, 'triangle'); }, + explode() { + if (!context || !enabled) return; + const source = context.createBufferSource(); + const filter = context.createBiquadFilter(); + const gain = context.createGain(); + const now = context.currentTime; + source.buffer = noise; + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(2400, now); + filter.frequency.exponentialRampToValueAtTime(80, now + 0.28); + gain.gain.setValueAtTime(0.5, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.28); + source.connect(filter).connect(gain).connect(master); + source.start(); + source.onended = () => { source.disconnect(); filter.disconnect(); gain.disconnect(); }; + tone(100, 35, 0.24); + }, + thrust(value) { + if (context) engineGain.gain.setTargetAtTime(value ? 0.13 : 0, context.currentTime, 0.05); + }, + toggle() { + enabled = !enabled; + if (context) master.gain.setTargetAtTime(enabled ? 0.22 : 0, context.currentTime, 0.02); + return enabled; + }, + close() { if (context) { engine?.stop(); context.close().catch(() => {}); } }, + }; +} diff --git a/assets/space-egg/targets.js b/assets/space-egg/targets.js new file mode 100644 index 0000000..65264f0 --- /dev/null +++ b/assets/space-egg/targets.js @@ -0,0 +1,10 @@ +export function targetStrength(tagName) { + if (tagName === 'IMG' || tagName === 'H1') return 4; + if (tagName === 'H2') return 3; + if (['H3', 'H4', 'P', 'LI'].includes(tagName)) return 2; + return 1; +} + +export function damageEdge(remaining, maximum) { + return 100 - (1 - remaining / maximum) * 36; +} diff --git a/assets/space-egg/trigger.js b/assets/space-egg/trigger.js new file mode 100644 index 0000000..f9c00ce --- /dev/null +++ b/assets/space-egg/trigger.js @@ -0,0 +1,71 @@ +console.info('We’re hiring. Nyfiken på vad som finns under huven? Det gillar vi. https://athega.se/jobba/'); +console.info('Psst … rymden väntar. Fem snabba klick på loggan på startsidan, eller ↑ ↑ ↓ ↓ ← → ← → B A.'); + +const sequence = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'KeyB', 'KeyA']; +let entered = []; +let lastKeyTime = 0; +let clicks = []; +let active = false; + +async function reveal(invitedPeerId = null) { + if (active) return; + active = true; + entered = []; + clicks = []; + const controller = new AbortController(); + const cancel = event => { if (event.key === 'Escape') controller.abort(); }; + window.addEventListener('keydown', cancel); + try { + const { openGame } = await import('./game.js?v=14'); + controller.signal.throwIfAborted(); + await openGame(() => { active = false; }, invitedPeerId, controller.signal); + } catch (error) { + active = false; + if (!controller.signal.aborted) console.warn('Rymdskeppet kunde inte starta.', error); + } finally { + window.removeEventListener('keydown', cancel); + } +} + +document.addEventListener('keydown', event => { + if (active || event.repeat || event.ctrlKey || event.metaKey || event.altKey + || event.target.closest('input, textarea, select, [contenteditable]')) return; + const now = performance.now(); + if (now - lastKeyTime > 2000) entered = []; + lastKeyTime = now; + entered.push(event.code); + while (!entered.every((code, index) => code === sequence[index])) entered.shift(); + // Once ↑ ↑ is followed by the next code key, keep the page still. + if (entered.length >= 3) event.preventDefault(); + if (entered.join(',') === sequence.join(',')) { + event.preventDefault(); + reveal(); + } +}); + +document.querySelector('.brand')?.addEventListener('click', event => { + // A normal logo click still takes visitors home from other pages. + if (location.pathname !== '/' || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + // Preserve the normal homepage-logo action while counting secret clicks. + window.scrollTo({ top: 0, behavior: 'instant' }); + const now = performance.now(); + clicks = clicks.filter(time => now - time < 2200); + clicks.push(now); + if (clicks.length >= 5) reveal(); +}); + +// Reading a link is cheap; the game and PeerJS are still loaded on demand. +function invitationFromHash() { + const id = new URLSearchParams(location.hash.slice(1)).get('space'); + if (!/^athega-space-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id || '')) return; + if (!active && location.pathname !== '/') { + location.replace('/' + location.hash); + return; + } + history.replaceState(history.state, '', location.pathname + location.search); + if (active) window.dispatchEvent(new CustomEvent('athega-space-invite', { detail: id })); + else reveal(id); +} +window.addEventListener('hashchange', invitationFromHash); +invitationFromHash(); diff --git a/assets/space-egg/view.js b/assets/space-egg/view.js new file mode 100644 index 0000000..4bbd172 --- /dev/null +++ b/assets/space-egg/view.js @@ -0,0 +1,90 @@ +export function gameMarkup() { + return ` + +
+ +
+
Athega / Space000002:000 kills · 0 dödsfall×1 · 0.0 sSoloflygning
    Skrov 100Redo
    +
    +
    + +
    + Hemligt uppdrag / 001 +

    Vi gillar att
    bryta ny mark.

    +

    Men du får börja med den här sidan. Ta kontroll över skeppet och skjut layouten i småbitar.

    +

    Dator: piltangenter eller W A S D + mellanslag.
    N: navigationsskott – träffa en intern länk.
    Mobil: styrspak, ELD och NAV.
    Plocka upp MIN/BOM. M: lägg mina · B: släpp bomb (1 sekund).
    Din mina skadar aldrig dig. Din bomb kan göra det.
    Powerups: 3X trippelskott · RF snabbeld · SK sköld · + reparation · T turbo · G↓ gravitation · S↕ snabbscroll för alla.
    Täta träffar ger upp till ×5. Rensa sidan snabbt för tidsbonus.
    Esc eller Avsluta återställer allt.

    + +
    + + + +
    `; +} + +export const gameTheme = ` + body[data-space-dark] { --paper: #111b26; --ink: #e5edf5; --muted: #b2c0ce; --line: #344452; --line-strong: #536b80; --neutral: #1b2a39; --on-dark-muted: #c5d3e1; background: radial-gradient(ellipse at 80% 15%, #163a5366, transparent 55%), radial-gradient(ellipse at 10% 80%, #37205255, transparent 60%), #050b14; background-attachment: fixed; color: var(--ink); } + body[data-space-dark] .site-header { background: #0d1620ed; border-color: #344452; } + body[data-space-dark] .page-hero { background: transparent; } + body[data-space-dark] :is(.dark-section, .unified-callout, .article-body pre) { background: #080f17; } + `; + +// Do not make the real site inert until the game's controls are styled and usable. +export function waitForStyles(link, signal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => finish(new Error('Spelstilarna kunde inte laddas.')), 10000); + function finish(error) { + clearTimeout(timer); + link.onload = link.onerror = null; + signal?.removeEventListener('abort', abort); + if (error) reject(error); else resolve(); + } + function abort() { finish(signal.reason); } + link.onload = () => finish(); + link.onerror = () => finish(new Error('Spelstilarna kunde inte laddas.')); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + else if (link.sheet) finish(); + }); +} diff --git a/docs/space-egg/README.md b/docs/space-egg/README.md new file mode 100644 index 0000000..d584f4f --- /dev/null +++ b/docs/space-egg/README.md @@ -0,0 +1,176 @@ +# Athega Space + +Fem snabba klick (inom 2,2 sekunder) på startsidans logga, eller Konamikoden +`↑ ↑ ↓ ↓ ← → ← → B A` på valfri sida, öppnar Athega Space. Bara den lilla +triggern laddas normalt; spel, ljud och CSS laddas vid aktivering. PeerJS laddas först +när spelaren väljer Skapa rum eller Anslut. + +## Kontroller och poäng + +- Pilar/WASD styr skeppet, mellanslag skjuter och N skjuter blå navigationsskott. +- Mobilen har styrspak, ELD och NAV. Meny-knappen i spelet öppnar mobilmenyn. +- Vanlig eld förstör även menyn. Navigationsskott följer interna sidlänkar; + externa länkar, e-post, nedladdningar och bloggdemon navigeras inte. +- Byt uppdrag finns alltid kvar, även om alla sidans länkar är förstörda. + Sidan scrollar långsamt ned och upp (18 px/s) och vänder vid kanterna. + Värden kan pausa scrollen. En helt rensad sida öppnar uppdragsväljaren. +- Länkar och små mål försvinner på en träff, textblock på två, H2-rubriker på tre + och huvudrubriker/bilder på fyra. Skadan syns som urtag, toning och splitter. + Poäng och powerups ges först när objektet är helt förstört. +- Täta träffar inom två sekunder bygger upp till ×5 kombopoäng. Snabbare + sidrensning ger större tidsbonus (75 per mål minus 15 per sekund, minst 0). +- Var tredje förstört objekt släpper en powerup. Flyg nära för att plocka upp den: + 3X/trippelskott och RF/snabbeld (12 s), SK/sköld och T/turbo (10 s), + +/reparation (+40 skrov), G↓/gravitation och S↕/snabbscroll (8 s). + Gravitation och snabbscroll påverkar hela rummet; snabbscroll ökar till 72 px/s. + Alla får olika startsignaler för effekterna och en kort slutsignal. Ljud av gäller + även dessa signaler. Skeppsträffar visar ljusblixt, träffring och skada/skydd. + Övriga powerups gäller den egna piloten. +- Under **Spelregler** kan värden slå på friendly fire och kollisionsskador. + Båda är av från början och kan ändras under spelet. Skepp studsar även mot + varandra när kollisionsregeln är på; sköld skyddar mot skada men stoppar inte + studsen. Skeppet har 100 skrov; + skott ger 20 skada och kollisioner 25. Kort skydd efter träff förhindrar skada + varje bildruta. Vid noll skrov återkommer skeppet efter 3 s med 3 s skydd. +- Mörkt/Ljust växlar tillfälligt spelläge. Mörkt läge har stjärnor och nebulosa. + Ljud kan stängas av. Reducerad rörelse ger stilla stjärnor, enklare toning och + färre partiklar utan explosionsringar. +- Esc/Avsluta återställer sidan. Efter virtuella sidbyten laddas den aktuella + destinationen om till den vanliga sajten, med rätt metadata och normala skript. + + +## Minor och bomber + +Plocka upp **MIN** eller **BOM** från förstörda objekt. **M** placerar en mina, +**B** släpper en bomb; samma knappar finns på skärmen för touch. +Du kan bära tre av varje och ha högst tre av varje utplacerade samtidigt. + +- Minan aktiveras efter 0,6 sekunder och exploderar när ett annat levande skepp + kommer inom 60 pixlar. Ägaren kan varken utlösa minan eller skadas av den. +- Bomben exploderar efter en sekund och kan skada även den som släppte den. +- Friendly fire styr skadan på andra piloter. Sköld och respawnskydd gäller. +- Minor ger 60 skada inom 95 pixlar och bomber 80 inom 150 pixlar. + Sidobjekt i explosionen tar två respektive tre träffar. +- Värden bestämmer ammunition, utplacering, utlösning och träffade piloter. + Alla ser explosionerna. Självsprängning ger ingen kill. +- Ammunitionen försvinner vid krasch. Utplacerade vapen försvinner vid sidbyte, + och en ny rond nollställer både ammunition och utplacerade vapen. + +Vanliga besökare laddar bara den lilla `trigger.js`, som också skriver en diskret +ledtråd i konsolen. Spelkod, spelstilar och ljud skapas först vid aktivering. +PeerJS laddas först när man skapar eller ansluter till ett rum. + +## Ronder och vinnare + +En rond varar **120 sekunder** från start, även över sidbyten och öppna menyer. +Flest kills vinner; lika många ger delad seger. En kill ges till skytten när +kompisskeppets skrov når noll. Krasch mot sidan eller ett annat skepp ger ett +dödsfall men ingen kill. Skeppets explosion syns och hörs hos alla. +Värden samordnar kills, dödsfall och slutresultat. Vid 0:00 stannar spelet och +visar resultatlistan. Värden kan sedan ta alla tillbaka till lobbyn för en ny rond; +skrov, objektskador, powerups och poäng återställs. Solo har också en tvåminutersrond. + +## Upp till fyra spelare med en inbjudningslänk + +1. Aktivera spelet och välj **Spela tillsammans**. Skriv ett valfritt pilotnamn. +2. Välj **Skapa rum**, sedan **Kopiera inbjudningslänk**. Skicka samma länk till + upp till tre kompisar och håll spelet öppet. +3. Kompisarna öppnar länken, skriver sina pilotnamn och väljer **Anslut**. + Alla väntar i lobbyn tills värden klickar **Starta spelet**. En sen anslutning + till en redan startad rond följer den pågående matchen. + Ingen svarskod behövs. Lobbyn visar besättningen, värden och lediga platser. + Namnen visas också vid skeppen och i spelarlistan. +4. Inbjudningslänken använder alltid startsidan. Efter anslutning följer gästen + värdens aktuella sida, även om rummet skapades på exempelvis bloggarkivet. + Äldre länkar med en undersida normaliseras till lobbyn automatiskt. + +Alla behöver samma version av sajten på samma origin (protokoll, värd och port). +Lokalt kan flera fönster använda samma localhost-adress; den adressen fungerar +inte på kompisens dator. För spel mellan datorer behöver sajten vara publicerad. + +PeerJS 1.5.5 serveras från sajten och använder kostnadsfria PeerJS Cloud för att +koppla ihop spelarna. Inbjudan öppnar spellobbyn, men varken PeerJS eller +anslutningstjänsten kontaktas förrän spelaren väljer Skapa rum eller Anslut. +STUN (`stun.cloudflare.com:3478`) hjälper webbläsarna hitta en nätverksväg. +WebRTC DataChannel skickar speldata mellan webbläsarna; värden vidarebefordrar +kompisarnas positioner och skott. Ingen kamera eller mikrofon används. +Det egna pilotnamnet sparas i webbläsarens localStorage och fylls i vid nästa +spel eller inbjudan. Töm namnfältet för att glömma det sparade namnet. Om lagring +är blockerad går det fortfarande att spela. Namnet delas med deltagarna och +ingår också i signaleringen. + +Det finns ingen TURN-reläserver: vissa företagsnät och mobilnät kan därför inte +koppla ihop sig. Vid misslyckad anslutning går det att försöka igen eller byta +nätverk. Inbjudan gäller medan värden är ansluten. När en gäst lämnar frigörs +platsen; om värden lämnar fortsätter övriga i sololäge. + +Värden avgör gemensamma träffar och poäng och synkroniserar förstörda objekt med +stabila index plus en innehållssignatur. Alla spelar i en gemensam layout på +1280 × 800. På mindre skärmar följer kameran skeppet, medan kontrollerna behåller +sin storlek. Radar och kantmarkörer visar kompisar utanför bilden. Den vanliga +sidan ligger kvar orörd bakom spelvyn och återkommer när spelet avslutas. +Värden styr scroll, regler och gemensamma powerup-effekter. Skeppsträffar skickas +via värden, som kontrollerar skottets ägare, livslängd och friendly fire-regeln. +Den träffade klienten tillämpar sköld/skrov och skickar träffeffekten till alla; +sidkollisioner räknas lokalt i den gemensamma geometrin. +Sidbyten hämtar statiskt sidinnehåll utan att starta om spelet eller WebRTC. +Detta är en lekfull co-op-prototyp, inte ett tävlingsläge med skydd mot fusk. + +## Kodens ansvar och isolering + +Vanliga sidan inkluderar endast `trigger.js`. Den känner igen aktivering och +skriver konsolhälsningen. Inga spelstilar, ljud, iframe, PeerJS eller +signalering laddas innan aktivering. Escape kan avbryta en pågående uppstart. +Misslyckad uppstart städar upp det som skapats och lämnar sidan användbar. + +| Modul | Ansvar | +| --- | --- | +| `trigger.js` | Aktivering, inbjudningsfragment och dynamisk import | +| `arena.js` | Separat sandboxad iframe och gemensam spelgeometri | +| `view.js`, `game.css` | Shadow DOM-gränssnitt och speltema | +| `rendering.js`, `sound.js` | Ritfunktioner och syntetiserat ljud | +| `game.js` | Spelloop, lokal pilot, sidobjekt och samordning av livscykeln | +| `multiplayer.js` | PeerJS, anslutningar, besättning och vidarebefordran | +| `protocol.js` | Tillåtna meddelanderiktningar och matchning av rond/sidrevision | +| `ordnance.js` | Ammunition, minor, bomber och värdens explosionsbeslut | +| `physics.js`, `combat.js`, `targets.js`, `round.js`, `scoring.js` | Spelregler och beräkningar | +| `navigation.js`, `invitation.js` | Tillåtna sidlänkar och inbjudningar | + +Värden beslutar om sidobjekt, gemensamma effekter, ammunition och rondresultat. +Varje klient simulerar sitt skepp och tillämpar skrov/sköld. Transporten skiljer +piloternas anrop från värdens beslut. Varje spelmeddelande har rondnummer, +sidrevision och URL, så gamla träffar inte påverkar en ny rond eller ett återbesök. +En ny anslutning ogiltigförklarar köade meddelanden från föregående anslutning. + +Positioner får hoppas över vid köbildning. Skador, explosioner och övergångar +får inte tappas tyst; vid för stor kö stängs anslutningen i stället. Menyer +släpper styrningen men pausar inte den pågående matchen. Tidbonus, skydd och +respawn räknar verklig tid. Håll värdens flik aktiv: webbläsarens begränsning av +bakgrundsflikar kan ändå bromsa fysik och scroll. Rondklockan fortsätter gå. + +Den vanliga sidans DOM förstörs aldrig. Spelskalet gör den tillfälligt inert; +städningen återställer tidigare inert-värden, fokus och scroll, tar bort iframe +samt avslutar händelselyssnare, timers, ljud och nätverksanslutningar. +Den äldre `assets/site.js` används inte. Vid ändring av redan publicerade +spelmoduler behöver versionsparametrarna på deras import-/resurslänkar uppdateras. + +## Regressionstester + +`npm test` kör spelregler och transporttester med en lokal PeerJS-testdubbel, +utan kontakt med signaleringstjänsten. `npm run build`, `npm run check` och +`npm run check:urls` kontrollerar den statiska sajten. + +Två valfria browsertester ligger i `scripts/`. Installera Python-paketet +`playwright` och Chromium i en separat testmiljö. Kör mot en redan startad server: + +```sh +python scripts/space-browser-smoke.py http://localhost:8082/ +python scripts/space-browser-multiplayer.py http://localhost:8082/ +``` + +Smoke-testet kontrollerar mobil/desktop, lazy loading, upprepad öppning/stängning +och misslyckad CSS-laddning. Multiplayer-testet använder riktiga WebRTC-klienter +och kräver tillgång till PeerJS Cloud. Det kontrollerar väntelobby, gamla +rond-/sidmeddelanden, navigering, kills, vinnare och omstart. Testkrokar injiceras +endast i webbläsarens testsvar; de ingår inte i publicerad spelkod. +`PLAYWRIGHT_CHROMIUM_EXECUTABLE` kan ange en befintlig Chromium-installation. diff --git a/eleventy.config.js b/eleventy.config.js index 3c4b8e6..c911bb9 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -82,6 +82,11 @@ export default function(eleventyConfig) { // Kopiera assets eleventyConfig.addPassthroughCopy("assets"); + // Serve the pinned browser bundle locally; only multiplayer loads it. + eleventyConfig.addPassthroughCopy({ + "node_modules/peerjs/dist/peerjs.min.js": "assets/space-egg/vendor/peerjs-1.5.5.min.js", + "node_modules/peerjs/LICENSE": "assets/space-egg/vendor/peerjs-LICENSE.txt", + }); eleventyConfig.addPassthroughCopy("favicon.ico"); // Kopiera bilder från innehållsmappar @@ -108,6 +113,7 @@ export default function(eleventyConfig) { // Ignorera dokumentationsfiler (ska inte generera sidor) eleventyConfig.ignores.add("README.md"); + eleventyConfig.ignores.add("docs/space-egg/README.md"); eleventyConfig.ignores.add("CLAUDE.md"); eleventyConfig.ignores.add("AGENTS.md"); diff --git a/package-lock.json b/package-lock.json index 048072f..15c5f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "devDependencies": { "@11ty/eleventy": "^3.0.0", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "peerjs": "1.5.5", "sass": "^1.77.0" }, "engines": { @@ -213,6 +214,16 @@ "node": ">=18" } }, + "node_modules/@msgpack/msgpack": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", + "integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -983,6 +994,13 @@ "node": ">= 8" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -1688,6 +1706,40 @@ "node": ">= 0.8" } }, + "node_modules/peerjs": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.5.5.tgz", + "integrity": "sha512-viMUCPDL6CSfOu0ZqVcFqbWRXNHIbv2lPqNbrBIjbFYrflebOjItJ4hPfhjnuUCstqciHVu9vVJ7jFqqKi/EuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^2.8.0", + "eventemitter3": "^4.0.7", + "peerjs-js-binarypack": "^2.1.0", + "webrtc-adapter": "^9.0.0" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/peer" + } + }, + "node_modules/peerjs-js-binarypack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-2.1.0.tgz", + "integrity": "sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/peer" + } + }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -1878,6 +1930,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "dev": true, + "license": "MIT" + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -2080,6 +2139,20 @@ "dev": true, "license": "MIT" }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/package.json b/package.json index f3f0608..5b601da 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@11ty/eleventy": "^3.0.0", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "peerjs": "1.5.5", "sass": "^1.77.0" } } diff --git a/scripts/space-arena.test.mjs b/scripts/space-arena.test.mjs new file mode 100644 index 0000000..4b25ead --- /dev/null +++ b/scripts/space-arena.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cameraFor, WORLD_WIDTH, WORLD_HEIGHT } from '../assets/space-egg/arena.js'; + +test('desktop sees the shared world with centered margins', () => { + const camera = cameraFor(1440, 1000, { x: 640, y: 400 }); + assert.equal(camera.x, 0); + assert.equal(camera.y, 0); + assert.equal(camera.left, 80); + assert.equal(camera.top, 100); + assert.equal(camera.visibleWidth, WORLD_WIDTH); + assert.equal(camera.visibleHeight, WORLD_HEIGHT); +}); +test('portrait camera follows the pilot and stops at world edges', () => { + const middle = cameraFor(390, 844, { x: 640, y: 400 }); + assert.equal(middle.x, 445); + assert.equal(640 * middle.scale + middle.left, 195); + assert.equal(cameraFor(390, 844, { x: 0, y: 0 }).x, 0); + assert.equal(cameraFor(390, 844, { x: 1280, y: 800 }).x, 890); +}); +test('landscape camera crops vertically without changing world dimensions', () => { + const camera = cameraFor(844, 390, { x: 640, y: 400 }); + assert.equal(camera.visibleWidth, WORLD_WIDTH); + assert.ok(camera.visibleHeight < WORLD_HEIGHT); + assert.ok(Math.abs(400 * camera.scale + camera.top - 195) < 0.01); +}); diff --git a/scripts/space-browser-multiplayer.py b/scripts/space-browser-multiplayer.py new file mode 100644 index 0000000..d6d41e3 --- /dev/null +++ b/scripts/space-browser-multiplayer.py @@ -0,0 +1,102 @@ +"""Live two-player regression. Requires Playwright, a local build server and PeerJS Cloud access. +Run like space-browser-smoke.py; hooks are injected in browser routes, never shipped. +""" +from pathlib import Path +from playwright.sync_api import sync_playwright +import os +import sys +BASE = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8080/' +source = (Path(__file__).resolve().parent.parent / 'assets/space-egg/game.js').read_text() +hook = "let crashCount=0;const crash=sound.crash;sound.crash=function(){crashCount++;return crash.call(sound)};window.__test={ship,fire,send:message=>network.send(message),navigate:url=>navigateTo({destination:new URL(url,currentUrl)}),target:()=>({id:targets.findIndex(n=>n.tagName==='H1'),health:targetHealth.get(targets.find(n=>n.tagName==='H1'))||4}),state:()=>({hull,respawn,roomStarted,roundEnded,roundRemaining,roundDeadline,roundSerial,pageRevision,currentUrl,stats:[...roundStats.values()],crashCount}),vulnerable:()=>{invulnerable=0},expire:()=>{roundDeadline=performance.now()+50}};" +source = source.replace(' if (invitedPeerId) showLobby(invitedPeerId);', hook + ' if (invitedPeerId) showLobby(invitedPeerId);') +with sync_playwright() as p: + b = p.chromium.launch(executable_path=os.getenv('PLAYWRIGHT_CHROMIUM_EXECUTABLE'), args=['--disable-background-timer-throttling', '--disable-renderer-backgrounding']) + errors = [] + + def page(): + c = b.new_context(viewport={'width': 1440, 'height': 1000}) + c.route('**/space-egg/game.js?*', lambda r: r.fulfill(status=200, body=source, content_type='application/javascript')) + g = c.new_page() + g.on('pageerror', lambda e: errors.append(str(e))) + return g + h = page() + h.goto(BASE) + for k in ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']: + h.keyboard.press(k) + h.locator('.multiplayer').click() + h.locator('.pilot-name').fill('Chrille') + h.locator('.rules summary').click() + h.locator('.friendly-fire').check() + h.locator('.host-game').click() + h.wait_for_function("document.querySelector('#athega-space-game').shadowRoot.querySelector('.outgoing-link').value.length>0", timeout=25000) + link = h.locator('.outgoing-link').input_value() + g = page() + g.goto(link) + g.locator('.pilot-name').fill('Mats') + g.locator('.join-game').click() + try: + g.locator('.connection-status').filter(has_text='2 / 4').wait_for(timeout=35000) + except Exception: + print('HOST:', h.locator('.lobby-status').inner_text(), flush=True) + print('GUEST:', g.locator('.lobby-status').inner_text(), flush=True) + print('BROWSER ERRORS:', errors, flush=True) + raise + assert g.locator('.lobby').is_visible() + h.locator('.start-room').click() + g.locator('.lobby').wait_for(state='hidden') + h.locator('.scroll-toggle').click() + assert 117 < g.evaluate('window.__test.state().roundRemaining') <= 120 + h.evaluate("()=>{const s=window.__test.state();window.__test.send({type:'target-damage',id:window.__test.target().id,remaining:1,page:s.currentUrl,round:s.roundSerial-1,revision:s.pageRevision})}") + g.wait_for_timeout(150) + assert g.evaluate('window.__test.target().health') == 4 + h.evaluate('Object.assign(window.__test.ship,{vx:100,vy:0})') + h.locator('.multiplayer').click() + before = h.evaluate('window.__test.ship.x') + h.wait_for_timeout(250) + assert h.evaluate('window.__test.ship.x') > before + h.locator('.lobby-close').click() + h.evaluate("window.__test.navigate('/blogg/')") + g.wait_for_function("window.__test.state().currentUrl.includes('/blogg/')") + h.evaluate("window.__test.navigate('/')") + g.wait_for_function("new URL(window.__test.state().currentUrl).pathname==='/'") + h.evaluate("()=>{const s=window.__test.state();window.__test.send({type:'target-damage',id:window.__test.target().id,remaining:1,page:s.currentUrl,round:s.roundSerial,revision:s.pageRevision-1})}") + g.wait_for_timeout(150) + assert g.evaluate('window.__test.target().health') == 4 + print('OLD ROUND/PAGE MESSAGES IGNORED; MENUS KEEP SIMULATING; PAGE NAVIGATION SYNCS', flush=True) + h.evaluate('Object.assign(window.__test.ship,{x:300,y:60,vx:0,vy:0,angle:0})') + g.evaluate('Object.assign(window.__test.ship,{x:440,y:60,vx:0,vy:0,angle:0})') + g.wait_for_timeout(200) + for i in range(5): + g.evaluate('window.__test.vulnerable()') + h.evaluate('window.__test.fire()') + g.wait_for_timeout(400) + assert g.evaluate('window.__test.state().hull') == 100 - 20 * (i + 1), g.evaluate('window.__test.state()') + for x in [h, g]: + stats = x.evaluate('window.__test.state().stats') + assert next((s for s in stats if s['name'] == 'Chrille'))['kills'] == 1, stats + assert next((s for s in stats if s['name'] == 'Mats'))['deaths'] == 1, stats + assert x.evaluate('window.__test.state().crashCount') == 1 + print('ONE KILL, ONE DEATH AND CRASH AUDIO ON BOTH', flush=True) + h.evaluate('window.__test.expire()') + h.locator('.results').wait_for() + g.locator('.results').wait_for() + assert h.locator('.result-title').inner_text() == 'Chrille vinner!' + assert g.locator('.result-title').inner_text() == 'Chrille vinner!' + assert g.locator('.rematch').is_hidden() + print('SAME WINNER AT TIME LIMIT', flush=True) + h.screenshot(path='/tmp/athega-round-results.png') + h.locator('.rematch').click() + g.locator('.lobby').wait_for() + assert g.locator('.results').is_hidden() + assert g.evaluate('window.__test.state().hull') == 100 + assert all((s['kills'] == 0 for s in g.evaluate('window.__test.state().stats'))) + g.wait_for_timeout(1200) + assert g.evaluate('window.__test.state().roundDeadline') == 0 + assert g.evaluate('window.__test.state().roundRemaining') == 120 + h.locator('.start-room').click() + g.locator('.lobby').wait_for(state='hidden') + assert not g.evaluate('window.__test.state().roundEnded') + print('REMATCH RESETS AND STARTS TOGETHER', flush=True) + assert not errors, errors + print('NO ERRORS', flush=True) + b.close() diff --git a/scripts/space-browser-smoke.py b/scripts/space-browser-smoke.py new file mode 100644 index 0000000..461f06a --- /dev/null +++ b/scripts/space-browser-smoke.py @@ -0,0 +1,76 @@ +"""Optional browser regression: pip install playwright && playwright install chromium. +Run against npm start, e.g. python scripts/space-browser-smoke.py http://localhost:8082/ +PLAYWRIGHT_CHROMIUM_EXECUTABLE can select an existing Chromium installation. +""" +import os +import sys +from playwright.sync_api import sync_playwright + +BASE = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8080/' +CODE = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a'] + +def activate(page): + for key in CODE: + page.keyboard.press(key) + +with sync_playwright() as playwright: + options = {'headless': True} + if os.getenv('PLAYWRIGHT_CHROMIUM_EXECUTABLE'): + options['executable_path'] = os.environ['PLAYWRIGHT_CHROMIUM_EXECUTABLE'] + browser = playwright.chromium.launch(**options) + errors = [] + for viewport in [{'width': 1440, 'height': 1000}, {'width': 390, 'height': 844}]: + page = browser.new_page(viewport=viewport) + requests = [] + page.on('request', lambda request: requests.append(request.url)) + page.on('pageerror', lambda error: errors.append(str(error))) + page.goto(BASE) + page.wait_for_timeout(250) + assets = [url for url in requests if '/space-egg/' in url] + assert len(assets) == 1 and 'trigger.js?' in assets[0], assets + original = page.locator('main').inner_html() + for _ in range(2): + activate(page) + page.locator('.launch').click() + page.locator('.lay-mine').wait_for() + assert not any('peerjs' in url.lower() for url in requests) + page.keyboard.press('Escape') + page.locator('#athega-space-game').wait_for(state='detached') + assert page.locator('main').inner_html() == original + assert page.locator('[inert]').count() == 0 + assert page.locator('#athega-space-arena').count() == 0 + page.close() + print('PASS: desktop/mobile, lazy assets, repeat open/close, original DOM restored') + + page = browser.new_page() + page.on('pageerror', lambda error: errors.append(str(error))) + page.route('**/space-egg/game.css?*', lambda route: route.abort()) + page.goto(BASE) + with page.expect_event('console', predicate=lambda message: 'Rymdskeppet kunde inte starta.' in message.text): + activate(page) + page.wait_for_function("document.querySelector('#athega-space-game') === null && document.querySelector('#athega-space-arena') === null") + assert page.locator('[inert]').count() == 0 + page.unroute('**/space-egg/game.css?*') + activate(page) + page.locator('.launch').click() + page.keyboard.press('Escape') + page.locator('#athega-space-game').wait_for(state='detached') + print('PASS: failed CSS load releases page and allows retry') + # Hold the initial arena fetch to verify Escape can cancel startup. + pending = [] + page.route(BASE, lambda route: pending.append(route) if route.request.resource_type == 'fetch' else route.continue_()) + with page.expect_request(lambda request: request.resource_type == 'fetch' and request.url == BASE): + activate(page) + page.keyboard.press('Escape') + for route in pending: + route.abort() + page.unroute(BASE) + page.wait_for_timeout(100) + assert page.locator('[inert]').count() == 0 + activate(page) + page.locator('.launch').click() + page.keyboard.press('Escape') + page.locator('#athega-space-game').wait_for(state='detached') + print('PASS: Escape cancels startup and permits a fresh attempt') + assert not errors, errors + browser.close() diff --git a/scripts/space-combat.test.mjs b/scripts/space-combat.test.mjs new file mode 100644 index 0000000..57b5e05 --- /dev/null +++ b/scripts/space-combat.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { damageHull, expandedRect, effectSeconds, POWERUPS, advanceScroll } from '../assets/space-egg/combat.js'; +import { hitsRect } from '../assets/space-egg/physics.js'; + +test('damage respects protection and never leaves hull below zero', () => { + assert.equal(damageHull(100, 25, false), 75); + assert.equal(damageHull(10, 20, false), 0); + assert.equal(damageHull(100, 25, true), 100); + assert.equal(damageHull(0, 25, false), 0); +}); +test('ship radius collides with thin text even at high speed', () => { + const rect = expandedRect({ left: 100, right: 150, top: 50, bottom: 52 }, 10); + assert.equal(hitsRect(90, 0, 90, 100, rect), true); + assert.equal(hitsRect(89, 0, 89, 100, rect), false); +}); +test('gravity expires in real time and duration stays bounded for joining players', () => { + assert.equal(effectSeconds(9000, 1000), 8); + assert.equal(effectSeconds(9000, 8500), 0.5); + assert.equal(effectSeconds(9000, 10000), 0); + assert.equal(effectSeconds(100000, 0), POWERUPS.gravity.duration); +}); + +test('continuous scroll reflects at both ends without losing fractional motion', () => { + assert.deepEqual(advanceScroll(99, 1, 4, 100), { position: 97, direction: -1 }); + assert.deepEqual(advanceScroll(1, -1, 4, 100), { position: 3, direction: 1 }); + assert.deepEqual(advanceScroll(0, 1, 0.3, 100), { position: 0.3, direction: 1 }); + assert.deepEqual(advanceScroll(10, 1, 10, 0), { position: 0, direction: 1 }); +}); + +test('ship contacts separate overlapping hulls with stable opposite normals', async () => { + const { shipContact } = await import('../assets/space-egg/combat.js'); + assert.equal(shipContact({ x: 0, y: 0 }, { x: 31, y: 0 }), null); + assert.deepEqual(shipContact({ x: 10, y: 0 }, { x: 0, y: 0 }), { nx: 1, ny: 0, overlap: 20 }); + assert.equal(shipContact({ x: 0, y: 0 }, { x: 10, y: 0 }).nx, -1); + assert.equal(shipContact({ x: 0, y: 0 }, { x: 0, y: 0 }, -1).nx, -1); +}); diff --git a/scripts/space-ordnance.test.mjs b/scripts/space-ordnance.test.mjs new file mode 100644 index 0000000..0235e1c --- /dev/null +++ b/scripts/space-ordnance.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createOrdnance, mineTriggered, blastVictims, blastTouches } from '../assets/space-egg/ordnance.js'; + +const pilots = [{ id: 'host', x: 100, y: 100, alive: true }, { id: 'guest', x: 120, y: 100, alive: true }]; + +test('mines arm after a delay and never trigger on their owner', () => { + const mine = { owner: 'host', x: 100, y: 100, armedAt: 600 }; + assert.equal(mineTriggered(mine, pilots, 599), false); + assert.equal(mineTriggered(mine, pilots.slice(0, 1), 700), false); + assert.equal(mineTriggered(mine, pilots, 600), true); + assert.equal(mineTriggered(mine, [{ ...pilots[1], x: 160 }], 600), true); + assert.equal(mineTriggered(mine, [{ ...pilots[1], x: 161 }], 600), false); + assert.equal(mineTriggered(mine, [pilots[0], { ...pilots[1], alive: false }], 700), false); +}); + +test('friendly fire protects others; mines protect owners but bombs can hurt them', () => { + const blast = { owner: 'host', kind: 'mine', x: 100, y: 100 }; + assert.deepEqual(blastVictims(blast, pilots, false), []); + assert.deepEqual(blastVictims(blast, pilots, true), [{ id: 'guest', damage: 60 }]); + assert.deepEqual(blastVictims({ ...blast, kind: 'bomb' }, pilots, false), [{ id: 'host', damage: 80 }]); + assert.equal(blastTouches({ left: 190, right: 300, top: 100, bottom: 110 }, blast), true); + assert.equal(blastTouches({ left: 196, right: 300, top: 100, bottom: 110 }, blast), false); +}); + +function setup() { + let clock = 0; + const sent = [], blasts = []; + const ordnance = createOrdnance({ ownId: () => 'host', authoritative: () => true, + now: () => clock, send: message => sent.push(message), onChange() {}, + onBlast: (...args) => blasts.push(args) }); + const grant = (kind, id) => { ordnance.recordDrop(id, kind); ordnance.collect(kind, id); }; + return { ordnance, sent, blasts, grant, time: value => { clock = value; } }; +} + +test('ammunition validates pickups, rejects duplicates, caps stock and is consumed', () => { + const { ordnance: o, grant } = setup(); + o.collect('mine', 99); + assert.equal(o.ammo.mine, 0); + grant('mine', 1); o.collect('mine', 1); + assert.equal(o.ammo.mine, 1); + for (let id = 2; id < 6; id++) grant('mine', id); + assert.equal(o.ammo.mine, 3); + o.deploy('mine', -1, 100); assert.equal(o.ammo.mine, 3); + for (let i = 0; i < 4; i++) o.deploy('mine', 100, 100); + assert.equal(o.hazards.length, 3); assert.equal(o.ammo.mine, 0); + grant('mine', 6); o.deploy('mine', 100, 100); + assert.equal(o.hazards.length, 3); assert.equal(o.ammo.mine, 1); + o.clearAmmo('host'); assert.equal(o.ammo.mine, 0); + o.clearField(); assert.equal(o.hazards.length, 0); +}); + +test('bombs detonate once at one second and guests never run their own fuse', () => { + const { ordnance: o, grant, time, blasts } = setup(); + grant('bomb', 1); o.deploy('bomb', 100, 100); + let guestBlasts = 0; + const guest = createOrdnance({ ownId: () => 'guest', authoritative: () => false, + now: () => 10000, send() {}, onChange() {}, onBlast() { guestBlasts++; } }); + guest.restore(o.snapshot()); guest.tick(pilots, true); + assert.equal(guestBlasts, 0); + time(999); o.tick(pilots, true); assert.equal(blasts.length, 0); + time(1000); o.tick(pilots, true); assert.equal(blasts.length, 1); + o.tick(pilots, true); assert.equal(blasts.length, 1); + assert.equal(o.hazards.length, 0); +}); + +test('blast arrives before a death callback replaces guest hazard state', () => { + let clock = 0, guestBlasts = 0; + const guest = createOrdnance({ ownId: () => 'guest', authoritative: () => false, + now: () => clock, send() {}, onChange() {}, onBlast() { guestBlasts++; } }); + const host = createOrdnance({ ownId: () => 'host', authoritative: () => true, + now: () => clock, send: message => guest.handle(message, 'host'), onChange() {}, + onBlast() { host.clearAmmo('host'); } }); + host.recordDrop(1, 'bomb'); host.collect('bomb', 1); host.deploy('bomb', 100, 100); + clock = 1000; host.tick(pilots, true); + assert.equal(guestBlasts, 1); assert.equal(guest.hazards.length, 0); +}); diff --git a/scripts/space-physics.test.mjs b/scripts/space-physics.test.mjs new file mode 100644 index 0000000..f2e5902 --- /dev/null +++ b/scripts/space-physics.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { hitsRect } from '../assets/space-egg/physics.js'; + +const rect = { left: 10, right: 40, top: 20, bottom: 24 }; +test('fast shots hit thin text even when both endpoints are outside it', () => { + assert.equal(hitsRect(25, 0, 25, 50, rect), true); + assert.equal(hitsRect(25, 50, 25, 0, rect), true); +}); +test('near misses and a target behind the shot do not collide', () => { + assert.equal(hitsRect(5, 0, 5, 50, rect), false); + assert.equal(hitsRect(25, 30, 25, 50, rect), false); + assert.equal(hitsRect(0, 0, 11, 20, rect), true); + assert.equal(hitsRect(0, 0, 11, 30, rect), false); +}); +test('shots starting inside a target and stationary points are handled', () => { + assert.equal(hitsRect(20, 22, 50, 50, rect), true); + assert.equal(hitsRect(20, 22, 20, 22, rect), true); + assert.equal(hitsRect(0, 0, 0, 0, rect), false); +}); + +test('the nearest obstacle wins regardless of DOM order and can shield a pilot', async () => { + const { firstHit } = await import('../assets/space-egg/physics.js'); + const near = { node: 'wall', rect: { left: 10, right: 15, top: 0, bottom: 10 } }; + const far = { pilot: 'pilot', rect: { left: 20, right: 25, top: 0, bottom: 10 } }; + assert.equal(firstHit(0, 5, 30, 5, [far, near]), near); + assert.equal(firstHit(30, 5, 0, 5, [near, far]), far); + assert.equal(firstHit(0, 15, 30, 15, [near, far]), null); +}); diff --git a/scripts/space-protocol.test.mjs b/scripts/space-protocol.test.mjs new file mode 100644 index 0000000..9b6d18a --- /dev/null +++ b/scripts/space-protocol.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { acceptsMessage, matchesWorld, replaceableMessage } from '../assets/space-egg/protocol.js'; + +test('only the host can publish authoritative state and damage', () => { + for (const type of ['state', 'round', 'destroy', 'pilot-damage', 'ordnance-state', 'ordnance-blast']) { + assert.equal(acceptsMessage(type, true, false), false); + assert.equal(acceptsMessage(type, false, false), false); + assert.equal(acceptsMessage(type, false, true), true); + } + assert.equal(acceptsMessage('hit', true, false), true); + assert.equal(acceptsMessage('hit', false, false), false); + assert.equal(acceptsMessage('position', false, false), true); + assert.equal(acceptsMessage('unknown', true, false), false); +}); + +test('delayed messages cannot cross rounds or repeat visits to the same page', () => { + const message = { round: 2, revision: 3, page: 'https://athega.se/' }; + assert.equal(matchesWorld(message, 2, 3, message.page), true); + assert.equal(matchesWorld(message, 3, 3, message.page), false); + assert.equal(matchesWorld(message, 2, 4, message.page), false); + assert.equal(matchesWorld(message, 2, 3, 'https://athega.se/blogg/'), false); +}); + +test('congestion can discard positions but not gameplay transitions', () => { + assert.equal(replaceableMessage('position'), true); + assert.equal(replaceableMessage('scroll'), true); + for (const type of ['shot', 'hit', 'state', 'round', 'impact', 'ordnance-blast']) assert.equal(replaceableMessage(type), false); +}); diff --git a/scripts/space-round.test.mjs b/scripts/space-round.test.mjs new file mode 100644 index 0000000..4365eea --- /dev/null +++ b/scripts/space-round.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ROUND_SECONDS, roundWinners, clockLabel } from '../assets/space-egg/round.js'; + +test('rounds last two minutes and the clock never shows negative time', () => { + assert.equal(ROUND_SECONDS, 120); + assert.equal(clockLabel(120), '2:00'); + assert.equal(clockLabel(59.1), '1:00'); + assert.equal(clockLabel(-0.1), '0:00'); +}); +test('most kills wins and ties include every tied pilot', () => { + assert.deepEqual(roundWinners([{ id: 'a', kills: 2 }, { id: 'b', kills: 1 }]), ['a']); + assert.deepEqual(roundWinners([{ id: 'a', kills: 2 }, { id: 'b', kills: 2 }, { id: 'c', kills: 0 }]), ['a', 'b']); + assert.deepEqual(roundWinners([]), []); +}); + +test('malformed round snapshots are rejected before they can change game state', async () => { + const { validRoundSnapshot } = await import('../assets/space-egg/round.js'); + const state = { serial: 1, started: false, ended: false, remaining: 120, players: [{ id: 'a', kills: 0, deaths: 0 }] }; + assert.equal(validRoundSnapshot(state), true); + for (const invalid of [null, { ...state, players: [null] }, { ...state, remaining: Infinity }, { ...state, serial: -1 }, { ...state, players: [{ id: 'a', kills: -1, deaths: 0 }] }]) { + assert.equal(validRoundSnapshot(invalid), false); + } +}); diff --git a/scripts/space-scoring.test.mjs b/scripts/space-scoring.test.mjs new file mode 100644 index 0000000..cb2bb29 --- /dev/null +++ b/scripts/space-scoring.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { hitReward, pageReward } from '../assets/space-egg/scoring.js'; +import { internalDestination } from '../assets/space-egg/navigation.js'; + +test('quick hits build a combo up to five and a pause resets it', () => { + assert.deepEqual(hitReward(100, -Infinity, 1, 1), { combo: 1, points: 100 }); + assert.deepEqual(hitReward(100, 1, 2, 1), { combo: 2, points: 200 }); + assert.deepEqual(hitReward(250, 1, 3, 5), { combo: 5, points: 1250 }); + assert.deepEqual(hitReward(100, 1, 3.01, 5), { combo: 1, points: 100 }); +}); +test('clearing an equal page faster gives more points, never negative', () => { + assert.ok(pageReward(5, 5) > pageReward(5, 10)); + assert.equal(pageReward(5, 100), 0); +}); +test('navigation shots only follow local site pages', () => { + const current = 'https://athega.se/systemutveckling/'; + assert.equal(internalDestination('/om-oss/', current).pathname, '/om-oss/'); + assert.equal(internalDestination('#kontakt', current).hash, '#kontakt'); + for (const href of ['https://github.com/athega/', 'mailto:reception@athega.se', 'javascript:alert(1)', '/assets/blog/demo.html', '/rapport.pdf']) { + assert.equal(internalDestination(href, current), null); + } +}); diff --git a/scripts/space-signaling.test.mjs b/scripts/space-signaling.test.mjs new file mode 100644 index 0000000..0c6c440 --- /dev/null +++ b/scripts/space-signaling.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { invitationLink, invitationPeer, isPeerId } from '../assets/space-egg/invitation.js'; +import { pilotName } from '../assets/space-egg/multiplayer.js'; + +const id = 'athega-space-12345678-1234-4123-8123-123456789abc'; +const page = 'https://athega.se/systemutveckling/'; + +test('an invitation always opens the root lobby and carries its room in the fragment', () => { + const link = invitationLink(id, page + '?preview=1#kontakt'); + assert.equal(new URL(link).pathname, '/'); + assert.equal(new URL(link).search, ''); + assert.equal(new URL(link).hash, '#space=' + id); + assert.equal(invitationPeer(link, page), id); +}); +test('foreign origins, malformed ids and old manual codes cannot join a room', () => { + for (const value of ['https://evil.example/#space=' + id, page + '#space=short', 'ATHEGA-INBJUDAN:abc', 'javascript:alert(1)', '']) { + assert.equal(invitationPeer(value, page), null); + } + assert.equal(isPeerId(id), true); + assert.equal(isPeerId(null), false); +}); +test('pilot names stay short and readable without control characters', () => { + assert.equal(pilotName(' Mats & Chrille '), 'Mats & Chrille'); + assert.equal(pilotName('Pilot\u202e\u0000'), 'Pilot'); + assert.equal(pilotName({ name: 'no' }), ''); + assert.equal(Array.from(pilotName('🚀'.repeat(30))).length, 20); +}); diff --git a/scripts/space-targets.test.mjs b/scripts/space-targets.test.mjs new file mode 100644 index 0000000..0aec324 --- /dev/null +++ b/scripts/space-targets.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { targetStrength, damageEdge } from '../assets/space-egg/targets.js'; + +test('links are light targets while headlines and images take more hits', () => { + assert.equal(targetStrength('A'), 1); + assert.equal(targetStrength('P'), 2); + assert.equal(targetStrength('H2'), 3); + assert.equal(targetStrength('IMG'), 4); + assert.equal(targetStrength('H1'), 4); +}); +test('progressive damage removes more of the object while keeping a hittable remainder', () => { + assert.ok(damageEdge(3, 4) > damageEdge(2, 4)); + assert.ok(damageEdge(2, 4) > damageEdge(1, 4)); + assert.ok(damageEdge(1, 4) > 60); +}); diff --git a/scripts/space-transport.test.mjs b/scripts/space-transport.test.mjs new file mode 100644 index 0000000..49577dc --- /dev/null +++ b/scripts/space-transport.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { createMultiplayer } from '../assets/space-egg/multiplayer.js'; + +// Exercise the actual admission/relay/close code without the public signal server. +function fakePeerLibrary() { + const peers = new Map(); + class Connection extends EventEmitter { + constructor(peer, metadata) { super(); this.peer = peer; this.metadata = metadata; this.open = false; this.dataChannel = { bufferedAmount: 0 }; } + send(value) { queueMicrotask(() => { if (this.other.open) this.other.emit('data', value); }); } + close() { + if (this.closed) return; + this.closed = true; this.open = false; this.emit('close'); this.other.close(); + } + } + return class Peer extends EventEmitter { + constructor(id) { super(); this.id = id; this.links = []; peers.set(id, this); queueMicrotask(() => this.emit('open', id)); } + connect(id, options) { + const destination = peers.get(id); + const outgoing = new Connection(id, options.metadata); + const incoming = new Connection(this.id, options.metadata); + outgoing.other = incoming; incoming.other = outgoing; + this.links.push(outgoing); destination.links.push(incoming); + queueMicrotask(() => { + destination.emit('connection', incoming); + outgoing.open = incoming.open = true; + incoming.emit('open'); outgoing.emit('open'); + }); + return outgoing; + } + destroy() { this.links.forEach(link => link.close()); peers.delete(this.id); } + static peers = peers; + }; +} +const flush = () => new Promise(resolve => setImmediate(resolve)); + +test('host admits four pilots, relays authenticated senders and rejects guest authority', async t => { + const Peer = fakePeerLibrary(); + const received = [[], [], [], [], []]; + const clients = received.map(messages => createMultiplayer({ loadPeer: async () => Peer, + onMessage: (message, sender) => messages.push({ message, sender }), onConnected() {}, onStatus() {} })); + t.after(() => clients.forEach(client => client.close())); + const id = await clients[0].host('Host'); + for (let i = 1; i < 4; i++) await clients[i].join(id, `Guest ${i}`); + await flush(); + for (let i = 0; i < 4; i++) assert.equal(clients[i].playerIds.length, 4); + clients[1].send({ type: 'position', x: 0.5 }); + clients[1].send({ type: 'state', score: 100000 }); + await flush(); + assert.equal(received[0].length, 1); + assert.equal(received[2].length, 1); + assert.equal(received[2][0].sender, clients[1].playerId); + await assert.rejects(clients[4].join(id), /fullt/); + clients[3].close(); await flush(); + assert.equal(clients[0].playerIds.length, 3); +}); + +test('critical updates survive congestion; excessive backlog disconnects cleanly', async t => { + const Peer = fakePeerLibrary(); + const received = []; + const host = createMultiplayer({ loadPeer: async () => Peer, onMessage: message => received.push(message), onConnected() {}, onStatus() {} }); + const guest = createMultiplayer({ loadPeer: async () => Peer, onMessage() {}, onConnected() {}, onStatus() {} }); + t.after(() => { host.close(); guest.close(); }); + await guest.join(await host.host()); + const connection = Peer.peers.get(guest.playerId).links[0]; + connection.dataChannel.bufferedAmount = 100000; + guest.send({ type: 'position' }); guest.send({ type: 'hit' }); + await flush(); assert.deepEqual(received, [{ type: 'hit' }]); + connection.dataChannel.bufferedAmount = 1000001; + guest.send({ type: 'hit' }); await flush(); + assert.equal(host.connected, false); assert.equal(guest.connected, false); +});