Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,7 @@ eller borttagning av äldre filer och bloggdemon.

Kör `npm run build` efter ändringar, eller `npm start` för lokal förhandsvisning.
Om utvecklingsservern får en annan port än 8080 används adressen den skriver ut.

## Hemligt rymdspel

Kontroller, multiplayer, arkitektur och tester finns i [spelets README](docs/space-egg/README.md).
1 change: 1 addition & 0 deletions _includes/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@
{% include "header.html" %}
{{ content }}
{% include "footer.html" %}
<script type="module" src="/assets/space-egg/trigger.js?v=14"></script>
</body>
</html>
76 changes: 76 additions & 0 deletions assets/space-egg/arena.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export const WORLD_WIDTH = 1280;
export const WORLD_HEIGHT = 800;

// Every pilot uses this layout viewport. Only the camera changes with the screen.
export function cameraFor(viewWidth, viewHeight, ship) {
const scale = Math.min(1, Math.max(viewWidth / WORLD_WIDTH, viewHeight / WORLD_HEIGHT));
const visibleWidth = viewWidth / scale;
const visibleHeight = viewHeight / scale;
const x = Math.max(0, Math.min(WORLD_WIDTH - visibleWidth, ship.x - visibleWidth / 2));
const y = Math.max(0, Math.min(WORLD_HEIGHT - visibleHeight, ship.y - visibleHeight / 2));
return {
x, y, scale,
left: Math.max(0, (viewWidth - WORLD_WIDTH * scale) / 2) - x * scale,
top: Math.max(0, (viewHeight - WORLD_HEIGHT * scale) / 2) - y * scale,
visibleWidth: Math.min(WORLD_WIDTH, visibleWidth),
visibleHeight: Math.min(WORLD_HEIGHT, visibleHeight),
};
}

export async function createArena(url, { signal } = {}) {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error('Spelplanen kunde inte laddas.');
const page = new DOMParser().parseFromString(await response.text(), 'text/html');
page.querySelectorAll('script, base, meta[http-equiv="refresh"]').forEach(node => node.remove());
const base = page.createElement('base');
base.href = response.url;
page.head.prepend(base);
const style = page.createElement('style');
style.textContent = 'html { scrollbar-width: none; scroll-behavior: auto !important; } ::-webkit-scrollbar { display: none; }';
page.head.append(style);
const container = document.createElement('div');
container.id = 'athega-space-arena';
container.inert = true;
container.setAttribute('aria-hidden', 'true');
container.style.cssText = 'visibility:hidden;position:fixed;inset:0;overflow:hidden;background:#050b14;z-index:2147483646;pointer-events:none';
const iframe = document.createElement('iframe');
iframe.title = 'Gemensam spelplan';
iframe.setAttribute('sandbox', 'allow-same-origin');
iframe.style.cssText = `position:absolute;width:${WORLD_WIDTH}px;height:${WORLD_HEIGHT}px;border:0;transform-origin:0 0`;
container.append(iframe);
iframe.srcdoc = '<!doctype html>' + page.documentElement.outerHTML;
document.body.append(container);
try {
// A sandboxed document may eagerly fetch archive images. The arena only needs
// its DOM, styles and fonts; waiting for window.load stalls large blog pages.
await new Promise((resolve, reject) => {
const started = performance.now();
const poll = setInterval(() => {
if (signal?.aborted) { clearInterval(poll); reject(signal.reason); return; }
const doc = iframe.contentDocument;
// Force layout so web fonts are requested before checking their status.
doc?.documentElement?.getBoundingClientRect();
if (doc?.querySelector('main') && doc.readyState !== 'loading'
&& [...doc.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && doc.fonts.status === 'loaded') {
clearInterval(poll);
resolve();
} else if (performance.now() - started > 15000) {
clearInterval(poll);
reject(new Error('Spelplanen tog för lång tid att ladda.'));
}
}, 50);
});
} catch (error) {
container.remove();
throw error;
}
return {
container,
document: iframe.contentDocument,
window: iframe.contentWindow,
position(camera) {
iframe.style.transform = `translate(${camera.left}px, ${camera.top}px) scale(${camera.scale})`;
},
close() { container.remove(); },
};
}
44 changes: 44 additions & 0 deletions assets/space-egg/combat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export const POWERUPS = {
triple: { label: '3X', name: 'Trippelskott', duration: 12 },
mine: { label: 'MIN', name: 'Mina · placera med M', duration: 0 },
shield: { label: 'SK', name: 'Sköld', duration: 10 },
bomb: { label: 'BOM', name: 'Bomb · släpp med B', duration: 0 },
rapid: { label: 'RF', name: 'Snabbeld', duration: 12 },
repair: { label: '+', name: 'Reparation +40', duration: 0 },
turbo: { label: 'T', name: 'Turbo', duration: 10 },
scroll: { label: 'S↕', name: 'Snabbscroll för alla', duration: 8 },
gravity: { label: 'G↓', name: 'Gravitation för alla', duration: 8 },
};

export function damageHull(hull, amount, protectedShip) {
if (protectedShip || hull <= 0) return hull;
return Math.max(0, hull - amount);
}

export function expandedRect(rect, radius) {
return { left: rect.left - radius, right: rect.right + radius, top: rect.top - radius, bottom: rect.bottom + radius };
}

export function effectSeconds(deadline, now) {
return Math.max(0, Math.min(POWERUPS.gravity.duration, (deadline - now) / 1000));
}

// Reflect at the document edges; keep fractional pixels between animation frames.
export function advanceScroll(position, direction, distance, maximum) {
if (maximum <= 0) return { position: 0, direction: 1 };
let next = Math.max(0, Math.min(maximum, position)) + direction * distance;
while (next < 0 || next > maximum) {
if (next > maximum) { next = maximum * 2 - next; direction = -1; }
if (next < 0) { next = -next; direction = 1; }
}
return { position: next, direction };
}

export function shipContact(ship, other, fallbackSide = 1) {
const dx = ship.x - other.x;
const dy = ship.y - other.y;
const distance = Math.hypot(dx, dy);
const separation = 30;
if (distance >= separation) return null;
return { nx: distance ? dx / distance : fallbackSide, ny: distance ? dy / distance : 0, overlap: separation - distance };
}
120 changes: 120 additions & 0 deletions assets/space-egg/game.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
:host { all: initial; position: fixed; inset: 0; z-index: 2147483647; color: #fff; font: 14px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; }
* { box-sizing: border-box; }
.world-canvas { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }
button { font: inherit; cursor: pointer; color: inherit; border: 1px solid #5d6267; background: #151a20; border-radius: 8px; min-height: 44px; padding: 9px 13px; touch-action: manipulation; }
button:hover { background: #29313a; border-color: #ff6600; }
button:focus-visible { outline: 2px solid #ff8c40; outline-offset: 3px; }
[hidden] { display: none !important; }
.hud { position: absolute; z-index: 2; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); top: 12px; display: flex; justify-content: space-between; align-items: start; gap: 12px; pointer-events: none; }
.hud > * { pointer-events: auto; }
.scoreboard { background: #111820f2; border: 1px solid #4b535d; border-left: 3px solid #ff6600; border-radius: 6px; padding: 10px 16px; box-shadow: 0 8px 32px #0003; }
.pace { display: block; font-size: 11px; color: #ffb480; font-variant-numeric: tabular-nums; }
.power-status { display: block; color: #8ee7d1; font-size: 10px; margin-top: 4px; }
.kicker { display: block; font-size: 10px; letter-spacing: .16em; text-transform: uppercase; color: #ff9b58; }
.score { display: block; font-size: 25px; font-variant-numeric: tabular-nums; line-height: 1.2; }
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; max-width: min(65%, 380px); gap: 8px; }
.briefing { position: absolute; inset: 0; display: grid; place-items: center; padding: 20px; background: #08101aa8; backdrop-filter: blur(5px); }
.panel { width: min(100%, 440px); background: #101820; border: 1px solid #49525c; border-top: 3px solid #ff6600; border-radius: 16px; padding: 32px; box-shadow: 0 30px 100px #0007; }
h1 { font: 500 clamp(30px, 7vw, 42px)/1.1 system-ui, sans-serif; letter-spacing: -.04em; margin: 12px 0 20px; }
p { color: #c5cdd5; margin: 12px 0; }
.primary { background: #ff6600; color: #111315; border-color: #ff6600; font-weight: 700; width: 100%; margin-top: 16px; }
.primary:hover { background: #ff8b3d; }
.instructions { font-size: 12px; border-block: 1px solid #36414c; padding: 14px 0; }
.bottom { position: absolute; bottom: max(20px, env(safe-area-inset-bottom)); left: 16px; right: 16px; display: flex; justify-content: center; pointer-events: none; }
.bottom button { pointer-events: auto; background: #101820ed; }
.hint { position: absolute; bottom: 78px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 32px); text-align: center; padding: 6px 12px; border-radius: 6px; background: #101820ed; color: #dce2e7; font-size: 11px; pointer-events: none; }
.touch { display: none; }
@media (pointer: coarse), (max-width: 640px) {
.touch { display: flex; position: absolute; bottom: max(22px, env(safe-area-inset-bottom)); align-items: center; justify-content: center; }
.stick { left: 20px; width: 104px; height: 104px; border: 1px solid #74808b; background: #101820df; border-radius: 50%; touch-action: none; }
.stick::before { content: ''; position: absolute; inset: 34px; border: 1px solid #ff9b58; border-radius: 50%; background: #ff660033; transform: translate(var(--stick-x, 0px), var(--stick-y, 0px)); }
.stick span { position: absolute; bottom: 9px; font-size: 9px; letter-spacing: .1em; color: #c5cdd5; }
.navigate { right: 110px; width: 60px; height: 60px; bottom: max(32px, env(safe-area-inset-bottom)); border-radius: 50%; border: 2px solid #52d8f5; background: #122934; color: #a5edff; font-weight: bold; touch-action: none; }
.fire { right: 20px; width: 80px; height: 80px; border-radius: 50%; background: #ff6600; border: 3px solid #ffae77; color: #111315; font-weight: bold; touch-action: none; box-shadow: 0 0 24px #ff660033; }
.bottom { bottom: 148px; }
.hint { bottom: 204px; }
.scoreboard { padding: 8px 10px; }
.score { font-size: 21px; }
.actions { gap: 5px; }
.actions button { padding: 8px; font-size: 12px; }
.panel { padding: 24px; }
}

@media (max-height: 600px) {
.briefing { display: block; overflow-y: auto; padding-top: 160px; }
.panel { margin: 0 auto; }
}

@media (max-width: 360px) {
.actions { max-width: 170px; flex-wrap: wrap; justify-content: flex-end; }
.stick { left: 12px; width: 88px; height: 88px; }
.fire { right: 12px; }
.navigate { right: 102px; }
}

.connection-status { display: block; font-size: 9px; color: #a6bacb; max-width: 135px; }
.lobby, .missions { position: absolute; inset: 0; z-index: 5; overflow-y: auto; padding: 24px; background: #07101de8; display: flex; }
.lobby .panel, .missions .panel { margin: auto; }
h2 { font: 500 28px/1.2 system-ui, sans-serif; margin: 12px 0; }
.lobby p, .missions p { font-size: 12px; }
.lobby-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
label { display: block; font-size: 12px; color: #c5d4e1; margin: 12px 0; }
input, select { display: block; width: 100%; margin-top: 6px; padding: 10px; border: 1px solid #536b80; border-radius: 6px; background: #08121d; color: #e5f3ff; font: 12px/1.4 ui-monospace, monospace; }
button:disabled { opacity: .45; cursor: default; }
.bottom { gap: 8px; }
@media (max-width: 640px) {
.hud { gap: 6px; }
.scoreboard { max-width: 128px; }
.actions { max-width: 62%; }
.actions button { min-height: 36px; font-size: 11px; padding: 6px 8px; }
.lobby, .missions { padding: 12px; }
.lobby .panel, .missions .panel { padding: 20px; }
}

.local-invite { border-left: 2px solid #ff6600; padding-left: 10px; }

.pilot-list { list-style: none; margin: 6px 0 0; padding: 0; font-size: 10px; max-width: 140px; overflow-wrap: anywhere; }

.hull-status { display: block; color: #e5f3ff; font-size: 11px; margin-top: 6px; }
.gravity-status { display: block; color: #ffd16c; font-size: 11px; margin-top: 6px; }
.rules, .join-manually { border-top: 1px solid #36414c; margin-top: 16px; padding-top: 12px; }
summary { cursor: pointer; font-size: 12px; padding: 5px 0; }
summary span, .name-label > span { color: #a6b6c5; font-size: 10px; }
.rule { display: flex; align-items: center; gap: 12px; cursor: pointer; }
.rule input { width: 20px; height: 20px; margin: 0; accent-color: #ff6600; }
.rule small { display: block; color: #a6b6c5; font-size: 11px; }
.rules p { font-size: 11px; }
.lobby-status:empty { display: none; }
.lobby-actions { justify-content: space-between; }
.disconnect { border-color: transparent; color: #a6b6c5; }
input:focus-visible, summary:focus-visible { outline: 2px solid #ff8c40; outline-offset: 3px; }
.crew { margin: 18px 0; padding: 12px 14px; border: 1px solid #36414c; border-radius: 8px; background: #08121d; }
.crew h3 { display: flex; justify-content: space-between; margin: 0 0 8px; font: 12px ui-monospace, monospace; color: #c5d4e1; }
.crew-list { list-style: none; margin: 0; padding: 0; font-size: 13px; overflow-wrap: anywhere; }
.crew-list li { padding: 4px 0; }
.crew-list li::before { content: '●'; margin-right: 10px; }

.radar { position: absolute; right: 16px; bottom: 84px; width: 132px; padding: 2px; border: 1px solid #4b657a; background: #08121ded; pointer-events: none; }
.radar canvas { display: block; width: 128px; height: 80px; }
.radar span { display: block; font-size: 8px; text-align: center; letter-spacing: .2em; color: #aec2d3; }
.offscreen-pilots { position: absolute; inset: 0; pointer-events: none; }
.offscreen-pilot { position: absolute; transform: translate(-50%, -50%); background: #08121deb; border: 1px solid currentColor; padding: 4px 7px; border-radius: 6px; font-size: 10px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (pointer: coarse), (max-width: 640px) {
.radar { top: 110px; bottom: auto; right: 12px; transform: scale(.8); transform-origin: top right; }
}

.round-clock { display: block; font-size: 22px; font-variant-numeric: tabular-nums; color: #ffd18e; }
.kill-count { display: block; font-size: 10px; margin-bottom: 4px; }
.results { position: absolute; inset: 0; z-index: 6; display: flex; overflow-y: auto; padding: 24px; background: #07101df2; }
.results .panel { margin: auto; }
.result-list { padding: 0; list-style: none; font-size: 13px; }
.result-list li { padding: 12px 0; border-bottom: 1px solid #36414c; }
.result-list .winner { color: #ffb26f; }
.result-exit { margin-top: 12px; }

.ordnance { position: absolute; right: 16px; bottom: 20px; display: flex; gap: 8px; }
.ordnance button { min-width: 80px; border-color: #ba853f; }
@media (pointer: coarse), (max-width: 900px) {
.ordnance { left: 16px; right: auto; bottom: 196px; }
.hint { display: none; }
}
Loading
Loading