From 442bd33f811d7f202591db624fcf455295614893 Mon Sep 17 00:00:00 2001 From: Christian Lizell Date: Fri, 2 Oct 2026 15:26:39 +0200 Subject: [PATCH 01/11] Add hidden spaceship game with touch controls and arcade audio --- README.md | 14 ++ _includes/layout.html | 1 + assets/space-egg/game.css | 43 ++++ assets/space-egg/game.js | 356 +++++++++++++++++++++++++++++++++ assets/space-egg/physics.js | 20 ++ assets/space-egg/sound.js | 87 ++++++++ assets/space-egg/trigger.js | 45 +++++ scripts/space-physics.test.mjs | 20 ++ 8 files changed, 586 insertions(+) create mode 100644 assets/space-egg/game.css create mode 100644 assets/space-egg/game.js create mode 100644 assets/space-egg/physics.js create mode 100644 assets/space-egg/sound.js create mode 100644 assets/space-egg/trigger.js create mode 100644 scripts/space-physics.test.mjs diff --git a/README.md b/README.md index 92c5778..24b4618 100644 --- a/README.md +++ b/README.md @@ -481,3 +481,17 @@ 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 + +Fem snabba klick (inom 2,2 sekunder) på loggan på startsidan, eller Konamikoden +`↑ ↑ ↓ ↓ ← → ← → B A` på valfri sida, öppnar Athega Space. En liten trigger +laddas normalt; canvas-spelet, dess CSS och Web Audio-ljud laddas först vid aktivering. +Inga paket, externa tjänster eller lagrade spelaruppgifter används. + +Startpanelen beskriver kontrollerna: pilar/WASD, mellanslag, eller mobilens +styrspak och skjutknapp. Nästa sektor bläddrar genom sidan. Esc/Avsluta återställer +träffade element, fokus och ursprunglig scrollposition. Ljud kan stängas av. +Reducerad rörelse ger färre partiklar utan glöd eller explosionsringar. +Koden finns i `assets/space-egg/`; den äldre `assets/site.js` används inte. diff --git a/_includes/layout.html b/_includes/layout.html index 256dca3..7f38cad 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/game.css b/assets/space-egg/game.css new file mode 100644 index 0000000..24aecc3 --- /dev/null +++ b/assets/space-egg/game.css @@ -0,0 +1,43 @@ +: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; } +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: max(16px, env(safe-area-inset-top)); 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; } +.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; 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; } + .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: 90px; } + .panel { margin: 0 auto; } +} diff --git a/assets/space-egg/game.js b/assets/space-egg/game.js new file mode 100644 index 0000000..1d2dea0 --- /dev/null +++ b/assets/space-egg/game.js @@ -0,0 +1,356 @@ +import { hitsRect } from './physics.js'; +import { createSound } from './sound.js'; + +export function openGame(onClose) { + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; + const previousFocus = document.activeElement; + const initialScroll = { x: scrollX, y: scrollY }; + const controller = new AbortController(); + const listen = (node, event, handler, options = {}) => node.addEventListener(event, handler, { ...options, signal: controller.signal }); + const sound = createSound(); + const host = document.createElement('div'); + host.id = 'athega-space-game'; + // Keep the overlay out of document flow even while its lazy CSS is loading. + host.style.cssText = 'position:fixed;inset:0;z-index:2147483647'; + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + +
+ +
+
Athega / Space00000
+
+
+ +
+ 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.
Mobil: styrspak och ELD.
Esc eller Avsluta återställer allt.

+ +
+
`; + const inertStates = [...document.body.children].map(node => [node, node.inert]); + inertStates.forEach(([node]) => { node.inert = true; }); + document.body.append(host); + const find = selector => shadow.querySelector(selector); + const canvas = find('canvas'); + const context = canvas.getContext('2d'); + const launchButton = find('.launch'); + const scoreOutput = find('.score'); + const hint = find('.hint'); + const stick = find('.stick'); + const destroyed = new Map(); + const keys = new Set(); + const shots = []; + const particles = []; + const rings = []; + const joystick = { active: false, x: 0, y: 0 }; + const ship = { x: innerWidth / 2, y: innerHeight * 0.62, vx: 0, vy: 0, angle: -Math.PI / 2 }; + let width = innerWidth; + let height = innerHeight; + let running = false; + let closed = false; + let frame; + let lastTime = 0; + let cooldown = 0; + let score = 0; + let holdingFire = false; + let targetRects = []; + let targetsDirty = true; + let sector = 1; + + // Keep the page geometry intact: hide only hit elements, never remove DOM nodes. + const targetSelector = 'h1, h2, h3, h4, p, li, img, a.text-link'; + const targets = [...document.querySelectorAll('main, footer')].flatMap(root => [...root.querySelectorAll(targetSelector)]) + .filter(node => !node.querySelector(targetSelector) && !node.closest('pre, code, [hidden]')); + + function resize() { + width = innerWidth; + height = innerHeight; + const scale = Math.min(devicePixelRatio || 1, 2); + canvas.width = width * scale; + canvas.height = height * scale; + context.setTransform(scale, 0, 0, scale, 0, 0); + ship.x = Math.min(ship.x, width - 20); + ship.y = Math.min(ship.y, height - 20); + targetsDirty = true; + } + + function updateTargets() { + targetRects = targets.filter(node => !destroyed.has(node) && node.checkVisibility({ visibilityProperty: true, opacityProperty: true })).flatMap(node => { + // Text ranges follow actual lines, so shots can pass through empty margins. + const range = document.createRange(); + range.selectNodeContents(node); + const rects = node.tagName === 'IMG' ? [node.getBoundingClientRect()] : [...range.getClientRects()]; + return rects.filter(rect => rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < height) + .map(rect => ({ node, rect })); + }); + targetsDirty = false; + } + + function close() { + if (closed) return; + closed = true; + cancelAnimationFrame(frame); + controller.abort(); + sound.close(); + destroyed.forEach((original, node) => { + if (original.value) node.style.setProperty('visibility', original.value, original.priority); + else node.style.removeProperty('visibility'); + if (!original.hadStyle && !node.getAttribute('style')) node.removeAttribute('style'); + }); + inertStates.forEach(([node, wasInert]) => { node.inert = wasInert; }); + host.remove(); + window.scrollTo({ left: initialScroll.x, top: initialScroll.y, behavior: 'instant' }); + previousFocus?.focus({ preventScroll: true }); + onClose(); + } + + 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 destroy(target, x, y) { + const node = target.node; + destroyed.set(node, { value: node.style.getPropertyValue('visibility'), priority: node.style.getPropertyPriority('visibility'), hadStyle: node.hasAttribute('style') }); + node.style.setProperty('visibility', 'hidden', 'important'); + score += node.tagName === 'IMG' ? 250 : 100; + scoreOutput.textContent = String(score).padStart(5, '0'); + burst(x, y, target.rect); + sound.explode(); + targetsDirty = true; + if (destroyed.size === targets.length) hint.textContent = 'Sidan är röjd. Snyggt flugit! Avsluta för att återställa.'; + } + + function fire() { + const dx = Math.cos(ship.angle); + const dy = Math.sin(ship.angle); + shots.push({ x: ship.x + dx * 19, y: ship.y + dy * 19, vx: dx * 750 + ship.vx, vy: dy * 750 + ship.vy, life: 1.3 }); + sound.shoot(); + cooldown = 0.16; + } + + function resetControls() { + keys.clear(); + holdingFire = 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; + if (document.hidden) return; + if (targetsDirty) updateTargets(); + 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 (thrust) { + ship.vx += Math.cos(ship.angle) * 380 * dt; + ship.vy += Math.sin(ship.angle) * 380 * 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; + const speed = Math.hypot(ship.vx, ship.vy); + if (speed > 360) { ship.vx *= 360 / speed; ship.vy *= 360 / speed; } + ship.x = (ship.x + ship.vx * dt + width) % width; + ship.y = (ship.y + ship.vy * dt + height) % height; + cooldown -= dt; + if ((keys.has('Space') || holdingFire) && cooldown <= 0) fire(); + context.clearRect(0, 0, width, height); + 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 hit = targetRects.find(target => !destroyed.has(target.node) && hitsRect(shot.x, shot.y, x, y, target.rect)); + shot.life -= dt; + if (hit) { + 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 = '#ff6600'; + context.lineWidth = 3; + context.shadowColor = '#ff6600'; + context.shadowBlur = reducedMotion ? 0 : 10; + context.stroke(); + context.shadowBlur = 0; + shot.x = x; + shot.y = y; + } + 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; + 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); + } + drawShip(thrust, now); + } + + function drawShip(thrust, now) { + context.save(); + context.translate(ship.x, ship.y); + context.rotate(ship.angle); + context.shadowColor = '#ff6600'; + 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 = '#ff6600'; + 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 = '#ff6600'; + context.lineWidth = 2; + context.stroke(); + context.shadowBlur = 0; + context.fillStyle = '#d8f4ff'; + context.fillRect(0, -2, 6, 4); + context.restore(); + } + + listen(launchButton, 'click', () => { + sound.init(); + sound.launch(); + find('.briefing').hidden = true; + find('.play-ui').hidden = false; + if (matchMedia('(pointer: coarse)').matches || width <= 640) hint.textContent = 'Dra för att styra · Håll ELD för att skjuta'; + running = true; + lastTime = performance.now(); + find('.exit').focus({ preventScroll: true }); + frame = requestAnimationFrame(tick); + }); + listen(find('.exit'), 'click', close); + listen(find('.sound'), 'click', event => { + const enabled = sound.toggle(); + event.currentTarget.textContent = enabled ? 'Ljud på' : 'Ljud av'; + event.currentTarget.setAttribute('aria-pressed', String(enabled)); + event.currentTarget.setAttribute('aria-label', enabled ? 'Ljud på' : 'Ljud av'); + }); + listen(find('.sector'), 'click', () => { + const atBottom = scrollY + height >= document.documentElement.scrollHeight - 4; + window.scrollTo({ top: atBottom ? 0 : scrollY + height * 0.7, behavior: 'instant' }); + sector = atBottom ? 1 : sector + 1; + hint.textContent = `Sektor ${String(sector).padStart(2, '0')} · Fortsätt röja!`; + targetsDirty = true; + }); + const movementKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD', 'Space']; + listen(window, 'keydown', event => { + if (event.code === 'Escape') { event.preventDefault(); close(); return; } + if (!running || event.ctrlKey || event.metaKey || event.altKey) 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(); 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 buttons = [...shadow.querySelectorAll('button')].filter(button => button.getClientRects().length); + 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); + listen(window, 'resize', resize); + listen(window, 'scroll', () => { targetsDirty = true; }, { passive: true }); + listen(document, 'load', () => { targetsDirty = true; }, { capture: true }); + listen(stick, 'pointerdown', event => { + 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(); + }); + resize(); + launchButton.focus({ preventScroll: true }); + window.scrollTo({ left: initialScroll.x, top: initialScroll.y, behavior: 'instant' }); +} diff --git a/assets/space-egg/physics.js b/assets/space-egg/physics.js new file mode 100644 index 0000000..f3f8606 --- /dev/null +++ b/assets/space-egg/physics.js @@ -0,0 +1,20 @@ +// Swept collision: fast shots must not jump over a thin line of text. +export function hitsRect(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 false; + 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 false; + } + return true; +} diff --git a/assets/space-egg/sound.js b/assets/space-egg/sound.js new file mode 100644 index 0000000..74c54ca --- /dev/null +++ b/assets/space-egg/sound.js @@ -0,0 +1,87 @@ +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)); + }, + 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/trigger.js b/assets/space-egg/trigger.js new file mode 100644 index 0000000..b4c2058 --- /dev/null +++ b/assets/space-egg/trigger.js @@ -0,0 +1,45 @@ +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() { + if (active) return; + active = true; + entered = []; + clicks = []; + try { + const { openGame } = await import('./game.js?v=1'); + openGame(() => { active = false; }); + } catch (error) { + active = false; + console.warn('Rymdskeppet kunde inte starta.', error); + } +} + +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(); + const now = performance.now(); + clicks = clicks.filter(time => now - time < 2200); + clicks.push(now); + if (clicks.length >= 5) reveal(); +}); diff --git a/scripts/space-physics.test.mjs b/scripts/space-physics.test.mjs new file mode 100644 index 0000000..2c5fbbb --- /dev/null +++ b/scripts/space-physics.test.mjs @@ -0,0 +1,20 @@ +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); +}); From 887fd24c15f13032562e21cb4ecf9837a4310ed9 Mon Sep 17 00:00:00 2001 From: Christian Lizell Date: Fri, 2 Oct 2026 17:04:15 +0200 Subject: [PATCH 02/11] Add co-op WebRTC, mission navigation and arcade powerups --- README.md | 68 +++- _includes/layout.html | 2 +- assets/space-egg/game.css | 36 +- assets/space-egg/game.js | 659 ++++++++++++++++++++++++++++--- assets/space-egg/multiplayer.js | 98 +++++ assets/space-egg/navigation.js | 8 + assets/space-egg/scoring.js | 8 + assets/space-egg/sound.js | 1 + assets/space-egg/trigger.js | 2 +- scripts/space-scoring.test.mjs | 23 ++ scripts/space-signaling.test.mjs | 21 + 11 files changed, 857 insertions(+), 69 deletions(-) create mode 100644 assets/space-egg/multiplayer.js create mode 100644 assets/space-egg/navigation.js create mode 100644 assets/space-egg/scoring.js create mode 100644 scripts/space-scoring.test.mjs create mode 100644 scripts/space-signaling.test.mjs diff --git a/README.md b/README.md index 24b4618..0a2c9db 100644 --- a/README.md +++ b/README.md @@ -485,13 +485,61 @@ Om utvecklingsservern får en annan port än 8080 används adressen den skriver ## Hemligt rymdspel -Fem snabba klick (inom 2,2 sekunder) på loggan på startsidan, eller Konamikoden -`↑ ↑ ↓ ↓ ← → ← → B A` på valfri sida, öppnar Athega Space. En liten trigger -laddas normalt; canvas-spelet, dess CSS och Web Audio-ljud laddas först vid aktivering. -Inga paket, externa tjänster eller lagrade spelaruppgifter används. - -Startpanelen beskriver kontrollerna: pilar/WASD, mellanslag, eller mobilens -styrspak och skjutknapp. Nästa sektor bläddrar genom sidan. Esc/Avsluta återställer -träffade element, fokus och ursprunglig scrollposition. Ljud kan stängas av. -Reducerad rörelse ger färre partiklar utan glöd eller explosionsringar. -Koden finns i `assets/space-egg/`; den äldre `assets/site.js` används inte. +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. Inga nya npm-paket. + +### 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. + En rensad sektor byts automatiskt; en helt rensad sida öppnar uppdragsväljaren. +- Täta träffar inom två sekunder bygger upp till ×5 kombopoäng. Snabbare + sektorrensning ger större tidsbonus (75 per mål minus 15 per sekund, minst 0). +- Var tredje träff släpper 3X/trippelskott eller RF/snabbeld. Flyg nära för att + plocka upp dem; effekten varar 12 sekunder. Powerups gäller den egna piloten. +- 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. + +### Två spelare med två koder + +Båda ska öppna samma version av sajten på samma origin (protokoll, värd och port). +För lokal provkörning fungerar två fönster/webbläsare mot samma localhost-adress. +En localhost-adress på din dator kan inte öppnas direkt på kompisens dator. + +1. Båda aktiverar spelet och väljer **Två spelare**. +2. Värden väljer **Skapa spel**, kopierar **Inbjudningskod – skicka till kompisen** (`ATHEGA-INBJUDAN:…`) och skickar den + till kompisen. Kodgenereringen kan ta upp till tio sekunder. +3. Kompisen klistrar in den under **Inkommande kod** och väljer **Svara på + inbjudan**. Kompisens nya **Svarskod – skicka tillbaka till värden** (`ATHEGA-SVAR:…`) + skickas tillbaka till värden. Skicka inte tillbaka den ursprungliga inbjudan. +4. Värden klistrar in svaret under **Inkommande kod** och väljer **Anslut med + svarskoden**. Båda får status **Två piloter** när anslutningen är uppe. +5. Ni röjer tillsammans: den andra piloten är blå, träffar och poäng delas och + båda följer med vid sektor-/sidbyte. Byt uppdrag fungerar även efter full rensning. + +WebRTC DataChannel skickar speldata direkt mellan webbläsarna. Koderna ersätter +signaleringstjänsten. STUN (`stun.cloudflare.com:3478`) används bara när man +väljer multiplayer för att hitta en nätverksväg. Ingen kamera/mikrofon används, +inga spelaruppgifter sparas och ingen spelserver tar emot innehållet. +Det finns ingen TURN-reläserver i prototypen: vissa företagsnät och mobilnät +kan därför inte koppla ihop sig. Vid misslyckad anslutning kan man nollställa, +skapa nya koder eller prova ett annat nätverk. Om en spelare lämnar fortsätter +kompisen 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. Skeppens visuella positioner normaliseras +mellan skärmstorlekar; varje spelares träffar följer den egna responsiva layouten. +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. + +Koden finns i `assets/space-egg/`: `trigger.js`, `game.js`, `game.css`, +`physics.js`, `scoring.js`, `sound.js`, `navigation.js` och `multiplayer.js`. +Den äldre `assets/site.js` används inte. Vid ändring av redan publicerade +spelmoduler behöver versionsparametrarna på deras import-/resurslänkar uppdateras. diff --git a/_includes/layout.html b/_includes/layout.html index 7f38cad..327a4cd 100644 --- a/_includes/layout.html +++ b/_includes/layout.html @@ -30,6 +30,6 @@ {% include "header.html" %} {{ content }} {% include "footer.html" %} - + diff --git a/assets/space-egg/game.css b/assets/space-egg/game.css index 24aecc3..6ac3b81 100644 --- a/assets/space-egg/game.css +++ b/assets/space-egg/game.css @@ -5,12 +5,14 @@ button { font: inherit; cursor: pointer; color: inherit; border: 1px solid #5d62 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: max(16px, env(safe-area-inset-top)); display: flex; justify-content: space-between; align-items: start; gap: 12px; pointer-events: none; } +.hud { position: absolute; z-index: 2; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); top: calc(var(--space-header-bottom, 72px) + 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; gap: 8px; } +.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; } @@ -27,6 +29,7 @@ p { color: #c5cdd5; margin: 12px 0; } .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; } @@ -38,6 +41,33 @@ p { color: #c5cdd5; margin: 12px 0; } } @media (max-height: 600px) { - .briefing { display: block; overflow-y: auto; padding-top: 90px; } + .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; } +textarea, 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; } +textarea { resize: vertical; } +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; } +} diff --git a/assets/space-egg/game.js b/assets/space-egg/game.js index 1d2dea0..c5a241c 100644 --- a/assets/space-egg/game.js +++ b/assets/space-egg/game.js @@ -1,9 +1,22 @@ -import { hitsRect } from './physics.js'; -import { createSound } from './sound.js'; +import { hitsRect } from './physics.js?v=3'; +import { createSound } from './sound.js?v=3'; +import { internalDestination } from './navigation.js?v=3'; +import { createMultiplayer, decodeSignal } from './multiplayer.js?v=3'; +import { hitReward, sectorReward } from './scoring.js?v=3'; export function openGame(onClose) { const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; const previousFocus = document.activeElement; + const originalUrl = location.href; + let currentUrl = originalUrl; + let loadingPage = false; + let pageChanged = false; + let network = null; + let ghost = null; + let networkTime = 0; + let messageQueue = Promise.resolve(); + let connectionAttempt = 0; + const pendingHits = new Set(); const initialScroll = { x: scrollX, y: scrollY }; const controller = new AbortController(); const listen = (node, event, handler, options = {}) => node.addEventListener(event, handler, { ...options, signal: controller.signal }); @@ -14,28 +27,59 @@ export function openGame(onClose) { host.style.cssText = 'position:fixed;inset:0;z-index:2147483647'; const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` - +
-
Athega / Space00000
-
+
Athega / Space00000×1 · 0.0 sSoloflygningSektor 01
+
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.
Mobil: styrspak och ELD.
Esc eller Avsluta återställer allt.

+

Dator: piltangenter eller W A S D + mellanslag.
N: navigationsskott – träffa en intern länk.
Mobil: styrspak, ELD och NAV.
Plocka upp 3X och RF för trippelskott och snabbeld.
Täta träffar ger upp till ×5. Rensa snabbt för tidsbonus.
Esc eller Avsluta återställer allt.

+ +
`; - const inertStates = [...document.body.children].map(node => [node, node.inert]); + let dark = true; + const previousTheme = document.body.getAttribute('data-space-dark'); + const themeStyle = document.createElement('style'); + themeStyle.textContent = ` + 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; } + `; + document.head.append(themeStyle); + document.body.toggleAttribute('data-space-dark', dark); + let inertStates = [...document.body.children].map(node => [node, node.inert]); inertStates.forEach(([node]) => { node.inert = true; }); document.body.append(host); const find = selector => shadow.querySelector(selector); @@ -45,11 +89,26 @@ export function openGame(onClose) { const scoreOutput = find('.score'); const hint = find('.hint'); const stick = find('.stick'); + let mobileMenu = document.querySelector('.mobile-menu'); + let menuWasOpen = mobileMenu?.open; const destroyed = new Map(); const keys = new Set(); const shots = []; 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 }; + let sectorTargets = new Set(); + let sectorDirty = true; + let sectorClearTime = 0; + let sectorElapsed = 0; + let gameTime = 0; + let lastHitTime = -Infinity; + let combo = 1; + const labels = []; + let lastPowerLabel = ''; const joystick = { active: false, x: 0, y: 0 }; const ship = { x: innerWidth / 2, y: innerHeight * 0.62, vx: 0, vy: 0, angle: -Math.PI / 2 }; let width = innerWidth; @@ -60,17 +119,37 @@ export function openGame(onClose) { 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; let sector = 1; // Keep the page geometry intact: hide only hit elements, never remove DOM nodes. - const targetSelector = 'h1, h2, h3, h4, p, li, img, a.text-link'; - const targets = [...document.querySelectorAll('main, footer')].flatMap(root => [...root.querySelectorAll(targetSelector)]) - .filter(node => !node.querySelector(targetSelector) && !node.closest('pre, code, [hidden]')); + const targetSelector = 'h1, h2, h3, h4, p, li, img, a, summary'; + function collectTargets() { + return [...document.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() { + const headerBottom = document.querySelector('.site-header')?.getBoundingClientRect().bottom ?? 0; + host.style.setProperty('--space-header-bottom', `${Math.max(0, headerBottom)}px`); + find('.menu-toggle').hidden = !mobileMenu?.checkVisibility(); width = innerWidth; height = innerHeight; const scale = Math.min(devicePixelRatio || 1, 2); @@ -80,10 +159,11 @@ export function openGame(onClose) { ship.x = Math.min(ship.x, width - 20); ship.y = Math.min(ship.y, height - 20); targetsDirty = true; + sectorDirty = true; } function updateTargets() { - targetRects = targets.filter(node => !destroyed.has(node) && node.checkVisibility({ visibilityProperty: true, opacityProperty: true })).flatMap(node => { + 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 = document.createRange(); range.selectNodeContents(node); @@ -91,25 +171,46 @@ export function openGame(onClose) { return rects.filter(rect => rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < height) .map(rect => ({ node, rect })); }); + linkRects = [...document.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 })); + }); + if (sectorDirty) { + sectorTargets = new Set(targetRects.filter(({ node }) => !node.closest('.site-header')).map(({ node }) => node)); + sectorClearTime = 0; + sectorElapsed = 0; + sectorDirty = false; + } targetsDirty = false; } - function close() { + function close(reloadPage = true) { if (closed) return; closed = true; cancelAnimationFrame(frame); controller.abort(); sound.close(); + themeStyle.remove(); + if (previousTheme === null) document.body.removeAttribute('data-space-dark'); + else document.body.setAttribute('data-space-dark', previousTheme); + animations.forEach(animation => animation.cancel()); destroyed.forEach((original, node) => { if (original.value) node.style.setProperty('visibility', original.value, original.priority); else node.style.removeProperty('visibility'); if (!original.hadStyle && !node.getAttribute('style')) node.removeAttribute('style'); }); inertStates.forEach(([node, wasInert]) => { node.inert = wasInert; }); + if (mobileMenu) mobileMenu.open = menuWasOpen; host.remove(); 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) { @@ -124,29 +225,128 @@ export function openGame(onClose) { if (particles.length > 300) particles.splice(0, particles.length - 300); } - function destroy(target, x, y) { - const node = target.node; + function rememberTarget(node) { destroyed.set(node, { value: node.style.getPropertyValue('visibility'), priority: node.style.getPropertyPriority('visibility'), hadStyle: node.hasAttribute('style') }); - node.style.setProperty('visibility', 'hidden', 'important'); - score += node.tagName === 'IMG' ? 250 : 100; + } + + 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)) network.send({ type: 'hit', id: targets.indexOf(node), page: currentUrl }); + pendingHits.add(node); + return; + } + pendingHits.delete(node); + rememberTarget(node); + const animation = node.animate(reducedMotion ? [ + { opacity: getComputedStyle(node).opacity }, { opacity: 0 }, + ] : [ + { opacity: 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 reward = remote || hitReward(node.tagName === 'IMG' ? 250 : 100, lastHitTime, gameTime, combo); + combo = reward.combo; + lastHitTime = gameTime; + 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 (destroyed.size === targets.length) hint.textContent = 'Sidan är röjd. Snyggt flugit! Avsluta för att återställa.'; + if (network?.connected && network.isHost) network.send({ type: 'destroy', page: currentUrl, id: targets.indexOf(node), score, points: reward.points, combo }); + if (destroyed.size % 3 === 0) { + const kind = destroyed.size % 6 === 0 ? 'rapid' : 'triple'; + pickups.push({ x: Math.max(24, Math.min(width - 24, x)), y: Math.max(100, Math.min(height - 100, y)), kind, life: 14 }); + if (pickups.length > 6) pickups.shift(); + } } - function fire() { + async function navigateTo(target, fromPeer = false) { + const destination = target.destination; + if (loadingPage) return; + if (network?.connected && !network.isHost && !fromPeer) { + network.send({ 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 = document.getElementById(decodeURIComponent(destination.hash.slice(1))); } catch { /* Ignore invalid fragments. */ } + if (anchor) anchor.scrollIntoView({ behavior: 'instant', block: 'start' }); + else if (!destination.hash) window.scrollTo({ top: 0, behavior: 'instant' }); + targetsDirty = sectorDirty = true; + return; + } + 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) return; + animations.forEach(animation => animation.cancel()); + animations.clear(); + for (const [node] of inertStates) node.remove(); + currentUrl = response.url; + history.pushState(null, '', currentUrl); + pageChanged = true; + const pageNodes = [...nextDocument.body.children].filter(node => !['SCRIPT', 'STYLE'].includes(node.tagName)); + inertStates = pageNodes.map(node => { + const imported = document.importNode(node, true); + // Page demos may contain scripts; game navigation only imports static content. + imported.querySelectorAll('script').forEach(script => script.remove()); + const wasInert = imported.inert; + imported.inert = true; + document.body.insertBefore(imported, host); + return [imported, wasInert]; + }); + destroyed.clear(); + pendingHits.clear(); + shots.length = particles.length = rings.length = pickups.length = labels.length = 0; + mobileMenu = document.querySelector('.mobile-menu'); + menuWasOpen = mobileMenu?.open; + targets = collectTargets(); + window.scrollTo({ top: 0, behavior: 'instant' }); + resize(); + sector++; + ship.x = width / 2 + (network?.connected && !network.isHost ? 48 : 0); + ship.y = height * 0.62; + ship.vx = ship.vy = 0; + 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') { const dx = Math.cos(ship.angle); const dy = Math.sin(ship.angle); - shots.push({ x: ship.x + dx * 19, y: ship.y + dy * 19, vx: dx * 750 + ship.vx, vy: dy * 750 + ship.vy, life: 1.3 }); + const angles = kind === 'destroy' && powers.triple > 0 ? [-0.17, 0, 0.17] : [0]; + for (const offset of angles) { + const angle = ship.angle + offset; + shots.push({ 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(); - cooldown = 0.16; + network?.send({ type: 'shot', page: currentUrl, x: ship.x / width, y: ship.y / height, angle: ship.angle, 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'); @@ -158,8 +358,30 @@ export function openGame(onClose) { frame = requestAnimationFrame(tick); const dt = Math.min((now - lastTime) / 1000 || 0, 0.035); lastTime = now; - if (document.hidden) return; + if (document.hidden || loadingPage || !find('.lobby').hidden || !find('.missions').hidden) return; + gameTime += dt; + sectorElapsed += dt; + if (gameTime - lastHitTime > 2) combo = 1; + find('.pace').textContent = `×${combo} · ${sectorElapsed.toFixed(1)} s`; if (targetsDirty) updateTargets(); + powers.triple = Math.max(0, powers.triple - dt); + powers.rapid = Math.max(0, powers.rapid - dt); + const powerLabel = [powers.triple > 0 ? `3X ${Math.ceil(powers.triple)}s` : '', powers.rapid > 0 ? `RF ${Math.ceil(powers.rapid)}s` : ''].filter(Boolean).join(' · ') || `Sektor ${String(sector).padStart(2, '0')}`; + if (powerLabel !== lastPowerLabel) { + find('.power-status').textContent = powerLabel; + lastPowerLabel = powerLabel; + } + if (sectorTargets.size > 0 && [...sectorTargets].every(node => destroyed.has(node))) { + sectorClearTime += dt; + hint.textContent = 'Sektor rensad! Gör klart för nästa…'; + if (sectorClearTime > 1.4 && (!network?.connected || network.isHost)) { + const bonus = sectorReward(sectorTargets.size, Math.max(0, sectorElapsed - sectorClearTime)); + score += bonus; + scoreOutput.textContent = String(score).padStart(5, '0'); + labels.push({ x: width / 2, y: height / 2, text: `SEKTOR KLAR +${bonus}`, life: 2 }); + nextSector(true); + } + } let thrust = keys.has('ArrowUp') || keys.has('KeyW'); if (joystick.active) { const amount = Math.hypot(joystick.x, joystick.y); @@ -184,15 +406,37 @@ export function openGame(onClose) { ship.x = (ship.x + ship.vx * dt + width) % width; ship.y = (ship.y + ship.vy * dt + height) % height; cooldown -= dt; - if ((keys.has('Space') || holdingFire) && cooldown <= 0) fire(); + if (cooldown <= 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 hit = targetRects.find(target => !destroyed.has(target.node) && hitsRect(shot.x, shot.y, x, y, target.rect)); + const candidates = shot.remote ? [] : shot.kind === 'navigate' ? linkRects : targetRects; + const hit = candidates.find(target => !isDestroyed(target.node) && hitsRect(shot.x, shot.y, x, y, target.rect)); shot.life -= dt; if (hit) { + 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; @@ -201,9 +445,9 @@ export function openGame(onClose) { context.beginPath(); context.moveTo(shot.x, shot.y); context.lineTo(x, y); - context.strokeStyle = '#ff6600'; - context.lineWidth = 3; - context.shadowColor = '#ff6600'; + context.strokeStyle = shot.kind === 'navigate' || shot.remote ? '#00b7dd' : '#ff6600'; + context.lineWidth = shot.kind === 'navigate' ? 5 : 3; + context.shadowColor = shot.kind === 'navigate' ? '#00d4ff' : '#ff6600'; context.shadowBlur = reducedMotion ? 0 : 10; context.stroke(); context.shadowBlur = 0; @@ -233,14 +477,77 @@ export function openGame(onClose) { 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 (distance < 130) { pickup.x += dx * dt * 2.8; pickup.y += dy * dt * 2.8; } + if (distance < 28) { + powers[pickup.kind] = 12; + sound.powerup(); + hint.textContent = pickup.kind === 'triple' ? 'TRIPPELSKOTT · Tre skott åt gången i 12 sekunder!' : 'SNABBELD · Dubbelt eldhastighet i 12 sekunder!'; + 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(pickup.kind === 'triple' ? '3X' : 'RF', 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; + network.send({ type: 'position', page: currentUrl, x: ship.x / width, y: ship.y / height, angle: ship.angle, thrust }); + } + if (ghost) { + ghost.x += (ghost.targetX - ghost.x) * Math.min(1, dt * 14); + ghost.y += (ghost.targetY - ghost.y) * Math.min(1, dt * 14); + drawShip(ghost.thrust, now, ghost, '#56dfff'); + } drawShip(thrust, now); } - function drawShip(thrust, now) { + function drawShip(thrust, now, craft = ship, color = '#ff6600') { context.save(); - context.translate(ship.x, ship.y); - context.rotate(ship.angle); - context.shadowColor = '#ff6600'; + context.translate(craft.x, craft.y); + 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; @@ -248,7 +555,7 @@ export function openGame(onClose) { context.moveTo(-10, -5); context.lineTo(-flame - 8, 0); context.lineTo(-10, 5); - context.fillStyle = '#ff6600'; + context.fillStyle = color; context.fill(); } context.beginPath(); @@ -259,7 +566,7 @@ export function openGame(onClose) { context.closePath(); context.fillStyle = '#111820'; context.fill(); - context.strokeStyle = '#ff6600'; + context.strokeStyle = color; context.lineWidth = 2; context.stroke(); context.shadowBlur = 0; @@ -268,46 +575,94 @@ export function openGame(onClose) { context.restore(); } - listen(launchButton, 'click', () => { - sound.init(); - sound.launch(); + function start() { + if (running) return; find('.briefing').hidden = true; find('.play-ui').hidden = false; - if (matchMedia('(pointer: coarse)').matches || width <= 640) hint.textContent = 'Dra för att styra · Håll ELD för att skjuta'; + if (matchMedia('(pointer: coarse)').matches || width <= 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(); + 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); + } + if (!soundEnabled) sound.toggle(); + updateSoundButton(); + listen(find('.sound'), 'click', () => { + sound.init(); + soundEnabled = sound.toggle(); + updateSoundButton(); }); - listen(find('.exit'), 'click', close); - listen(find('.sound'), 'click', event => { - const enabled = sound.toggle(); - event.currentTarget.textContent = enabled ? 'Ljud på' : 'Ljud av'; - event.currentTarget.setAttribute('aria-pressed', String(enabled)); - event.currentTarget.setAttribute('aria-label', enabled ? 'Ljud på' : 'Ljud av'); + function updateThemeButton() { + find('.theme').textContent = dark ? 'Mörkt' : 'Ljust'; + find('.theme').setAttribute('aria-pressed', String(dark)); + } + updateThemeButton(); + listen(find('.theme'), 'click', () => { + dark = !dark; + document.body.toggleAttribute('data-space-dark', dark); + updateThemeButton(); }); - listen(find('.sector'), 'click', () => { - const atBottom = scrollY + height >= document.documentElement.scrollHeight - 4; - window.scrollTo({ top: atBottom ? 0 : scrollY + height * 0.7, behavior: 'instant' }); - sector = atBottom ? 1 : sector + 1; - hint.textContent = `Sektor ${String(sector).padStart(2, '0')} · Fortsätt röja!`; + listen(find('.menu-toggle'), 'click', event => { + mobileMenu.open = !mobileMenu.open; + event.currentTarget.setAttribute('aria-expanded', String(mobileMenu.open)); targetsDirty = true; }); - const movementKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD', 'Space']; + function nextSector(automatic = false) { + if (network?.connected && !network.isHost) { network.send({ type: 'sector', page: currentUrl }); return; } + const headerHeight = document.querySelector('.site-header')?.getBoundingClientRect().height ?? 0; + const remaining = targets.filter(node => !isDestroyed(node) && !node.closest('.site-header') + && node.checkVisibility({ visibilityProperty: true, opacityProperty: true })); + if (!remaining.length && automatic) { + sectorTargets.clear(); + hint.textContent = 'Sidan är rensad! Navigationsskjut en menylänk för nästa uppdrag.'; + sound.launch(); + showMissions(); + return; + } + const forward = remaining.find(node => node.getBoundingClientRect().top > headerHeight + 80); + const next = forward || remaining[0]; + const top = next ? scrollY + next.getBoundingClientRect().top - headerHeight - 90 : 0; + window.scrollTo({ top: Math.max(0, top), behavior: 'instant' }); + sector++; + sectorClearTime = 0; + sectorDirty = true; + targetsDirty = true; + shots.length = 0; + pickups.length = 0; + ship.vx = ship.vy = 0; + hint.textContent = `Sektor ${String(sector).padStart(2, '0')} · Fortsätt röja!`; + if (automatic) sound.launch(); + broadcastState(); + } + listen(find('.sector'), 'click', () => nextSector()); + 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 (!find('.lobby').hidden || !find('.missions').hidden) return; if (!running || event.ctrlKey || event.metaKey || event.altKey) 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(); keys.add(event.code); } + 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 buttons = [...shadow.querySelectorAll('button')].filter(button => button.getClientRects().length); + const buttons = [...shadow.querySelectorAll('button, textarea, select')].filter(button => button.getClientRects().length); const index = buttons.indexOf(shadow.activeElement); const next = event.shiftKey ? (index - 1 + buttons.length) % buttons.length : (index + 1) % buttons.length; event.preventDefault(); @@ -316,11 +671,13 @@ export function openGame(onClose) { listen(canvas, 'pointerdown', () => { keyboardFocus = false; }); listen(window, 'blur', resetControls); listen(document, 'visibilitychange', resetControls); - listen(window, 'pagehide', close); + listen(window, 'pagehide', () => close(false)); + listen(window, 'popstate', () => { close(false); location.reload(); }); listen(window, 'resize', resize); - listen(window, 'scroll', () => { targetsDirty = true; }, { passive: true }); + listen(window, 'scroll', () => { targetsDirty = true; sectorDirty = true; }, { passive: true }); listen(document, 'load', () => { targetsDirty = true; }, { capture: true }); listen(stick, 'pointerdown', event => { + sound.init(); joystick.active = true; stick.setPointerCapture(event.pointerId); steer(event); @@ -350,6 +707,200 @@ export function openGame(onClose) { 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 document.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; + } + + function broadcastState() { + if (!network?.connected || !network.isHost) return; + network.send({ type: 'state', page: currentUrl, targetCount: targets.length, signature: targetSignature(), destroyed: [...destroyed.keys()].map(node => targets.indexOf(node)), score, sector, scroll: scrollY / Math.max(1, document.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) { + if (closed) return; + if (message.type === 'state' && !network.isHost) { + const destination = internalDestination(message.page, currentUrl); + if (!destination || !Array.isArray(message.destroyed) || message.destroyed.length > 5000 + || !Number.isSafeInteger(message.score) || message.score < 0 || !Number.isSafeInteger(message.sector) + || !Number.isFinite(message.scroll) || message.scroll < 0 || message.scroll > 1) return; + if (destination.href !== currentUrl) await navigateTo({ destination }, true); + if (closed) return; + if (message.targetCount !== targets.length || message.signature !== targetSignature()) { find('.connection-status').textContent = 'Olika sidversioner – ladda om båda sidorna.'; return; } + 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'); + } + score = message.score; + sector = message.sector; + scoreOutput.textContent = String(score).padStart(5, '0'); + window.scrollTo({ top: message.scroll * Math.max(0, document.documentElement.scrollHeight - height), behavior: 'instant' }); + targetsDirty = sectorDirty = true; + return; + } + if (message.page !== currentUrl || loadingPage) 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) && ['destroy', 'navigate'].includes(message.kind)) { + for (const offset of message.triple && message.kind === 'destroy' ? [-0.17, 0, 0.17] : [0]) { + shots.push({ remote: true, kind: message.kind, x: message.x * width, y: message.y * height, vx: Math.cos(message.angle + offset) * 750, vy: Math.sin(message.angle + offset) * 750, life: 1.3 }); + } + } else if (message.type === 'navigate' && network.isHost) { + const destination = internalDestination(message.url, currentUrl); + if (destination) await navigateTo({ destination }); + } else if (message.type === 'sector' && network.isHost) nextSector(); + } + + function makeNetwork() { + network?.close(); + network = createMultiplayer({ + onMessage(message) { + if (message.type === 'position') { + if (message.page !== currentUrl || !validPoint(message)) return; + if (!ghost) ghost = { x: message.x * width, y: message.y * height }; + Object.assign(ghost, { targetX: message.x * width, targetY: message.y * height, angle: message.angle, thrust: Boolean(message.thrust) }); + return; + } + messageQueue = messageQueue.then(() => receive(message)).catch(() => { hint.textContent = 'Ett spelmeddelande kunde inte läsas.'; }); + }, + onConnected() { + if (!network.isHost) ship.x = Math.min(width - 24, width / 2 + 48); + find('.lobby').hidden = true; + start(); + broadcastState(); + }, + onStatus(status) { + if (closed) return; + find('.lobby-status').textContent = status; + find('.connection-status').textContent = network?.connected ? 'Två piloter' : status; + if (!network?.connected) { ghost = null; pendingHits.clear(); } + }, + }); + return network; + } + + listen(find('.multiplayer'), 'click', () => { + resetControls(); + find('.lobby').hidden = false; + find('.host-game').focus({ preventScroll: true }); + }); + listen(find('.lobby-close'), 'click', () => { find('.lobby').hidden = true; }); + async function connectionAction(action) { + const attempt = ++connectionAttempt; + const status = find('.lobby-status'); + status.textContent = 'Förbereder anslutningen…'; + find('.host-game').disabled = find('.join-game').disabled = true; + try { + const code = await action(); + if (closed || attempt !== connectionAttempt) return; + if (code) { + find('.outgoing-code').value = code; + find('.copy-code').disabled = false; + find('.code-label').textContent = network.isHost ? 'Inbjudningskod – skicka till kompisen' : 'Svarskod – skicka tillbaka till värden'; + status.textContent = network.isHost ? 'Skicka din inbjudningskod. Kompisen ska klistra in den och klicka Svara på inbjudan. Klistra sedan in kompisens NYA svarskod här och klicka Anslut med svarskoden.' : 'Nästan klart! Kopiera din NYA svarskod nedan och skicka tillbaka den. Värden ska klistra in den och klicka Anslut med svarskoden. Vänta kvar här.'; + } else status.textContent = 'Kopplar ihop skeppen…'; + } catch (error) { + if (!closed && attempt === connectionAttempt) status.textContent = error.message || 'Anslutningen gick inte att skapa.'; + } + } + listen(find('.incoming-code'), 'input', () => { + const value = find('.incoming-code').value; + const label = find('.incoming-type'); + if (!value.trim()) { label.textContent = ''; return; } + try { + const { type } = decodeSignal(value); + label.textContent = type === 'offer' + ? 'INBJUDAN: klicka Svara på inbjudan för att skapa en NY kod att skicka tillbaka.' + : 'SVARSKOD: värden ska klicka Anslut med svarskoden.'; + if (network?.isHost && type === 'offer') label.textContent = 'Det här är en inbjudan. Du är värd och behöver kompisens NYA svarskod (ATHEGA-SVAR).'; + } catch (error) { label.textContent = error.message; } + }); + listen(find('.host-game'), 'click', () => connectionAction(async () => { + const code = await makeNetwork().offer(); + find('.accept-answer').hidden = false; + find('.join-game').hidden = true; + return code; + })); + listen(find('.join-game'), 'click', () => connectionAction(() => makeNetwork().answer(find('.incoming-code').value))); + listen(find('.accept-answer'), 'click', () => connectionAction(() => network.accept(find('.incoming-code').value))); + listen(find('.copy-code'), 'click', async () => { + try { await navigator.clipboard.writeText(find('.outgoing-code').value); find('.lobby-status').textContent = 'Koden är kopierad.'; } + catch { find('.outgoing-code').select(); find('.lobby-status').textContent = 'Markeringen är klar. Kopiera koden manuellt.'; } + }); + listen(find('.disconnect'), 'click', () => { + connectionAttempt++; + network?.close(); + network = null; + ghost = null; + pendingHits.clear(); + find('.incoming-code').value = find('.outgoing-code').value = ''; + find('.incoming-type').textContent = ''; + find('.host-game').disabled = find('.join-game').disabled = false; + find('.accept-answer').hidden = true; + find('.join-game').hidden = false; + find('.code-label').textContent = 'Din kod att skicka'; + find('.copy-code').disabled = true; + find('.lobby-status').textContent = 'Skapa ett nytt spel eller svara på en inbjudan.'; + find('.connection-status').textContent = 'Soloflygning'; + }); resize(); launchButton.focus({ preventScroll: true }); window.scrollTo({ left: initialScroll.x, top: initialScroll.y, behavior: 'instant' }); diff --git a/assets/space-egg/multiplayer.js b/assets/space-egg/multiplayer.js new file mode 100644 index 0000000..15ef2e9 --- /dev/null +++ b/assets/space-egg/multiplayer.js @@ -0,0 +1,98 @@ +export function encodeSignal(description) { + const prefix = description.type === 'offer' ? 'ATHEGA-INBJUDAN:' : 'ATHEGA-SVAR:'; + return prefix + btoa(JSON.stringify({ version: 1, type: description.type, sdp: description.sdp })); +} + +export function decodeSignal(value, expected) { + if (typeof value !== 'string' || value.length > 32000) throw new Error('Koden är för lång eller saknas.'); + const compact = value.replace(/\s/g, ''); + const encoded = compact.replace(/^ATHEGA-(INBJUDAN|SVAR):/i, ''); + let description; + try { description = JSON.parse(atob(encoded)); } catch { throw new Error('Koden gick inte att läsa. Kopiera hela koden med knappen Kopiera kod.'); } + if (!description || description.version !== 1 || !['offer', 'answer'].includes(description.type) || typeof description.sdp !== 'string') { + throw new Error('Det här är inte en giltig spelkod. Skapa en ny kod.'); + } + if (expected && description.type !== expected) { + throw new Error(expected === 'answer' + ? 'Det här är en INBJUDAN, inte ett svar. Kompisen måste klicka Svara på inbjudan och skicka tillbaka den nya koden märkt ATHEGA-SVAR.' + : 'Det här är en SVARSKOD. Den ska tillbaka till värden, som väljer Anslut med svarskoden.'); + } + return { type: description.type, sdp: description.sdp }; +} + +// Manual WebRTC signaling: the users exchange the offer and answer themselves. +export function createMultiplayer({ onMessage, onConnected, onStatus }) { + let peer; + let channel; + let host = false; + let timer; + let closed = false; + const controller = new AbortController(); + + function attach(dataChannel) { + channel = dataChannel; + channel.onopen = () => { clearTimeout(timer); onStatus('Två piloter anslutna'); onConnected(); }; + channel.onclose = () => { if (!closed) onStatus('Kompisen kopplade från. Du kan fortsätta själv.'); }; + channel.onerror = () => { if (!closed) onStatus('Anslutningen avbröts. Prova nya koder.'); }; + channel.onmessage = event => { + if (typeof event.data !== 'string' || event.data.length > 48000) return; + try { const message = JSON.parse(event.data); if (message && typeof message.type === 'string') onMessage(message); } catch { /* Ignore malformed peer messages. */ } + }; + } + + function create(isHost) { + if (peer) throw new Error('Koppla från innan du skapar en ny anslutning.'); + host = isHost; + peer = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.cloudflare.com:3478' }] }); + peer.ondatachannel = event => attach(event.channel); + peer.onconnectionstatechange = () => { + if (peer.connectionState === 'failed') onStatus('Ingen kontakt. Nätverket kan kräva en reläserver. Prova ett annat nätverk.'); + if (peer.connectionState === 'disconnected') onStatus('Kontakten bröts. Försöker återansluta…'); + if (peer.connectionState === 'connected') onStatus('Två piloter anslutna'); + }; + if (host) attach(peer.createDataChannel('athega-space', { ordered: true })); + } + + async function code() { + if (peer.iceGatheringState !== 'complete') { + await new Promise((resolve, reject) => { + let wait; + const finish = () => { clearTimeout(wait); peer.removeEventListener('icegatheringstatechange', change); controller.signal.removeEventListener('abort', abort); resolve(); }; + const change = () => { if (peer.iceGatheringState === 'complete') finish(); }; + const abort = () => { finish(); reject(new Error('Anslutningen stängdes.')); }; + wait = setTimeout(finish, 10000); + peer.addEventListener('icegatheringstatechange', change); + controller.signal.addEventListener('abort', abort, { once: true }); + }); + } + if (closed) throw new Error('Anslutningen stängdes.'); + return encodeSignal(peer.localDescription); + } + + + function waitForConnection() { + clearTimeout(timer); + timer = setTimeout(() => { + if (!channel?.readyState || channel.readyState !== 'open') onStatus('Ingen kontakt ännu. Kontrollera svarskoden eller prova ett annat nätverk.'); + }, 25000); + } + + return { + get connected() { return channel?.readyState === 'open'; }, + get isHost() { return host; }, + async offer() { create(true); await peer.setLocalDescription(await peer.createOffer()); return code(); }, + async answer(value) { + const offer = decodeSignal(value, 'offer'); + create(false); + await peer.setRemoteDescription(offer); + await peer.setLocalDescription(await peer.createAnswer()); + return code(); + }, + async accept(value) { await peer.setRemoteDescription(decodeSignal(value, 'answer')); waitForConnection(); }, + send(message) { + if (channel?.readyState !== 'open' || channel.bufferedAmount > 65536) return; + channel.send(JSON.stringify(message)); + }, + close() { closed = true; clearTimeout(timer); controller.abort(); channel?.close(); peer?.close(); }, + }; +} diff --git a/assets/space-egg/navigation.js b/assets/space-egg/navigation.js new file mode 100644 index 0000000..08a92a6 --- /dev/null +++ b/assets/space-egg/navigation.js @@ -0,0 +1,8 @@ +export function internalDestination(href, currentUrl) { + const destination = new URL(href, currentUrl); + 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/scoring.js b/assets/space-egg/scoring.js new file mode 100644 index 0000000..9ff24f7 --- /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 sectorReward(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 index 74c54ca..8bffcbb 100644 --- a/assets/space-egg/sound.js +++ b/assets/space-egg/sound.js @@ -56,6 +56,7 @@ export function createSound() { 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)); }, shoot() { tone(950, 140, 0.12, 'triangle'); }, explode() { if (!context || !enabled) return; diff --git a/assets/space-egg/trigger.js b/assets/space-egg/trigger.js index b4c2058..af08c64 100644 --- a/assets/space-egg/trigger.js +++ b/assets/space-egg/trigger.js @@ -10,7 +10,7 @@ async function reveal() { entered = []; clicks = []; try { - const { openGame } = await import('./game.js?v=1'); + const { openGame } = await import('./game.js?v=3'); openGame(() => { active = false; }); } catch (error) { active = false; diff --git a/scripts/space-scoring.test.mjs b/scripts/space-scoring.test.mjs new file mode 100644 index 0000000..b0745d5 --- /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, sectorReward } 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 sector faster gives more points, never negative', () => { + assert.ok(sectorReward(5, 5) > sectorReward(5, 10)); + assert.equal(sectorReward(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..5adfd32 --- /dev/null +++ b/scripts/space-signaling.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { encodeSignal, decodeSignal } from '../assets/space-egg/multiplayer.js'; + +test('invitation and reply have visibly distinct prefixes and round-trip', () => { + for (const [type, prefix] of [['offer', 'ATHEGA-INBJUDAN:'], ['answer', 'ATHEGA-SVAR:']]) { + const description = { type, sdp: 'v=0\r\ns=test\r\n' }; + const code = encodeSignal(description); + assert.ok(code.startsWith(prefix)); + assert.deepEqual(decodeSignal(code, type), description); + assert.deepEqual(decodeSignal(' ' + code.slice(0, 30) + '\n' + code.slice(30), type), description); + } +}); +test('returning the original invitation explains the missing answer step', () => { + assert.throws(() => decodeSignal(encodeSignal({ type: 'offer', sdp: 'v=0' }), 'answer'), /INBJUDAN.*Svara på inbjudan.*ATHEGA-SVAR/); +}); +test('old unprefixed codes still work and invalid codes are rejected clearly', () => { + assert.equal(decodeSignal(btoa(JSON.stringify({ version: 1, type: 'answer', sdp: 'v=0' })), 'answer').type, 'answer'); + assert.throws(() => decodeSignal('inte en kod'), /gick inte att läsa/); + assert.throws(() => decodeSignal(btoa('null')), /giltig spelkod/); +}); From 6850eb740811b4c26e1b39941fe5baef30ca0cf6 Mon Sep 17 00:00:00 2001 From: Christian Lizell Date: Wed, 7 Oct 2026 14:50:18 +0200 Subject: [PATCH 03/11] Add lazy PeerJS invitations, four-player co-op and pilot names --- README.md | 54 +++--- _includes/layout.html | 2 +- assets/space-egg/game.css | 6 +- assets/space-egg/game.js | 168 ++++++++++-------- assets/space-egg/invitation.js | 23 +++ assets/space-egg/multiplayer.js | 292 +++++++++++++++++++++++-------- assets/space-egg/trigger.js | 17 +- eleventy.config.js | 5 + package-lock.json | 73 ++++++++ package.json | 1 + scripts/space-signaling.test.mjs | 37 ++-- 11 files changed, 486 insertions(+), 192 deletions(-) create mode 100644 assets/space-egg/invitation.js diff --git a/README.md b/README.md index 0a2c9db..b8e6379 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,8 @@ Om utvecklingsservern får en annan port än 8080 används adressen den skriver 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. Inga nya npm-paket. +triggern laddas normalt; spel, ljud och CSS laddas vid aktivering. PeerJS laddas först +när spelaren väljer Skapa spel eller Anslut. ### Kontroller och poäng @@ -507,31 +508,30 @@ triggern laddas normalt; spel, ljud och CSS laddas vid aktivering. Inga nya npm- - Esc/Avsluta återställer sidan. Efter virtuella sidbyten laddas den aktuella destinationen om till den vanliga sajten, med rätt metadata och normala skript. -### Två spelare med två koder - -Båda ska öppna samma version av sajten på samma origin (protokoll, värd och port). -För lokal provkörning fungerar två fönster/webbläsare mot samma localhost-adress. -En localhost-adress på din dator kan inte öppnas direkt på kompisens dator. - -1. Båda aktiverar spelet och väljer **Två spelare**. -2. Värden väljer **Skapa spel**, kopierar **Inbjudningskod – skicka till kompisen** (`ATHEGA-INBJUDAN:…`) och skickar den - till kompisen. Kodgenereringen kan ta upp till tio sekunder. -3. Kompisen klistrar in den under **Inkommande kod** och väljer **Svara på - inbjudan**. Kompisens nya **Svarskod – skicka tillbaka till värden** (`ATHEGA-SVAR:…`) - skickas tillbaka till värden. Skicka inte tillbaka den ursprungliga inbjudan. -4. Värden klistrar in svaret under **Inkommande kod** och väljer **Anslut med - svarskoden**. Båda får status **Två piloter** när anslutningen är uppe. -5. Ni röjer tillsammans: den andra piloten är blå, träffar och poäng delas och - båda följer med vid sektor-/sidbyte. Byt uppdrag fungerar även efter full rensning. - -WebRTC DataChannel skickar speldata direkt mellan webbläsarna. Koderna ersätter -signaleringstjänsten. STUN (`stun.cloudflare.com:3478`) används bara när man -väljer multiplayer för att hitta en nätverksväg. Ingen kamera/mikrofon används, -inga spelaruppgifter sparas och ingen spelserver tar emot innehållet. -Det finns ingen TURN-reläserver i prototypen: vissa företagsnät och mobilnät -kan därför inte koppla ihop sig. Vid misslyckad anslutning kan man nollställa, -skapa nya koder eller prova ett annat nätverk. Om en spelare lämnar fortsätter -kompisen i sololäge. +### Upp till fyra spelare med en inbjudningslänk + +1. Aktivera spelet och välj **Spela tillsammans**. Skriv ett valfritt pilotnamn. +2. Välj **Skapa spel**, 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**. + Ingen svarskod behövs. Namnen visas vid skeppen och i spelarlistan. + +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 spel 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. +Pilotnamn hålls i minnet och delas med deltagarna; de 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. Skeppens visuella positioner normaliseras @@ -540,6 +540,6 @@ 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. Koden finns i `assets/space-egg/`: `trigger.js`, `game.js`, `game.css`, -`physics.js`, `scoring.js`, `sound.js`, `navigation.js` och `multiplayer.js`. +`physics.js`, `scoring.js`, `sound.js`, `navigation.js`, `invitation.js` och `multiplayer.js`. Den äldre `assets/site.js` används inte. Vid ändring av redan publicerade spelmoduler behöver versionsparametrarna på deras import-/resurslänkar uppdateras. diff --git a/_includes/layout.html b/_includes/layout.html index 327a4cd..5de32f8 100644 --- a/_includes/layout.html +++ b/_includes/layout.html @@ -30,6 +30,6 @@ {% include "header.html" %} {{ content }} {% include "footer.html" %} - + diff --git a/assets/space-egg/game.css b/assets/space-egg/game.css index 6ac3b81..9fd32d0 100644 --- a/assets/space-egg/game.css +++ b/assets/space-egg/game.css @@ -59,7 +59,7 @@ 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; } -textarea, 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; } +input, textarea, 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; } textarea { resize: vertical; } button:disabled { opacity: .45; cursor: default; } .bottom { gap: 8px; } @@ -71,3 +71,7 @@ button:disabled { opacity: .45; cursor: default; } .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; } diff --git a/assets/space-egg/game.js b/assets/space-egg/game.js index c5a241c..089c6bd 100644 --- a/assets/space-egg/game.js +++ b/assets/space-egg/game.js @@ -1,10 +1,11 @@ import { hitsRect } from './physics.js?v=3'; import { createSound } from './sound.js?v=3'; import { internalDestination } from './navigation.js?v=3'; -import { createMultiplayer, decodeSignal } from './multiplayer.js?v=3'; +import { createMultiplayer, MAX_PLAYERS } from './multiplayer.js?v=4'; +import { invitationLink, invitationPeer } from './invitation.js?v=4'; import { hitReward, sectorReward } from './scoring.js?v=3'; -export function openGame(onClose) { +export function openGame(onClose, invitedPeerId = null) { const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; const previousFocus = document.activeElement; const originalUrl = location.href; @@ -12,7 +13,7 @@ export function openGame(onClose) { let loadingPage = false; let pageChanged = false; let network = null; - let ghost = null; + const ghosts = new Map(); let networkTime = 0; let messageQueue = Promise.resolve(); let connectionAttempt = 0; @@ -27,12 +28,12 @@ export function openGame(onClose) { host.style.cssText = 'position:fixed;inset:0;z-index:2147483647'; const shadow = host.attachShadow({ mode: 'open' }); shadow.innerHTML = ` - +
-
Athega / Space00000×1 · 0.0 sSoloflygningSektor 01
-
+
Athega / Space00000×1 · 0.0 sSoloflygning
    Sektor 01
    +