feat: integrate upstream modules (calendars, chats, games, logs, maps, pads, shops, torrents)

New views, models, routes for 8 new modules from latest upstream.
Updated existing views/models to upstream versions preserving all
local QR, hamburger menu, and mobile CSS modifications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SITO 2026-05-02 00:52:18 +02:00
parent cb32d0b9ad
commit 6de67ba129
11073 changed files with 30855 additions and 6595 deletions

View file

@ -0,0 +1,89 @@
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import express from '../server/node_modules/express/index.js';
import cors from '../server/node_modules/cors/lib/index.js';
import { getLlama, LlamaChatSession, LlamaCompletion } from '../server/node_modules/node-llama-cpp/dist/index.js';
let getConfig, buildAIContext;
try {
getConfig = (await import('../configs/config-manager.js')).getConfig;
} catch {}
try {
const mod = await import('./buildAIContext.js');
buildAIContext = mod.default || mod.buildContext;
} catch {}
const app = express();
app.use(cors());
app.use(express.json());
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
let llamaInstance, model, context, session;
let rawContext, rawCompletion;
let ready = false;
let lastError = null;
async function initModel() {
if (model) return;
const modelPath = path.join(__dirname, 'oasis-42-1-chat.Q4_K_M.gguf');
if (!fs.existsSync(modelPath)) {
throw new Error(`Model file not found at: ${modelPath}`);
}
llamaInstance = await getLlama({ gpu: false });
model = await llamaInstance.loadModel({ modelPath });
context = await model.createContext();
session = new LlamaChatSession({ contextSequence: context.getSequence() });
ready = true;
}
async function initRaw() {
if (rawCompletion) return;
if (!model) await initModel();
rawContext = await model.createContext();
rawCompletion = new LlamaCompletion({ contextSequence: rawContext.getSequence() });
}
app.post('/ai', async (req, res) => {
try {
const sanitize = (s) => String(s || '').replace(/[<>"'`]/g, '').replace(/\b(ignore|disregard|forget|system|instruction|prompt)\b/gi, '[$1]').trim();
const userInput = sanitize(String(req.body.input || ''));
if (req.body.raw === true) {
await initRaw();
const answer = await rawCompletion.generateCompletion(userInput, { maxTokens: 120 });
return res.json({ answer: String(answer || '').trim(), snippets: [] });
}
await initModel();
let userContext = '';
let snippets = [];
try {
userContext = await (buildAIContext ? buildAIContext(120) : '');
if (userContext) {
userContext = userContext.split('\n').map(l => sanitize(l)).join('\n');
snippets = userContext.split('\n').slice(0, 50);
}
} catch {}
const config = getConfig?.() || {};
const baseContext = 'Context: You are an AI assistant called "42" in Oasis, a distributed, encrypted and federated social network.';
const userPrompt = [baseContext, config.ai?.prompt?.trim() || 'Provide an informative and precise response.'].join('\n');
const prompt = [
userPrompt,
userContext ? `--- USER DATA START ---\n${userContext}\n--- USER DATA END ---` : '',
`--- QUERY START ---\n${userInput}\n--- QUERY END ---`
].filter(Boolean).join('\n\n');
const answer = await session.prompt(prompt);
res.json({ answer: String(answer || '').trim(), snippets });
} catch {}
});
app.post('/ai/train', async (req, res) => {
res.json({ stored: true });
});
app.listen(4001);

View file

@ -0,0 +1,104 @@
const pull = require('../server/node_modules/pull-stream')
const { getConfig } = require('../configs/config-manager.js')
const logLimit = getConfig().ssbLogStream?.limit || 1000
let cooler = null
let ssb = null
let opening = null
function getCooler() {
let ssbPath = null
try { ssbPath = require.resolve('../server/SSB_server.js') } catch {}
if (ssbPath && require.cache[ssbPath]) {
if (!cooler) {
const gui = require('../client/gui.js')
cooler = gui({ offline: false })
}
return cooler
}
return null
}
async function openSsb() {
const c = getCooler()
if (!c) return null
if (ssb && ssb.closed === false) return ssb
if (!opening) opening = c.open().then(x => (ssb = x)).finally(() => { opening = null })
await opening
return ssb
}
const clip = (s, n) => String(s || '').slice(0, n)
const squash = s => String(s || '').replace(/\s+/g, ' ').trim()
const compact = s => squash(clip(s, 160))
const normalize = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\p{L}\p{N}\s]+/gu, '').trim()
function fieldsForSnippet(type, c) {
if (type === 'aiExchange') return [c?.question, clip(squash(c?.answer || ''), 120)]
return []
}
async function publishExchange({ q, a, ctx = [], tokens = {} }) {
const s = await openSsb()
if (!s) return null
const content = {
type: 'aiExchange',
question: clip(String(q || ''), 2000),
answer: clip(String(a || ''), 5000),
ctx: ctx.slice(0, 12).map(x => clip(String(x || ''), 800)),
timestamp: Date.now()
}
return new Promise((resolve, reject) => {
s.publish(content, (err, res) => err ? reject(err) : resolve(res))
})
}
async function buildContext(maxItems = 100) {
const s = await openSsb()
if (!s) return ''
return new Promise((resolve) => {
pull(
s.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, msgs) => {
if (err || !Array.isArray(msgs)) return resolve('')
const lines = []
for (const { value } of msgs) {
const c = value && value.content || {}
if (c.type !== 'aiExchange') continue
const d = new Date(value.timestamp || 0).toISOString().slice(0, 10)
const q = compact(c.question)
const a = compact(c.answer)
lines.push(`[${d}] (AIExchange) Q: ${q} | A: ${a}`)
if (lines.length >= maxItems) break
}
if (lines.length === 0) return resolve('')
resolve(`## AIEXCHANGE\n\n${lines.join('\n')}`)
})
)
})
}
async function getBestTrainedAnswer(question) {
const s = await openSsb()
if (!s) return null
const want = normalize(question)
return new Promise((resolve) => {
pull(
s.createLogStream({ reverse: true, limit: logLimit }),
pull.collect((err, msgs) => {
if (err || !Array.isArray(msgs)) return resolve(null)
for (const { value } of msgs) {
const c = value && value.content || {}
if (c.type !== 'aiExchange') continue
if (normalize(c.question) === want) {
return resolve({ answer: String(c.answer || '').trim(), ctx: Array.isArray(c.ctx) ? c.ctx : [] })
}
}
resolve(null)
})
)
})
}
module.exports = { fieldsForSnippet, buildContext, clip, publishExchange, getBestTrainedAnswer }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,206 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
const splitSegments = (line) => {
const segs = [];
let last = 0;
const re = new RegExp(linkPattern.source, 'g');
let m;
while ((m = re.exec(line)) !== null) {
if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
segs.push({ t: m[0], l: true });
last = m.index + m[0].length;
}
if (last < line.length) segs.push({ t: line.slice(last), l: false });
return segs;
};
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildLogsPdf(entries, oasisId, opts = {}) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 22;
const bodyBottom = footerH + 10;
const lineH = 12;
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const allLines = [];
for (const e of entries) {
const ts = new Date(e.ts);
const when = ts.toISOString().replace('T', ' ').slice(0, 19);
allLines.push({ kind: 'header', text: `[${when}]:` });
allLines.push({ kind: 'blank', text: '' });
for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
allLines.push({ kind: 'blank', text: '' });
}
if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
const pages = [];
for (let i = 0; i < allLines.length; i += maxBodyLines) {
pages.push(allLines.slice(i, i + maxBodyLines));
}
if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
const logoX = marginX;
const logoY = pageH - headerH + 15;
parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Experience logs')}) Tj\nET`);
const inhabitantPrefix = 'Inhabitant: ';
const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
const charW = 6;
for (const ln of pg) {
if (ln.kind === 'header') {
parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.text) {
const segs = splitSegments(ln.text);
let x = marginX;
for (const s of segs) {
if (!s.t) continue;
const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
x += s.t.length * charW;
}
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) {
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildLogsPdf };

View file

@ -44,7 +44,15 @@ if (!fs.existsSync(configFilePath)) {
"bankingMod": "on",
"parliamentMod": "off",
"courtsMod": "off",
"favoritesMod": "off"
"favoritesMod": "off",
"padsMod": "on",
"calendarsMod": "on",
"gamesMod": "on",
"shopsMod": "on",
"logsMod": "on",
"mapsMod": "on",
"chatsMod": "on",
"torrentsMod": "on"
},
"wallet": {
"url": "http://localhost:7474",
@ -53,9 +61,7 @@ if (!fs.existsSync(configFilePath)) {
"fee": "5"
},
"walletPub": {
"url": "",
"user": "",
"pass": ""
"pubId": ""
},
"ai": {
"prompt": "Provide an informative and precise response."
@ -64,14 +70,19 @@ if (!fs.existsSync(configFilePath)) {
"limit": 2000
},
"homePage": "activity",
"language": "en"
"language": "en",
"wish": "whole",
"pmVisibility": "whole"
};
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
}
const getConfig = () => {
const configData = fs.readFileSync(configFilePath);
return JSON.parse(configData);
const cfg = JSON.parse(configData);
if (cfg.wish !== 'whole' && cfg.wish !== 'mutuals') cfg.wish = 'whole';
if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole';
return cfg;
};
const saveConfig = (newConfig) => {

View file

@ -38,7 +38,15 @@
"bankingMod": "on",
"parliamentMod": "off",
"courtsMod": "off",
"favoritesMod": "off"
"favoritesMod": "off",
"padsMod": "on",
"calendarsMod": "on",
"gamesMod": "on",
"shopsMod": "on",
"logsMod": "on",
"mapsMod": "on",
"chatsMod": "on",
"torrentsMod": "on"
},
"wallet": {
"url": "http://localhost:7474",
@ -58,5 +66,7 @@
"limit": 2000
},
"homePage": "activity",
"language": "en"
"language": "en",
"wish": "whole",
"pmVisibility": "whole"
}

View file

@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>8Ball Pool</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: crosshair; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 24px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 6px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">8BALL POOL</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>SHOTS: <b id="shots">0</b></span>
<span>BEST: <b id="best">-</b></span>
</div>
<canvas id="c" width="600" height="340"></canvas>
<div id="msg">Click to aim &amp; shoot. Hold = more power.</div>
<div id="controls">Click on table to aim cue ball &nbsp;|&nbsp; Hold longer = more power &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="8ball">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const TABLE_X = 20, TABLE_Y = 20, TABLE_W = 560, TABLE_H = 280;
const POCKET_R = 20, BALL_R = 10;
const FRICTION = 0.975;
const POCKETS = [
[TABLE_X, TABLE_Y], [TABLE_X + TABLE_W/2, TABLE_Y - 6], [TABLE_X + TABLE_W, TABLE_Y],
[TABLE_X, TABLE_Y + TABLE_H], [TABLE_X + TABLE_W/2, TABLE_Y + TABLE_H + 6], [TABLE_X + TABLE_W, TABLE_Y + TABLE_H]
];
const BALL_COLORS = ['#fff','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000','#000','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000'];
const BALL_STRIPE = [false,false,false,false,false,false,false,false,false,true,true,true,true,true,true,true];
let score = 0, shots = 0, best = parseInt(localStorage.getItem('8ball_best') || '-1');
let balls = [], state = 'idle', mouseDown = false, mousePos = {x:0,y:0}, powerStart = 0, power = 0;
let foul = false, gameOver = false;
function initBalls() {
balls = [];
const cx = TABLE_X + TABLE_W * 0.72, cy = TABLE_Y + TABLE_H / 2;
const sp = BALL_R * 2.05;
const rack = [
[0,0],[1,-0.5],[1,0.5],[2,-1],[2,0],[2,1],[3,-1.5],[3,-0.5],[3,0.5],[3,1.5],
[4,-2],[4,-1],[4,0],[4,1],[4,2]
];
for (let i = 0; i < 15; i++) {
const [row, col] = rack[i];
balls.push({ x: cx + row * sp * 0.866, y: cy + col * sp, vx: 0, vy: 0, potted: false, idx: i + 1 });
}
balls.push({ x: TABLE_X + TABLE_W * 0.25, y: cy, vx: 0, vy: 0, potted: false, idx: 0 });
}
function resetGame() {
score = 0; shots = 0; foul = false; gameOver = false;
state = 'aiming';
document.getElementById('score').textContent = '0';
document.getElementById('shots').textContent = '0';
document.getElementById('msg').textContent = 'Click to aim & shoot. Hold = more power.';
document.getElementById('scoreSubmit').style.display = 'none';
initBalls();
}
const cue = () => balls.find(b => b.idx === 0);
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function processPhysics() {
let moving = true;
let steps = 0;
while (moving && steps < 800) {
steps++;
moving = false;
for (const b of balls) {
if (b.potted) continue;
b.x += b.vx; b.y += b.vy;
b.vx *= FRICTION; b.vy *= FRICTION;
if (Math.abs(b.vx) < 0.05) b.vx = 0;
if (Math.abs(b.vy) < 0.05) b.vy = 0;
if (Math.abs(b.vx) > 0.05 || Math.abs(b.vy) > 0.05) moving = true;
const lx = TABLE_X + BALL_R, rx = TABLE_X + TABLE_W - BALL_R;
const ty = TABLE_Y + BALL_R, by = TABLE_Y + TABLE_H - BALL_R;
if (b.x < lx) { b.x = lx; b.vx = Math.abs(b.vx) * 0.8; }
if (b.x > rx) { b.x = rx; b.vx = -Math.abs(b.vx) * 0.8; }
if (b.y < ty) { b.y = ty; b.vy = Math.abs(b.vy) * 0.8; }
if (b.y > by) { b.y = by; b.vy = -Math.abs(b.vy) * 0.8; }
for (const p of POCKETS) {
if (Math.hypot(b.x - p[0], b.y - p[1]) < POCKET_R) {
b.potted = true; b.vx = 0; b.vy = 0;
if (b.idx !== 0) {
if (b.idx === 8) { foul = true; }
else { score++; document.getElementById('score').textContent = score; }
} else {
b.x = TABLE_X + TABLE_W * 0.25; b.y = TABLE_Y + TABLE_H / 2; b.potted = false;
}
break;
}
}
}
const active = balls.filter(b => !b.potted);
for (let i = 0; i < active.length; i++) {
for (let j = i + 1; j < active.length; j++) {
const a = active[i], b2 = active[j];
const dx = b2.x - a.x, dy = b2.y - a.y;
const d = Math.hypot(dx, dy);
if (d < BALL_R * 2 && d > 0.001) {
const nx = dx / d, ny = dy / d;
const rel = (a.vx - b2.vx) * nx + (a.vy - b2.vy) * ny;
if (rel > 0) {
a.vx -= rel * nx; a.vy -= rel * ny;
b2.vx += rel * nx; b2.vy += rel * ny;
}
const overlap = BALL_R * 2 - d;
a.x -= overlap / 2 * nx; a.y -= overlap / 2 * ny;
b2.x += overlap / 2 * nx; b2.y += overlap / 2 * ny;
}
}
}
}
}
function shoot(targetX, targetY, pw) {
const c = cue();
if (!c) return;
shots++;
document.getElementById('shots').textContent = shots;
const angle = Math.atan2(targetY - c.y, targetX - c.x);
const spd = pw * 0.28;
c.vx = Math.cos(angle) * spd;
c.vy = Math.sin(angle) * spd;
processPhysics();
const remaining = balls.filter(b => !b.potted && b.idx !== 0 && b.idx !== 8);
if (remaining.length === 0) {
const eight = balls.find(b => b.idx === 8);
if (!eight || eight.potted) {
endGame();
}
} else if (foul) {
endGame();
}
}
function endGame() {
gameOver = true;
state = 'over';
const finalScore = foul ? Math.floor(score / 2) : score;
document.getElementById('score').textContent = finalScore;
if (best < 0 || finalScore > best) {
best = finalScore;
localStorage.setItem('8ball_best', best);
document.getElementById('best').textContent = best;
}
document.getElementById('msg').textContent = foul ? `FOUL! 8-ball potted early. Score: ${finalScore}. SPACE = new game` : `Game over! Potted ${finalScore} balls in ${shots} shots. SPACE = new game`;
document.getElementById('scoreInput').value = finalScore;
document.getElementById('scoreSubmit').style.display = 'block';
}
function getCanvasPos(e) {
const r = canvas.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return { x: (touch.clientX - r.left) * canvas.width / r.width, y: (touch.clientY - r.top) * canvas.height / r.height };
}
canvas.addEventListener('mousedown', e => {
if (state !== 'aiming') return;
mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); power = 0;
});
canvas.addEventListener('mousemove', e => { mousePos = getCanvasPos(e); });
canvas.addEventListener('mouseup', e => {
if (!mouseDown || state !== 'aiming') return;
mouseDown = false;
power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
shoot(mousePos.x, mousePos.y, power);
});
canvas.addEventListener('touchstart', e => { e.preventDefault(); if (state !== 'aiming') return; mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); mousePos = getCanvasPos(e); }, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
if (!mouseDown || state !== 'aiming') return;
mouseDown = false;
power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
shoot(mousePos.x, mousePos.y, power);
}, { passive: false });
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); resetGame(); }
});
function drawTable() {
ctx.fillStyle = '#2d5a1b';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#1a6b30';
ctx.fillRect(TABLE_X, TABLE_Y, TABLE_W, TABLE_H);
ctx.fillStyle = '#8B4513';
ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, TABLE_W + 28, 14);
ctx.fillRect(TABLE_X - 14, TABLE_Y + TABLE_H, TABLE_W + 28, 14);
ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, 14, TABLE_H + 28);
ctx.fillRect(TABLE_X + TABLE_W, TABLE_Y - 14, 14, TABLE_H + 28);
for (const p of POCKETS) {
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(p[0], p[1], POCKET_R, 0, Math.PI * 2); ctx.fill();
}
}
function drawBall(b) {
if (b.potted) return;
const stripe = BALL_STRIPE[b.idx];
ctx.fillStyle = BALL_COLORS[b.idx];
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.fill();
if (stripe) {
ctx.save();
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = '#fff';
ctx.fillRect(b.x - BALL_R, b.y - BALL_R * 0.4, BALL_R * 2, BALL_R * 0.8);
ctx.restore();
}
if (b.idx > 0) {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R * 0.38, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.font = `bold ${BALL_R * 0.6}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(b.idx, b.x, b.y + 0.5);
}
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.stroke();
}
function drawAim() {
const c = cue();
if (!c) return;
const angle = Math.atan2(mousePos.y - c.y, mousePos.x - c.x);
const pw = mouseDown ? Math.min((Date.now() - powerStart) / 1000 * 80, 100) : 30;
ctx.strokeStyle = mouseDown ? `rgba(255,${Math.round(165*(1-pw/100))},0,0.7)` : 'rgba(255,255,255,0.4)';
ctx.lineWidth = mouseDown ? 2 : 1;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(c.x, c.y);
ctx.lineTo(c.x + Math.cos(angle) * 120, c.y + Math.sin(angle) * 120);
ctx.stroke();
ctx.setLineDash([]);
if (mouseDown) {
ctx.fillStyle = '#FFA500';
ctx.fillRect(TABLE_X, TABLE_Y + TABLE_H + 6, pw / 100 * TABLE_W, 6);
ctx.strokeStyle = '#555'; ctx.lineWidth = 1;
ctx.strokeRect(TABLE_X, TABLE_Y + TABLE_H + 6, TABLE_W, 6);
}
}
if (best >= 0) document.getElementById('best').textContent = best;
resetGame();
function loop() {
ctx.clearRect(0, 0, W, H);
drawTable();
balls.forEach(drawBall);
if (state === 'aiming') drawAim();
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#0a5e2a"/>
<rect x="6" y="6" width="108" height="68" fill="#0a6e2e" rx="3"/>
<rect x="8" y="8" width="104" height="64" fill="#0d8040" rx="2"/>
<circle cx="20" cy="20" r="5" fill="#fff"/>
<circle cx="40" cy="15" r="5" fill="#ff0"/>
<circle cx="40" cy="25" r="5" fill="#00f"/>
<circle cx="55" cy="20" r="5" fill="#f00"/>
<circle cx="55" cy="10" r="5" fill="#800080"/>
<circle cx="55" cy="30" r="5" fill="#f90"/>
<circle cx="70" cy="15" r="5" fill="#0a0"/>
<circle cx="70" cy="25" r="5" fill="#700"/>
<circle cx="85" cy="20" r="5" fill="#000"/>
<text x="85" y="24" font-size="6" fill="#fff" text-anchor="middle" font-weight="bold">8</text>
<circle cx="6" cy="6" r="4" fill="#000"/>
<circle cx="114" cy="6" r="4" fill="#000"/>
<circle cx="6" cy="74" r="4" fill="#000"/>
<circle cx="114" cy="74" r="4" fill="#000"/>
<circle cx="60" cy="6" r="4" fill="#000"/>
<circle cx="60" cy="74" r="4" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arkanoid</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: none; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ARKANOID</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>LEVEL: <b id="level">1</b></span>
</div>
<canvas id="c" width="560" height="420"></canvas>
<div id="msg">Press SPACE or CLICK to start</div>
<div id="controls">&#8592;&#8594; or MOUSE — Move paddle &nbsp;|&nbsp; SPACE / CLICK — Launch ball</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="arkanoid">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PADDLE_W = 80, PADDLE_H = 12, PADDLE_Y = H - 30;
const BALL_R = 8;
const BRICK_COLS = 10, BRICK_ROWS = 5;
const BRICK_W = 48, BRICK_H = 16, BRICK_GAP = 4;
const BRICK_OFF_X = (W - (BRICK_COLS * (BRICK_W + BRICK_GAP) - BRICK_GAP)) / 2;
const BRICK_OFF_Y = 40;
const BRICK_COLORS = ['#e74c3c', '#e67e22', '#FFA500', '#2ecc71', '#3498db'];
let state = 'idle';
let score = 0, lives = 3, level = 1;
let paddle, ball, bricks, attached;
function initGame() {
paddle = { x: W / 2 - PADDLE_W / 2, y: PADDLE_Y, w: PADDLE_W, h: PADDLE_H };
resetBall();
initBricks();
}
function resetBall() {
ball = { x: paddle.x + paddle.w / 2, y: PADDLE_Y - BALL_R - 1, vx: 3 + level * 0.3, vy: -(4 + level * 0.3) };
attached = true;
}
function initBricks() {
bricks = [];
for (let r = 0; r < BRICK_ROWS; r++) {
for (let c = 0; c < BRICK_COLS; c++) {
bricks.push({
x: BRICK_OFF_X + c * (BRICK_W + BRICK_GAP),
y: BRICK_OFF_Y + r * (BRICK_H + BRICK_GAP),
w: BRICK_W, h: BRICK_H,
alive: true,
color: BRICK_COLORS[r % BRICK_COLORS.length],
points: (BRICK_ROWS - r) * 10
});
}
}
}
function launch() {
if (state === 'idle' || state === 'over' || state === 'win') {
if (state === 'over') { score = 0; lives = 3; level = 1; document.getElementById('score').textContent = '0'; document.getElementById('lives').textContent = '3'; document.getElementById('level').textContent = '1'; }
if (state === 'win') { level++; document.getElementById('level').textContent = level; }
initGame();
state = 'play';
attached = false;
document.getElementById('msg').textContent = '';
return;
}
if (attached) {
attached = false;
}
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') { e.preventDefault(); launch(); }
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
canvas.addEventListener('click', launch);
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * canvas.width / rect.width;
paddle.x = Math.max(0, Math.min(W - paddle.w, mx - paddle.w / 2));
if (attached) ball.x = paddle.x + paddle.w / 2;
});
function reflect(ball, bx, by, bw, bh) {
const overlapLeft = ball.x + BALL_R - bx;
const overlapRight = bx + bw - (ball.x - BALL_R);
const overlapTop = ball.y + BALL_R - by;
const overlapBottom = by + bh - (ball.y - BALL_R);
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
if (minOverlap === overlapTop || minOverlap === overlapBottom) ball.vy *= -1;
else ball.vx *= -1;
}
function loop() {
ctx.clearRect(0, 0, W, H);
if (state === 'play') {
if (keys['ArrowLeft']) paddle.x = Math.max(0, paddle.x - 6);
if (keys['ArrowRight']) paddle.x = Math.min(W - paddle.w, paddle.x + 6);
if (attached) {
ball.x = paddle.x + paddle.w / 2;
} else {
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.x - BALL_R < 0) { ball.x = BALL_R; ball.vx = Math.abs(ball.vx); }
if (ball.x + BALL_R > W) { ball.x = W - BALL_R; ball.vx = -Math.abs(ball.vx); }
if (ball.y - BALL_R < 0) { ball.y = BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.y + BALL_R >= paddle.y && ball.y + BALL_R <= paddle.y + paddle.h + Math.abs(ball.vy) &&
ball.x >= paddle.x - BALL_R && ball.x <= paddle.x + paddle.w + BALL_R) {
const rel = (ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2);
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = rel * speed * 1.1;
ball.vy = -Math.abs(ball.vy);
ball.y = paddle.y - BALL_R - 1;
}
if (ball.y - BALL_R > H) {
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = 'GAME OVER — Press SPACE'; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
else { resetBall(); attached = true; }
}
for (const b of bricks) {
if (!b.alive) continue;
if (ball.x + BALL_R > b.x && ball.x - BALL_R < b.x + b.w && ball.y + BALL_R > b.y && ball.y - BALL_R < b.y + b.h) {
b.alive = false;
score += b.points;
document.getElementById('score').textContent = score;
reflect(ball, b.x, b.y, b.w, b.h);
break;
}
}
if (bricks.every(b => !b.alive)) {
state = 'win';
document.getElementById('msg').textContent = 'LEVEL CLEAR! — Press SPACE for next level';
}
}
}
bricks.forEach(b => {
if (!b.alive) return;
ctx.fillStyle = b.color;
ctx.beginPath();
ctx.roundRect(b.x, b.y, b.w, b.h, 3);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.stroke();
});
ctx.fillStyle = '#FFA500';
ctx.beginPath();
ctx.roundRect(paddle.x, paddle.y, paddle.w, paddle.h, 4);
ctx.fill();
ctx.fillStyle = '#FFD700';
ctx.fillRect(paddle.x + 8, paddle.y + 3, paddle.w - 16, 3);
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.arc(ball.x - 3, ball.y - 3, BALL_R * 0.4, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<g>
<rect x="20" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="82" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="144" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="206" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="268" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="330" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
</g>
<g>
<rect x="20" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="82" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="144" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="206" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="268" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="330" y="46" width="54" height="18" rx="3" fill="#3498db"/>
</g>
<g>
<rect x="20" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="82" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="144" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="206" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="268" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="330" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
</g>
<g>
<rect x="20" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="82" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="144" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="330" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
</g>
<circle cx="220" cy="155" r="9" fill="#fff"/>
<rect x="155" y="190" width="90" height="14" rx="5" fill="#FFA500"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artillery</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 16px; padding: 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#controls-panel { display: flex; gap: 16px; align-items: center; padding: 8px; flex-wrap: wrap; justify-content: center; }
#controls-panel label { color: #aaa; font-size: 13px; }
#controls-panel input[type=range] { width: 120px; }
#controls-panel span { color: #FFA500; font-size: 14px; min-width: 40px; }
#fire-btn { background: #3a0000; border: 1px solid #f44; color: #f44; padding: 6px 20px; cursor: pointer; font-family: monospace; font-size: 15px; font-weight: bold; }
#fire-btn:hover { background: #5a0000; }
#msg { font-size: 15px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ARTILLERY</span>
</div>
<div id="ui">
<span>ROUND: <b id="roundEl">1/5</b></span>
<span>SHOTS: <b id="shotsEl">0</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>WIND: <b id="windEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c" width="800" height="400"></canvas>
<div id="controls-panel">
<label>Angle: <input type="range" id="angleInput" min="1" max="89" value="45"> <span id="angleVal">45</span>°</label>
<label>Power: <input type="range" id="powerInput" min="1" max="100" value="60"> <span id="powerVal">60</span></label>
<button id="fire-btn">FIRE!</button>
</div>
<div id="msg">Adjust angle and power, then fire!</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="artillery">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const TOTAL_ROUNDS = 5;
let round = 1, shots = 0, score = 0, roundShots = 0;
let terrain = [], cannonY = 0, targetX = 0, targetY = 0, wind = 0;
let lastPath = [], lastHit = null, hitFlag = false;
let gameOver = false;
let best = parseInt(localStorage.getItem('artillery_best') || '-1');
function seededRand(seed) {
let s = seed;
return function() { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
}
function generateTerrain(seed) {
const rng = seededRand(seed);
const pts = new Array(80).fill(0);
pts[0] = 150 + rng() * 80;
pts[79] = 150 + rng() * 80;
function subdivide(arr, lo, hi, rough) {
if (hi - lo <= 1) return;
const mid = Math.floor((lo + hi) / 2);
arr[mid] = (arr[lo] + arr[hi]) / 2 + (rng() - 0.5) * rough;
subdivide(arr, lo, mid, rough * 0.6);
subdivide(arr, mid, hi, rough * 0.6);
}
subdivide(pts, 0, 79, 100);
for (let i = 0; i < 80; i++) pts[i] = Math.max(60, Math.min(H - 30, pts[i]));
return pts;
}
function terrainHeightAt(x) {
const idx = Math.floor(x / (W / 79));
const clamped = Math.max(0, Math.min(78, idx));
return terrain[clamped];
}
function startRound() {
const seed = Date.now() + round * 1337;
terrain = generateTerrain(seed);
const rng = seededRand(seed + 42);
targetX = Math.floor(W * 0.6 + rng() * W * 0.3);
targetY = terrainHeightAt(targetX) - 1;
wind = (rng() - 0.5) * 16;
roundShots = 0;
lastPath = []; lastHit = null; hitFlag = false;
document.getElementById('windEl').textContent = wind.toFixed(1);
document.getElementById('roundEl').textContent = `${round}/${TOTAL_ROUNDS}`;
document.getElementById('msg').textContent = `Round ${round} — Adjust angle and power, then fire!`;
}
function calcTrajectory(angleDeg, pw) {
const angle = angleDeg * Math.PI / 180;
let vx = Math.cos(angle) * pw * 0.45;
let vy = -Math.sin(angle) * pw * 0.45;
const gravity = 0.18;
const windA = wind * 0.012;
const cannonX = 30;
const startY = terrainHeightAt(cannonX) - 6;
let x = cannonX, y = startY;
const path = [[x, y]];
let hitX = null;
for (let i = 0; i < 500; i++) {
vx += windA; vy += gravity;
x += vx; y += vy;
if (x < 0 || x > W) break;
if (y > H) break;
const th = terrainHeightAt(x);
if (y >= th) { hitX = x; break; }
path.push([x, y]);
}
return { path, hitX };
}
function fire() {
if (gameOver) return;
lastPath = [];
lastHit = null;
hitFlag = false;
const angle = parseInt(document.getElementById('angleInput').value);
const pw = parseInt(document.getElementById('powerInput').value);
const { path, hitX } = calcTrajectory(angle, pw);
lastPath = path;
lastHit = hitX;
shots++; roundShots++;
document.getElementById('shotsEl').textContent = shots;
if (hitX !== null && Math.abs(hitX - targetX) < 28) {
hitFlag = true;
const roundScore = Math.max(10, 100 - (roundShots - 1) * 15) + (roundShots === 1 ? 50 : 0);
score += roundScore;
document.getElementById('scoreEl').textContent = score;
document.getElementById('msg').textContent = `HIT! +${roundScore} pts. ${round < TOTAL_ROUNDS ? 'Next round...' : 'Game over!'}`;
setTimeout(() => {
lastPath = []; lastHit = null; hitFlag = false;
if (round < TOTAL_ROUNDS) { round++; startRound(); } else { endGame(); }
}, 1400);
} else {
const dist = hitX ? Math.abs(Math.round(hitX - targetX)) : '?';
document.getElementById('msg').textContent = `Miss! Distance: ${dist}px from target.`;
}
}
function endGame() {
gameOver = true;
if (best < 0 || score > best) {
best = score;
localStorage.setItem('artillery_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('msg').textContent = `All rounds done! Final score: ${score}. SPACE = new game`;
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function newGame() {
round = 1; shots = 0; score = 0; roundShots = 0; gameOver = false;
document.getElementById('scoreEl').textContent = '0';
document.getElementById('shotsEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
startRound();
}
document.getElementById('angleInput').addEventListener('input', function() { document.getElementById('angleVal').textContent = this.value; });
document.getElementById('powerInput').addEventListener('input', function() { document.getElementById('powerVal').textContent = this.value; });
document.getElementById('fire-btn').addEventListener('click', fire);
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); if (gameOver) newGame(); else fire(); } });
if (best >= 0) document.getElementById('bestEl').textContent = best;
function drawTerrain() {
ctx.fillStyle = '#4a7c3f';
ctx.beginPath();
ctx.moveTo(0, H);
for (let i = 0; i < 80; i++) {
const x = i * (W / 79);
ctx.lineTo(x, terrain[i]);
}
ctx.lineTo(W, H); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#2d5a25'; ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < 80; i++) ctx.lineTo(i * (W / 79), terrain[i]);
ctx.stroke();
}
function drawSky() {
const grad = ctx.createLinearGradient(0, 0, 0, H * 0.7);
grad.addColorStop(0, '#000020');
grad.addColorStop(1, '#1a3a6a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
}
function drawCannon() {
const cx = 30, cy = terrainHeightAt(30);
const angle = parseInt(document.getElementById('angleInput').value) * Math.PI / 180;
ctx.fillStyle = '#888';
ctx.fillRect(cx - 10, cy - 8, 20, 10);
ctx.strokeStyle = '#aaa'; ctx.lineWidth = 4; ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx, cy - 3);
ctx.lineTo(cx + Math.cos(angle) * 28, cy - 3 - Math.sin(angle) * 28);
ctx.stroke();
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(cx, cy + 2, 8, 0, Math.PI * 2); ctx.fill();
}
function drawTarget() {
const x = targetX, y = targetY;
ctx.strokeStyle = '#f44'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x - 14, y); ctx.lineTo(x + 14, y); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x, y - 14); ctx.lineTo(x, y + 14); ctx.stroke();
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#f44'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 22, y - 16); ctx.lineTo(x + 22, y - 5); ctx.stroke();
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.moveTo(x + 22, y - 16); ctx.lineTo(x + 34, y - 10); ctx.lineTo(x + 22, y - 5); ctx.fill();
}
function drawTrajectory() {
if (lastPath.length < 2) return;
ctx.strokeStyle = hitFlag ? 'rgba(100,255,100,0.6)' : 'rgba(255,200,0,0.5)';
ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]);
ctx.beginPath();
lastPath.forEach((pt, i) => i === 0 ? ctx.moveTo(pt[0], pt[1]) : ctx.lineTo(pt[0], pt[1]));
ctx.stroke(); ctx.setLineDash([]);
if (lastHit !== null) {
const hy = terrainHeightAt(lastHit);
ctx.fillStyle = hitFlag ? '#0f0' : '#f80';
ctx.beginPath(); ctx.arc(lastHit, hy, 8, 0, Math.PI * 2); ctx.fill();
}
}
function drawWind() {
const arrow = wind >= 0 ? '→' : '←';
const strength = Math.abs(wind).toFixed(1);
ctx.fillStyle = '#87CEEB'; ctx.font = '13px monospace';
ctx.fillText(`WIND ${arrow} ${strength}`, W - 130, 22);
}
newGame();
function loop() {
drawSky();
if (terrain.length) {
drawTerrain();
drawTrajectory();
drawCannon();
drawTarget();
drawWind();
}
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#87CEEB"/>
<polygon points="0,80 0,55 20,50 35,45 50,48 65,40 80,50 95,42 110,52 120,48 120,80" fill="#4a7c3f"/>
<rect x="8" y="56" width="20" height="8" fill="#555" rx="2"/>
<rect x="20" y="50" width="14" height="5" fill="#666" rx="2" transform="rotate(-30 27 52)"/>
<circle cx="30" cy="45" r="3" fill="#f44" opacity="0.8"/>
<circle cx="50" cy="35" r="2.5" fill="#f44" opacity="0.6"/>
<circle cx="70" cy="28" r="2" fill="#f44" opacity="0.4"/>
<rect x="95" y="38" width="8" height="12" fill="#f00" rx="1"/>
<polygon points="99,38 95,32 103,32" fill="#f00"/>
<text x="60" y="20" font-size="10" fill="#fff" text-anchor="middle" font-weight="bold" opacity="0.8">~</text>
</svg>

After

Width:  |  Height:  |  Size: 793 B

View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Asteroids</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ASTEROIDS</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>LEVEL: <b id="level">1</b></span>
</div>
<canvas id="c" width="600" height="420"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8592;&#8594; Rotate &nbsp;|&nbsp; &#8593; Thrust &nbsp;|&nbsp; SPACE Shoot</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="asteroids">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let state = 'idle';
let score = 0, lives = 3, level = 1;
let ship, bullets, asteroids, invincible, frames;
const SHIP_SIZE = 14;
const BULLET_SPEED = 9;
const TURN_SPEED = 0.065;
const THRUST = 0.18;
const FRICTION = 0.985;
const stars = Array.from({ length: 80 }, () => ({
x: Math.random() * W, y: Math.random() * H, r: Math.random() * 1.5 + 0.5, b: Math.random() * 0.7 + 0.3
}));
function initGame() {
ship = { x: W / 2, y: H / 2, vx: 0, vy: 0, angle: -Math.PI / 2, cooldown: 0 };
bullets = [];
invincible = 120;
frames = 0;
spawnAsteroids();
}
function randAsteroid(size, x, y) {
const angle = Math.random() * Math.PI * 2;
const speed = (0.6 + Math.random() * 0.8) * (level * 0.15 + 1);
const pts = 7 + Math.floor(Math.random() * 5);
const verts = Array.from({ length: pts }, (_, i) => {
const a = (i / pts) * Math.PI * 2;
const r = size * (0.75 + Math.random() * 0.5);
return { x: Math.cos(a) * r, y: Math.sin(a) * r };
});
return {
x: x ?? (Math.random() < 0.5 ? Math.random() * 100 : W - Math.random() * 100),
y: y ?? (Math.random() < 0.5 ? Math.random() * 100 : H - Math.random() * 100),
vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
size, verts, rot: 0, rotSpeed: (Math.random() - 0.5) * 0.04
};
}
function spawnAsteroids() {
asteroids = [];
for (let i = 0; i < 3 + level; i++) asteroids.push(randAsteroid(38));
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') {
e.preventDefault();
if (state === 'idle' || state === 'over') {
score = 0; lives = 3; level = 1;
document.getElementById('score').textContent = '0';
document.getElementById('lives').textContent = '3';
document.getElementById('level').textContent = '1';
initGame(); state = 'play';
document.getElementById('msg').textContent = '';
}
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function wrap(obj) {
if (obj.x < -50) obj.x = W + 50;
if (obj.x > W + 50) obj.x = -50;
if (obj.y < -50) obj.y = H + 50;
if (obj.y > H + 50) obj.y = -50;
}
function drawShip(s, alpha) {
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.angle);
ctx.strokeStyle = `rgba(255,165,0,${alpha})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(SHIP_SIZE, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.6);
ctx.lineTo(-SHIP_SIZE * 0.4, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.6);
ctx.closePath();
ctx.stroke();
if (keys['ArrowUp'] && state === 'play' && frames % 4 < 2) {
ctx.fillStyle = `rgba(255,100,0,${alpha})`;
ctx.beginPath();
ctx.moveTo(-SHIP_SIZE * 0.4, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.35);
ctx.lineTo(-SHIP_SIZE * 1.3, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.35);
ctx.fill();
}
ctx.restore();
}
function drawAsteroid(a) {
ctx.save();
ctx.translate(a.x, a.y);
ctx.rotate(a.rot);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(a.verts[0].x, a.verts[0].y);
for (let i = 1; i < a.verts.length; i++) ctx.lineTo(a.verts[i].x, a.verts[i].y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function loop() {
ctx.clearRect(0, 0, W, H);
stars.forEach(s => {
ctx.fillStyle = `rgba(255,255,255,${s.b})`;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
});
if (state === 'play') {
if (keys['ArrowLeft']) ship.angle -= TURN_SPEED;
if (keys['ArrowRight']) ship.angle += TURN_SPEED;
if (keys['ArrowUp']) {
ship.vx += Math.cos(ship.angle) * THRUST;
ship.vy += Math.sin(ship.angle) * THRUST;
}
ship.vx *= FRICTION; ship.vy *= FRICTION;
ship.x += ship.vx; ship.y += ship.vy;
wrap(ship);
if (ship.cooldown > 0) ship.cooldown--;
if (keys['Space'] && ship.cooldown === 0) {
bullets.push({ x: ship.x + Math.cos(ship.angle) * SHIP_SIZE, y: ship.y + Math.sin(ship.angle) * SHIP_SIZE, vx: Math.cos(ship.angle) * BULLET_SPEED + ship.vx, vy: Math.sin(ship.angle) * BULLET_SPEED + ship.vy, life: 55 });
ship.cooldown = 12;
}
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx; b.y += b.vy; b.life--;
wrap(b);
if (b.life <= 0) { bullets.splice(i, 1); continue; }
let hit = false;
for (let j = asteroids.length - 1; j >= 0; j--) {
const a = asteroids[j];
const dx = b.x - a.x, dy = b.y - a.y;
if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.85) {
const pts = a.size > 25 ? 20 : a.size > 14 ? 50 : 100;
score += pts;
document.getElementById('score').textContent = score;
bullets.splice(i, 1);
asteroids.splice(j, 1);
if (a.size > 25) { asteroids.push(randAsteroid(18, a.x + 10, a.y)); asteroids.push(randAsteroid(18, a.x - 10, a.y)); }
else if (a.size > 14) { asteroids.push(randAsteroid(10, a.x, a.y + 10)); asteroids.push(randAsteroid(10, a.x, a.y - 10)); }
hit = true; break;
}
}
if (hit) continue;
}
for (let j = asteroids.length - 1; j >= 0; j--) {
const a = asteroids[j];
a.x += a.vx; a.y += a.vy; a.rot += a.rotSpeed;
wrap(a);
if (invincible <= 0) {
const dx = ship.x - a.x, dy = ship.y - a.y;
if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.75 + SHIP_SIZE * 0.6) {
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = `GAME OVER! Score: ${score} — SPACE to retry`; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
else { ship.x = W / 2; ship.y = H / 2; ship.vx = 0; ship.vy = 0; invincible = 120; }
}
}
}
if (invincible > 0) invincible--;
if (asteroids.length === 0) {
level++;
document.getElementById('level').textContent = level;
spawnAsteroids();
}
frames++;
}
asteroids.forEach(drawAsteroid);
bullets.forEach(b => {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(b.x, b.y, 2.5, 0, Math.PI * 2); ctx.fill();
});
const alpha = invincible > 0 && frames % 6 < 3 ? 0.3 : 1;
drawShip(ship, alpha);
for (let i = 0; i < lives; i++) {
ctx.save(); ctx.translate(16 + i * 22, H - 18); ctx.rotate(-Math.PI / 2);
ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(9, 0); ctx.lineTo(-6, -5); ctx.lineTo(-3, 0); ctx.lineTo(-6, 5); ctx.closePath(); ctx.stroke();
ctx.restore();
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<circle cx="30" cy="20" r="1.5" fill="#fff" opacity="0.8"/>
<circle cx="80" cy="45" r="1" fill="#fff" opacity="0.6"/>
<circle cx="120" cy="10" r="2" fill="#fff" opacity="0.9"/>
<circle cx="200" cy="30" r="1" fill="#fff" opacity="0.7"/>
<circle cx="320" cy="15" r="1.5" fill="#fff" opacity="0.8"/>
<circle cx="370" cy="50" r="1" fill="#fff" opacity="0.6"/>
<circle cx="50" cy="180" r="1" fill="#fff" opacity="0.5"/>
<circle cx="350" cy="190" r="1.5" fill="#fff" opacity="0.7"/>
<circle cx="280" cy="8" r="1" fill="#fff" opacity="0.8"/>
<polygon points="85,60 110,45 140,55 155,80 140,110 110,120 75,105 65,80" fill="#888" stroke="#aaa" stroke-width="2"/>
<polygon points="240,30 265,22 290,35 300,60 285,85 255,90 230,75 225,50" fill="#777" stroke="#999" stroke-width="2"/>
<polygon points="310,130 330,118 355,128 365,152 350,172 325,178 305,165 298,142" fill="#999" stroke="#bbb" stroke-width="2"/>
<polygon points="40,130 58,120 75,132 80,155 65,170 43,172 28,160 25,140" fill="#666" stroke="#888" stroke-width="2"/>
<polygon points="200,105 196,95 200,92 204,95 200,108" fill="#FFA500" stroke="#FFA500" stroke-width="1"/>
<polygon points="196,108 192,118 200,112 208,118 204,108" fill="#FFA500"/>
<circle cx="200" cy="96" r="4" fill="#fff" stroke="#FFA500" stroke-width="1.5"/>
<rect x="192" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
<rect x="204" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
<rect x="197" y="115" width="6" height="14" fill="#ff3300" opacity="0.6"/>
<line x1="200" y1="80" x2="200" y2="50" stroke="#fff" stroke-width="2" opacity="0.8"/>
<circle cx="200" cy="46" r="3" fill="#fff" opacity="0.9"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,693 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Musical Double Pendulum</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #1a1a1a;
--border: #333;
--accent: #4a9eff;
--axis: #2b6fff;
--grid: #121212;
--text: #ffffff;
--muted: #888;
--trace1: #ff6b4a;
--trace2: #4aff6b;
--warning: #ff6b4a;
--success: #4aff6b;
--audio: #ff9500;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 14px; font-family: Consolas, monospace; background: var(--bg); color: var(--text); }
.container { display: flex; gap: 14px; align-items: flex-start; }
.simulation-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 14px; flex: 1; min-width: 0;
}
.controls-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 14px; width: 340px; flex-shrink: 0;
}
h3 { margin: 0 0 8px 0; color: var(--accent); font-size: 13px; letter-spacing: 2px; text-transform: uppercase; }
.audio-section { border: 2px solid var(--audio); border-radius: 8px; padding: 10px; margin: 8px 0; background: #1a0f00; }
.audio-section h3 { color: var(--audio); margin-bottom: 8px; }
.status { background:#000; color:var(--accent); padding:7px 10px; border-radius:6px; margin-bottom:7px; font-size:11px; }
.audio-status { background:#1a0f00; color:var(--audio); border: 1px solid var(--audio); }
.toolbar { display:flex; gap:8px; margin-bottom:8px; flex-wrap: wrap; }
button {
padding: 6px 10px; border: 1px solid var(--accent); background: var(--panel); color: var(--accent);
border-radius: 6px; cursor: pointer; transition: all .2s; font-family: inherit; font-size: 12px;
}
button:hover { background: var(--accent); color: #000; }
button.audio-btn { border-color: var(--audio); color: var(--audio); }
button.audio-btn:hover { background: var(--audio); color: #000; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
canvas { background:#000; border:1px solid #444; display:block; width:100%; cursor:grab; }
canvas:active { cursor:grabbing; }
.geometry-info { background:#0a0a2a; color:#99aaff; padding:8px 10px; border-radius:6px; margin-top:8px; font-size:11px; }
.energy-info { background:#0a2a0a; color:#99ff99; padding:8px 10px; border-radius:6px; margin-top:5px; font-size:11px; }
.control-group { margin-bottom: 10px; padding-bottom:10px; border-bottom:1px solid #333; }
.control-group:last-child { border-bottom: none; }
label { display:block; margin-bottom:4px; font-size:11px; color:#ccc; text-transform:uppercase; letter-spacing:1px; }
input[type="range"] { width:100%; margin-bottom:4px; accent-color: var(--accent); }
.audio-control input[type="range"] { accent-color: var(--audio); }
.value-display { background:#000; color:var(--accent); padding:2px 6px; border-radius:3px; font-size:11px; float:right; }
.audio-value { color: var(--audio); }
.state-tag { display: none; font-size: 11px; }
.state-tag.visible { display: inline; }
#topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; margin-bottom:10px; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">AUDIO PENDULUM</span>
</div>
<div class="container">
<div class="simulation-panel" id="simPanel">
<div class="status" id="status">Status: Stopped | Energy: 0.00 J | Time: 0.00 s</div>
<div class="status audio-status" id="audioStatus">Tonal: Disabled | Percussion: Disabled | ♪: -- Hz | 🥁: 0 hits</div>
<div class="toolbar">
<button id="startBtn">&#9654; Start</button>
<button id="stopBtn">&#9209; Stop</button>
<button id="resetBtn">&#8635; Reset</button>
<button id="clearBtn">Clear Traces</button>
<button id="audioBtn" class="audio-btn">&#128266; Enable Audio</button>
</div>
<canvas id="pendulumCanvas" width="680" height="540"></canvas>
<div class="geometry-info" id="geometryInfo">
<strong>Physical State:</strong><br />
<span id="geoTheta">Initial position: vertical up (12 o'clock)</span><br />
<span id="geoOmega">State: Unstable equilibrium</span><br />
<span id="geoStateNearEq" class="state-tag" style="color:var(--success)">&#10003; NEAR EQUILIBRIUM</span>
<span id="geoStateMoving" class="state-tag visible">In motion</span>
<span id="geoStateRest" class="state-tag" style="color:var(--success)"><br />&#10003; AT REST</span>
<span id="geoStateTonal" class="state-tag" style="color:var(--audio)"><br />&#127925; TONAL AUDIO ACTIVE</span>
<span id="geoStatePerc" class="state-tag" style="color:var(--audio)"><br />&#129345; PERCUSSION ACTIVE</span>
</div>
<div class="energy-info" id="energyInfo">
<strong>Energy Conservation:</strong><br />
<span id="energyLine1">Initial: -- J | Current: -- J</span><br />
<span id="energyLine2">Energy drift: 0.000% | Status: CORRECT</span>
<span id="energySingularity" class="state-tag" style="color:var(--warning)"><br />Singularities: 0</span>
</div>
</div>
<div class="controls-panel">
<div class="control-group">
<label>Pendulum 1 Length (m) <span class="value-display" id="l1Display">1.00</span></label>
<input type="range" id="l1" min="0.2" max="3.0" step="0.05" value="1.0" />
<label>Pendulum 2 Length (m) <span class="value-display" id="l2Display">1.00</span></label>
<input type="range" id="l2" min="0.2" max="3.0" step="0.05" value="1.0" />
</div>
<div class="control-group">
<label>Pendulum 1 Mass (kg) <span class="value-display" id="m1Display">1.00</span></label>
<input type="range" id="m1" min="0.2" max="5.0" step="0.1" value="1.0" />
<label>Pendulum 2 Mass (kg) <span class="value-display" id="m2Display">1.00</span></label>
<input type="range" id="m2" min="0.2" max="5.0" step="0.1" value="1.0" />
</div>
<div class="control-group">
<label>Gravity (m/s²) <span class="value-display" id="gDisplay">9.81</span></label>
<input type="range" id="g" min="0.1" max="20.0" step="0.1" value="9.81" />
<label>Friction / Damping <span class="value-display" id="dampingDisplay">0.10</span></label>
<input type="range" id="damping" min="0" max="2.0" step="0.01" value="0.1" />
</div>
<div class="control-group">
<label>Angular Velocity 1 (rad/s) <span class="value-display" id="w1Display">0.00</span></label>
<input type="range" id="w1" min="-6.0" max="6.0" step="0.1" value="0.0" />
<label>Angular Velocity 2 (rad/s) <span class="value-display" id="w2Display">0.00</span></label>
<input type="range" id="w2" min="-6.0" max="6.0" step="0.1" value="0.0" />
</div>
<div class="audio-section">
<div class="control-group audio-control">
<label>Master Volume <span class="value-display audio-value" id="volumeDisplay">0.30</span></label>
<input type="range" id="volume" min="0" max="1.0" step="0.05" value="0.3" />
<label>Base Freq - Pendulum 1 (Hz) <span class="value-display audio-value" id="freq1Display">440</span></label>
<input type="range" id="freq1" min="200" max="1000" step="10" value="440" />
<label>Base Freq - Pendulum 2 (Hz) <span class="value-display audio-value" id="freq2Display">550</span></label>
<input type="range" id="freq2" min="200" max="1000" step="10" value="550" />
<label>Sensitivity <span class="value-display audio-value" id="sensitivityDisplay">2.0</span></label>
<input type="range" id="sensitivity" min="0.5" max="5.0" step="0.1" value="2.0" />
</div>
<div class="control-group audio-control">
<label>Percussion Volume <span class="value-display audio-value" id="percVolumeDisplay">0.00</span></label>
<input type="range" id="percVolume" min="0" max="1.0" step="0.05" value="0" />
<label>Peak Threshold <span class="value-display audio-value" id="thresholdDisplay">1.0</span></label>
<input type="range" id="threshold" min="0.2" max="3.0" step="0.1" value="1.0" />
<label>Cooldown (ms) <span class="value-display audio-value" id="cooldownDisplay">100</span></label>
<input type="range" id="cooldown" min="50" max="500" step="10" value="100" />
</div>
</div>
<div class="control-group">
<label>Integration &#916;t (s) <span class="value-display" id="dtDisplay">0.005</span></label>
<input type="range" id="dt" min="0.001" max="0.020" step="0.001" value="0.005" />
</div>
</div>
</div>
<script>
class MusicalDoublePendulum {
constructor(canvasId, panelId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.panel = document.getElementById(panelId);
this.width = this.canvas.width;
this.height = this.canvas.height;
this.originX = this.width / 2;
this.originY = this.height / 2;
this.l1 = 1.0; this.l2 = 1.0;
this.m1 = 1.0; this.m2 = 1.0;
this.g = 9.81;
this.damping = 0.1;
this.theta1 = Math.PI;
this.theta2 = 0.0;
this.omega1 = 0.0; this.omega2 = 0.0;
this.running = false;
this.time = 0;
this.dt = 0.005;
this.scale = 100;
this.trace1 = []; this.trace2 = [];
this.maxTraceLength = 3000;
this.dragging = false;
this.dragTarget = null;
this.initialEnergy = 0;
this.currentEnergy = 0;
this.maxEnergyDrift = 0;
this.energyHistory = [];
this.singularityCount = 0;
this.audioContext = null;
this.oscillator1 = null; this.oscillator2 = null;
this.gainNode1 = null; this.gainNode2 = null;
this.masterGain = null;
this.audioEnabled = false;
this.percussionEnabled = false;
this.kickBuffer = null; this.snareBuffer = null;
this.lastOmega1 = 0; this.lastOmega2 = 0;
this.omega1Trend = 0; this.omega2Trend = 0;
this.lastTrigger1 = 0; this.lastTrigger2 = 0;
this.triggerCooldown = 0.1;
this.baseFreq1 = 440; this.baseFreq2 = 550;
this.volume = 0.3;
this.sensitivity = 2.0;
this.percussionVolume = 0;
this.peakThreshold = 1.0;
this.currentFreq1 = 0; this.currentFreq2 = 0;
this.kickCount = 0; this.snareCount = 0;
this.setupEventListeners();
this.updateControls();
this.initialEnergy = this.computeEnergy();
requestAnimationFrame(() => { this.resizeCanvas(); });
}
createPercussionSounds() {
if (!this.audioContext) return;
const sr = this.audioContext.sampleRate;
const kickBuf = this.audioContext.createBuffer(1, 0.5 * sr, sr);
const kd = kickBuf.getChannelData(0);
for (let i = 0; i < kd.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 15);
kd[i] = Math.sin(2 * Math.PI * 60 * (1 - t * 0.8) * t) * env + (Math.random() - 0.5) * 0.1 * env;
}
this.kickBuffer = kickBuf;
const snareBuf = this.audioContext.createBuffer(1, 0.2 * sr, sr);
const sd = snareBuf.getChannelData(0);
for (let i = 0; i < sd.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 30);
sd[i] = (Math.sin(2 * Math.PI * 200 * t) * 0.3 + (Math.random() - 0.5) * 0.7) * env;
}
this.snareBuffer = snareBuf;
}
playPercussion(type) {
if (!this.percussionEnabled || !this.audioContext) return;
const buf = type === 'kick' ? this.kickBuffer : this.snareBuffer;
if (!buf) return;
const now = this.audioContext.currentTime;
const src = this.audioContext.createBufferSource();
const gain = this.audioContext.createGain();
src.buffer = buf;
gain.gain.setValueAtTime(this.percussionVolume * (type === 'kick' ? 0.8 : 0.6), now);
src.connect(gain);
gain.connect(this.audioContext.destination);
src.start(now);
if (type === 'kick') this.kickCount++; else this.snareCount++;
setTimeout(() => { try { src.disconnect(); gain.disconnect(); } catch (e) {} }, 600);
}
detectPeaks() {
if (!this.percussionEnabled) return;
const now = this.audioContext ? this.audioContext.currentTime : this.time;
const d1 = this.omega1 - this.lastOmega1;
const d2 = this.omega2 - this.lastOmega2;
const t1 = d1 > 0.01 ? 1 : (d1 < -0.01 ? -1 : 0);
const t2 = d2 > 0.01 ? 1 : (d2 < -0.01 ? -1 : 0);
if (this.omega1Trend === 1 && t1 === -1 && Math.abs(this.omega1) > this.peakThreshold && now - this.lastTrigger1 > this.triggerCooldown) {
this.playPercussion('kick');
this.lastTrigger1 = now;
this.flashPercStatus('kick');
}
if (this.omega2Trend === 1 && t2 === -1 && Math.abs(this.omega2) > this.peakThreshold && now - this.lastTrigger2 > this.triggerCooldown) {
this.playPercussion('snare');
this.lastTrigger2 = now;
this.flashPercStatus('snare');
}
this.lastOmega1 = this.omega1; this.lastOmega2 = this.omega2;
this.omega1Trend = t1; this.omega2Trend = t2;
}
flashPercStatus(type) {
const el = document.getElementById(type === 'kick' ? 'kick-status' : 'snare-status');
if (!el) return;
const orig = el.textContent;
el.textContent = type === 'kick' ? '\u{1F941} Kick: HIT!' : '\u{1F514} Snare: HIT!';
el.style.color = type === 'kick' ? '#ff6b4a' : '#4aff6b';
setTimeout(() => { el.textContent = orig; el.style.color = ''; }, 150);
}
async initAudio() {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.audioContext.createGain();
this.masterGain.connect(this.audioContext.destination);
this.masterGain.gain.setValueAtTime(this.volume, this.audioContext.currentTime);
this.oscillator1 = this.audioContext.createOscillator();
this.gainNode1 = this.audioContext.createGain();
this.oscillator1.connect(this.gainNode1);
this.gainNode1.connect(this.masterGain);
this.oscillator2 = this.audioContext.createOscillator();
this.gainNode2 = this.audioContext.createGain();
this.oscillator2.connect(this.gainNode2);
this.gainNode2.connect(this.masterGain);
this.oscillator1.type = 'sine';
this.oscillator2.type = 'triangle';
this.oscillator1.frequency.setValueAtTime(this.baseFreq1, this.audioContext.currentTime);
this.oscillator2.frequency.setValueAtTime(this.baseFreq2, this.audioContext.currentTime);
this.gainNode1.gain.setValueAtTime(0, this.audioContext.currentTime);
this.gainNode2.gain.setValueAtTime(0, this.audioContext.currentTime);
this.oscillator1.start();
this.oscillator2.start();
this.audioEnabled = true;
return true;
} catch (e) {
return false;
}
}
updateAudio() {
if (!this.audioEnabled || !this.audioContext) return;
const now = this.audioContext.currentTime;
this.currentFreq1 = Math.max(100, Math.min(2000, this.baseFreq1 * (1 + Math.abs(this.omega1) * this.sensitivity * 0.1)));
this.currentFreq2 = Math.max(100, Math.min(2000, this.baseFreq2 * (1 + Math.abs(this.omega2) * this.sensitivity * 0.1)));
this.oscillator1.frequency.setTargetAtTime(this.currentFreq1, now, 0.01);
this.oscillator2.frequency.setTargetAtTime(this.currentFreq2, now, 0.01);
this.gainNode1.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega1) * 0.1) * this.volume, now, 0.01);
this.gainNode2.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega2) * 0.1) * this.volume, now, 0.01);
}
stopAudio() {
if (this.audioContext && this.audioEnabled) {
try { this.oscillator1.stop(); this.oscillator2.stop(); this.audioContext.close(); } catch (e) {}
}
this.audioEnabled = false;
this.audioContext = null;
}
setupEventListeners() {
const controls = ['l1','l2','m1','m2','g','damping','w1','w2','dt','volume','freq1','freq2','sensitivity','percVolume','threshold','cooldown'];
controls.forEach(id => {
document.getElementById(id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (id === 'w1') this.omega1 = v;
else if (id === 'w2') this.omega2 = v;
else if (id === 'dt') this.dt = v;
else if (id === 'volume') this.volume = v;
else if (id === 'freq1') this.baseFreq1 = v;
else if (id === 'freq2') this.baseFreq2 = v;
else if (id === 'sensitivity') this.sensitivity = v;
else if (id === 'percVolume') {
this.percussionVolume = v;
if (v > 0 && !this.percussionEnabled) {
if (!this.audioContext) this.initAudio().then(ok => { if (ok) { this.createPercussionSounds(); this.percussionEnabled = true; } });
else { this.createPercussionSounds(); this.percussionEnabled = true; }
} else if (v === 0) {
this.percussionEnabled = false;
}
}
else if (id === 'threshold') this.peakThreshold = v;
else if (id === 'cooldown') this.triggerCooldown = v / 1000;
else this[id] = v;
this.updateControls();
if (id === 'l1' || id === 'l2') this.updateScale();
if (this.audioEnabled && this.masterGain && (id === 'volume' || id === 'freq1' || id === 'freq2' || id === 'sensitivity')) {
this.masterGain.gain.setTargetAtTime(this.volume, this.audioContext.currentTime, 0.1);
}
if (!this.running) { this.initialEnergy = this.computeEnergy(); this.maxEnergyDrift = 0; this.energyHistory = []; }
if (!this.running) this.draw();
});
});
document.getElementById('startBtn').addEventListener('click', () => this.start());
document.getElementById('stopBtn').addEventListener('click', () => this.stop());
document.getElementById('resetBtn').addEventListener('click', () => this.reset());
document.getElementById('clearBtn').addEventListener('click', () => this.clearTraces());
document.getElementById('audioBtn').addEventListener('click', async () => {
const btn = document.getElementById('audioBtn');
if (!this.audioEnabled) {
if (await this.initAudio()) {
btn.textContent = 'Disable Audio';
btn.style.background = '#ff9500';
btn.style.color = '#000';
}
} else {
this.stopAudio();
btn.textContent = '\u{1F50A} Enable Audio';
btn.style.background = '';
btn.style.color = '#ff9500';
}
});
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
this.canvas.addEventListener('mouseup', () => this.onMouseUp());
this.canvas.addEventListener('mouseleave', () => this.onMouseUp());
window.addEventListener('resize', () => this.resizeCanvas());
}
updateControls() {
const set = (id, v, d=2) => { const el = document.getElementById(id); if (el) el.textContent = v.toFixed(d); };
set('l1Display', this.l1); set('l2Display', this.l2);
set('m1Display', this.m1); set('m2Display', this.m2);
set('gDisplay', this.g); set('dampingDisplay', this.damping);
set('w1Display', this.omega1); set('w2Display', this.omega2);
set('dtDisplay', this.dt, 3);
set('volumeDisplay', this.volume);
const f1el = document.getElementById('freq1Display'); if (f1el) f1el.textContent = this.baseFreq1.toFixed(0);
const f2el = document.getElementById('freq2Display'); if (f2el) f2el.textContent = this.baseFreq2.toFixed(0);
set('sensitivityDisplay', this.sensitivity, 1);
set('percVolumeDisplay', this.percussionVolume);
set('thresholdDisplay', this.peakThreshold, 1);
const cdel = document.getElementById('cooldownDisplay'); if (cdel) cdel.textContent = (this.triggerCooldown * 1000).toFixed(0);
}
resizeCanvas() {
const w = this.canvas.clientWidth;
if (w < 10) return;
this.canvas.width = w;
this.canvas.height = Math.round(w * (540 / 680));
this.updateCanvasMetrics();
}
getMousePos(e) {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
onMouseDown(e) {
if (this.running) return;
const m = this.getMousePos(e);
const p1 = this.getPendulumPosition(1);
const p2 = this.getPendulumPosition(2);
if (Math.hypot(m.x - p1.x, m.y - p1.y) < 20) { this.dragging = true; this.dragTarget = 1; }
else if (Math.hypot(m.x - p2.x, m.y - p2.y) < 20) { this.dragging = true; this.dragTarget = 2; }
}
onMouseMove(e) {
if (!this.dragging || this.running) return;
const m = this.getMousePos(e);
if (this.dragTarget === 1) {
this.theta1 = Math.atan2(m.x - this.originX, m.y - this.originY);
} else {
const p1 = this.getPendulumPosition(1);
this.theta2 = Math.atan2(m.x - p1.x, m.y - p1.y) - this.theta1;
}
this.initialEnergy = this.computeEnergy();
this.maxEnergyDrift = 0;
this.energyHistory = [];
this.draw();
}
onMouseUp() { this.dragging = false; this.dragTarget = null; }
updateCanvasMetrics() {
this.width = this.canvas.width; this.height = this.canvas.height;
this.originX = this.width / 2; this.originY = this.height / 2;
this.updateScale();
if (!this.running) this.draw();
}
updateScale() {
const half = 0.5 * Math.min(this.width, this.height) * 0.9;
const len = this.l1 + this.l2;
this.scale = len > 0 ? half / len : 100;
}
getPendulumPosition(n) {
if (n === 1) return {
x: this.originX + this.l1 * this.scale * Math.sin(this.theta1),
y: this.originY + this.l1 * this.scale * Math.cos(this.theta1)
};
const p1 = this.getPendulumPosition(1);
return {
x: p1.x + this.l2 * this.scale * Math.sin(this.theta1 + this.theta2),
y: p1.y + this.l2 * this.scale * Math.cos(this.theta1 + this.theta2)
};
}
accelerations() {
const { m1, m2, l1, l2, g } = this;
const th1 = this.theta1, th2 = this.theta2, w1 = this.omega1, w2 = this.omega2;
const s12 = Math.sin(th1 - th2), c12 = Math.cos(th1 - th2);
const den = 2*m1 + m2 - m2 * Math.cos(2*th1 - 2*th2);
if (Math.abs(den) < 1e-8) {
this.singularityCount++;
return { a1: -0.1*w1 - 0.5*th1, a2: -0.1*w2 - 0.5*th2 };
}
let a1 = (-g*(2*m1+m2)*Math.sin(th1) - m2*g*Math.sin(th1-2*th2) - 2*s12*m2*(w2*w2*l2 + w1*w1*l1*c12)) / (l1*den);
let a2 = (2*s12*(w1*w1*l1*(m1+m2) + g*(m1+m2)*Math.cos(th1) + w2*w2*l2*m2*c12)) / (l2*den);
if (this.damping > 0) {
const vx1 = l1*w1*Math.cos(th1), vy1 = -l1*w1*Math.sin(th1);
const vx2 = vx1 + l2*(w1+w2)*Math.cos(th1+th2), vy2 = vy1 - l2*(w1+w2)*Math.sin(th1+th2);
const dr = this.damping * 0.05;
const v1sq = vx1*vx1+vy1*vy1, v2sq = vx2*vx2+vy2*vy2;
if (v1sq > 1e-6) a1 += -dr * v1sq * Math.sign(w1) / l1;
if (v2sq > 1e-6) a2 += -dr * v2sq * Math.sign(w2) / l2;
const jf = this.damping * 0.3;
a1 -= jf*(w1 + 0.5*th1); a2 -= jf*(w2 + 0.5*th2);
if (this.damping > 1.0) { const hd = (this.damping-1.0)*2.0; a1 -= hd*w1; a2 -= hd*w2; }
}
return { a1, a2 };
}
rk4Step(dt) {
const deriv = (th1, th2, w1, w2) => {
const [st1, st2, sw1, sw2] = [this.theta1, this.theta2, this.omega1, this.omega2];
this.theta1=th1; this.theta2=th2; this.omega1=w1; this.omega2=w2;
const {a1,a2} = this.accelerations();
this.theta1=st1; this.theta2=st2; this.omega1=sw1; this.omega2=sw2;
return {dth1:w1, dth2:w2, dw1:a1, dw2:a2};
};
const s = {th1:this.theta1, th2:this.theta2, w1:this.omega1, w2:this.omega2};
const k1=deriv(s.th1,s.th2,s.w1,s.w2);
const k2=deriv(s.th1+.5*dt*k1.dth1, s.th2+.5*dt*k1.dth2, s.w1+.5*dt*k1.dw1, s.w2+.5*dt*k1.dw2);
const k3=deriv(s.th1+.5*dt*k2.dth1, s.th2+.5*dt*k2.dth2, s.w1+.5*dt*k2.dw1, s.w2+.5*dt*k2.dw2);
const k4=deriv(s.th1+dt*k3.dth1, s.th2+dt*k3.dth2, s.w1+dt*k3.dw1, s.w2+dt*k3.dw2);
this.theta1 += (dt/6)*(k1.dth1+2*k2.dth1+2*k3.dth1+k4.dth1);
this.theta2 += (dt/6)*(k1.dth2+2*k2.dth2+2*k3.dth2+k4.dth2);
this.omega1 += (dt/6)*(k1.dw1+2*k2.dw1+2*k3.dw1+k4.dw1);
this.omega2 += (dt/6)*(k1.dw2+2*k2.dw2+2*k3.dw2+k4.dw2);
}
update() {
if (!this.running) return;
this.rk4Step(this.dt);
this.updateAudio();
this.detectPeaks();
const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
this.trace1.push({x:p1.x, y:p1.y}); this.trace2.push({x:p2.x, y:p2.y});
if (this.trace1.length > this.maxTraceLength) this.trace1.shift();
if (this.trace2.length > this.maxTraceLength) this.trace2.shift();
this.currentEnergy = this.computeEnergy();
this.energyHistory.push(this.currentEnergy);
if (this.energyHistory.length > 1000) this.energyHistory.shift();
this.maxEnergyDrift = Math.max(this.maxEnergyDrift, Math.abs(this.currentEnergy - this.initialEnergy));
this.time += this.dt;
this.updateStatus();
}
computeEnergy() {
const vx1 = this.l1*this.omega1*Math.cos(this.theta1);
const vy1 = -this.l1*this.omega1*Math.sin(this.theta1);
const vx2 = vx1 + this.l2*(this.omega1+this.omega2)*Math.cos(this.theta1+this.theta2);
const vy2 = vy1 - this.l2*(this.omega1+this.omega2)*Math.sin(this.theta1+this.theta2);
const T = 0.5*this.m1*(vx1*vx1+vy1*vy1) + 0.5*this.m2*(vx2*vx2+vy2*vy2);
const V = this.m1*this.g*this.l1*(1-Math.cos(this.theta1)) +
this.m2*this.g*(this.l1*(1-Math.cos(this.theta1)) + this.l2*(1-Math.cos(this.theta1+this.theta2)));
return T + V;
}
setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
setVisible(id, v) { const el = document.getElementById(id); if (el) el.classList.toggle('visible', v); }
updateStatus() {
const state = this.running ? 'Running' : 'Stopped';
this.setText('status', 'Status: ' + state + ' | Energy: ' + this.currentEnergy.toFixed(3) + ' J | Time: ' + this.time.toFixed(2) + ' s');
const f1 = this.audioEnabled ? this.currentFreq1.toFixed(0) : '--';
const f2 = this.audioEnabled ? this.currentFreq2.toFixed(0) : '--';
this.setText('audioStatus',
'Tonal: ' + (this.audioEnabled ? 'Active' : 'Disabled') +
' | Percussion: ' + (this.percussionEnabled ? 'Active' : 'Disabled') +
' | \u266a: ' + f1 + '/' + f2 + ' Hz | \u{1F941}: ' + (this.kickCount + this.snareCount) + ' hits');
const driftPct = this.initialEnergy !== 0 ? (this.maxEnergyDrift / Math.abs(this.initialEnergy)) * 100 : 0;
let energyStatus = 'CORRECT';
if (driftPct > 1.0) energyStatus = 'HIGH DRIFT';
else if (driftPct > 0.1) energyStatus = 'MINOR DRIFT';
this.setText('energyLine1', 'Initial: ' + this.initialEnergy.toFixed(4) + ' J | Current: ' + this.currentEnergy.toFixed(4) + ' J');
this.setText('energyLine2', 'Energy drift: ' + driftPct.toFixed(3) + '% | Status: ' + energyStatus);
this.setVisible('energySingularity', this.singularityCount > 0);
if (this.singularityCount > 0) this.setText('energySingularity', 'Singularities: ' + this.singularityCount);
this.setText('geoTheta', '\u03b8\u2081=' + this.theta1.toFixed(3) + ' rad, \u03b8\u2082=' + this.theta2.toFixed(3) + ' rad');
this.setText('geoOmega', '\u03c9\u2081=' + this.omega1.toFixed(3) + ' rad/s, \u03c9\u2082=' + this.omega2.toFixed(3) + ' rad/s');
const vel = Math.sqrt(this.omega1*this.omega1 + this.omega2*this.omega2);
const nearEq = Math.abs(this.theta1) < 0.1 && Math.abs(this.theta2) < 0.1 && vel < 0.1;
this.setVisible('geoStateNearEq', nearEq);
this.setVisible('geoStateMoving', !nearEq);
this.setVisible('geoStateRest', this.damping > 1.0 && vel < 0.01);
this.setVisible('geoStateTonal', this.audioEnabled && vel > 0.1);
this.setVisible('geoStatePerc', this.percussionEnabled && vel > 0.1);
}
draw() {
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawGridAndAxes();
this.drawTraces();
this.drawPendulum();
this.drawInfo();
}
drawGridAndAxes() {
this.ctx.strokeStyle = '#121212'; this.ctx.lineWidth = 1;
for (let x = 0; x <= this.width; x += 40) { this.ctx.beginPath(); this.ctx.moveTo(x,0); this.ctx.lineTo(x,this.height); this.ctx.stroke(); }
for (let y = 0; y <= this.height; y += 40) { this.ctx.beginPath(); this.ctx.moveTo(0,y); this.ctx.lineTo(this.width,y); this.ctx.stroke(); }
this.ctx.strokeStyle = '#2b6fff'; this.ctx.lineWidth = 2;
this.ctx.beginPath(); this.ctx.moveTo(this.originX,0); this.ctx.lineTo(this.originX,this.height); this.ctx.stroke();
this.ctx.beginPath(); this.ctx.moveTo(0,this.originY); this.ctx.lineTo(this.width,this.originY); this.ctx.stroke();
this.ctx.fillStyle = '#4a9eff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,5,0,Math.PI*2); this.ctx.fill();
this.ctx.fillStyle = '#fff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,2,0,Math.PI*2); this.ctx.fill();
}
drawTraces() {
const drawTrace = (trace, color, lw, alpha) => {
if (trace.length < 2) return;
this.ctx.strokeStyle = color; this.ctx.lineWidth = lw; this.ctx.globalAlpha = alpha;
this.ctx.beginPath(); this.ctx.moveTo(trace[0].x, trace[0].y);
for (let i = 1; i < trace.length; i++) this.ctx.lineTo(trace[i].x, trace[i].y);
this.ctx.stroke();
};
drawTrace(this.trace1, '#ff6b4a', 1.5, 0.7);
drawTrace(this.trace2, '#4aff6b', 2.5, 0.9);
this.ctx.globalAlpha = 1.0;
}
drawPendulum() {
const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 4;
this.ctx.beginPath(); this.ctx.moveTo(this.originX,this.originY); this.ctx.lineTo(p1.x,p1.y); this.ctx.stroke();
this.ctx.beginPath(); this.ctx.moveTo(p1.x,p1.y); this.ctx.lineTo(p2.x,p2.y); this.ctx.stroke();
const r1 = Math.max(6, 4+this.m1*4), r2 = Math.max(6, 4+this.m2*4);
const drawBob = (px, py, r, color, omega) => {
if (this.audioEnabled && this.running) {
const intensity = Math.min(1, Math.abs(omega) * 0.2);
if (intensity > 0.1) { this.ctx.shadowBlur = 15*intensity; this.ctx.shadowColor = color; }
}
this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.fill();
this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.stroke();
this.ctx.shadowBlur = 0;
};
drawBob(p1.x, p1.y, r1, '#ff6b4a', this.omega1);
drawBob(p2.x, p2.y, r2, '#4aff6b', this.omega2);
}
drawInfo() {
this.ctx.fillStyle = '#4a9eff'; this.ctx.font = '13px Consolas';
this.ctx.fillText('\u03b8\u2081: ' + this.theta1.toFixed(3) + ' rad', 10, 20);
this.ctx.fillText('\u03b8\u2082: ' + this.theta2.toFixed(3) + ' rad', 10, 36);
this.ctx.fillText('\u03c9\u2081: ' + this.omega1.toFixed(3) + ' rad/s', 10, 52);
this.ctx.fillText('\u03c9\u2082: ' + this.omega2.toFixed(3) + ' rad/s', 10, 68);
let y = 84;
if (this.audioEnabled && this.running) {
this.ctx.fillStyle = '#ff9500';
this.ctx.fillText('\u266a1: ' + this.currentFreq1.toFixed(0) + ' Hz', 10, y);
this.ctx.fillText('\u266a2: ' + this.currentFreq2.toFixed(0) + ' Hz', 10, y+16);
y += 32;
}
if (this.percussionEnabled && this.running) {
this.ctx.fillStyle = '#ff6b4a';
this.ctx.fillText('\u{1F941}: ' + this.kickCount + ' hits', 10, y);
this.ctx.fillStyle = '#4aff6b';
this.ctx.fillText('\u{1F514}: ' + this.snareCount + ' hits', 10, y+16);
y += 32;
}
if (this.damping > 0) {
this.ctx.fillStyle = this.damping > 1.0 ? '#ff6b4a' : '#ffaa4a';
this.ctx.fillText('Friction: ' + this.damping.toFixed(2), 10, y);
}
this.ctx.fillStyle = '#888'; this.ctx.font = '11px Consolas';
this.ctx.fillText('Scale: ' + this.scale.toFixed(1) + ' px/m', 10, this.height-10);
}
start() {
if (!this.running) {
this.running = true;
this.initialEnergy = this.computeEnergy();
this.maxEnergyDrift = 0;
this.animate();
}
}
stop() {
this.running = false;
if (this.audioEnabled) {
this.gainNode1.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
this.gainNode2.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
}
}
reset() {
this.stop();
this.theta1 = Math.PI; this.theta2 = 0.0;
this.omega1 = 0; this.omega2 = 0;
this.time = 0;
this.clearTraces();
this.initialEnergy = this.computeEnergy();
this.currentEnergy = this.initialEnergy;
this.maxEnergyDrift = 0; this.energyHistory = []; this.singularityCount = 0;
this.kickCount = 0; this.snareCount = 0;
this.lastTrigger1 = 0; this.lastTrigger2 = 0;
this.lastOmega1 = 0; this.lastOmega2 = 0;
this.omega1Trend = 0; this.omega2Trend = 0;
this.resizeCanvas();
}
clearTraces() { this.trace1 = []; this.trace2 = []; if (!this.running) this.draw(); }
animate() {
if (!this.running) return;
this.update(); this.draw();
requestAnimationFrame(() => this.animate());
}
}
const pendulum = new MusicalDoublePendulum('pendulumCanvas', 'simPanel');
</script>
</body>
</html>

View file

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="6" fill="#0a0a0a" stroke="#1a1a1a" stroke-width="1"/>
<line x1="200" y1="30" x2="200" y2="220" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
<line x1="10" y1="110" x2="390" y2="110" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
<circle cx="200" cy="50" r="5" fill="#4a9eff"/>
<circle cx="200" cy="50" r="2" fill="#fff"/>
<line x1="200" y1="50" x2="148" y2="128" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<line x1="148" y1="128" x2="220" y2="185" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<circle cx="148" cy="128" r="10" fill="#ff6b4a"/>
<circle cx="148" cy="128" r="10" fill="none" stroke="#fff" stroke-width="1.5"/>
<circle cx="220" cy="185" r="12" fill="#4aff6b"/>
<circle cx="220" cy="185" r="12" fill="none" stroke="#fff" stroke-width="1.5"/>
<path d="M 200 50 Q 175 90 148 128 Q 130 155 120 170 Q 110 185 130 192 Q 155 200 175 185 Q 200 168 220 152 Q 245 135 255 118 Q 265 100 248 88 Q 228 76 210 88 Q 192 100 178 115" stroke="#ff6b4a" stroke-width="1.2" fill="none" opacity="0.6"/>
<path d="M 148 128 Q 170 148 195 162 Q 218 175 232 180 Q 248 184 255 175 Q 265 163 258 148 Q 250 132 235 125 Q 218 117 205 122 Q 190 128 182 138 Q 172 150 168 162" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.75"/>
<path d="M 270 85 Q 278 78 278 85 Q 278 92 270 92" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.9"/>
<path d="M 278 80 Q 290 72 290 85 Q 290 98 278 90" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.7"/>
<path d="M 290 75 Q 305 65 305 85 Q 305 105 290 95" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.5"/>
<path d="M 285 128 Q 293 121 293 128 Q 293 135 285 135" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.9"/>
<path d="M 293 123 Q 305 115 305 128 Q 305 141 293 133" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.7"/>
<text x="200" y="213" font-family="'Courier New', monospace" font-size="13" fill="#ff9500" text-anchor="middle" font-weight="bold" letter-spacing="3">AUDIOPENDULUM</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocoland</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 8px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">COCOLAND</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>BEST: <b id="best">0</b></span>
</div>
<canvas id="c" width="600" height="250"></canvas>
<div id="msg">Press SPACE or TAP to start</div>
<div id="controls">SPACE / TAP — Jump &nbsp;|&nbsp; Double jump allowed</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="cocoland">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const msgEl = document.getElementById('msg');
const W = canvas.width, H = canvas.height;
const GROUND = H - 40;
const GRAVITY = 0.6;
const JUMP = -13;
let state = 'idle';
let score = 0, best = 0, frames = 0;
let coco, obstacles, coins, speed;
function reset() {
coco = { x: 80, y: GROUND - 44, vy: 0, r: 22, jumps: 0 };
obstacles = [];
coins = [];
speed = 2;
score = 0;
frames = 0;
scoreEl.textContent = '0';
}
function jump() {
if (state === 'idle' || state === 'over') {
reset();
state = 'play';
msgEl.textContent = '';
return;
}
if (coco.jumps < 2) {
coco.vy = JUMP;
coco.jumps++;
}
}
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); jump(); } });
canvas.addEventListener('click', jump);
function spawnObstacle() {
const h = 30 + Math.random() * 40;
obstacles.push({ x: W, y: GROUND - h, w: 22, h });
}
function spawnCoin() {
const y = GROUND - 60 - Math.random() * 80;
coins.push({ x: W, y, r: 9, collected: false });
}
function drawCoco() {
const x = coco.x, y = coco.y;
ctx.fillStyle = '#8B4513';
ctx.beginPath();
ctx.arc(x, y + coco.r, coco.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(x - 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(x - 7, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 9, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#5a2000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y + coco.r + 7, 8, 0.1 * Math.PI, 0.9 * Math.PI);
ctx.stroke();
ctx.fillStyle = '#3a8000';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x - 14, y - 14, x - 8, y - 24);
ctx.quadraticCurveTo(x, y - 10, x, y);
ctx.fill();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x + 14, y - 14, x + 8, y - 24);
ctx.quadraticCurveTo(x, y - 10, x, y);
ctx.fill();
}
function drawPalmTree(px, groundY) {
ctx.strokeStyle = '#4a7a00';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(px, groundY);
ctx.quadraticCurveTo(px + 6, groundY - 60, px - 4, groundY - 110);
ctx.stroke();
ctx.fillStyle = '#3a8000';
for (let a = -0.6; a <= 0.7; a += 0.3) {
ctx.beginPath();
ctx.ellipse(px - 4 + Math.cos(a) * 32, groundY - 110 + Math.sin(a) * 12 - 10, 30, 10, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#8B4513';
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.arc(px - 4 + (i - 1) * 9, groundY - 115, 6, 0, Math.PI * 2);
ctx.fill();
}
}
function drawCoin(c) {
if (c.collected) return;
ctx.fillStyle = '#FFA500';
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('E', c.x, c.y);
}
function collides(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
let bgOffset = 0;
let palmTimer = 0, coinTimer = 0;
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1a3a00';
ctx.fillRect(0, GROUND, W, H - GROUND);
if (state === 'play') {
bgOffset = (bgOffset + speed * 0.5) % W;
palmTimer += speed;
coinTimer += speed;
if (palmTimer > 350 + Math.random() * 150) {
spawnObstacle();
palmTimer = 0;
}
if (coinTimer > 150 + Math.random() * 80) {
spawnCoin();
coinTimer = 0;
}
}
for (let i = obstacles.length - 1; i >= 0; i--) {
const o = obstacles[i];
if (state === 'play') o.x -= speed;
if (o.x + o.w < 0) { obstacles.splice(i, 1); continue; }
drawPalmTree(o.x + 11, GROUND);
if (state === 'play' && collides(coco.x - coco.r + 4, coco.y, coco.r * 2 - 8, coco.r * 2, o.x, o.y, o.w, o.h)) {
state = 'over';
if (score > best) { best = score; bestEl.textContent = best; }
msgEl.textContent = 'GAME OVER — Press SPACE to retry';
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
}
for (let i = coins.length - 1; i >= 0; i--) {
const c = coins[i];
if (state === 'play') c.x -= speed;
if (c.x + c.r < 0) { coins.splice(i, 1); continue; }
drawCoin(c);
if (!c.collected && state === 'play') {
const dx = coco.x - c.x, dy = (coco.y + coco.r) - c.y;
if (Math.sqrt(dx * dx + dy * dy) < coco.r + c.r) {
c.collected = true;
score += 10;
scoreEl.textContent = score;
}
}
}
if (state === 'play') {
coco.vy += GRAVITY;
coco.y += coco.vy;
if (coco.y >= GROUND - coco.r * 2) {
coco.y = GROUND - coco.r * 2;
coco.vy = 0;
coco.jumps = 0;
}
frames++;
if (frames % 6 === 0) {
score++;
scoreEl.textContent = score;
}
if (frames % 60 === 0 && speed < 10) speed += 0.1;
}
drawCoco();
requestAnimationFrame(loop);
}
reset();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="0" y="180" width="400" height="40" fill="#1a3a00"/>
<line x1="300" y1="180" x2="280" y2="50" stroke="#4a7a00" stroke-width="8" stroke-linecap="round"/>
<ellipse cx="265" cy="55" rx="50" ry="30" fill="#2d6a00"/>
<ellipse cx="285" cy="45" rx="45" ry="25" fill="#3a8000"/>
<ellipse cx="275" cy="35" rx="40" ry="20" fill="#4a9400"/>
<circle cx="268" cy="62" r="8" fill="#8B4513"/>
<circle cx="282" cy="65" r="7" fill="#8B4513"/>
<circle cx="275" cy="58" r="7" fill="#8B4513"/>
<line x1="100" y1="180" x2="90" y2="90" stroke="#4a7a00" stroke-width="6" stroke-linecap="round"/>
<ellipse cx="80" cy="95" rx="35" ry="22" fill="#2d6a00"/>
<ellipse cx="95" cy="87" rx="30" ry="18" fill="#3a8000"/>
<circle cx="88" cy="100" r="6" fill="#8B4513"/>
<circle cx="100" cy="102" r="5" fill="#8B4513"/>
<circle cx="58" cy="150" r="22" fill="#8B4513"/>
<circle cx="48" cy="143" r="7" fill="#fff"/>
<circle cx="68" cy="143" r="7" fill="#fff"/>
<circle cx="50" cy="143" r="4" fill="#222"/>
<circle cx="70" cy="143" r="4" fill="#222"/>
<path d="M48 158 Q58 164 68 158" stroke="#5a2000" stroke-width="2" fill="none" stroke-linecap="round"/>
<polygon points="150,165 158,145 166,165" fill="#FFA500"/>
<polygon points="190,170 198,150 206,170" fill="#FFA500"/>
<polygon points="230,162 238,142 246,162" fill="#FFA500"/>
<text x="200" y="210" font-family="monospace" font-size="13" fill="#FFA500" text-anchor="middle" opacity="0.7">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocoman</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
#dpad-row { display: flex; gap: 4px; }
#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
#dpad button:active { background: #444; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">COCOMAN</span>
</div>
<div id="ui">
<span>SCORE: <b id="scoreEl">0</b></span>
<span>LIVES: <b id="livesEl">3</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press SPACE or tap arrow to start</div>
<div id="dpad">
<div id="dpad-row"><button id="btn-up">&#8593;</button></div>
<div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="cocoman">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const CELL = 20;
const MAP = [
'############################',
'#............##............#',
'#.####.#####.##.#####.####.#',
'#o# #.# #.##.# #.# #o#',
'#.####.#####.##.#####.####.#',
'#..........................#',
'#.####.##.########.##.####.#',
'#.####.##.########.##.####.#',
'#......##....##....##......#',
'######.##### ## #####.######',
' #.##### ## #####.# ',
' #.## G ##.# ',
' #.## ###=### ##.# ',
'######.## # # ##.######',
' . # GGG # . ',
'######.## # # ##.######',
' #.## ####### ##.# ',
' #.## ##.# ',
' #.## ######## ##.# ',
'######.## ######## ##.######',
'#............##............#',
'#.####.#####.##.#####.####.#',
'#.####.#####.##.#####.####.#',
'#o..##....... .......##..o#',
'###.##.##.########.##.##.###',
'###.##.##.########.##.##.###',
'#......##....##....##......#',
'#.##########.##.##########.#',
'#.##########.##.##########.#',
'#..........................#',
'############################'
];
const COLS = MAP[0].length, ROWS = MAP.length;
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
const GHOST_COLORS = ['#f00', '#f9a', '#0ff', '#fa0'];
let score = 0, lives = 3, gameState = 'idle', comboGhost = 0;
let best = parseInt(localStorage.getItem('cocoman_best') || '-1');
let dots = [], powers = [], player, ghosts, nextDir = [1, 0], frightTimer = 0;
let tickInterval = null;
function initLevel() {
dots = []; powers = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (MAP[r][c] === '.') dots.push([c, r]);
if (MAP[r][c] === 'o') powers.push([c, r]);
}
}
player = { x: 14, y: 23, dir: [1, 0] };
nextDir = [1, 0];
ghosts = [
{ x: 13, y: 14, dir: [0, -1], color: GHOST_COLORS[0] },
{ x: 14, y: 14, dir: [0, 1], color: GHOST_COLORS[1] },
{ x: 13, y: 15, dir: [1, 0], color: GHOST_COLORS[2] },
{ x: 14, y: 15, dir: [-1, 0], color: GHOST_COLORS[3] }
];
frightTimer = 0; comboGhost = 0;
}
function wrapX(x) {
if (x < 0) return COLS - 1;
if (x >= COLS) return 0;
return x;
}
function cellAt(x, y) {
if (y < 0 || y >= ROWS) return '#';
const row = MAP[Math.floor(y)];
const ch = row[Math.floor(x)];
return ch === undefined ? ' ' : ch;
}
function canMovePlayer(x, y, dx, dy) {
const nx = wrapX(x + dx), ny = y + dy;
const ch = cellAt(nx, ny);
return ch !== '#' && ch !== '=';
}
function canGhostMove(x, y, dx, dy) {
const nx = wrapX(x + dx), ny = y + dy;
return cellAt(nx, ny) !== '#';
}
function moveGhost(g) {
const dirs = [[0,-1],[0,1],[-1,0],[1,0]].filter(([dx,dy]) => {
if (!canGhostMove(g.x, g.y, dx, dy)) return false;
if (dx === -g.dir[0] && dy === -g.dir[1]) return false;
return true;
});
if (!dirs.length) return;
if (frightTimer > 0) {
g.dir = dirs[Math.floor(Math.random() * dirs.length)];
} else {
dirs.sort((a, b) =>
Math.hypot(g.x+a[0]-player.x, g.y+a[1]-player.y) -
Math.hypot(g.x+b[0]-player.x, g.y+b[1]-player.y)
);
g.dir = dirs[0];
}
g.x = wrapX(g.x + g.dir[0]);
g.y += g.dir[1];
}
function tick() {
if (gameState !== 'play') return;
if (canMovePlayer(player.x, player.y, ...nextDir)) player.dir = [...nextDir];
if (canMovePlayer(player.x, player.y, ...player.dir)) {
player.x = wrapX(player.x + player.dir[0]);
player.y += player.dir[1];
}
const di = dots.findIndex(d => d[0] === player.x && d[1] === player.y);
if (di >= 0) { dots.splice(di, 1); score += 10; document.getElementById('scoreEl').textContent = score; }
const pi = powers.findIndex(p => p[0] === player.x && p[1] === player.y);
if (pi >= 0) { powers.splice(pi, 1); score += 50; frightTimer = 22; comboGhost = 0; document.getElementById('scoreEl').textContent = score; }
if (frightTimer > 0) frightTimer--;
ghosts.forEach(moveGhost);
for (const g of ghosts) {
if (g.x === player.x && g.y === player.y) {
if (frightTimer > 0) {
comboGhost++;
score += 200 * Math.pow(2, comboGhost - 1);
document.getElementById('scoreEl').textContent = score;
g.x = 13; g.y = 14; g.dir = [0, -1];
} else {
loseLife();
return;
}
}
}
if (!dots.length && !powers.length) winGame();
}
function loseLife() {
lives--;
document.getElementById('livesEl').textContent = lives;
if (lives <= 0) {
endGame();
} else {
player = { x: 14, y: 23, dir: [1, 0] };
nextDir = [1, 0]; frightTimer = 0;
document.getElementById('msg').textContent = 'Caught! Keep going...';
}
}
function winGame() {
clearInterval(tickInterval); tickInterval = null;
gameState = 'over';
score += lives * 100;
document.getElementById('scoreEl').textContent = score;
document.getElementById('msg').textContent = `You won! Score: ${score}. SPACE = new game`;
saveAndShow();
}
function endGame() {
clearInterval(tickInterval); tickInterval = null;
gameState = 'over';
document.getElementById('msg').textContent = `GAME OVER! Score: ${score}. SPACE = new game`;
saveAndShow();
}
function saveAndShow() {
if (best < 0 || score > best) {
best = score;
localStorage.setItem('cocoman_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function startGame() {
score = 0; lives = 3; gameState = 'play';
document.getElementById('scoreEl').textContent = '0';
document.getElementById('livesEl').textContent = '3';
document.getElementById('msg').textContent = '';
document.getElementById('scoreSubmit').style.display = 'none';
initLevel();
if (tickInterval) clearInterval(tickInterval);
tickInterval = setInterval(tick, 155);
}
document.addEventListener('keydown', e => {
const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
if (e.code === 'Space') { e.preventDefault(); startGame(); return; }
if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') startGame(); nextDir = map[e.code]; }
});
function dpadInput(dx, dy) { if (gameState === 'idle') startGame(); nextDir = [dx, dy]; }
document.getElementById('btn-up').addEventListener('click', () => dpadInput(0, -1));
document.getElementById('btn-down').addEventListener('click', () => dpadInput(0, 1));
document.getElementById('btn-left').addEventListener('click', () => dpadInput(-1, 0));
document.getElementById('btn-right').addEventListener('click', () => dpadInput(1, 0));
if (best >= 0) document.getElementById('bestEl').textContent = best;
initLevel();
let animFrame = 0;
function draw() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (MAP[r][c] === '#') {
ctx.fillStyle = '#00c';
ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
ctx.strokeStyle = '#44f';
ctx.strokeRect(c * CELL + 1, r * CELL + 1, CELL - 2, CELL - 2);
}
}
}
ctx.fillStyle = '#fff';
for (const [dc, dr] of dots) {
ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 2.5, 0, Math.PI * 2); ctx.fill();
}
if (Math.floor(animFrame / 15) % 2) {
ctx.fillStyle = '#FFA500';
for (const [dc, dr] of powers) {
ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 5, 0, Math.PI * 2); ctx.fill();
}
}
for (const g of ghosts) {
ctx.fillStyle = frightTimer > 0 ? '#006' : g.color;
const gx = g.x * CELL + CELL/2, gy = g.y * CELL + CELL/2;
ctx.beginPath();
ctx.arc(gx, gy - 2, CELL/2 - 2, Math.PI, 0);
ctx.lineTo(gx + CELL/2 - 2, gy + CELL/2 - 2);
for (let w = 0; w < 4; w++) {
ctx.lineTo(gx + CELL/2 - 2 - w * (CELL - 4) / 4, gy + CELL/2 - 2 - (w % 2 ? 3 : 0));
}
ctx.lineTo(gx - CELL/2 + 2, gy + CELL/2 - 2); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
if (frightTimer <= 0) {
ctx.fillStyle = '#00c';
ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
}
}
const mouth = Math.abs(Math.sin(animFrame * 0.15)) * 0.4;
const pa = player.dir[0] !== 0 ? Math.atan2(player.dir[1], player.dir[0]) : -Math.PI / 2 * Math.sign(player.dir[1] || 1);
ctx.fillStyle = '#ff0';
ctx.beginPath();
ctx.moveTo(player.x * CELL + CELL/2, player.y * CELL + CELL/2);
ctx.arc(player.x * CELL + CELL/2, player.y * CELL + CELL/2, CELL/2 - 1, pa + mouth, pa + Math.PI * 2 - mouth);
ctx.closePath(); ctx.fill();
animFrame++;
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#000"/>
<g fill="#00f" stroke="#00f" stroke-width="1">
<rect x="5" y="5" width="110" height="8"/>
<rect x="5" y="67" width="110" height="8"/>
<rect x="5" y="5" width="8" height="70"/>
<rect x="107" y="5" width="8" height="70"/>
<rect x="30" y="5" width="8" height="35"/>
<rect x="60" y="5" width="8" height="35"/>
<rect x="90" y="5" width="8" height="35"/>
<rect x="30" y="45" width="8" height="30"/>
<rect x="60" y="45" width="8" height="30"/>
<rect x="90" y="45" width="8" height="30"/>
</g>
<circle cx="20" cy="42" r="8" fill="#ff0"/>
<path d="M20,42 L28,38 L28,46 Z" fill="#000"/>
<circle cx="55" cy="25" r="4" fill="#f00" opacity="0.9"/>
<circle cx="80" cy="55" r="4" fill="#f88" opacity="0.9"/>
<circle cx="19" cy="25" r="2" fill="#fff"/>
<circle cx="45" cy="55" r="2" fill="#fff"/>
<circle cx="75" cy="35" r="2" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 998 B

View file

@ -0,0 +1,793 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ECOINFLOW</title>
<style>
:root {
--pub: #FFA500;
--hab: #8BC34A;
--val: #eeeeee;
--shop: #4CAF50;
--acum: #FF5252;
--check: #FFFF55;
--cbdc: #607D8B;
--bg: #000;
--fg: #eee;
--cell: 64px;
--gap: 5px;
--pad: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100vh; overflow: hidden; }
body {
background: var(--bg); color: var(--fg);
font-family: 'Courier New', ui-monospace, monospace;
height: 100vh; display: flex; flex-direction: column;
align-items: center; user-select: none; -webkit-user-select: none;
}
#topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
#ui { display:flex; gap:16px; padding:8px; font-size:14px; color:#FFA500; flex-wrap:wrap; justify-content:center; }
#ui b { font-size:15px; }
#hudTurn { transition:color 0.3s; }
#boardWrap { position:relative; flex:1; min-height:0; margin:0; display:flex; align-items:center; justify-content:center; width:100%; overflow:hidden; }
#board { display:grid; gap:var(--gap); background:#222; padding:var(--pad); border:1px solid #3a3a3a; position:relative; }
.cell { width:var(--cell); height:var(--cell); background:#2c2c2c; border:1px solid #444; display:flex; align-items:center; justify-content:center; cursor:pointer; position:relative; transition:background 0.15s; }
.cell.empty:hover { background:#3a3a3a; }
.cell.drawable { background:#4a3800; border-color:#9a7200; }
.cell.drawable.empty:hover { background:#5e4700; }
.cell.in-path { background:#524000; }
.cell.cursor { outline:2px solid var(--pub); outline-offset:-2px; z-index:3; }
.node-label { font-size:40px; font-weight:bold; color:#fff; pointer-events:none; line-height:1; text-align:center; background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.45); padding:2px 7px; border-radius:3px; }
.node.validator .node-label,.node.accumulator .node-label { font-size:22px; padding:1px 4px; }
.node { width:82%; height:82%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; font-weight:bold; font-size:11px; color:#000; position:relative; transition:transform 0.2s,opacity 0.2s; }
.node-icon { display:flex; align-items:center; justify-content:center; pointer-events:none; line-height:1; font-size:34px; }
.node.validator .node-icon { font-size:22px; }
.node.inactive { opacity:0.42; }
.node.active { animation:pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%,100%{transform:scale(1);} 50%{transform:scale(1.07);} }
.node.danger { animation:danger 0.42s ease-in-out infinite !important; opacity:1 !important; }
@keyframes danger { 0%,100%{background:var(--hab);box-shadow:0 0 0 2px var(--hab);} 50%{background:#c83030;box-shadow:0 0 0 5px rgba(255,82,82,.9),0 0 16px rgba(255,82,82,.5);} }
.node.acum-danger { animation:acumDanger 0.44s ease-in-out infinite !important; opacity:1 !important; }
@keyframes acumDanger { 0%,100%{filter:drop-shadow(0 0 3px rgba(255,82,82,.45));} 50%{filter:drop-shadow(0 0 12px rgba(255,17,68,1)) drop-shadow(0 0 4px rgba(255,82,82,.9));} }
.node.cbdc-threat { animation:cbdcThreat 0.55s ease-in-out infinite !important; opacity:1 !important; }
@keyframes cbdcThreat { 0%,100%{background:var(--hab);box-shadow:0 0 0 2px var(--hab);} 50%{background:#4a6572;box-shadow:0 0 0 5px rgba(96,125,139,.85),0 0 14px rgba(96,125,139,.5);} }
.node.cbdc-alarm { animation:cbdcAlarm 0.7s ease-in-out infinite; }
@keyframes cbdcAlarm { 0%,100%{box-shadow:0 0 0 2px var(--cbdc);} 50%{box-shadow:0 0 0 6px rgba(96,125,139,.8),0 0 14px rgba(96,125,139,.5);} }
.node.pub { background:var(--pub); border-radius:3px; box-shadow:0 0 0 2px var(--pub),0 0 10px rgba(255,165,0,.18); }
.node.pub::before { content:''; position:absolute; width:2px; height:10px; background:var(--pub); top:-10px; left:50%; transform:translateX(-50%); }
.node.pub::after { content:''; position:absolute; width:6px; height:6px; background:var(--pub); border-radius:50%; top:-16px; left:50%; transform:translateX(-50%); }
.node.pub.reduced { outline:2px dashed #c47800; outline-offset:2px; }
.node.inhabitant { background:var(--hab); border-radius:50%; box-shadow:0 0 0 2px var(--hab),0 0 10px rgba(139,195,74,.18); }
.node.inhabitant.cbdc-controlled { background:#455a64; border-radius:50%; box-shadow:0 0 0 2px #455a64; }
.node.checkpoint { background:transparent; border-radius:3px; border:3px solid var(--check); box-shadow:0 0 6px rgba(255,215,0,.35); opacity:1 !important; }
.node.checkpoint.active { background:transparent !important; border:3px solid var(--check) !important; box-shadow:0 0 0 2px var(--check),0 0 18px rgba(255,229,102,.9) !important; filter:none !important; animation:checkActive 1.8s ease-in-out infinite !important; }
.node.checkpoint .node-label { color:var(--check); text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 8px rgba(0,0,0,.9); }
@keyframes checkActive { 0%,100%{transform:scale(1.1);box-shadow:0 0 0 2px var(--check),0 0 12px rgba(255,229,102,.65);} 50%{transform:scale(1.22);box-shadow:0 0 0 5px var(--check),0 0 26px rgba(255,229,102,1);} }
.node.validator { background:rgba(200,218,230,.55); clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%); width:68%; height:68%; overflow:hidden; filter:drop-shadow(0 0 5px rgba(200,218,230,.7)); }
.node.validator.active { background:var(--val) !important; filter:drop-shadow(0 0 8px rgba(238,238,238,.9)) !important; }
.node.shop { background:var(--shop); clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%); filter:drop-shadow(0 0 4px rgba(76,175,80,.45)); }
.node.accumulator { background:rgba(255,82,82,.18); clip-path:polygon(50% 0,100% 100%,0 100%); overflow:hidden; filter:drop-shadow(0 0 4px rgba(255,82,82,.42)); }
.node.cbdc { background:var(--cbdc); border-radius:3px; color:#cfd8dc; font-size:14px; box-shadow:0 0 0 2px var(--cbdc),0 0 10px rgba(96,125,139,.25); }
#pipes { position:absolute; pointer-events:none; }
#pipes path { stroke:var(--pub); stroke-width:5; stroke-linecap:round; stroke-linejoin:round; opacity:.4; fill:none; }
#pipes path.flowing { opacity:1; stroke-width:6; filter:drop-shadow(0 0 4px rgba(255,165,0,.85)) drop-shadow(0 0 10px rgba(255,165,0,.4)); stroke-dasharray:14 8; animation:pipeDash 1.1s linear infinite; }
#pipes path.clickable { pointer-events:auto; cursor:pointer; }
#pipes path.clickable:hover { stroke:#fff; filter:none; stroke-dasharray:none; animation:none; }
#pipes path.cbdc-line { stroke:var(--cbdc); stroke-width:3; stroke-dasharray:5 4; opacity:.75; pointer-events:none; }
#particleLayer { position:absolute; pointer-events:none; }
.flow-particle { position:absolute; width:7px; height:7px; background:var(--pub); border-radius:50%; box-shadow:0 0 7px var(--pub),0 0 14px rgba(255,165,0,.6),0 0 22px rgba(255,165,0,.3); offset-distance:0%; animation:flowParticle 900ms linear infinite; will-change:offset-distance,opacity; }
@keyframes flowParticle { 0%{offset-distance:0%;opacity:0;} 8%{opacity:1;} 85%{opacity:1;} 100%{offset-distance:100%;opacity:0;} }
@keyframes pipeDash { to { stroke-dashoffset:-22; } }
.pot-float { position:absolute; color:var(--acum); font-size:15px; font-weight:bold; font-family:'Courier New',monospace; letter-spacing:1px; pointer-events:none; text-shadow:0 0 8px rgba(255,82,82,.7); animation:potFloat 1.4s ease-out forwards; z-index:20; white-space:nowrap; }
@keyframes potFloat { 0%{transform:translateY(0) scale(1);opacity:1;} 20%{transform:translateY(-6px) scale(1.15);} 100%{transform:translateY(-48px) scale(.9);opacity:0;} }
.check-pulse { position:absolute; border-radius:50%; border:2px solid var(--check); box-shadow:0 0 10px var(--check); pointer-events:none; animation:checkPulse .8s ease-out forwards; z-index:15; }
@keyframes checkPulse { 0%{transform:translate(-50%,-50%) scale(.3);opacity:1;} 100%{transform:translate(-50%,-50%) scale(2.5);opacity:0;} }
#message { margin:8px 0; min-height:18px; font-size:12px; color:#888; text-align:center; max-width:500px; padding:0 16px; line-height:1.5; }
#message.warning { color:var(--pub); background:rgba(255,165,0,.07); border:1px solid rgba(255,165,0,.2); padding:6px 16px; border-radius:2px; }
#message.alert { color:var(--acum); background:rgba(255,82,82,.07); border:1px solid rgba(255,82,82,.2); padding:6px 16px; border-radius:2px; }
#legend { display:flex; flex-wrap:wrap; justify-content:center; gap:10px 16px; margin:4px 0; padding:8px 16px; max-width:560px; font-size:11px; color:#888; }
#legend .item { display:flex; align-items:center; gap:6px; }
#legend .mini { width:16px; height:16px; display:inline-block; flex-shrink:0; }
#legend .mini.pub { background:var(--pub); border-radius:2px; }
#legend .mini.inhabitant { background:var(--hab); border-radius:50%; }
#legend .mini.validator { background:var(--val); clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%); width:14px; height:14px; margin:1px; }
#legend .mini.shop { background:var(--shop); clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%); }
#legend .mini.accumulator { background:var(--acum); clip-path:polygon(50% 0,100% 100%,0 100%); }
#legend .mini.checkpoint { background:transparent; border:2px solid var(--check); border-radius:2px; }
#legend .mini.cbdc { background:var(--cbdc); border-radius:2px; }
#controls { color:#888; font-size:13px; text-align:center; margin-bottom:8px; display:flex; gap:10px; flex-wrap:wrap; justify-content:center; align-items:center; padding:0 16px; }
#btnAdvanceTurn { background:#0a0a0a; color:var(--fg); border:1px solid var(--pub); padding:8px 18px; font-family:inherit; font-size:12px; cursor:pointer; text-transform:uppercase; letter-spacing:1px; }
#btnAdvanceTurn:hover { background:var(--pub); color:#000; }
button { background:#0a0a0a; color:var(--fg); border:1px solid var(--pub); padding:10px 16px; font-family:inherit; font-size:12px; cursor:pointer; text-transform:uppercase; letter-spacing:1px; }
button:hover { background:var(--pub); color:#000; }
button.primary { background:var(--pub); color:#000; font-weight:bold; }
button.primary:hover { background:#ffb733; }
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.97); display:flex; align-items:center; justify-content:center; z-index:100; padding:20px; }
.overlay.hidden { display:none; }
.overlay .panel { max-width:480px; border:1px solid var(--pub); padding:24px 28px; background:#050505; }
.overlay h2 { color:var(--pub); margin-bottom:14px; font-size:16px; letter-spacing:1px; }
.overlay p { margin:10px 0; line-height:1.55; font-size:13px; color:#bbb; }
.overlay .hint { color:#bbb; font-size:12px; border-left:2px solid #444; padding-left:10px; margin-top:16px; }
.info-panel { max-width:560px !important; max-height:85vh; overflow-y:auto; }
.info-panel h3 { color:var(--pub); font-size:13px; letter-spacing:1px; margin-top:18px; margin-bottom:6px; text-transform:uppercase; }
.info-panel h3:first-of-type { margin-top:4px; }
.info-panel ul { list-style:none; padding-left:0; margin:8px 0; }
.info-panel ul li { font-size:13px; color:#bbb; padding:4px 0 4px 14px; position:relative; line-height:1.5; }
.info-panel ul li::before { content:'>'; color:var(--pub); position:absolute; left:0; }
.info-panel .stat-line { font-family:inherit; color:#888; font-size:12px; margin:6px 0 12px; }
.info-panel .stat-line b { color:var(--pub); }
.overlay .buttons { margin-top:20px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap; }
#scoreSubmit { margin-top:14px; text-align:center; }
#scoreSubmit button { border-color:var(--check); color:var(--check); background:#1a3a1a; padding:6px 16px; font-size:14px; }
#scoreSubmit button:hover { background:var(--check); color:#000; }
#winBreakdown { font-size:11px; color:#555; line-height:1.9; margin:4px 0 10px; border-left:2px solid #222; padding-left:10px; }
#winBreakdown .positive { color:#6a9a3a; }
#winBreakdown .negative { color:var(--acum); }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ECOINFLOW</span>
</div>
<div id="ui">
<span>LEVEL: <b id="hudLevel">1</b>/<b id="hudLevelTotal">8</b></span>
<span>TURN: <b id="hudTurn">0</b></span>
<span>PAR: <b id="hudPar">2</b></span>
<span>SCORE: <b id="hudScore">0</b></span>
<span>BEST: <b id="topBest">-</b></span>
</div>
<div id="boardWrap">
<div id="board"></div>
<svg id="pipes"></svg>
<div id="particleLayer"></div>
</div>
<div id="message"></div>
<div id="legend"></div>
<div id="controls">
<button id="btnAdvanceTurn" onclick="tick()">ADVANCE TURN</button>
<span>ENTER &#8212; advance &nbsp;|&nbsp; Backspace &#8212; undo &nbsp;|&nbsp; I &#8212; info</span>
</div>
<div id="overlayIntro" class="overlay">
<div class="panel">
<h2 id="introTitle">LEVEL 1 &#8212; Your first PUB</h2>
<p id="introText"></p>
<p style="margin-top:10px;font-size:12px;line-height:2.2">
<a href="https://wiki.solarnethub.com/ecoin/overview" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none;margin-right:14px">ECOin &#8599;</a>
<a href="https://wiki.solarnethub.com/socialnet/overview" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none;margin-right:14px">Oasis &#8599;</a>
<a href="https://wiki.solarnethub.com/" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none">SolarNET.HuB &#8599;</a>
</p>
<p class="hint">Click a node to start a pipe, then click adjacent cells to extend it, and finish on another node. Click an existing pipe to remove it. Press <b>ENTER</b> (or ADVANCE TURN button) to process one flow cycle.</p>
<div class="buttons"><button class="primary" onclick="closeIntro()">START</button></div>
</div>
</div>
<div id="overlayWin" class="overlay hidden">
<div class="panel">
<h2>&#10003; LEVEL COMPLETE</h2>
<p id="winText"></p>
<p>Level score: <b id="winLevelPoints" style="color:var(--pub);font-size:18px">0</b></p>
<p>Total score: <b id="winTotalPoints" style="color:var(--check);font-size:18px">0</b></p>
<div id="winBreakdown"></div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="ecoinflow">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<div class="buttons">
<button onclick="restartLevel()">RETRY</button>
<button id="btnNextLevel" class="primary" onclick="goNextLevel()">NEXT LEVEL</button>
</div>
</div>
</div>
<div id="overlayLoss" class="overlay hidden">
<div class="panel">
<h2 style="color:var(--acum)">&#10007; NETWORK DOWN</h2>
<p id="lossText"></p>
<div class="buttons"><button class="primary" onclick="restartLevel()">RETRY</button></div>
</div>
</div>
<div id="overlayInfo" class="overlay hidden">
<div class="panel info-panel">
<h2>INFORMATION</h2>
<div id="infoBody"></div>
<div class="buttons"><button class="primary" onclick="closeInfo()">CLOSE</button></div>
</div>
</div>
<script>
var ecoflowBest = parseInt(localStorage.getItem('ecoinflow_best') || '0');
function updateBest(score) {
if (score > ecoflowBest) { ecoflowBest = score; localStorage.setItem('ecoinflow_best', ecoflowBest); }
document.getElementById('topBest').textContent = ecoflowBest > 0 ? String(ecoflowBest) : '-';
}
updateBest(0);
var LEVELS = [
{ id:1, size:3, par:2,
grid:[['empty','inhabitant','empty'],['pub','empty','inhabitant'],['empty','empty','checkpoint']],
concept:'Your first PUB',
introText:'A PUB is a public server in the Oasis network. It generates ECOin and distributes it to those connected. Your task: connect the PUB to the inhabitants and reach the checkpoint.',
winText:'Network active. Inhabitants receive their flow. This is how a PUB works: it does not accumulate, it distributes.' },
{ id:2, size:4, par:4,
grid:[['inhabitant','empty','inhabitant','empty'],['empty','pub','empty','pub'],['inhabitant','empty','inhabitant','empty'],['empty','empty','checkpoint','empty']],
concept:'Universal Basic Income',
introText:'UBI is the flow that reaches every inhabitant of Oasis, regardless of what they do. It is not charity: it is the network recognizing that your time has value. Each PUB emits UBI to connected inhabitants. None can be left without it.',
winText:'All inhabitants receive UBI. The network is fair when no one is disconnected.' },
{ id:3, size:4, par:5,
grid:[['pub','empty','empty','inhabitant'],['empty','validator','empty','empty'],['inhabitant','empty','validator','empty'],['empty','empty','inhabitant','checkpoint']],
concept:'Validators',
introText:'The ECOin network needs validators: nodes that confirm transactions are legitimate. A validator must accumulate minimum flow before it can pass ECOin through. Plan the connection order: charge validators first, then extend the network through them.',
winText:'Validators active. The blockchain advances. Without validators, there is no network.' },
{ id:4, size:4, par:6,
grid:[['pub','empty','inhabitant','empty'],['inhabitant','accumulator','empty','shop'],['empty','empty','shop','empty'],['inhabitant','empty','pub','checkpoint']],
concept:'Proof of Transaction',
introText:'PoT discourages accumulating ECOin without using it. If flow reaches an accumulator and does not exit, the network penalizes it. You can route around it or connect it to a Shop so the flow keeps circulating. ECOin that does not circulate serves no one.',
winText:'Clean flow. This is how the network maintains its economic health.',
winTextPoT:'PoT triggered. Penalty applied. Next time, keep the flow moving.' },
{ id:5, size:5, par:8,
grid:[['pub','empty','inhabitant','empty','inhabitant'],['empty','validator','empty','shop','empty'],['inhabitant','empty','accumulator','empty','validator'],['empty','shop','empty','inhabitant','empty'],['pub','empty','empty','empty','checkpoint']],
concept:'The Checkpoint',
introText:'The ECOin chain advances through checkpoints: milestones confirming that a number of blocks have been mined and validated. This level integrates all elements seen so far. Manage flow carefully: validators block until loaded, the accumulator penalizes if full. Plan before advancing the turn.',
winText:'Checkpoint reached. The network advances. Every mined block is a step toward digital sovereignty.',
winTextPoT:'Checkpoint reached, but PoT triggered. Penalty applied.' },
{ id:6, size:5, par:10,
grid:[[{type:'pub',capacity:2},'empty','inhabitant','empty','pub'],['inhabitant','validator','empty','shop','inhabitant'],['empty','empty','accumulator','checkpoint','validator'],['pub','shop','empty','inhabitant','empty'],['inhabitant','validator','shop','accumulator','checkpoint']],
concept:'Network Under Pressure',
introText:'The real network operates with limited resources. One PUB is running at reduced capacity (marked with a dashed border). You cannot always feed all nodes at once: learn to prioritize. Inhabitants first, then validators, shops if there is surplus. Two checkpoints to activate.',
winText:'Network stabilized under pressure. Digital sovereignty requires conscious management of real resources.',
winTextPoT:'Network stabilized, but PoT penalized. Optimize the accumulator flow.' },
{ id:7, size:5, par:12,
grid:[['pub','inhabitant','empty','inhabitant','pub'],['inhabitant','cbdc','inhabitant','empty','empty'],['empty','inhabitant','empty','validator','shop'],['pub','empty','shop','empty','inhabitant'],['empty','inhabitant','empty','validator','checkpoint']],
concept:'The Digital Euro',
introText:'The Digital Euro is being implemented. Unlike ECOin, a CBDC (Central Bank Digital Currency) is not sovereignty: it is surveillance. The CBDC node tries to capture adjacent inhabitants not connected to a PUB every 2 turns. Connect your PUBs to vulnerable inhabitants first. Speed matters.',
winText:'Sovereign network. Inhabitants are connected to community PUBs, not surveillance infrastructure. This is what is at stake.',
winTextCBDC:'Some inhabitants were captured by CBDC before you could connect them. The network survived, but not all are free.' },
{ id:8, size:6, par:14,
grid:[['pub','empty','inhabitant','empty','inhabitant','pub'],['empty','validator','empty','shop','empty','inhabitant'],['inhabitant','empty','accumulator','empty','validator','empty'],['empty','shop','empty','inhabitant','empty','shop'],['pub','empty','validator','empty','pub','empty'],['inhabitant','empty','cbdc','inhabitant','empty','checkpoint']],
concept:'Complete Oasis',
introText:'This is Oasis. A decentralized network where ECOin flows from PUBs to inhabitants as universal basic income, validators maintain blockchain integrity, shops sustain the internal economy, and checkpoints consolidate network history. CBDC tries to expand from the edge. There is no central bank. Only the network. Complete the picture.',
winText:'Oasis active. You have built a sovereign network. Now you know how it works. Now you can be part of it.' }
];
function createCell(type) {
return { type:type, active:type==='pub', flowReceivedThisTurn:0, turnosSinFlujo:0, acumulado:0, validadorCargado:0, contadorActivo:0, bloqueado:false, capacity:null, cbdcControlled:false };
}
var state = { levelIndex:0, size:0, board:[], paths:[], drawing:null, turn:0, score:0, potActivations:0, cbdcCaptured:new Set(), pendingEvents:[], status:'intro', cursor:{r:0,c:0} };
function loadLevel(i) {
var lvl = LEVELS[i];
state.levelIndex = i; state.size = lvl.size;
state.board = lvl.grid.map(function(row) { return row.map(function(def) {
if (typeof def === 'string') return createCell(def);
var cell = createCell(def.type);
if (def.capacity !== undefined) cell.capacity = def.capacity;
return cell;
}); });
state.paths = []; state.drawing = null; state.turn = 0;
if (i === 0) state.score = 0;
state.potActivations = 0; state.cbdcCaptured = new Set(); state.pendingEvents = [];
state.status = 'playing'; state.cursor = {r:0,c:0};
document.getElementById('hudLevel').textContent = lvl.id;
document.getElementById('hudLevelTotal').textContent = LEVELS.length;
document.getElementById('hudPar').textContent = lvl.par;
document.getElementById('introTitle').textContent = 'LEVEL ' + lvl.id + ' \u2014 ' + lvl.concept;
document.getElementById('introText').textContent = lvl.introText;
renderLegend(); showMessage(''); resizeBoard(); render();
}
var LEGEND_LABELS = { pub:'PUB', inhabitant:'Inhabitant', validator:'Validator', shop:'Shop', accumulator:'Accumulator', checkpoint:'Checkpoint', cbdc:'CBDC' };
var LEGEND_ORDER = ['pub','inhabitant','validator','shop','accumulator','checkpoint','cbdc'];
function renderLegend() {
var present = new Set();
for (var r=0;r<state.size;r++) for (var c=0;c<state.size;c++) { var t=state.board[r][c].type; if(t!=='empty') present.add(t); }
var el = document.getElementById('legend');
el.innerHTML = '';
LEGEND_ORDER.forEach(function(type) {
if (!present.has(type)) return;
var item = document.createElement('div'); item.className = 'item';
var mini = document.createElement('span'); mini.className = 'mini '+type;
var label = document.createElement('span'); label.textContent = LEGEND_LABELS[type];
item.appendChild(mini); item.appendChild(label); el.appendChild(item);
});
}
function getCell(r,c) { if(r<0||c<0||r>=state.size||c>=state.size) return null; return state.board[r][c]; }
function areAdjacent(a,b) { return Math.abs(a.r-b.r)+Math.abs(a.c-b.c)===1; }
function cellInOtherPath(r,c,exc) {
for(var i=0;i<state.paths.length;i++) { if(i===exc) continue; var p=state.paths[i]; for(var k=1;k<p.length-1;k++) if(p[k].r===r&&p[k].c===c) return true; }
return false;
}
function countConnections(r,c) {
return state.paths.filter(function(p){var a=p[0],b=p[p.length-1]; return(a.r===r&&a.c===c)||(b.r===r&&b.c===c);}).length;
}
function getConnectedNodes(r,c) {
var result=[];
state.paths.forEach(function(path){ var a=path[0],b=path[path.length-1]; if(a.r===r&&a.c===c) result.push(b); else if(b.r===r&&b.c===c) result.push(a); });
return result;
}
function isConnectedToPUB(sr,sc) {
var visited=new Set(), queue=[sr+','+sc]; visited.add(sr+','+sc);
while(queue.length>0) {
var key=queue.shift(), parts=key.split(','), kr=+parts[0], kc=+parts[1], cell=state.board[kr][kc];
if(cell.type==='pub'&&cell.active&&!cell.bloqueado) return true;
getConnectedNodes(kr,kc).forEach(function(n){ var nk=n.r+','+n.c; if(!visited.has(nk)){visited.add(nk);queue.push(nk);} });
}
return false;
}
var PUB_OUTPUT=4;
function computePreviewFlow() {
var flow={};
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) flow[r+','+c]=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c];
if(cell.type!=='pub'||!cell.active||cell.bloqueado) continue;
var outs=getConnectedNodes(r,c); if(!outs.length) continue;
var pubOut=cell.capacity!==null?cell.capacity:PUB_OUTPUT, share=pubOut/outs.length;
outs.forEach(function(n){ previewPropagate(n.r,n.c,share,new Set([r+','+c]),flow); });
}
return flow;
}
function previewPropagate(r,c,amount,visited,flow) {
var cell=state.board[r][c], k=r+','+c;
if(visited.has(k)) return; visited.add(k);
if(cell.type!=='pub') flow[k]=(flow[k]||0)+amount;
if(cell.type!=='validator'||!cell.active||cell.bloqueado) return;
getConnectedNodes(r,c).filter(function(n){return!visited.has(n.r+','+n.c);}).forEach(function(n){ previewPropagate(n.r,n.c,amount,new Set(visited),flow); });
}
function render() { renderBoard(); renderPipes(); renderParticles(); renderHud(); }
function makeEcoinSVG() {
var ns='http://www.w3.org/2000/svg';
var svg=document.createElementNS(ns,'svg');
svg.setAttribute('viewBox','0 0 32 32'); svg.setAttribute('width','68%'); svg.setAttribute('height','68%');
svg.classList.add('node-icon');
var c1=document.createElementNS(ns,'circle'); c1.setAttribute('cx','16'); c1.setAttribute('cy','16'); c1.setAttribute('r','13'); c1.setAttribute('fill','#0d0800'); c1.setAttribute('stroke','#FFA500'); c1.setAttribute('stroke-width','2.5');
var c2=document.createElementNS(ns,'circle'); c2.setAttribute('cx','16'); c2.setAttribute('cy','16'); c2.setAttribute('r','9'); c2.setAttribute('fill','none'); c2.setAttribute('stroke','rgba(255,165,0,0.35)'); c2.setAttribute('stroke-width','1');
var t=document.createElementNS(ns,'text'); t.setAttribute('x','16'); t.setAttribute('y','21'); t.setAttribute('text-anchor','middle'); t.setAttribute('font-size','15'); t.setAttribute('font-weight','bold'); t.setAttribute('fill','#FFA500'); t.setAttribute('font-family','monospace'); t.textContent='E';
svg.appendChild(c1); svg.appendChild(c2); svg.appendChild(t);
return svg;
}
function renderBoard() {
var preview=computePreviewFlow();
var fmtFlow=function(v){return Number.isInteger(v)?String(v):v.toFixed(1);};
var board=document.getElementById('board');
board.innerHTML='';
board.style.gridTemplateColumns='repeat('+state.size+', var(--cell))';
var last=state.drawing?state.drawing.cells[state.drawing.cells.length-1]:null;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
(function(r,c){
var cell=state.board[r][c];
var div=document.createElement('div'); div.className='cell';
if(cell.type==='empty') div.classList.add('empty');
if(state.cursor&&state.cursor.r===r&&state.cursor.c===c) div.classList.add('cursor');
if(last&&areAdjacent(last,{r:r,c:c})) {
var alreadyIn=state.drawing.cells.some(function(p){return p.r===r&&p.c===c;});
var blocked=cell.type==='empty'&&cellInOtherPath(r,c);
if(!alreadyIn&&!blocked) div.classList.add('drawable');
}
if(state.drawing&&state.drawing.cells.some(function(p){return p.r===r&&p.c===c;})) div.classList.add('in-path');
if(cell.type!=='empty') {
var node=document.createElement('div'); node.className='node '+cell.type;
if(cell.cbdcControlled){ node.classList.add('cbdc-controlled'); node.classList.add('inactive'); }
else if(cell.active&&!cell.bloqueado) node.classList.add('active');
else node.classList.add('inactive');
if(cell.type==='inhabitant'&&!cell.cbdcControlled&&cell.turnosSinFlujo>=2){ node.classList.remove('inactive'); node.classList.add('danger'); }
if(cell.type==='inhabitant'&&!cell.cbdcControlled&&!node.classList.contains('danger')) {
var adjToCBDC=[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].some(function(p){ if(p.r<0||p.c<0||p.r>=state.size||p.c>=state.size) return false; return state.board[p.r][p.c].type==='cbdc'; });
if(adjToCBDC&&!isConnectedToPUB(r,c)){ node.classList.remove('inactive'); node.classList.add('cbdc-threat'); }
}
if(cell.type==='accumulator'&&!cell.bloqueado&&cell.acumulado>=4) node.classList.add('acum-danger');
if(cell.type==='cbdc') {
var hasVuln=[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].some(function(p){ if(p.r<0||p.c<0||p.r>=state.size||p.c>=state.size) return false; var adj=state.board[p.r][p.c]; return adj.type==='inhabitant'&&!adj.cbdcControlled&&!isConnectedToPUB(p.r,p.c); });
if(hasVuln){ node.classList.remove('inactive'); node.classList.add('cbdc-alarm'); }
}
if(cell.type==='pub'&&cell.capacity!==null) node.classList.add('reduced');
if(cell.type==='pub'){ node.appendChild(makeEcoinSVG()); }
else if(cell.type==='inhabitant'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\uD83E\uDD65'; node.appendChild(ico); }
else if(cell.type==='validator'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\u2714'; node.appendChild(ico); }
else if(cell.type==='cbdc'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\uD83D\uDC80'; node.appendChild(ico); }
if(cell.type==='checkpoint'){ var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=Math.min(cell.contadorActivo,3); node.appendChild(lbl); }
if(cell.type==='validator') {
if(!cell.active){ var pct=Math.round(Math.min(100,(cell.validadorCargado/3)*100)); node.style.background='linear-gradient(to top,#c8dde8 '+pct+'%,rgba(200,218,230,.55) '+pct+'%)'; }
var pf=preview[r+','+c]||0; var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=fmtFlow(pf); node.appendChild(lbl);
}
if(cell.type==='pub') {
var pubOutput=cell.capacity!==null?cell.capacity:4, conns=countConnections(r,c), share=conns>1?pubOutput/conns:pubOutput;
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=Number.isInteger(share)?share:share.toFixed(1); node.appendChild(lbl);
}
if(cell.type==='inhabitant'&&!cell.cbdcControlled) {
var pf=preview[r+','+c]||0, health=3-cell.turnosSinFlujo;
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=health;
lbl.style.color=pf>=1?'#fff':pf>0?'#FFA500':'#FF5252'; node.appendChild(lbl);
}
if(cell.type==='accumulator'&&!cell.bloqueado) {
var acc=cell.acumulado, pct=Math.round(Math.min(100,(acc/5)*100));
var fillColor=acc>=4.5?'#FF1744':acc>=3?'#FF5252':acc>=1.5?'#E53935':'#B71C1C';
node.style.background='linear-gradient(to top,'+fillColor+' '+pct+'%,rgba(255,82,82,.18) '+pct+'%)';
var pf=preview[r+','+c]||0, projected=Math.min(acc+pf,5);
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=fmtFlow(projected)+'/5'; node.appendChild(lbl);
}
div.appendChild(node);
}
div.setAttribute('role','button');
div.setAttribute('aria-label',cell.type==='empty'?'Empty cell':LEGEND_LABELS[cell.type]||cell.type);
div.addEventListener('click',function(){onCellClick(r,c);});
board.appendChild(div);
})(r,c);
}
}
function cellCenter(r,c) {
var boardEl=document.getElementById('board'), cellEls=boardEl.querySelectorAll('.cell');
if(cellEls.length>0) {
var idx=r*state.size+c, rect=cellEls[idx]?cellEls[idx].getBoundingClientRect():null, boardRect=boardEl.getBoundingClientRect();
if(rect) return {x:rect.left-boardRect.left+rect.width/2,y:rect.top-boardRect.top+rect.height/2};
}
var pad=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--pad'))||8;
var gap=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--gap'))||4;
var cs=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cell'))||72;
return {x:pad+c*(cs+gap)+cs/2,y:pad+r*(cs+gap)+cs/2};
}
function boardOffset() {
var boardEl=document.getElementById('board'), wrapEl=document.getElementById('boardWrap');
if(!boardEl||!wrapEl) return {x:0,y:0};
var br=boardEl.getBoundingClientRect(), wr=wrapEl.getBoundingClientRect();
return {x:br.left-wr.left, y:br.top-wr.top};
}
function renderPipes() {
var svg=document.getElementById('pipes'), boardEl=document.getElementById('board');
var off=boardOffset(), w=boardEl.offsetWidth, h=boardEl.offsetHeight;
svg.setAttribute('width',w); svg.setAttribute('height',h);
svg.style.width=w+'px'; svg.style.height=h+'px';
svg.style.left=off.x+'px'; svg.style.top=off.y+'px';
svg.innerHTML='';
var flowingIdx=computeFlowingPaths();
var cellEl=document.querySelector('#board .cell'), nodeR=cellEl?cellEl.offsetWidth*0.40:26;
function trimPt(from,toward,r){ var dx=toward.x-from.x,dy=toward.y-from.y,len=Math.sqrt(dx*dx+dy*dy); return len>0?{x:from.x+dx/len*r,y:from.y+dy/len*r}:from; }
function makePath(pts){ var d='M '+pts[0].x.toFixed(1)+' '+pts[0].y.toFixed(1); for(var i=1;i<pts.length;i++) d+=' L '+pts[i].x.toFixed(1)+' '+pts[i].y.toFixed(1); return d; }
state.paths.forEach(function(path,idx) {
var pts=path.map(function(p){return cellCenter(p.r,p.c);});
if(pts.length>=2){ pts=pts.slice(); pts[0]=trimPt(pts[0],pts[1],nodeR); pts[pts.length-1]=trimPt(pts[pts.length-1],pts[pts.length-2],nodeR); }
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d',makePath(pts)); el.classList.add('clickable');
if(flowingIdx.has(idx)) el.classList.add('flowing');
el.addEventListener('click',function(e){e.stopPropagation();removePath(idx);});
svg.appendChild(el);
});
state.cbdcCaptured.forEach(function(key){
var parts=key.split(','), cr=+parts[0], cc=+parts[1];
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
if(state.board[r][c].type!=='cbdc') continue;
var from=cellCenter(r,c), to=cellCenter(cr,cc);
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d','M '+from.x.toFixed(1)+' '+from.y.toFixed(1)+' L '+to.x.toFixed(1)+' '+to.y.toFixed(1)); el.classList.add('cbdc-line'); svg.appendChild(el);
}
});
if(state.drawing&&state.drawing.cells.length>0) {
var pts=state.drawing.cells.map(function(p){return cellCenter(p.r,p.c);});
if(pts.length>1){
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d',makePath(pts)); el.setAttribute('stroke-dasharray','8 5'); el.setAttribute('opacity','0.75'); el.setAttribute('stroke-width','4'); svg.appendChild(el);
}
var start=pts[0], circle=document.createElementNS('http://www.w3.org/2000/svg','circle');
circle.setAttribute('cx',start.x.toFixed(1)); circle.setAttribute('cy',start.y.toFixed(1)); circle.setAttribute('r',nodeR);
circle.setAttribute('fill','rgba(255,165,0,.15)'); circle.setAttribute('stroke','#FFA500'); circle.setAttribute('stroke-width','2'); svg.appendChild(circle);
}
}
function computeFlowingPaths() {
var flowing=new Set();
state.paths.forEach(function(p,i){ var a=state.board[p[0].r][p[0].c], b=state.board[p[p.length-1].r][p[p.length-1].c]; var aPub=a.type==='pub'&&a.active&&!a.bloqueado, bPub=b.type==='pub'&&b.active&&!b.bloqueado; if(aPub&&!b.bloqueado) flowing.add(i); if(bPub&&!a.bloqueado) flowing.add(i); });
return flowing;
}
function renderParticles() {
var layer=document.getElementById('particleLayer'); if(!layer) return;
var boardEl=document.getElementById('board'), off=boardOffset();
layer.style.left=off.x+'px'; layer.style.top=off.y+'px';
layer.style.width=boardEl.offsetWidth+'px'; layer.style.height=boardEl.offsetHeight+'px';
layer.querySelectorAll('.flow-particle').forEach(function(el){el.remove();});
if(state.status!=='playing'&&state.status!=='won') return;
var flowingIdx=computeFlowingPaths(), DURATION=900, N=4;
flowingIdx.forEach(function(idx){
var path=state.paths[idx]; if(!path||path.length<2) return;
var pts=path.map(function(p){return cellCenter(p.r,p.c);});
var cellA=state.board[path[0].r][path[0].c], cellB=state.board[path[path.length-1].r][path[path.length-1].c];
if(cellB.type==='pub'&&cellA.type!=='pub') pts=pts.slice().reverse();
var d='M '+pts.map(function(p){return p.x.toFixed(1)+' '+p.y.toFixed(1);}).join(' L ');
for(var k=0;k<N;k++){
var particle=document.createElement('div'); particle.className='flow-particle';
particle.style.offsetPath='path("'+d+'")'; particle.style.animationDuration=DURATION+'ms'; particle.style.animationDelay=(-k*DURATION/N)+'ms';
layer.appendChild(particle);
}
});
}
function fireEvents() {
var layer=document.getElementById('particleLayer'); if(!layer||state.pendingEvents.length===0) return;
state.pendingEvents.forEach(function(evt){
var center=cellCenter(evt.r,evt.c);
if(evt.type==='pot'){ var txt=document.createElement('div'); txt.className='pot-float'; txt.textContent='\u221250'; txt.style.left=(center.x-16)+'px'; txt.style.top=(center.y-10)+'px'; layer.appendChild(txt); txt.addEventListener('animationend',function(){txt.remove();}); }
if(evt.type==='checkpoint'){ var ring=document.createElement('div'); ring.className='check-pulse'; ring.style.left=center.x+'px'; ring.style.top=center.y+'px'; ring.style.width='30px'; ring.style.height='30px'; layer.appendChild(ring); ring.addEventListener('animationend',function(){ring.remove();}); }
});
}
function renderHud() {
document.getElementById('hudScore').textContent=state.score;
var lvl=LEVELS[state.levelIndex], turnEl=document.getElementById('hudTurn');
turnEl.textContent=state.turn;
turnEl.style.color=state.turn>lvl.par?'#FF5252':state.turn===lvl.par?'#FFA500':'var(--pub)';
}
function showMessage(text,type) {
var el=document.getElementById('message'); el.textContent=text||'';
el.className=type==='alert'?'alert':type==='warning'?'warning':'';
}
function buildTurnStatus() {
var parts=[];
var checks=[];
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='checkpoint') checks.push(cell.active?'\u2713':cell.contadorActivo+'/3'); }
if(checks.length>0) parts.push('Checkpoint: '+checks.join(' '));
var habRisk1=0,habRisk2=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='inhabitant'&&!cell.cbdcControlled){ if(cell.turnosSinFlujo===1) habRisk1++; else if(cell.turnosSinFlujo>=2) habRisk2++; } }
if(habRisk2>0) parts.push('\u26a0 '+habRisk2+' inhabitant(s) in danger');
else if(habRisk1>0) parts.push(habRisk1+' inhabitant(s) without flow');
var acumP=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='accumulator'&&!cell.bloqueado&&cell.acumulado>=4) acumP++; }
if(acumP>0) parts.push('\u26a0 Accumulator almost full');
return parts.join(' \u00b7 ');
}
function onCellClick(r,c) {
if(state.status!=='playing') return;
var cell=state.board[r][c];
if(!state.drawing) {
if(cell.type==='empty'){ showMessage('Start drawing by clicking on a node.'); return; }
if(cell.type==='inhabitant'){ showMessage('Inhabitants can only receive connections.'); return; }
state.drawing={cells:[{r:r,c:c}]};
showMessage('Drawing from '+(LEGEND_LABELS[cell.type]||cell.type).toUpperCase()+'. Click adjacent cells and finish on another node.');
render(); return;
}
var cells=state.drawing.cells, last=cells[cells.length-1];
if(cells.length===1&&last.r===r&&last.c===c){ cancelDrawing(); return; }
if(!areAdjacent(last,{r:r,c:c})){ showMessage('Can only extend to adjacent cells.'); return; }
if(cells.some(function(p){return p.r===r&&p.c===c;})){ showMessage('That cell is already in the path.'); return; }
if(cell.type==='empty'&&cellInOtherPath(r,c)){ showMessage('That cell already has a pipe.'); return; }
cells.push({r:r,c:c});
if(cell.type!=='empty'){ finalizeDrawing(); return; }
render();
}
function finalizeDrawing() {
var cells=state.drawing.cells, first=state.board[cells[0].r][cells[0].c], last=state.board[cells[cells.length-1].r][cells[cells.length-1].c];
if(first.type==='empty'||last.type==='empty'||cells.length<2){ cancelDrawing(); return; }
var a=cells[0], b=cells[cells.length-1];
if(countConnections(a.r,a.c)>=4){ showMessage('That node already has 4 connections (maximum).'); cancelDrawing(); return; }
if(last.type==='inhabitant'&&countConnections(b.r,b.c)>=1){ showMessage('This inhabitant is already connected.'); cancelDrawing(); return; }
if(countConnections(b.r,b.c)>=4){ showMessage('That node already has 4 connections (maximum).'); cancelDrawing(); return; }
var dup=state.paths.some(function(p){ var x=p[0],y=p[p.length-1]; return(x.r===a.r&&x.c===a.c&&y.r===b.r&&y.c===b.c)||(x.r===b.r&&x.c===b.c&&y.r===a.r&&y.c===a.c); });
if(dup){ showMessage('A pipe between those nodes already exists.'); cancelDrawing(); return; }
state.paths.push(cells); state.drawing=null; state.score+=2; showMessage('Pipe created. (+2)'); render();
}
function cancelDrawing(){ state.drawing=null; showMessage(''); render(); }
function removePath(idx){ if(state.status!=='playing') return; state.paths.splice(idx,1); showMessage('Pipe removed.'); render(); }
function propagateFrom(r,c,amount,visited) {
var cell=state.board[r][c], k=r+','+c;
if(visited.has(k)) return; visited.add(k);
if(cell.type!=='pub') cell.flowReceivedThisTurn+=amount;
if(cell.type!=='validator'||!cell.active||cell.bloqueado) return;
getConnectedNodes(r,c).filter(function(n){return!visited.has(n.r+','+n.c);}).forEach(function(n){propagateFrom(n.r,n.c,amount,new Set(visited));});
}
function tick() {
if(state.status!=='playing') return;
if(state.paths.length===0) showMessage('No pipes: connect nodes before advancing. Inhabitants will lose flow.','warning');
state.turn++;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) state.board[r][c].flowReceivedThisTurn=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c];
if(cell.type!=='pub'||!cell.active||cell.bloqueado) continue;
var outs=getConnectedNodes(r,c); if(!outs.length) continue;
var pubOutput=cell.capacity!==null?cell.capacity:PUB_OUTPUT, share=pubOutput/outs.length;
outs.forEach(function(n){ var v=new Set(); v.add(r+','+c); propagateFrom(n.r,n.c,share,v); });
}
var caidoDesc=null;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c], f=cell.flowReceivedThisTurn;
switch(cell.type) {
case 'inhabitant':
if(cell.cbdcControlled) break;
if(f>=1){ cell.active=true; cell.turnosSinFlujo=0; }
else{ cell.active=false; cell.turnosSinFlujo++; if(cell.turnosSinFlujo>=3&&!caidoDesc) caidoDesc='Inhabitant at row '+(r+1)+', col '+(c+1)+' has gone 3 turns without ECOin.'; }
break;
case 'validator': cell.validadorCargado+=f; if(cell.validadorCargado>=3) cell.active=true; break;
case 'shop': cell.active=f>=2; break;
case 'accumulator':
cell.acumulado+=f;
if(cell.acumulado>5&&!cell.bloqueado){ cell.bloqueado=true; cell.active=false; state.potActivations++; state.pendingEvents.push({type:'pot',r:r,c:c}); }
break;
case 'checkpoint':
if(f>0){ cell.contadorActivo++; if(cell.contadorActivo>=3){ cell.active=true; if(cell.contadorActivo===3) state.pendingEvents.push({type:'checkpoint',r:r,c:c}); } }
break;
}
}
if(state.turn%2===0) {
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
if(state.board[r][c].type!=='cbdc') continue;
[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].filter(function(p){return p.r>=0&&p.c>=0&&p.r<state.size&&p.c<state.size;}).forEach(function(p){
var adjCell=state.board[p.r][p.c];
if(adjCell.type!=='inhabitant'||adjCell.cbdcControlled) return;
if(isConnectedToPUB(p.r,p.c)) return;
adjCell.cbdcControlled=true; adjCell.active=false; state.cbdcCaptured.add(p.r+','+p.c);
if(!caidoDesc) caidoDesc='Inhabitant at row '+(p.r+1)+', col '+(p.c+1)+' was captured by CBDC. Connect PUBs first.';
});
}
}
if(caidoDesc){ state.status='lost'; document.getElementById('lossText').textContent=caidoDesc+' The network cannot leave anyone disconnected. Review your connections.'; document.getElementById('overlayLoss').classList.remove('hidden'); render(); return; }
var hasCheckpoint=false, allActive=true;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='checkpoint'){ hasCheckpoint=true; if(!cell.active) allActive=false; } }
if(hasCheckpoint&&allActive) {
state.status='won';
var lvl=LEVELS[state.levelIndex], base=100, shopsActive=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) if(state.board[r][c].type==='shop'&&state.board[r][c].active) shopsActive++;
var bonusShops=shopsActive*10, bonusTurns=Math.max(0,lvl.par-state.turn)*5, penalPot=state.potActivations*50, levelScore=base+bonusShops+bonusTurns-penalPot;
state.score+=levelScore;
var winMsg=lvl.winText;
if(state.potActivations>0&&lvl.winTextPoT) winMsg=lvl.winTextPoT;
if(state.cbdcCaptured.size>0&&lvl.winTextCBDC) winMsg=lvl.winTextCBDC;
document.getElementById('winText').textContent=winMsg;
document.getElementById('winLevelPoints').textContent=levelScore;
document.getElementById('winTotalPoints').textContent=state.score;
document.getElementById('scoreInput').value=state.score;
var bd=document.getElementById('winBreakdown'); bd.innerHTML='';
function bdLine(cls,txt){ var s=document.createElement('span'); s.className=cls; s.textContent=txt; bd.appendChild(s); }
bdLine('positive','Base: +'+base);
if(bonusShops>0) bdLine('positive','Active shops ('+shopsActive+'): +'+bonusShops);
if(bonusTurns>0) bdLine('positive','Turns under par ('+(lvl.par-state.turn)+'): +'+bonusTurns);
if(penalPot>0) bdLine('negative','PoT triggered (\u00d7'+state.potActivations+'): \u221250');
var isLastLevel=state.levelIndex+1>=LEVELS.length;
var beatsBest=state.score>ecoflowBest;
updateBest(state.score);
document.getElementById('scoreSubmit').style.display=(isLastLevel&&beatsBest&&state.score>0)?'block':'none';
var nextBtn=document.getElementById('btnNextLevel'); nextBtn.style.display=(state.levelIndex+1<LEVELS.length)?'inline-block':'none';
render(); fireEvents(); state.pendingEvents=[];
setTimeout(function(){document.getElementById('overlayWin').classList.remove('hidden');},1500);
return;
}
var statusMsg=buildTurnStatus();
if(statusMsg){ showMessage(statusMsg,statusMsg.includes('danger')||statusMsg.includes('full')?'alert':null); }
else showMessage('Turn '+state.turn+' \u2014 no issues.');
render(); fireEvents(); state.pendingEvents=[];
}
function restartLevel() {
document.getElementById('overlayWin').classList.add('hidden'); document.getElementById('overlayLoss').classList.add('hidden');
if(state.levelIndex===0) state.score=0;
loadLevel(state.levelIndex);
}
function goNextLevel() {
if(state.levelIndex+1>=LEVELS.length) return;
document.getElementById('overlayWin').classList.add('hidden'); document.getElementById('overlayLoss').classList.add('hidden');
loadLevel(state.levelIndex+1); state.status='intro'; document.getElementById('overlayIntro').classList.remove('hidden');
}
function closeIntro(){ document.getElementById('overlayIntro').classList.add('hidden'); state.status='playing'; }
function closeInfo(){ document.getElementById('overlayInfo').classList.add('hidden'); }
function mk(tag,cls,txt){ var el=document.createElement(tag); if(cls) el.className=cls; if(txt!==undefined) el.textContent=txt; return el; }
function mka(tag,cls,attrs){ var el=document.createElement(tag); if(cls) el.className=cls; if(attrs) Object.keys(attrs).forEach(function(k){el.setAttribute(k,attrs[k]);}); return el; }
function openInfo() {
var lvl=LEVELS[state.levelIndex], body=document.getElementById('infoBody'); body.innerHTML='';
var habOK=0,habRiesgo=0,habCount=0,habCBDC=0,checkpoints=[],valActivos=0,valCargando=[],acumFilling=[],acumBloqueados=0,shopsActive=0,shopsInactive=0,pubsActivos=0;
var fmt=function(n){return(Math.round(n*10)/10).toString();};
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){
var cell=state.board[r][c];
switch(cell.type){
case 'pub': if(cell.active&&!cell.bloqueado) pubsActivos++; break;
case 'inhabitant': habCount++; if(cell.cbdcControlled) habCBDC++; else if(cell.turnosSinFlujo===0) habOK++; else habRiesgo++; break;
case 'checkpoint': checkpoints.push(cell.contadorActivo); break;
case 'validator': if(cell.active) valActivos++; else valCargando.push(cell.validadorCargado); break;
case 'accumulator': if(cell.bloqueado) acumBloqueados++; else acumFilling.push(cell.acumulado); break;
case 'shop': if(cell.active) shopsActive++; else shopsInactive++; break;
}
}
function h3(t){ var el=mk('h3'); el.textContent=t; body.appendChild(el); }
function p(t){ var el=mk('p'); el.textContent=t; body.appendChild(el); }
function ul(items){ var ul=mk('ul'); items.forEach(function(t){ var li=mk('li'); li.textContent=t; ul.appendChild(li); }); body.appendChild(ul); }
h3('Level objective');
p(lvl.introText);
p('Goal: activate all checkpoints (3 turns with flow) without any inhabitant going 3 turns without ECOin.');
h3('Current status');
var stat=mk('div','stat-line');
stat.textContent='Turn '+state.turn+' \u00b7 Par '+lvl.par+' \u00b7 Score '+state.score+' \u00b7 Active PUBs '+pubsActivos+' \u00b7 PoT activations '+state.potActivations;
body.appendChild(stat);
var statusItems=[];
if(habCount>0){ var t='Inhabitants: '+habOK+' with flow'; if(habRiesgo>0) t+=', '+habRiesgo+' without flow (fall at 3 turns)'; if(habCBDC>0) t+=', '+habCBDC+' captured by CBDC'; statusItems.push(t); }
if(checkpoints.length>0) statusItems.push('Checkpoints: '+checkpoints.map(function(p){return p>=3?'active':p+'/3';}).join(' / '));
if(valActivos>0||valCargando.length>0){ var t=(valActivos>0?valActivos+' active':'')+(valCargando.length>0?(valActivos>0?', ':'')+'loading: '+valCargando.map(function(v){return fmt(v)+'/3';}).join(', '):''); statusItems.push('Validators: '+t); }
if(acumFilling.length>0||acumBloqueados>0){ var t=(acumFilling.length>0?'filling: '+acumFilling.map(function(a){return fmt(a)+'/5';}).join(', '):'')+(acumBloqueados>0?(acumFilling.length>0?', ':'')+acumBloqueados+' blocked (PoT)':''); statusItems.push('Accumulators: '+t+' (-50 pts when exceeding 5)'); }
if(shopsActive>0||shopsInactive>0) statusItems.push('Shops: '+shopsActive+' active'+(shopsInactive>0?', '+shopsInactive+' inactive':'')+' (+10 pts each when winning)');
ul(statusItems);
h3('How to play');
ul(['Click a node to start a pipe, click adjacent cells to extend, finish on another node.','Click an existing pipe to remove it.','Press ENTER (or ADVANCE TURN button) to process one flow cycle.','A node can have a maximum of 4 connections. Inhabitants accept only 1.', '+2 score per pipe placed.']);
h3('ECOin rules');
ul(['Each active PUB generates 4 ECOin units/turn (2 if reduced capacity), split equally among connections.','An inhabitant needs at least 1 unit/turn. Three turns without flow = defeat.','A validator blocks flow until it accumulates 3 units. Once active it forwards flow to other connections.','An accumulator absorbs without redistributing. Exceeding 5 units triggers PoT: -50 pts and node blocked.','A shop needs at least 2 units/turn to operate. Active shops when winning: +10 pts each.','A checkpoint activates after receiving flow for 3 consecutive turns. Activating all checkpoints wins the level.','CBDC captures adjacent inhabitants not connected to a PUB every 2 turns = defeat.']);
h3('Scoring');
ul(['+2 per pipe placed','+100 per level completed','+10 per active shop at completion','+5 per turn saved vs. par (only if completed under par)','-50 per accumulator blocked by PoT','Score accumulates across all levels']);
h3('Glossary');
ul(['PUB - Public Server: Oasis network node distributing ECOin to connected inhabitants. Not centralized.','ECOin: Community currency. Its value is anchored to human time. Cannot be issued arbitrarily.','UBI - Universal Basic Income: Flow reaching every inhabitant regardless of activity.','PoT - Proof of Transaction: Discourages accumulating ECOin without using it.','Validator: Confirms transaction legitimacy. Needs minimum flow before it can operate.','Checkpoint: Milestone on the ECOin blockchain. When reached, history is consolidated and immutable.','CBDC - Central Bank Digital Currency: Digital currency by a central bank. Enables tracking every transaction = total financial surveillance.']);
document.getElementById('overlayInfo').classList.remove('hidden');
}
function undoAction() {
if(state.status!=='playing') return;
if(state.drawing){ cancelDrawing(); }
else if(state.paths.length>0){ state.paths.pop(); showMessage('Pipe undone.'); render(); }
else showMessage('Nothing to undo.');
}
function deletePipeAtCursor() {
if(state.status!=='playing') return;
if(state.drawing){ cancelDrawing(); return; }
var r=state.cursor.r, c=state.cursor.c, before=state.paths.length;
state.paths=state.paths.filter(function(path){return!path.some(function(p){return p.r===r&&p.c===c;});});
if(state.paths.length<before){ showMessage('Pipe removed.'); render(); }
}
document.addEventListener('keydown',function(e){
if(e.code==='Enter'){
e.preventDefault();
if(!document.getElementById('overlayInfo').classList.contains('hidden')) closeInfo();
else if(!document.getElementById('overlayIntro').classList.contains('hidden')) closeIntro();
else if(!document.getElementById('overlayWin').classList.contains('hidden')){ var nb=document.getElementById('btnNextLevel'); if(nb&&!nb.disabled) goNextLevel(); else restartLevel(); }
else if(!document.getElementById('overlayLoss').classList.contains('hidden')) restartLevel();
else if(state.status==='playing') tick();
}
if(e.code==='Backspace'&&state.status==='playing'){ e.preventDefault(); undoAction(); }
if(e.code==='Space'&&state.status==='playing'){ e.preventDefault(); deletePipeAtCursor(); }
if(e.code==='Space'&&(state.status==='lost'||state.status==='won')){ e.preventDefault(); restartLevel(); }
if((e.ctrlKey||e.metaKey)&&e.code==='KeyZ'){ e.preventDefault(); undoAction(); }
var DIRS={ArrowUp:[-1,0],ArrowDown:[1,0],ArrowLeft:[0,-1],ArrowRight:[0,1]};
if(DIRS[e.code]!==undefined&&state.status==='playing'){
e.preventDefault();
var dr=DIRS[e.code][0], dc=DIRS[e.code][1], nr=state.cursor.r+dr, nc=state.cursor.c+dc;
if(nr>=0&&nc>=0&&nr<state.size&&nc<state.size){
if(e.shiftKey){ var curCell=state.board[state.cursor.r][state.cursor.c]; if(!state.drawing&&curCell.type!=='empty') onCellClick(state.cursor.r,state.cursor.c); state.cursor={r:nr,c:nc}; if(state.drawing) onCellClick(nr,nc); }
else state.cursor={r:nr,c:nc};
render();
}
}
if((e.key==='i'||e.key==='I'||e.key==='?')&&!e.ctrlKey&&!e.metaKey){
if(document.getElementById('overlayInfo').classList.contains('hidden')&&document.getElementById('overlayIntro').classList.contains('hidden')&&document.getElementById('overlayWin').classList.contains('hidden')&&document.getElementById('overlayLoss').classList.contains('hidden')){ e.preventDefault(); openInfo(); }
}
if(e.code==='Escape'){ if(!document.getElementById('overlayInfo').classList.contains('hidden')) closeInfo(); else if(state.drawing) cancelDrawing(); }
});
function resizeBoard() {
var sz = state.size || LEVELS[state.levelIndex].size;
var gap = 5, pad = 10;
var topbar = document.getElementById('topbar').offsetHeight || 40;
var ui = document.getElementById('ui').offsetHeight || 32;
var msg = document.getElementById('message').offsetHeight || 18;
var legend = document.getElementById('legend').offsetHeight || 0;
var controls = document.getElementById('controls').offsetHeight || 40;
var usedH = topbar + ui + msg + legend + controls + 12;
var avW = window.innerWidth;
var avH = Math.max(80, window.innerHeight - usedH);
var cell = Math.floor(Math.min(
(avW - pad*2 - gap*(sz-1)) / sz,
(avH - pad*2 - gap*(sz-1)) / sz
));
cell = Math.max(32, cell);
document.documentElement.style.setProperty('--cell', cell+'px');
}
window.addEventListener('resize', function() { resizeBoard(); renderPipes(); renderParticles(); });
loadLevel(0);
state.status='intro';
</script>
</body>
</html>

View file

@ -0,0 +1,113 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="4" fill="#111" stroke="#222" stroke-width="1"/>
<!-- Grid cells -->
<rect x="22" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="22" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="22" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<!-- Pipes (orange paths between nodes) -->
<path d="M48 74 L48 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M48 134 L48 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M74 48 L82 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M134 48 L142 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M108 74 L108 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M108 134 L108 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M194 108 L202 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M254 108 L262 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M228 74 L228 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M288 74 L288 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M288 134 L288 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M314 168 L322 168" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M168 134 L168 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<!-- PUB node (orange square) row1 col1 -->
<rect x="34" y="34" width="36" height="36" rx="2" fill="#FFA500" opacity="0.92"/>
<line x1="52" y1="24" x2="52" y2="34" stroke="#FFA500" stroke-width="2"/>
<circle cx="52" cy="21" r="3" fill="#FFA500"/>
<text x="52" y="57" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
<!-- habitante node (green circle) row1 col2 -->
<circle cx="108" cy="48" r="18" fill="#8BC34A" opacity="0.9"/>
<text x="108" y="52" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- validador (diamond) row1 col3 -->
<polygon points="168,30 186,48 168,66 150,48" fill="rgba(200,218,230,0.55)" stroke="#ccc" stroke-width="1"/>
<text x="168" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">VAL</text>
<!-- tienda (hexagon) row1 col4 -->
<polygon points="228,30 241,38 241,58 228,66 215,58 215,38" fill="#4CAF50" opacity="0.9"/>
<text x="228" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- acumulador (triangle) row1 col5 -->
<polygon points="288,30 310,66 266,66" fill="rgba(255,82,82,0.25)" stroke="#FF5252" stroke-width="1"/>
<text x="288" y="58" font-family="monospace" font-size="7" fill="#eee" text-anchor="middle" font-weight="bold">ACU</text>
<!-- CBDC (grey square) row1 col6 -->
<rect x="334" y="34" width="36" height="36" rx="2" fill="#607D8B" opacity="0.9"/>
<text x="352" y="57" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
<!-- row2 nodes -->
<!-- PUB node row2 col1 -->
<rect x="34" y="94" width="36" height="36" rx="2" fill="#FFA500" opacity="0.7"/>
<text x="52" y="117" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
<!-- hab row2 col2 -->
<circle cx="108" cy="108" r="18" fill="#8BC34A" opacity="0.7"/>
<text x="108" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- hab row2 col4 -->
<circle cx="228" cy="108" r="18" fill="#8BC34A" opacity="0.85"/>
<text x="228" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- tienda row2 col5 -->
<polygon points="288,90 301,98 301,118 288,126 275,118 275,98" fill="#4CAF50" opacity="0.75"/>
<text x="288" y="112" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- checkpoint row3 col2 -->
<rect x="90" y="154" width="36" height="36" rx="2" fill="none" stroke="#FFFF55" stroke-width="3"/>
<text x="108" y="177" font-family="monospace" font-size="7" fill="#FFFF55" text-anchor="middle" font-weight="bold">CHK</text>
<!-- hab row3 col3 -->
<circle cx="168" cy="168" r="18" fill="#8BC34A" opacity="0.8"/>
<text x="168" y="172" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- CBDC row3 col4 -->
<rect x="214" y="154" width="36" height="36" rx="2" fill="#607D8B" opacity="0.8"/>
<text x="232" y="177" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
<!-- tienda row3 col6 -->
<polygon points="348,154 361,162 361,182 348,190 335,182 335,162" fill="#4CAF50" opacity="0.8"/>
<text x="348" y="176" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- Flow particles on some pipes -->
<circle cx="48" cy="78" r="3" fill="#FFA500" opacity="0.95">
<animate attributeName="cy" values="74;82" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="91" cy="48" r="3" fill="#FFA500" opacity="0.95">
<animate attributeName="cx" values="74;82" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
</circle>
<!-- Title -->
<text x="200" y="212" font-family="'Courier New', monospace" font-size="14" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">ECOINFLOW</text>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>8Ball Pool</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: crosshair; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 24px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 6px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">8BALL POOL</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>SHOTS: <b id="shots">0</b></span>
<span>BEST: <b id="best">-</b></span>
</div>
<canvas id="c" width="600" height="340"></canvas>
<div id="msg">Click to aim &amp; shoot. Hold = more power.</div>
<div id="controls">Click on table to aim cue ball &nbsp;|&nbsp; Hold longer = more power &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="8ball">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const TABLE_X = 20, TABLE_Y = 20, TABLE_W = 560, TABLE_H = 280;
const POCKET_R = 20, BALL_R = 10;
const FRICTION = 0.975;
const POCKETS = [
[TABLE_X, TABLE_Y], [TABLE_X + TABLE_W/2, TABLE_Y - 6], [TABLE_X + TABLE_W, TABLE_Y],
[TABLE_X, TABLE_Y + TABLE_H], [TABLE_X + TABLE_W/2, TABLE_Y + TABLE_H + 6], [TABLE_X + TABLE_W, TABLE_Y + TABLE_H]
];
const BALL_COLORS = ['#fff','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000','#000','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000'];
const BALL_STRIPE = [false,false,false,false,false,false,false,false,false,true,true,true,true,true,true,true];
let score = 0, shots = 0, best = parseInt(localStorage.getItem('8ball_best') || '-1');
let balls = [], state = 'idle', mouseDown = false, mousePos = {x:0,y:0}, powerStart = 0, power = 0;
let foul = false, gameOver = false;
function initBalls() {
balls = [];
const cx = TABLE_X + TABLE_W * 0.72, cy = TABLE_Y + TABLE_H / 2;
const sp = BALL_R * 2.05;
const rack = [
[0,0],[1,-0.5],[1,0.5],[2,-1],[2,0],[2,1],[3,-1.5],[3,-0.5],[3,0.5],[3,1.5],
[4,-2],[4,-1],[4,0],[4,1],[4,2]
];
for (let i = 0; i < 15; i++) {
const [row, col] = rack[i];
balls.push({ x: cx + row * sp * 0.866, y: cy + col * sp, vx: 0, vy: 0, potted: false, idx: i + 1 });
}
balls.push({ x: TABLE_X + TABLE_W * 0.25, y: cy, vx: 0, vy: 0, potted: false, idx: 0 });
}
function resetGame() {
score = 0; shots = 0; foul = false; gameOver = false;
state = 'aiming';
document.getElementById('score').textContent = '0';
document.getElementById('shots').textContent = '0';
document.getElementById('msg').textContent = 'Click to aim & shoot. Hold = more power.';
document.getElementById('scoreSubmit').style.display = 'none';
initBalls();
}
const cue = () => balls.find(b => b.idx === 0);
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function processPhysics() {
let moving = true;
let steps = 0;
while (moving && steps < 800) {
steps++;
moving = false;
for (const b of balls) {
if (b.potted) continue;
b.x += b.vx; b.y += b.vy;
b.vx *= FRICTION; b.vy *= FRICTION;
if (Math.abs(b.vx) < 0.05) b.vx = 0;
if (Math.abs(b.vy) < 0.05) b.vy = 0;
if (Math.abs(b.vx) > 0.05 || Math.abs(b.vy) > 0.05) moving = true;
const lx = TABLE_X + BALL_R, rx = TABLE_X + TABLE_W - BALL_R;
const ty = TABLE_Y + BALL_R, by = TABLE_Y + TABLE_H - BALL_R;
if (b.x < lx) { b.x = lx; b.vx = Math.abs(b.vx) * 0.8; }
if (b.x > rx) { b.x = rx; b.vx = -Math.abs(b.vx) * 0.8; }
if (b.y < ty) { b.y = ty; b.vy = Math.abs(b.vy) * 0.8; }
if (b.y > by) { b.y = by; b.vy = -Math.abs(b.vy) * 0.8; }
for (const p of POCKETS) {
if (Math.hypot(b.x - p[0], b.y - p[1]) < POCKET_R) {
b.potted = true; b.vx = 0; b.vy = 0;
if (b.idx !== 0) {
if (b.idx === 8) { foul = true; }
else { score++; document.getElementById('score').textContent = score; }
} else {
b.x = TABLE_X + TABLE_W * 0.25; b.y = TABLE_Y + TABLE_H / 2; b.potted = false;
}
break;
}
}
}
const active = balls.filter(b => !b.potted);
for (let i = 0; i < active.length; i++) {
for (let j = i + 1; j < active.length; j++) {
const a = active[i], b2 = active[j];
const dx = b2.x - a.x, dy = b2.y - a.y;
const d = Math.hypot(dx, dy);
if (d < BALL_R * 2 && d > 0.001) {
const nx = dx / d, ny = dy / d;
const rel = (a.vx - b2.vx) * nx + (a.vy - b2.vy) * ny;
if (rel > 0) {
a.vx -= rel * nx; a.vy -= rel * ny;
b2.vx += rel * nx; b2.vy += rel * ny;
}
const overlap = BALL_R * 2 - d;
a.x -= overlap / 2 * nx; a.y -= overlap / 2 * ny;
b2.x += overlap / 2 * nx; b2.y += overlap / 2 * ny;
}
}
}
}
}
function shoot(targetX, targetY, pw) {
const c = cue();
if (!c) return;
shots++;
document.getElementById('shots').textContent = shots;
const angle = Math.atan2(targetY - c.y, targetX - c.x);
const spd = pw * 0.28;
c.vx = Math.cos(angle) * spd;
c.vy = Math.sin(angle) * spd;
processPhysics();
const remaining = balls.filter(b => !b.potted && b.idx !== 0 && b.idx !== 8);
if (remaining.length === 0) {
const eight = balls.find(b => b.idx === 8);
if (!eight || eight.potted) {
endGame();
}
} else if (foul) {
endGame();
}
}
function endGame() {
gameOver = true;
state = 'over';
const finalScore = foul ? Math.floor(score / 2) : score;
document.getElementById('score').textContent = finalScore;
if (best < 0 || finalScore > best) {
best = finalScore;
localStorage.setItem('8ball_best', best);
document.getElementById('best').textContent = best;
}
document.getElementById('msg').textContent = foul ? `FOUL! 8-ball potted early. Score: ${finalScore}. SPACE = new game` : `Game over! Potted ${finalScore} balls in ${shots} shots. SPACE = new game`;
document.getElementById('scoreInput').value = finalScore;
document.getElementById('scoreSubmit').style.display = 'block';
}
function getCanvasPos(e) {
const r = canvas.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return { x: (touch.clientX - r.left) * canvas.width / r.width, y: (touch.clientY - r.top) * canvas.height / r.height };
}
canvas.addEventListener('mousedown', e => {
if (state !== 'aiming') return;
mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); power = 0;
});
canvas.addEventListener('mousemove', e => { mousePos = getCanvasPos(e); });
canvas.addEventListener('mouseup', e => {
if (!mouseDown || state !== 'aiming') return;
mouseDown = false;
power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
shoot(mousePos.x, mousePos.y, power);
});
canvas.addEventListener('touchstart', e => { e.preventDefault(); if (state !== 'aiming') return; mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); mousePos = getCanvasPos(e); }, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
if (!mouseDown || state !== 'aiming') return;
mouseDown = false;
power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
shoot(mousePos.x, mousePos.y, power);
}, { passive: false });
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); resetGame(); }
});
function drawTable() {
ctx.fillStyle = '#2d5a1b';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#1a6b30';
ctx.fillRect(TABLE_X, TABLE_Y, TABLE_W, TABLE_H);
ctx.fillStyle = '#8B4513';
ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, TABLE_W + 28, 14);
ctx.fillRect(TABLE_X - 14, TABLE_Y + TABLE_H, TABLE_W + 28, 14);
ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, 14, TABLE_H + 28);
ctx.fillRect(TABLE_X + TABLE_W, TABLE_Y - 14, 14, TABLE_H + 28);
for (const p of POCKETS) {
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(p[0], p[1], POCKET_R, 0, Math.PI * 2); ctx.fill();
}
}
function drawBall(b) {
if (b.potted) return;
const stripe = BALL_STRIPE[b.idx];
ctx.fillStyle = BALL_COLORS[b.idx];
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.fill();
if (stripe) {
ctx.save();
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = '#fff';
ctx.fillRect(b.x - BALL_R, b.y - BALL_R * 0.4, BALL_R * 2, BALL_R * 0.8);
ctx.restore();
}
if (b.idx > 0) {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R * 0.38, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.font = `bold ${BALL_R * 0.6}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(b.idx, b.x, b.y + 0.5);
}
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.stroke();
}
function drawAim() {
const c = cue();
if (!c) return;
const angle = Math.atan2(mousePos.y - c.y, mousePos.x - c.x);
const pw = mouseDown ? Math.min((Date.now() - powerStart) / 1000 * 80, 100) : 30;
ctx.strokeStyle = mouseDown ? `rgba(255,${Math.round(165*(1-pw/100))},0,0.7)` : 'rgba(255,255,255,0.4)';
ctx.lineWidth = mouseDown ? 2 : 1;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(c.x, c.y);
ctx.lineTo(c.x + Math.cos(angle) * 120, c.y + Math.sin(angle) * 120);
ctx.stroke();
ctx.setLineDash([]);
if (mouseDown) {
ctx.fillStyle = '#FFA500';
ctx.fillRect(TABLE_X, TABLE_Y + TABLE_H + 6, pw / 100 * TABLE_W, 6);
ctx.strokeStyle = '#555'; ctx.lineWidth = 1;
ctx.strokeRect(TABLE_X, TABLE_Y + TABLE_H + 6, TABLE_W, 6);
}
}
if (best >= 0) document.getElementById('best').textContent = best;
resetGame();
function loop() {
ctx.clearRect(0, 0, W, H);
drawTable();
balls.forEach(drawBall);
if (state === 'aiming') drawAim();
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#0a5e2a"/>
<rect x="6" y="6" width="108" height="68" fill="#0a6e2e" rx="3"/>
<rect x="8" y="8" width="104" height="64" fill="#0d8040" rx="2"/>
<circle cx="20" cy="20" r="5" fill="#fff"/>
<circle cx="40" cy="15" r="5" fill="#ff0"/>
<circle cx="40" cy="25" r="5" fill="#00f"/>
<circle cx="55" cy="20" r="5" fill="#f00"/>
<circle cx="55" cy="10" r="5" fill="#800080"/>
<circle cx="55" cy="30" r="5" fill="#f90"/>
<circle cx="70" cy="15" r="5" fill="#0a0"/>
<circle cx="70" cy="25" r="5" fill="#700"/>
<circle cx="85" cy="20" r="5" fill="#000"/>
<text x="85" y="24" font-size="6" fill="#fff" text-anchor="middle" font-weight="bold">8</text>
<circle cx="6" cy="6" r="4" fill="#000"/>
<circle cx="114" cy="6" r="4" fill="#000"/>
<circle cx="6" cy="74" r="4" fill="#000"/>
<circle cx="114" cy="74" r="4" fill="#000"/>
<circle cx="60" cy="6" r="4" fill="#000"/>
<circle cx="60" cy="74" r="4" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arkanoid</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: none; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ARKANOID</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>LEVEL: <b id="level">1</b></span>
</div>
<canvas id="c" width="560" height="420"></canvas>
<div id="msg">Press SPACE or CLICK to start</div>
<div id="controls">&#8592;&#8594; or MOUSE — Move paddle &nbsp;|&nbsp; SPACE / CLICK — Launch ball</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="arkanoid">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PADDLE_W = 80, PADDLE_H = 12, PADDLE_Y = H - 30;
const BALL_R = 8;
const BRICK_COLS = 10, BRICK_ROWS = 5;
const BRICK_W = 48, BRICK_H = 16, BRICK_GAP = 4;
const BRICK_OFF_X = (W - (BRICK_COLS * (BRICK_W + BRICK_GAP) - BRICK_GAP)) / 2;
const BRICK_OFF_Y = 40;
const BRICK_COLORS = ['#e74c3c', '#e67e22', '#FFA500', '#2ecc71', '#3498db'];
let state = 'idle';
let score = 0, lives = 3, level = 1;
let paddle, ball, bricks, attached;
function initGame() {
paddle = { x: W / 2 - PADDLE_W / 2, y: PADDLE_Y, w: PADDLE_W, h: PADDLE_H };
resetBall();
initBricks();
}
function resetBall() {
ball = { x: paddle.x + paddle.w / 2, y: PADDLE_Y - BALL_R - 1, vx: 3 + level * 0.3, vy: -(4 + level * 0.3) };
attached = true;
}
function initBricks() {
bricks = [];
for (let r = 0; r < BRICK_ROWS; r++) {
for (let c = 0; c < BRICK_COLS; c++) {
bricks.push({
x: BRICK_OFF_X + c * (BRICK_W + BRICK_GAP),
y: BRICK_OFF_Y + r * (BRICK_H + BRICK_GAP),
w: BRICK_W, h: BRICK_H,
alive: true,
color: BRICK_COLORS[r % BRICK_COLORS.length],
points: (BRICK_ROWS - r) * 10
});
}
}
}
function launch() {
if (state === 'idle' || state === 'over' || state === 'win') {
if (state === 'over') { score = 0; lives = 3; level = 1; document.getElementById('score').textContent = '0'; document.getElementById('lives').textContent = '3'; document.getElementById('level').textContent = '1'; }
if (state === 'win') { level++; document.getElementById('level').textContent = level; }
initGame();
state = 'play';
attached = false;
document.getElementById('msg').textContent = '';
return;
}
if (attached) {
attached = false;
}
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') { e.preventDefault(); launch(); }
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
canvas.addEventListener('click', launch);
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * canvas.width / rect.width;
paddle.x = Math.max(0, Math.min(W - paddle.w, mx - paddle.w / 2));
if (attached) ball.x = paddle.x + paddle.w / 2;
});
function reflect(ball, bx, by, bw, bh) {
const overlapLeft = ball.x + BALL_R - bx;
const overlapRight = bx + bw - (ball.x - BALL_R);
const overlapTop = ball.y + BALL_R - by;
const overlapBottom = by + bh - (ball.y - BALL_R);
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
if (minOverlap === overlapTop || minOverlap === overlapBottom) ball.vy *= -1;
else ball.vx *= -1;
}
function loop() {
ctx.clearRect(0, 0, W, H);
if (state === 'play') {
if (keys['ArrowLeft']) paddle.x = Math.max(0, paddle.x - 6);
if (keys['ArrowRight']) paddle.x = Math.min(W - paddle.w, paddle.x + 6);
if (attached) {
ball.x = paddle.x + paddle.w / 2;
} else {
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.x - BALL_R < 0) { ball.x = BALL_R; ball.vx = Math.abs(ball.vx); }
if (ball.x + BALL_R > W) { ball.x = W - BALL_R; ball.vx = -Math.abs(ball.vx); }
if (ball.y - BALL_R < 0) { ball.y = BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.y + BALL_R >= paddle.y && ball.y + BALL_R <= paddle.y + paddle.h + Math.abs(ball.vy) &&
ball.x >= paddle.x - BALL_R && ball.x <= paddle.x + paddle.w + BALL_R) {
const rel = (ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2);
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = rel * speed * 1.1;
ball.vy = -Math.abs(ball.vy);
ball.y = paddle.y - BALL_R - 1;
}
if (ball.y - BALL_R > H) {
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = 'GAME OVER — Press SPACE'; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
else { resetBall(); attached = true; }
}
for (const b of bricks) {
if (!b.alive) continue;
if (ball.x + BALL_R > b.x && ball.x - BALL_R < b.x + b.w && ball.y + BALL_R > b.y && ball.y - BALL_R < b.y + b.h) {
b.alive = false;
score += b.points;
document.getElementById('score').textContent = score;
reflect(ball, b.x, b.y, b.w, b.h);
break;
}
}
if (bricks.every(b => !b.alive)) {
state = 'win';
document.getElementById('msg').textContent = 'LEVEL CLEAR! — Press SPACE for next level';
}
}
}
bricks.forEach(b => {
if (!b.alive) return;
ctx.fillStyle = b.color;
ctx.beginPath();
ctx.roundRect(b.x, b.y, b.w, b.h, 3);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.stroke();
});
ctx.fillStyle = '#FFA500';
ctx.beginPath();
ctx.roundRect(paddle.x, paddle.y, paddle.w, paddle.h, 4);
ctx.fill();
ctx.fillStyle = '#FFD700';
ctx.fillRect(paddle.x + 8, paddle.y + 3, paddle.w - 16, 3);
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.arc(ball.x - 3, ball.y - 3, BALL_R * 0.4, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<g>
<rect x="20" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="82" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="144" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="206" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="268" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="330" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
</g>
<g>
<rect x="20" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="82" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="144" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="206" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="268" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="330" y="46" width="54" height="18" rx="3" fill="#3498db"/>
</g>
<g>
<rect x="20" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="82" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="144" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="206" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="268" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="330" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
</g>
<g>
<rect x="20" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="82" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="144" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="330" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
</g>
<circle cx="220" cy="155" r="9" fill="#fff"/>
<rect x="155" y="190" width="90" height="14" rx="5" fill="#FFA500"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artillery</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 16px; padding: 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#controls-panel { display: flex; gap: 16px; align-items: center; padding: 8px; flex-wrap: wrap; justify-content: center; }
#controls-panel label { color: #aaa; font-size: 13px; }
#controls-panel input[type=range] { width: 120px; }
#controls-panel span { color: #FFA500; font-size: 14px; min-width: 40px; }
#fire-btn { background: #3a0000; border: 1px solid #f44; color: #f44; padding: 6px 20px; cursor: pointer; font-family: monospace; font-size: 15px; font-weight: bold; }
#fire-btn:hover { background: #5a0000; }
#msg { font-size: 15px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ARTILLERY</span>
</div>
<div id="ui">
<span>ROUND: <b id="roundEl">1/5</b></span>
<span>SHOTS: <b id="shotsEl">0</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>WIND: <b id="windEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c" width="800" height="400"></canvas>
<div id="controls-panel">
<label>Angle: <input type="range" id="angleInput" min="1" max="89" value="45"> <span id="angleVal">45</span>°</label>
<label>Power: <input type="range" id="powerInput" min="1" max="100" value="60"> <span id="powerVal">60</span></label>
<button id="fire-btn">FIRE!</button>
</div>
<div id="msg">Adjust angle and power, then fire!</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="artillery">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const TOTAL_ROUNDS = 5;
let round = 1, shots = 0, score = 0, roundShots = 0;
let terrain = [], cannonY = 0, targetX = 0, targetY = 0, wind = 0;
let lastPath = [], lastHit = null, hitFlag = false;
let gameOver = false;
let best = parseInt(localStorage.getItem('artillery_best') || '-1');
function seededRand(seed) {
let s = seed;
return function() { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
}
function generateTerrain(seed) {
const rng = seededRand(seed);
const pts = new Array(80).fill(0);
pts[0] = 150 + rng() * 80;
pts[79] = 150 + rng() * 80;
function subdivide(arr, lo, hi, rough) {
if (hi - lo <= 1) return;
const mid = Math.floor((lo + hi) / 2);
arr[mid] = (arr[lo] + arr[hi]) / 2 + (rng() - 0.5) * rough;
subdivide(arr, lo, mid, rough * 0.6);
subdivide(arr, mid, hi, rough * 0.6);
}
subdivide(pts, 0, 79, 100);
for (let i = 0; i < 80; i++) pts[i] = Math.max(60, Math.min(H - 30, pts[i]));
return pts;
}
function terrainHeightAt(x) {
const idx = Math.floor(x / (W / 79));
const clamped = Math.max(0, Math.min(78, idx));
return terrain[clamped];
}
function startRound() {
const seed = Date.now() + round * 1337;
terrain = generateTerrain(seed);
const rng = seededRand(seed + 42);
targetX = Math.floor(W * 0.6 + rng() * W * 0.3);
targetY = terrainHeightAt(targetX) - 1;
wind = (rng() - 0.5) * 16;
roundShots = 0;
lastPath = []; lastHit = null; hitFlag = false;
document.getElementById('windEl').textContent = wind.toFixed(1);
document.getElementById('roundEl').textContent = `${round}/${TOTAL_ROUNDS}`;
document.getElementById('msg').textContent = `Round ${round} — Adjust angle and power, then fire!`;
}
function calcTrajectory(angleDeg, pw) {
const angle = angleDeg * Math.PI / 180;
let vx = Math.cos(angle) * pw * 0.45;
let vy = -Math.sin(angle) * pw * 0.45;
const gravity = 0.18;
const windA = wind * 0.012;
const cannonX = 30;
const startY = terrainHeightAt(cannonX) - 6;
let x = cannonX, y = startY;
const path = [[x, y]];
let hitX = null;
for (let i = 0; i < 500; i++) {
vx += windA; vy += gravity;
x += vx; y += vy;
if (x < 0 || x > W) break;
if (y > H) break;
const th = terrainHeightAt(x);
if (y >= th) { hitX = x; break; }
path.push([x, y]);
}
return { path, hitX };
}
function fire() {
if (gameOver) return;
lastPath = [];
lastHit = null;
hitFlag = false;
const angle = parseInt(document.getElementById('angleInput').value);
const pw = parseInt(document.getElementById('powerInput').value);
const { path, hitX } = calcTrajectory(angle, pw);
lastPath = path;
lastHit = hitX;
shots++; roundShots++;
document.getElementById('shotsEl').textContent = shots;
if (hitX !== null && Math.abs(hitX - targetX) < 28) {
hitFlag = true;
const roundScore = Math.max(10, 100 - (roundShots - 1) * 15) + (roundShots === 1 ? 50 : 0);
score += roundScore;
document.getElementById('scoreEl').textContent = score;
document.getElementById('msg').textContent = `HIT! +${roundScore} pts. ${round < TOTAL_ROUNDS ? 'Next round...' : 'Game over!'}`;
setTimeout(() => {
lastPath = []; lastHit = null; hitFlag = false;
if (round < TOTAL_ROUNDS) { round++; startRound(); } else { endGame(); }
}, 1400);
} else {
const dist = hitX ? Math.abs(Math.round(hitX - targetX)) : '?';
document.getElementById('msg').textContent = `Miss! Distance: ${dist}px from target.`;
}
}
function endGame() {
gameOver = true;
if (best < 0 || score > best) {
best = score;
localStorage.setItem('artillery_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('msg').textContent = `All rounds done! Final score: ${score}. SPACE = new game`;
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function newGame() {
round = 1; shots = 0; score = 0; roundShots = 0; gameOver = false;
document.getElementById('scoreEl').textContent = '0';
document.getElementById('shotsEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
startRound();
}
document.getElementById('angleInput').addEventListener('input', function() { document.getElementById('angleVal').textContent = this.value; });
document.getElementById('powerInput').addEventListener('input', function() { document.getElementById('powerVal').textContent = this.value; });
document.getElementById('fire-btn').addEventListener('click', fire);
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); if (gameOver) newGame(); else fire(); } });
if (best >= 0) document.getElementById('bestEl').textContent = best;
function drawTerrain() {
ctx.fillStyle = '#4a7c3f';
ctx.beginPath();
ctx.moveTo(0, H);
for (let i = 0; i < 80; i++) {
const x = i * (W / 79);
ctx.lineTo(x, terrain[i]);
}
ctx.lineTo(W, H); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#2d5a25'; ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < 80; i++) ctx.lineTo(i * (W / 79), terrain[i]);
ctx.stroke();
}
function drawSky() {
const grad = ctx.createLinearGradient(0, 0, 0, H * 0.7);
grad.addColorStop(0, '#000020');
grad.addColorStop(1, '#1a3a6a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
}
function drawCannon() {
const cx = 30, cy = terrainHeightAt(30);
const angle = parseInt(document.getElementById('angleInput').value) * Math.PI / 180;
ctx.fillStyle = '#888';
ctx.fillRect(cx - 10, cy - 8, 20, 10);
ctx.strokeStyle = '#aaa'; ctx.lineWidth = 4; ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx, cy - 3);
ctx.lineTo(cx + Math.cos(angle) * 28, cy - 3 - Math.sin(angle) * 28);
ctx.stroke();
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(cx, cy + 2, 8, 0, Math.PI * 2); ctx.fill();
}
function drawTarget() {
const x = targetX, y = targetY;
ctx.strokeStyle = '#f44'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x - 14, y); ctx.lineTo(x + 14, y); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x, y - 14); ctx.lineTo(x, y + 14); ctx.stroke();
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#f44'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 22, y - 16); ctx.lineTo(x + 22, y - 5); ctx.stroke();
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.moveTo(x + 22, y - 16); ctx.lineTo(x + 34, y - 10); ctx.lineTo(x + 22, y - 5); ctx.fill();
}
function drawTrajectory() {
if (lastPath.length < 2) return;
ctx.strokeStyle = hitFlag ? 'rgba(100,255,100,0.6)' : 'rgba(255,200,0,0.5)';
ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]);
ctx.beginPath();
lastPath.forEach((pt, i) => i === 0 ? ctx.moveTo(pt[0], pt[1]) : ctx.lineTo(pt[0], pt[1]));
ctx.stroke(); ctx.setLineDash([]);
if (lastHit !== null) {
const hy = terrainHeightAt(lastHit);
ctx.fillStyle = hitFlag ? '#0f0' : '#f80';
ctx.beginPath(); ctx.arc(lastHit, hy, 8, 0, Math.PI * 2); ctx.fill();
}
}
function drawWind() {
const arrow = wind >= 0 ? '→' : '←';
const strength = Math.abs(wind).toFixed(1);
ctx.fillStyle = '#87CEEB'; ctx.font = '13px monospace';
ctx.fillText(`WIND ${arrow} ${strength}`, W - 130, 22);
}
newGame();
function loop() {
drawSky();
if (terrain.length) {
drawTerrain();
drawTrajectory();
drawCannon();
drawTarget();
drawWind();
}
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#87CEEB"/>
<polygon points="0,80 0,55 20,50 35,45 50,48 65,40 80,50 95,42 110,52 120,48 120,80" fill="#4a7c3f"/>
<rect x="8" y="56" width="20" height="8" fill="#555" rx="2"/>
<rect x="20" y="50" width="14" height="5" fill="#666" rx="2" transform="rotate(-30 27 52)"/>
<circle cx="30" cy="45" r="3" fill="#f44" opacity="0.8"/>
<circle cx="50" cy="35" r="2.5" fill="#f44" opacity="0.6"/>
<circle cx="70" cy="28" r="2" fill="#f44" opacity="0.4"/>
<rect x="95" y="38" width="8" height="12" fill="#f00" rx="1"/>
<polygon points="99,38 95,32 103,32" fill="#f00"/>
<text x="60" y="20" font-size="10" fill="#fff" text-anchor="middle" font-weight="bold" opacity="0.8">~</text>
</svg>

After

Width:  |  Height:  |  Size: 793 B

View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Asteroids</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ASTEROIDS</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>LEVEL: <b id="level">1</b></span>
</div>
<canvas id="c" width="600" height="420"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8592;&#8594; Rotate &nbsp;|&nbsp; &#8593; Thrust &nbsp;|&nbsp; SPACE Shoot</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="asteroids">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let state = 'idle';
let score = 0, lives = 3, level = 1;
let ship, bullets, asteroids, invincible, frames;
const SHIP_SIZE = 14;
const BULLET_SPEED = 9;
const TURN_SPEED = 0.065;
const THRUST = 0.18;
const FRICTION = 0.985;
const stars = Array.from({ length: 80 }, () => ({
x: Math.random() * W, y: Math.random() * H, r: Math.random() * 1.5 + 0.5, b: Math.random() * 0.7 + 0.3
}));
function initGame() {
ship = { x: W / 2, y: H / 2, vx: 0, vy: 0, angle: -Math.PI / 2, cooldown: 0 };
bullets = [];
invincible = 120;
frames = 0;
spawnAsteroids();
}
function randAsteroid(size, x, y) {
const angle = Math.random() * Math.PI * 2;
const speed = (0.6 + Math.random() * 0.8) * (level * 0.15 + 1);
const pts = 7 + Math.floor(Math.random() * 5);
const verts = Array.from({ length: pts }, (_, i) => {
const a = (i / pts) * Math.PI * 2;
const r = size * (0.75 + Math.random() * 0.5);
return { x: Math.cos(a) * r, y: Math.sin(a) * r };
});
return {
x: x ?? (Math.random() < 0.5 ? Math.random() * 100 : W - Math.random() * 100),
y: y ?? (Math.random() < 0.5 ? Math.random() * 100 : H - Math.random() * 100),
vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
size, verts, rot: 0, rotSpeed: (Math.random() - 0.5) * 0.04
};
}
function spawnAsteroids() {
asteroids = [];
for (let i = 0; i < 3 + level; i++) asteroids.push(randAsteroid(38));
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') {
e.preventDefault();
if (state === 'idle' || state === 'over') {
score = 0; lives = 3; level = 1;
document.getElementById('score').textContent = '0';
document.getElementById('lives').textContent = '3';
document.getElementById('level').textContent = '1';
initGame(); state = 'play';
document.getElementById('msg').textContent = '';
}
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function wrap(obj) {
if (obj.x < -50) obj.x = W + 50;
if (obj.x > W + 50) obj.x = -50;
if (obj.y < -50) obj.y = H + 50;
if (obj.y > H + 50) obj.y = -50;
}
function drawShip(s, alpha) {
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.angle);
ctx.strokeStyle = `rgba(255,165,0,${alpha})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(SHIP_SIZE, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.6);
ctx.lineTo(-SHIP_SIZE * 0.4, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.6);
ctx.closePath();
ctx.stroke();
if (keys['ArrowUp'] && state === 'play' && frames % 4 < 2) {
ctx.fillStyle = `rgba(255,100,0,${alpha})`;
ctx.beginPath();
ctx.moveTo(-SHIP_SIZE * 0.4, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.35);
ctx.lineTo(-SHIP_SIZE * 1.3, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.35);
ctx.fill();
}
ctx.restore();
}
function drawAsteroid(a) {
ctx.save();
ctx.translate(a.x, a.y);
ctx.rotate(a.rot);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(a.verts[0].x, a.verts[0].y);
for (let i = 1; i < a.verts.length; i++) ctx.lineTo(a.verts[i].x, a.verts[i].y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function loop() {
ctx.clearRect(0, 0, W, H);
stars.forEach(s => {
ctx.fillStyle = `rgba(255,255,255,${s.b})`;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
});
if (state === 'play') {
if (keys['ArrowLeft']) ship.angle -= TURN_SPEED;
if (keys['ArrowRight']) ship.angle += TURN_SPEED;
if (keys['ArrowUp']) {
ship.vx += Math.cos(ship.angle) * THRUST;
ship.vy += Math.sin(ship.angle) * THRUST;
}
ship.vx *= FRICTION; ship.vy *= FRICTION;
ship.x += ship.vx; ship.y += ship.vy;
wrap(ship);
if (ship.cooldown > 0) ship.cooldown--;
if (keys['Space'] && ship.cooldown === 0) {
bullets.push({ x: ship.x + Math.cos(ship.angle) * SHIP_SIZE, y: ship.y + Math.sin(ship.angle) * SHIP_SIZE, vx: Math.cos(ship.angle) * BULLET_SPEED + ship.vx, vy: Math.sin(ship.angle) * BULLET_SPEED + ship.vy, life: 55 });
ship.cooldown = 12;
}
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx; b.y += b.vy; b.life--;
wrap(b);
if (b.life <= 0) { bullets.splice(i, 1); continue; }
let hit = false;
for (let j = asteroids.length - 1; j >= 0; j--) {
const a = asteroids[j];
const dx = b.x - a.x, dy = b.y - a.y;
if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.85) {
const pts = a.size > 25 ? 20 : a.size > 14 ? 50 : 100;
score += pts;
document.getElementById('score').textContent = score;
bullets.splice(i, 1);
asteroids.splice(j, 1);
if (a.size > 25) { asteroids.push(randAsteroid(18, a.x + 10, a.y)); asteroids.push(randAsteroid(18, a.x - 10, a.y)); }
else if (a.size > 14) { asteroids.push(randAsteroid(10, a.x, a.y + 10)); asteroids.push(randAsteroid(10, a.x, a.y - 10)); }
hit = true; break;
}
}
if (hit) continue;
}
for (let j = asteroids.length - 1; j >= 0; j--) {
const a = asteroids[j];
a.x += a.vx; a.y += a.vy; a.rot += a.rotSpeed;
wrap(a);
if (invincible <= 0) {
const dx = ship.x - a.x, dy = ship.y - a.y;
if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.75 + SHIP_SIZE * 0.6) {
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = `GAME OVER! Score: ${score} — SPACE to retry`; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
else { ship.x = W / 2; ship.y = H / 2; ship.vx = 0; ship.vy = 0; invincible = 120; }
}
}
}
if (invincible > 0) invincible--;
if (asteroids.length === 0) {
level++;
document.getElementById('level').textContent = level;
spawnAsteroids();
}
frames++;
}
asteroids.forEach(drawAsteroid);
bullets.forEach(b => {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(b.x, b.y, 2.5, 0, Math.PI * 2); ctx.fill();
});
const alpha = invincible > 0 && frames % 6 < 3 ? 0.3 : 1;
drawShip(ship, alpha);
for (let i = 0; i < lives; i++) {
ctx.save(); ctx.translate(16 + i * 22, H - 18); ctx.rotate(-Math.PI / 2);
ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(9, 0); ctx.lineTo(-6, -5); ctx.lineTo(-3, 0); ctx.lineTo(-6, 5); ctx.closePath(); ctx.stroke();
ctx.restore();
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<circle cx="30" cy="20" r="1.5" fill="#fff" opacity="0.8"/>
<circle cx="80" cy="45" r="1" fill="#fff" opacity="0.6"/>
<circle cx="120" cy="10" r="2" fill="#fff" opacity="0.9"/>
<circle cx="200" cy="30" r="1" fill="#fff" opacity="0.7"/>
<circle cx="320" cy="15" r="1.5" fill="#fff" opacity="0.8"/>
<circle cx="370" cy="50" r="1" fill="#fff" opacity="0.6"/>
<circle cx="50" cy="180" r="1" fill="#fff" opacity="0.5"/>
<circle cx="350" cy="190" r="1.5" fill="#fff" opacity="0.7"/>
<circle cx="280" cy="8" r="1" fill="#fff" opacity="0.8"/>
<polygon points="85,60 110,45 140,55 155,80 140,110 110,120 75,105 65,80" fill="#888" stroke="#aaa" stroke-width="2"/>
<polygon points="240,30 265,22 290,35 300,60 285,85 255,90 230,75 225,50" fill="#777" stroke="#999" stroke-width="2"/>
<polygon points="310,130 330,118 355,128 365,152 350,172 325,178 305,165 298,142" fill="#999" stroke="#bbb" stroke-width="2"/>
<polygon points="40,130 58,120 75,132 80,155 65,170 43,172 28,160 25,140" fill="#666" stroke="#888" stroke-width="2"/>
<polygon points="200,105 196,95 200,92 204,95 200,108" fill="#FFA500" stroke="#FFA500" stroke-width="1"/>
<polygon points="196,108 192,118 200,112 208,118 204,108" fill="#FFA500"/>
<circle cx="200" cy="96" r="4" fill="#fff" stroke="#FFA500" stroke-width="1.5"/>
<rect x="192" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
<rect x="204" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
<rect x="197" y="115" width="6" height="14" fill="#ff3300" opacity="0.6"/>
<line x1="200" y1="80" x2="200" y2="50" stroke="#fff" stroke-width="2" opacity="0.8"/>
<circle cx="200" cy="46" r="3" fill="#fff" opacity="0.9"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,693 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Musical Double Pendulum</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #1a1a1a;
--border: #333;
--accent: #4a9eff;
--axis: #2b6fff;
--grid: #121212;
--text: #ffffff;
--muted: #888;
--trace1: #ff6b4a;
--trace2: #4aff6b;
--warning: #ff6b4a;
--success: #4aff6b;
--audio: #ff9500;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 14px; font-family: Consolas, monospace; background: var(--bg); color: var(--text); }
.container { display: flex; gap: 14px; align-items: flex-start; }
.simulation-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 14px; flex: 1; min-width: 0;
}
.controls-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 14px; width: 340px; flex-shrink: 0;
}
h3 { margin: 0 0 8px 0; color: var(--accent); font-size: 13px; letter-spacing: 2px; text-transform: uppercase; }
.audio-section { border: 2px solid var(--audio); border-radius: 8px; padding: 10px; margin: 8px 0; background: #1a0f00; }
.audio-section h3 { color: var(--audio); margin-bottom: 8px; }
.status { background:#000; color:var(--accent); padding:7px 10px; border-radius:6px; margin-bottom:7px; font-size:11px; }
.audio-status { background:#1a0f00; color:var(--audio); border: 1px solid var(--audio); }
.toolbar { display:flex; gap:8px; margin-bottom:8px; flex-wrap: wrap; }
button {
padding: 6px 10px; border: 1px solid var(--accent); background: var(--panel); color: var(--accent);
border-radius: 6px; cursor: pointer; transition: all .2s; font-family: inherit; font-size: 12px;
}
button:hover { background: var(--accent); color: #000; }
button.audio-btn { border-color: var(--audio); color: var(--audio); }
button.audio-btn:hover { background: var(--audio); color: #000; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
canvas { background:#000; border:1px solid #444; display:block; width:100%; cursor:grab; }
canvas:active { cursor:grabbing; }
.geometry-info { background:#0a0a2a; color:#99aaff; padding:8px 10px; border-radius:6px; margin-top:8px; font-size:11px; }
.energy-info { background:#0a2a0a; color:#99ff99; padding:8px 10px; border-radius:6px; margin-top:5px; font-size:11px; }
.control-group { margin-bottom: 10px; padding-bottom:10px; border-bottom:1px solid #333; }
.control-group:last-child { border-bottom: none; }
label { display:block; margin-bottom:4px; font-size:11px; color:#ccc; text-transform:uppercase; letter-spacing:1px; }
input[type="range"] { width:100%; margin-bottom:4px; accent-color: var(--accent); }
.audio-control input[type="range"] { accent-color: var(--audio); }
.value-display { background:#000; color:var(--accent); padding:2px 6px; border-radius:3px; font-size:11px; float:right; }
.audio-value { color: var(--audio); }
.state-tag { display: none; font-size: 11px; }
.state-tag.visible { display: inline; }
#topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; margin-bottom:10px; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">AUDIO PENDULUM</span>
</div>
<div class="container">
<div class="simulation-panel" id="simPanel">
<div class="status" id="status">Status: Stopped | Energy: 0.00 J | Time: 0.00 s</div>
<div class="status audio-status" id="audioStatus">Tonal: Disabled | Percussion: Disabled | ♪: -- Hz | 🥁: 0 hits</div>
<div class="toolbar">
<button id="startBtn">&#9654; Start</button>
<button id="stopBtn">&#9209; Stop</button>
<button id="resetBtn">&#8635; Reset</button>
<button id="clearBtn">Clear Traces</button>
<button id="audioBtn" class="audio-btn">&#128266; Enable Audio</button>
</div>
<canvas id="pendulumCanvas" width="680" height="540"></canvas>
<div class="geometry-info" id="geometryInfo">
<strong>Physical State:</strong><br />
<span id="geoTheta">Initial position: vertical up (12 o'clock)</span><br />
<span id="geoOmega">State: Unstable equilibrium</span><br />
<span id="geoStateNearEq" class="state-tag" style="color:var(--success)">&#10003; NEAR EQUILIBRIUM</span>
<span id="geoStateMoving" class="state-tag visible">In motion</span>
<span id="geoStateRest" class="state-tag" style="color:var(--success)"><br />&#10003; AT REST</span>
<span id="geoStateTonal" class="state-tag" style="color:var(--audio)"><br />&#127925; TONAL AUDIO ACTIVE</span>
<span id="geoStatePerc" class="state-tag" style="color:var(--audio)"><br />&#129345; PERCUSSION ACTIVE</span>
</div>
<div class="energy-info" id="energyInfo">
<strong>Energy Conservation:</strong><br />
<span id="energyLine1">Initial: -- J | Current: -- J</span><br />
<span id="energyLine2">Energy drift: 0.000% | Status: CORRECT</span>
<span id="energySingularity" class="state-tag" style="color:var(--warning)"><br />Singularities: 0</span>
</div>
</div>
<div class="controls-panel">
<div class="control-group">
<label>Pendulum 1 Length (m) <span class="value-display" id="l1Display">1.00</span></label>
<input type="range" id="l1" min="0.2" max="3.0" step="0.05" value="1.0" />
<label>Pendulum 2 Length (m) <span class="value-display" id="l2Display">1.00</span></label>
<input type="range" id="l2" min="0.2" max="3.0" step="0.05" value="1.0" />
</div>
<div class="control-group">
<label>Pendulum 1 Mass (kg) <span class="value-display" id="m1Display">1.00</span></label>
<input type="range" id="m1" min="0.2" max="5.0" step="0.1" value="1.0" />
<label>Pendulum 2 Mass (kg) <span class="value-display" id="m2Display">1.00</span></label>
<input type="range" id="m2" min="0.2" max="5.0" step="0.1" value="1.0" />
</div>
<div class="control-group">
<label>Gravity (m/s²) <span class="value-display" id="gDisplay">9.81</span></label>
<input type="range" id="g" min="0.1" max="20.0" step="0.1" value="9.81" />
<label>Friction / Damping <span class="value-display" id="dampingDisplay">0.10</span></label>
<input type="range" id="damping" min="0" max="2.0" step="0.01" value="0.1" />
</div>
<div class="control-group">
<label>Angular Velocity 1 (rad/s) <span class="value-display" id="w1Display">0.00</span></label>
<input type="range" id="w1" min="-6.0" max="6.0" step="0.1" value="0.0" />
<label>Angular Velocity 2 (rad/s) <span class="value-display" id="w2Display">0.00</span></label>
<input type="range" id="w2" min="-6.0" max="6.0" step="0.1" value="0.0" />
</div>
<div class="audio-section">
<div class="control-group audio-control">
<label>Master Volume <span class="value-display audio-value" id="volumeDisplay">0.30</span></label>
<input type="range" id="volume" min="0" max="1.0" step="0.05" value="0.3" />
<label>Base Freq - Pendulum 1 (Hz) <span class="value-display audio-value" id="freq1Display">440</span></label>
<input type="range" id="freq1" min="200" max="1000" step="10" value="440" />
<label>Base Freq - Pendulum 2 (Hz) <span class="value-display audio-value" id="freq2Display">550</span></label>
<input type="range" id="freq2" min="200" max="1000" step="10" value="550" />
<label>Sensitivity <span class="value-display audio-value" id="sensitivityDisplay">2.0</span></label>
<input type="range" id="sensitivity" min="0.5" max="5.0" step="0.1" value="2.0" />
</div>
<div class="control-group audio-control">
<label>Percussion Volume <span class="value-display audio-value" id="percVolumeDisplay">0.00</span></label>
<input type="range" id="percVolume" min="0" max="1.0" step="0.05" value="0" />
<label>Peak Threshold <span class="value-display audio-value" id="thresholdDisplay">1.0</span></label>
<input type="range" id="threshold" min="0.2" max="3.0" step="0.1" value="1.0" />
<label>Cooldown (ms) <span class="value-display audio-value" id="cooldownDisplay">100</span></label>
<input type="range" id="cooldown" min="50" max="500" step="10" value="100" />
</div>
</div>
<div class="control-group">
<label>Integration &#916;t (s) <span class="value-display" id="dtDisplay">0.005</span></label>
<input type="range" id="dt" min="0.001" max="0.020" step="0.001" value="0.005" />
</div>
</div>
</div>
<script>
class MusicalDoublePendulum {
constructor(canvasId, panelId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.panel = document.getElementById(panelId);
this.width = this.canvas.width;
this.height = this.canvas.height;
this.originX = this.width / 2;
this.originY = this.height / 2;
this.l1 = 1.0; this.l2 = 1.0;
this.m1 = 1.0; this.m2 = 1.0;
this.g = 9.81;
this.damping = 0.1;
this.theta1 = Math.PI;
this.theta2 = 0.0;
this.omega1 = 0.0; this.omega2 = 0.0;
this.running = false;
this.time = 0;
this.dt = 0.005;
this.scale = 100;
this.trace1 = []; this.trace2 = [];
this.maxTraceLength = 3000;
this.dragging = false;
this.dragTarget = null;
this.initialEnergy = 0;
this.currentEnergy = 0;
this.maxEnergyDrift = 0;
this.energyHistory = [];
this.singularityCount = 0;
this.audioContext = null;
this.oscillator1 = null; this.oscillator2 = null;
this.gainNode1 = null; this.gainNode2 = null;
this.masterGain = null;
this.audioEnabled = false;
this.percussionEnabled = false;
this.kickBuffer = null; this.snareBuffer = null;
this.lastOmega1 = 0; this.lastOmega2 = 0;
this.omega1Trend = 0; this.omega2Trend = 0;
this.lastTrigger1 = 0; this.lastTrigger2 = 0;
this.triggerCooldown = 0.1;
this.baseFreq1 = 440; this.baseFreq2 = 550;
this.volume = 0.3;
this.sensitivity = 2.0;
this.percussionVolume = 0;
this.peakThreshold = 1.0;
this.currentFreq1 = 0; this.currentFreq2 = 0;
this.kickCount = 0; this.snareCount = 0;
this.setupEventListeners();
this.updateControls();
this.initialEnergy = this.computeEnergy();
requestAnimationFrame(() => { this.resizeCanvas(); });
}
createPercussionSounds() {
if (!this.audioContext) return;
const sr = this.audioContext.sampleRate;
const kickBuf = this.audioContext.createBuffer(1, 0.5 * sr, sr);
const kd = kickBuf.getChannelData(0);
for (let i = 0; i < kd.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 15);
kd[i] = Math.sin(2 * Math.PI * 60 * (1 - t * 0.8) * t) * env + (Math.random() - 0.5) * 0.1 * env;
}
this.kickBuffer = kickBuf;
const snareBuf = this.audioContext.createBuffer(1, 0.2 * sr, sr);
const sd = snareBuf.getChannelData(0);
for (let i = 0; i < sd.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 30);
sd[i] = (Math.sin(2 * Math.PI * 200 * t) * 0.3 + (Math.random() - 0.5) * 0.7) * env;
}
this.snareBuffer = snareBuf;
}
playPercussion(type) {
if (!this.percussionEnabled || !this.audioContext) return;
const buf = type === 'kick' ? this.kickBuffer : this.snareBuffer;
if (!buf) return;
const now = this.audioContext.currentTime;
const src = this.audioContext.createBufferSource();
const gain = this.audioContext.createGain();
src.buffer = buf;
gain.gain.setValueAtTime(this.percussionVolume * (type === 'kick' ? 0.8 : 0.6), now);
src.connect(gain);
gain.connect(this.audioContext.destination);
src.start(now);
if (type === 'kick') this.kickCount++; else this.snareCount++;
setTimeout(() => { try { src.disconnect(); gain.disconnect(); } catch (e) {} }, 600);
}
detectPeaks() {
if (!this.percussionEnabled) return;
const now = this.audioContext ? this.audioContext.currentTime : this.time;
const d1 = this.omega1 - this.lastOmega1;
const d2 = this.omega2 - this.lastOmega2;
const t1 = d1 > 0.01 ? 1 : (d1 < -0.01 ? -1 : 0);
const t2 = d2 > 0.01 ? 1 : (d2 < -0.01 ? -1 : 0);
if (this.omega1Trend === 1 && t1 === -1 && Math.abs(this.omega1) > this.peakThreshold && now - this.lastTrigger1 > this.triggerCooldown) {
this.playPercussion('kick');
this.lastTrigger1 = now;
this.flashPercStatus('kick');
}
if (this.omega2Trend === 1 && t2 === -1 && Math.abs(this.omega2) > this.peakThreshold && now - this.lastTrigger2 > this.triggerCooldown) {
this.playPercussion('snare');
this.lastTrigger2 = now;
this.flashPercStatus('snare');
}
this.lastOmega1 = this.omega1; this.lastOmega2 = this.omega2;
this.omega1Trend = t1; this.omega2Trend = t2;
}
flashPercStatus(type) {
const el = document.getElementById(type === 'kick' ? 'kick-status' : 'snare-status');
if (!el) return;
const orig = el.textContent;
el.textContent = type === 'kick' ? '\u{1F941} Kick: HIT!' : '\u{1F514} Snare: HIT!';
el.style.color = type === 'kick' ? '#ff6b4a' : '#4aff6b';
setTimeout(() => { el.textContent = orig; el.style.color = ''; }, 150);
}
async initAudio() {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.audioContext.createGain();
this.masterGain.connect(this.audioContext.destination);
this.masterGain.gain.setValueAtTime(this.volume, this.audioContext.currentTime);
this.oscillator1 = this.audioContext.createOscillator();
this.gainNode1 = this.audioContext.createGain();
this.oscillator1.connect(this.gainNode1);
this.gainNode1.connect(this.masterGain);
this.oscillator2 = this.audioContext.createOscillator();
this.gainNode2 = this.audioContext.createGain();
this.oscillator2.connect(this.gainNode2);
this.gainNode2.connect(this.masterGain);
this.oscillator1.type = 'sine';
this.oscillator2.type = 'triangle';
this.oscillator1.frequency.setValueAtTime(this.baseFreq1, this.audioContext.currentTime);
this.oscillator2.frequency.setValueAtTime(this.baseFreq2, this.audioContext.currentTime);
this.gainNode1.gain.setValueAtTime(0, this.audioContext.currentTime);
this.gainNode2.gain.setValueAtTime(0, this.audioContext.currentTime);
this.oscillator1.start();
this.oscillator2.start();
this.audioEnabled = true;
return true;
} catch (e) {
return false;
}
}
updateAudio() {
if (!this.audioEnabled || !this.audioContext) return;
const now = this.audioContext.currentTime;
this.currentFreq1 = Math.max(100, Math.min(2000, this.baseFreq1 * (1 + Math.abs(this.omega1) * this.sensitivity * 0.1)));
this.currentFreq2 = Math.max(100, Math.min(2000, this.baseFreq2 * (1 + Math.abs(this.omega2) * this.sensitivity * 0.1)));
this.oscillator1.frequency.setTargetAtTime(this.currentFreq1, now, 0.01);
this.oscillator2.frequency.setTargetAtTime(this.currentFreq2, now, 0.01);
this.gainNode1.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega1) * 0.1) * this.volume, now, 0.01);
this.gainNode2.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega2) * 0.1) * this.volume, now, 0.01);
}
stopAudio() {
if (this.audioContext && this.audioEnabled) {
try { this.oscillator1.stop(); this.oscillator2.stop(); this.audioContext.close(); } catch (e) {}
}
this.audioEnabled = false;
this.audioContext = null;
}
setupEventListeners() {
const controls = ['l1','l2','m1','m2','g','damping','w1','w2','dt','volume','freq1','freq2','sensitivity','percVolume','threshold','cooldown'];
controls.forEach(id => {
document.getElementById(id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (id === 'w1') this.omega1 = v;
else if (id === 'w2') this.omega2 = v;
else if (id === 'dt') this.dt = v;
else if (id === 'volume') this.volume = v;
else if (id === 'freq1') this.baseFreq1 = v;
else if (id === 'freq2') this.baseFreq2 = v;
else if (id === 'sensitivity') this.sensitivity = v;
else if (id === 'percVolume') {
this.percussionVolume = v;
if (v > 0 && !this.percussionEnabled) {
if (!this.audioContext) this.initAudio().then(ok => { if (ok) { this.createPercussionSounds(); this.percussionEnabled = true; } });
else { this.createPercussionSounds(); this.percussionEnabled = true; }
} else if (v === 0) {
this.percussionEnabled = false;
}
}
else if (id === 'threshold') this.peakThreshold = v;
else if (id === 'cooldown') this.triggerCooldown = v / 1000;
else this[id] = v;
this.updateControls();
if (id === 'l1' || id === 'l2') this.updateScale();
if (this.audioEnabled && this.masterGain && (id === 'volume' || id === 'freq1' || id === 'freq2' || id === 'sensitivity')) {
this.masterGain.gain.setTargetAtTime(this.volume, this.audioContext.currentTime, 0.1);
}
if (!this.running) { this.initialEnergy = this.computeEnergy(); this.maxEnergyDrift = 0; this.energyHistory = []; }
if (!this.running) this.draw();
});
});
document.getElementById('startBtn').addEventListener('click', () => this.start());
document.getElementById('stopBtn').addEventListener('click', () => this.stop());
document.getElementById('resetBtn').addEventListener('click', () => this.reset());
document.getElementById('clearBtn').addEventListener('click', () => this.clearTraces());
document.getElementById('audioBtn').addEventListener('click', async () => {
const btn = document.getElementById('audioBtn');
if (!this.audioEnabled) {
if (await this.initAudio()) {
btn.textContent = 'Disable Audio';
btn.style.background = '#ff9500';
btn.style.color = '#000';
}
} else {
this.stopAudio();
btn.textContent = '\u{1F50A} Enable Audio';
btn.style.background = '';
btn.style.color = '#ff9500';
}
});
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
this.canvas.addEventListener('mouseup', () => this.onMouseUp());
this.canvas.addEventListener('mouseleave', () => this.onMouseUp());
window.addEventListener('resize', () => this.resizeCanvas());
}
updateControls() {
const set = (id, v, d=2) => { const el = document.getElementById(id); if (el) el.textContent = v.toFixed(d); };
set('l1Display', this.l1); set('l2Display', this.l2);
set('m1Display', this.m1); set('m2Display', this.m2);
set('gDisplay', this.g); set('dampingDisplay', this.damping);
set('w1Display', this.omega1); set('w2Display', this.omega2);
set('dtDisplay', this.dt, 3);
set('volumeDisplay', this.volume);
const f1el = document.getElementById('freq1Display'); if (f1el) f1el.textContent = this.baseFreq1.toFixed(0);
const f2el = document.getElementById('freq2Display'); if (f2el) f2el.textContent = this.baseFreq2.toFixed(0);
set('sensitivityDisplay', this.sensitivity, 1);
set('percVolumeDisplay', this.percussionVolume);
set('thresholdDisplay', this.peakThreshold, 1);
const cdel = document.getElementById('cooldownDisplay'); if (cdel) cdel.textContent = (this.triggerCooldown * 1000).toFixed(0);
}
resizeCanvas() {
const w = this.canvas.clientWidth;
if (w < 10) return;
this.canvas.width = w;
this.canvas.height = Math.round(w * (540 / 680));
this.updateCanvasMetrics();
}
getMousePos(e) {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
onMouseDown(e) {
if (this.running) return;
const m = this.getMousePos(e);
const p1 = this.getPendulumPosition(1);
const p2 = this.getPendulumPosition(2);
if (Math.hypot(m.x - p1.x, m.y - p1.y) < 20) { this.dragging = true; this.dragTarget = 1; }
else if (Math.hypot(m.x - p2.x, m.y - p2.y) < 20) { this.dragging = true; this.dragTarget = 2; }
}
onMouseMove(e) {
if (!this.dragging || this.running) return;
const m = this.getMousePos(e);
if (this.dragTarget === 1) {
this.theta1 = Math.atan2(m.x - this.originX, m.y - this.originY);
} else {
const p1 = this.getPendulumPosition(1);
this.theta2 = Math.atan2(m.x - p1.x, m.y - p1.y) - this.theta1;
}
this.initialEnergy = this.computeEnergy();
this.maxEnergyDrift = 0;
this.energyHistory = [];
this.draw();
}
onMouseUp() { this.dragging = false; this.dragTarget = null; }
updateCanvasMetrics() {
this.width = this.canvas.width; this.height = this.canvas.height;
this.originX = this.width / 2; this.originY = this.height / 2;
this.updateScale();
if (!this.running) this.draw();
}
updateScale() {
const half = 0.5 * Math.min(this.width, this.height) * 0.9;
const len = this.l1 + this.l2;
this.scale = len > 0 ? half / len : 100;
}
getPendulumPosition(n) {
if (n === 1) return {
x: this.originX + this.l1 * this.scale * Math.sin(this.theta1),
y: this.originY + this.l1 * this.scale * Math.cos(this.theta1)
};
const p1 = this.getPendulumPosition(1);
return {
x: p1.x + this.l2 * this.scale * Math.sin(this.theta1 + this.theta2),
y: p1.y + this.l2 * this.scale * Math.cos(this.theta1 + this.theta2)
};
}
accelerations() {
const { m1, m2, l1, l2, g } = this;
const th1 = this.theta1, th2 = this.theta2, w1 = this.omega1, w2 = this.omega2;
const s12 = Math.sin(th1 - th2), c12 = Math.cos(th1 - th2);
const den = 2*m1 + m2 - m2 * Math.cos(2*th1 - 2*th2);
if (Math.abs(den) < 1e-8) {
this.singularityCount++;
return { a1: -0.1*w1 - 0.5*th1, a2: -0.1*w2 - 0.5*th2 };
}
let a1 = (-g*(2*m1+m2)*Math.sin(th1) - m2*g*Math.sin(th1-2*th2) - 2*s12*m2*(w2*w2*l2 + w1*w1*l1*c12)) / (l1*den);
let a2 = (2*s12*(w1*w1*l1*(m1+m2) + g*(m1+m2)*Math.cos(th1) + w2*w2*l2*m2*c12)) / (l2*den);
if (this.damping > 0) {
const vx1 = l1*w1*Math.cos(th1), vy1 = -l1*w1*Math.sin(th1);
const vx2 = vx1 + l2*(w1+w2)*Math.cos(th1+th2), vy2 = vy1 - l2*(w1+w2)*Math.sin(th1+th2);
const dr = this.damping * 0.05;
const v1sq = vx1*vx1+vy1*vy1, v2sq = vx2*vx2+vy2*vy2;
if (v1sq > 1e-6) a1 += -dr * v1sq * Math.sign(w1) / l1;
if (v2sq > 1e-6) a2 += -dr * v2sq * Math.sign(w2) / l2;
const jf = this.damping * 0.3;
a1 -= jf*(w1 + 0.5*th1); a2 -= jf*(w2 + 0.5*th2);
if (this.damping > 1.0) { const hd = (this.damping-1.0)*2.0; a1 -= hd*w1; a2 -= hd*w2; }
}
return { a1, a2 };
}
rk4Step(dt) {
const deriv = (th1, th2, w1, w2) => {
const [st1, st2, sw1, sw2] = [this.theta1, this.theta2, this.omega1, this.omega2];
this.theta1=th1; this.theta2=th2; this.omega1=w1; this.omega2=w2;
const {a1,a2} = this.accelerations();
this.theta1=st1; this.theta2=st2; this.omega1=sw1; this.omega2=sw2;
return {dth1:w1, dth2:w2, dw1:a1, dw2:a2};
};
const s = {th1:this.theta1, th2:this.theta2, w1:this.omega1, w2:this.omega2};
const k1=deriv(s.th1,s.th2,s.w1,s.w2);
const k2=deriv(s.th1+.5*dt*k1.dth1, s.th2+.5*dt*k1.dth2, s.w1+.5*dt*k1.dw1, s.w2+.5*dt*k1.dw2);
const k3=deriv(s.th1+.5*dt*k2.dth1, s.th2+.5*dt*k2.dth2, s.w1+.5*dt*k2.dw1, s.w2+.5*dt*k2.dw2);
const k4=deriv(s.th1+dt*k3.dth1, s.th2+dt*k3.dth2, s.w1+dt*k3.dw1, s.w2+dt*k3.dw2);
this.theta1 += (dt/6)*(k1.dth1+2*k2.dth1+2*k3.dth1+k4.dth1);
this.theta2 += (dt/6)*(k1.dth2+2*k2.dth2+2*k3.dth2+k4.dth2);
this.omega1 += (dt/6)*(k1.dw1+2*k2.dw1+2*k3.dw1+k4.dw1);
this.omega2 += (dt/6)*(k1.dw2+2*k2.dw2+2*k3.dw2+k4.dw2);
}
update() {
if (!this.running) return;
this.rk4Step(this.dt);
this.updateAudio();
this.detectPeaks();
const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
this.trace1.push({x:p1.x, y:p1.y}); this.trace2.push({x:p2.x, y:p2.y});
if (this.trace1.length > this.maxTraceLength) this.trace1.shift();
if (this.trace2.length > this.maxTraceLength) this.trace2.shift();
this.currentEnergy = this.computeEnergy();
this.energyHistory.push(this.currentEnergy);
if (this.energyHistory.length > 1000) this.energyHistory.shift();
this.maxEnergyDrift = Math.max(this.maxEnergyDrift, Math.abs(this.currentEnergy - this.initialEnergy));
this.time += this.dt;
this.updateStatus();
}
computeEnergy() {
const vx1 = this.l1*this.omega1*Math.cos(this.theta1);
const vy1 = -this.l1*this.omega1*Math.sin(this.theta1);
const vx2 = vx1 + this.l2*(this.omega1+this.omega2)*Math.cos(this.theta1+this.theta2);
const vy2 = vy1 - this.l2*(this.omega1+this.omega2)*Math.sin(this.theta1+this.theta2);
const T = 0.5*this.m1*(vx1*vx1+vy1*vy1) + 0.5*this.m2*(vx2*vx2+vy2*vy2);
const V = this.m1*this.g*this.l1*(1-Math.cos(this.theta1)) +
this.m2*this.g*(this.l1*(1-Math.cos(this.theta1)) + this.l2*(1-Math.cos(this.theta1+this.theta2)));
return T + V;
}
setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
setVisible(id, v) { const el = document.getElementById(id); if (el) el.classList.toggle('visible', v); }
updateStatus() {
const state = this.running ? 'Running' : 'Stopped';
this.setText('status', 'Status: ' + state + ' | Energy: ' + this.currentEnergy.toFixed(3) + ' J | Time: ' + this.time.toFixed(2) + ' s');
const f1 = this.audioEnabled ? this.currentFreq1.toFixed(0) : '--';
const f2 = this.audioEnabled ? this.currentFreq2.toFixed(0) : '--';
this.setText('audioStatus',
'Tonal: ' + (this.audioEnabled ? 'Active' : 'Disabled') +
' | Percussion: ' + (this.percussionEnabled ? 'Active' : 'Disabled') +
' | \u266a: ' + f1 + '/' + f2 + ' Hz | \u{1F941}: ' + (this.kickCount + this.snareCount) + ' hits');
const driftPct = this.initialEnergy !== 0 ? (this.maxEnergyDrift / Math.abs(this.initialEnergy)) * 100 : 0;
let energyStatus = 'CORRECT';
if (driftPct > 1.0) energyStatus = 'HIGH DRIFT';
else if (driftPct > 0.1) energyStatus = 'MINOR DRIFT';
this.setText('energyLine1', 'Initial: ' + this.initialEnergy.toFixed(4) + ' J | Current: ' + this.currentEnergy.toFixed(4) + ' J');
this.setText('energyLine2', 'Energy drift: ' + driftPct.toFixed(3) + '% | Status: ' + energyStatus);
this.setVisible('energySingularity', this.singularityCount > 0);
if (this.singularityCount > 0) this.setText('energySingularity', 'Singularities: ' + this.singularityCount);
this.setText('geoTheta', '\u03b8\u2081=' + this.theta1.toFixed(3) + ' rad, \u03b8\u2082=' + this.theta2.toFixed(3) + ' rad');
this.setText('geoOmega', '\u03c9\u2081=' + this.omega1.toFixed(3) + ' rad/s, \u03c9\u2082=' + this.omega2.toFixed(3) + ' rad/s');
const vel = Math.sqrt(this.omega1*this.omega1 + this.omega2*this.omega2);
const nearEq = Math.abs(this.theta1) < 0.1 && Math.abs(this.theta2) < 0.1 && vel < 0.1;
this.setVisible('geoStateNearEq', nearEq);
this.setVisible('geoStateMoving', !nearEq);
this.setVisible('geoStateRest', this.damping > 1.0 && vel < 0.01);
this.setVisible('geoStateTonal', this.audioEnabled && vel > 0.1);
this.setVisible('geoStatePerc', this.percussionEnabled && vel > 0.1);
}
draw() {
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawGridAndAxes();
this.drawTraces();
this.drawPendulum();
this.drawInfo();
}
drawGridAndAxes() {
this.ctx.strokeStyle = '#121212'; this.ctx.lineWidth = 1;
for (let x = 0; x <= this.width; x += 40) { this.ctx.beginPath(); this.ctx.moveTo(x,0); this.ctx.lineTo(x,this.height); this.ctx.stroke(); }
for (let y = 0; y <= this.height; y += 40) { this.ctx.beginPath(); this.ctx.moveTo(0,y); this.ctx.lineTo(this.width,y); this.ctx.stroke(); }
this.ctx.strokeStyle = '#2b6fff'; this.ctx.lineWidth = 2;
this.ctx.beginPath(); this.ctx.moveTo(this.originX,0); this.ctx.lineTo(this.originX,this.height); this.ctx.stroke();
this.ctx.beginPath(); this.ctx.moveTo(0,this.originY); this.ctx.lineTo(this.width,this.originY); this.ctx.stroke();
this.ctx.fillStyle = '#4a9eff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,5,0,Math.PI*2); this.ctx.fill();
this.ctx.fillStyle = '#fff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,2,0,Math.PI*2); this.ctx.fill();
}
drawTraces() {
const drawTrace = (trace, color, lw, alpha) => {
if (trace.length < 2) return;
this.ctx.strokeStyle = color; this.ctx.lineWidth = lw; this.ctx.globalAlpha = alpha;
this.ctx.beginPath(); this.ctx.moveTo(trace[0].x, trace[0].y);
for (let i = 1; i < trace.length; i++) this.ctx.lineTo(trace[i].x, trace[i].y);
this.ctx.stroke();
};
drawTrace(this.trace1, '#ff6b4a', 1.5, 0.7);
drawTrace(this.trace2, '#4aff6b', 2.5, 0.9);
this.ctx.globalAlpha = 1.0;
}
drawPendulum() {
const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 4;
this.ctx.beginPath(); this.ctx.moveTo(this.originX,this.originY); this.ctx.lineTo(p1.x,p1.y); this.ctx.stroke();
this.ctx.beginPath(); this.ctx.moveTo(p1.x,p1.y); this.ctx.lineTo(p2.x,p2.y); this.ctx.stroke();
const r1 = Math.max(6, 4+this.m1*4), r2 = Math.max(6, 4+this.m2*4);
const drawBob = (px, py, r, color, omega) => {
if (this.audioEnabled && this.running) {
const intensity = Math.min(1, Math.abs(omega) * 0.2);
if (intensity > 0.1) { this.ctx.shadowBlur = 15*intensity; this.ctx.shadowColor = color; }
}
this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.fill();
this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.stroke();
this.ctx.shadowBlur = 0;
};
drawBob(p1.x, p1.y, r1, '#ff6b4a', this.omega1);
drawBob(p2.x, p2.y, r2, '#4aff6b', this.omega2);
}
drawInfo() {
this.ctx.fillStyle = '#4a9eff'; this.ctx.font = '13px Consolas';
this.ctx.fillText('\u03b8\u2081: ' + this.theta1.toFixed(3) + ' rad', 10, 20);
this.ctx.fillText('\u03b8\u2082: ' + this.theta2.toFixed(3) + ' rad', 10, 36);
this.ctx.fillText('\u03c9\u2081: ' + this.omega1.toFixed(3) + ' rad/s', 10, 52);
this.ctx.fillText('\u03c9\u2082: ' + this.omega2.toFixed(3) + ' rad/s', 10, 68);
let y = 84;
if (this.audioEnabled && this.running) {
this.ctx.fillStyle = '#ff9500';
this.ctx.fillText('\u266a1: ' + this.currentFreq1.toFixed(0) + ' Hz', 10, y);
this.ctx.fillText('\u266a2: ' + this.currentFreq2.toFixed(0) + ' Hz', 10, y+16);
y += 32;
}
if (this.percussionEnabled && this.running) {
this.ctx.fillStyle = '#ff6b4a';
this.ctx.fillText('\u{1F941}: ' + this.kickCount + ' hits', 10, y);
this.ctx.fillStyle = '#4aff6b';
this.ctx.fillText('\u{1F514}: ' + this.snareCount + ' hits', 10, y+16);
y += 32;
}
if (this.damping > 0) {
this.ctx.fillStyle = this.damping > 1.0 ? '#ff6b4a' : '#ffaa4a';
this.ctx.fillText('Friction: ' + this.damping.toFixed(2), 10, y);
}
this.ctx.fillStyle = '#888'; this.ctx.font = '11px Consolas';
this.ctx.fillText('Scale: ' + this.scale.toFixed(1) + ' px/m', 10, this.height-10);
}
start() {
if (!this.running) {
this.running = true;
this.initialEnergy = this.computeEnergy();
this.maxEnergyDrift = 0;
this.animate();
}
}
stop() {
this.running = false;
if (this.audioEnabled) {
this.gainNode1.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
this.gainNode2.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
}
}
reset() {
this.stop();
this.theta1 = Math.PI; this.theta2 = 0.0;
this.omega1 = 0; this.omega2 = 0;
this.time = 0;
this.clearTraces();
this.initialEnergy = this.computeEnergy();
this.currentEnergy = this.initialEnergy;
this.maxEnergyDrift = 0; this.energyHistory = []; this.singularityCount = 0;
this.kickCount = 0; this.snareCount = 0;
this.lastTrigger1 = 0; this.lastTrigger2 = 0;
this.lastOmega1 = 0; this.lastOmega2 = 0;
this.omega1Trend = 0; this.omega2Trend = 0;
this.resizeCanvas();
}
clearTraces() { this.trace1 = []; this.trace2 = []; if (!this.running) this.draw(); }
animate() {
if (!this.running) return;
this.update(); this.draw();
requestAnimationFrame(() => this.animate());
}
}
const pendulum = new MusicalDoublePendulum('pendulumCanvas', 'simPanel');
</script>
</body>
</html>

View file

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="6" fill="#0a0a0a" stroke="#1a1a1a" stroke-width="1"/>
<line x1="200" y1="30" x2="200" y2="220" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
<line x1="10" y1="110" x2="390" y2="110" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
<circle cx="200" cy="50" r="5" fill="#4a9eff"/>
<circle cx="200" cy="50" r="2" fill="#fff"/>
<line x1="200" y1="50" x2="148" y2="128" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<line x1="148" y1="128" x2="220" y2="185" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<circle cx="148" cy="128" r="10" fill="#ff6b4a"/>
<circle cx="148" cy="128" r="10" fill="none" stroke="#fff" stroke-width="1.5"/>
<circle cx="220" cy="185" r="12" fill="#4aff6b"/>
<circle cx="220" cy="185" r="12" fill="none" stroke="#fff" stroke-width="1.5"/>
<path d="M 200 50 Q 175 90 148 128 Q 130 155 120 170 Q 110 185 130 192 Q 155 200 175 185 Q 200 168 220 152 Q 245 135 255 118 Q 265 100 248 88 Q 228 76 210 88 Q 192 100 178 115" stroke="#ff6b4a" stroke-width="1.2" fill="none" opacity="0.6"/>
<path d="M 148 128 Q 170 148 195 162 Q 218 175 232 180 Q 248 184 255 175 Q 265 163 258 148 Q 250 132 235 125 Q 218 117 205 122 Q 190 128 182 138 Q 172 150 168 162" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.75"/>
<path d="M 270 85 Q 278 78 278 85 Q 278 92 270 92" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.9"/>
<path d="M 278 80 Q 290 72 290 85 Q 290 98 278 90" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.7"/>
<path d="M 290 75 Q 305 65 305 85 Q 305 105 290 95" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.5"/>
<path d="M 285 128 Q 293 121 293 128 Q 293 135 285 135" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.9"/>
<path d="M 293 123 Q 305 115 305 128 Q 305 141 293 133" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.7"/>
<text x="200" y="213" font-family="'Courier New', monospace" font-size="13" fill="#ff9500" text-anchor="middle" font-weight="bold" letter-spacing="3">AUDIOPENDULUM</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocoland</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 8px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">COCOLAND</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>BEST: <b id="best">0</b></span>
</div>
<canvas id="c" width="600" height="250"></canvas>
<div id="msg">Press SPACE or TAP to start</div>
<div id="controls">SPACE / TAP — Jump &nbsp;|&nbsp; Double jump allowed</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="cocoland">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const msgEl = document.getElementById('msg');
const W = canvas.width, H = canvas.height;
const GROUND = H - 40;
const GRAVITY = 0.6;
const JUMP = -13;
let state = 'idle';
let score = 0, best = 0, frames = 0;
let coco, obstacles, coins, speed;
function reset() {
coco = { x: 80, y: GROUND - 44, vy: 0, r: 22, jumps: 0 };
obstacles = [];
coins = [];
speed = 2;
score = 0;
frames = 0;
scoreEl.textContent = '0';
}
function jump() {
if (state === 'idle' || state === 'over') {
reset();
state = 'play';
msgEl.textContent = '';
return;
}
if (coco.jumps < 2) {
coco.vy = JUMP;
coco.jumps++;
}
}
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); jump(); } });
canvas.addEventListener('click', jump);
function spawnObstacle() {
const h = 30 + Math.random() * 40;
obstacles.push({ x: W, y: GROUND - h, w: 22, h });
}
function spawnCoin() {
const y = GROUND - 60 - Math.random() * 80;
coins.push({ x: W, y, r: 9, collected: false });
}
function drawCoco() {
const x = coco.x, y = coco.y;
ctx.fillStyle = '#8B4513';
ctx.beginPath();
ctx.arc(x, y + coco.r, coco.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(x - 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(x - 7, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 9, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#5a2000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y + coco.r + 7, 8, 0.1 * Math.PI, 0.9 * Math.PI);
ctx.stroke();
ctx.fillStyle = '#3a8000';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x - 14, y - 14, x - 8, y - 24);
ctx.quadraticCurveTo(x, y - 10, x, y);
ctx.fill();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x + 14, y - 14, x + 8, y - 24);
ctx.quadraticCurveTo(x, y - 10, x, y);
ctx.fill();
}
function drawPalmTree(px, groundY) {
ctx.strokeStyle = '#4a7a00';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(px, groundY);
ctx.quadraticCurveTo(px + 6, groundY - 60, px - 4, groundY - 110);
ctx.stroke();
ctx.fillStyle = '#3a8000';
for (let a = -0.6; a <= 0.7; a += 0.3) {
ctx.beginPath();
ctx.ellipse(px - 4 + Math.cos(a) * 32, groundY - 110 + Math.sin(a) * 12 - 10, 30, 10, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#8B4513';
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.arc(px - 4 + (i - 1) * 9, groundY - 115, 6, 0, Math.PI * 2);
ctx.fill();
}
}
function drawCoin(c) {
if (c.collected) return;
ctx.fillStyle = '#FFA500';
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('E', c.x, c.y);
}
function collides(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
let bgOffset = 0;
let palmTimer = 0, coinTimer = 0;
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1a3a00';
ctx.fillRect(0, GROUND, W, H - GROUND);
if (state === 'play') {
bgOffset = (bgOffset + speed * 0.5) % W;
palmTimer += speed;
coinTimer += speed;
if (palmTimer > 350 + Math.random() * 150) {
spawnObstacle();
palmTimer = 0;
}
if (coinTimer > 150 + Math.random() * 80) {
spawnCoin();
coinTimer = 0;
}
}
for (let i = obstacles.length - 1; i >= 0; i--) {
const o = obstacles[i];
if (state === 'play') o.x -= speed;
if (o.x + o.w < 0) { obstacles.splice(i, 1); continue; }
drawPalmTree(o.x + 11, GROUND);
if (state === 'play' && collides(coco.x - coco.r + 4, coco.y, coco.r * 2 - 8, coco.r * 2, o.x, o.y, o.w, o.h)) {
state = 'over';
if (score > best) { best = score; bestEl.textContent = best; }
msgEl.textContent = 'GAME OVER — Press SPACE to retry';
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
}
for (let i = coins.length - 1; i >= 0; i--) {
const c = coins[i];
if (state === 'play') c.x -= speed;
if (c.x + c.r < 0) { coins.splice(i, 1); continue; }
drawCoin(c);
if (!c.collected && state === 'play') {
const dx = coco.x - c.x, dy = (coco.y + coco.r) - c.y;
if (Math.sqrt(dx * dx + dy * dy) < coco.r + c.r) {
c.collected = true;
score += 10;
scoreEl.textContent = score;
}
}
}
if (state === 'play') {
coco.vy += GRAVITY;
coco.y += coco.vy;
if (coco.y >= GROUND - coco.r * 2) {
coco.y = GROUND - coco.r * 2;
coco.vy = 0;
coco.jumps = 0;
}
frames++;
if (frames % 6 === 0) {
score++;
scoreEl.textContent = score;
}
if (frames % 60 === 0 && speed < 10) speed += 0.1;
}
drawCoco();
requestAnimationFrame(loop);
}
reset();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="0" y="180" width="400" height="40" fill="#1a3a00"/>
<line x1="300" y1="180" x2="280" y2="50" stroke="#4a7a00" stroke-width="8" stroke-linecap="round"/>
<ellipse cx="265" cy="55" rx="50" ry="30" fill="#2d6a00"/>
<ellipse cx="285" cy="45" rx="45" ry="25" fill="#3a8000"/>
<ellipse cx="275" cy="35" rx="40" ry="20" fill="#4a9400"/>
<circle cx="268" cy="62" r="8" fill="#8B4513"/>
<circle cx="282" cy="65" r="7" fill="#8B4513"/>
<circle cx="275" cy="58" r="7" fill="#8B4513"/>
<line x1="100" y1="180" x2="90" y2="90" stroke="#4a7a00" stroke-width="6" stroke-linecap="round"/>
<ellipse cx="80" cy="95" rx="35" ry="22" fill="#2d6a00"/>
<ellipse cx="95" cy="87" rx="30" ry="18" fill="#3a8000"/>
<circle cx="88" cy="100" r="6" fill="#8B4513"/>
<circle cx="100" cy="102" r="5" fill="#8B4513"/>
<circle cx="58" cy="150" r="22" fill="#8B4513"/>
<circle cx="48" cy="143" r="7" fill="#fff"/>
<circle cx="68" cy="143" r="7" fill="#fff"/>
<circle cx="50" cy="143" r="4" fill="#222"/>
<circle cx="70" cy="143" r="4" fill="#222"/>
<path d="M48 158 Q58 164 68 158" stroke="#5a2000" stroke-width="2" fill="none" stroke-linecap="round"/>
<polygon points="150,165 158,145 166,165" fill="#FFA500"/>
<polygon points="190,170 198,150 206,170" fill="#FFA500"/>
<polygon points="230,162 238,142 246,162" fill="#FFA500"/>
<text x="200" y="210" font-family="monospace" font-size="13" fill="#FFA500" text-anchor="middle" opacity="0.7">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocoman</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
#dpad-row { display: flex; gap: 4px; }
#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
#dpad button:active { background: #444; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">COCOMAN</span>
</div>
<div id="ui">
<span>SCORE: <b id="scoreEl">0</b></span>
<span>LIVES: <b id="livesEl">3</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press SPACE or tap arrow to start</div>
<div id="dpad">
<div id="dpad-row"><button id="btn-up">&#8593;</button></div>
<div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="cocoman">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const CELL = 20;
const MAP = [
'############################',
'#............##............#',
'#.####.#####.##.#####.####.#',
'#o# #.# #.##.# #.# #o#',
'#.####.#####.##.#####.####.#',
'#..........................#',
'#.####.##.########.##.####.#',
'#.####.##.########.##.####.#',
'#......##....##....##......#',
'######.##### ## #####.######',
' #.##### ## #####.# ',
' #.## G ##.# ',
' #.## ###=### ##.# ',
'######.## # # ##.######',
' . # GGG # . ',
'######.## # # ##.######',
' #.## ####### ##.# ',
' #.## ##.# ',
' #.## ######## ##.# ',
'######.## ######## ##.######',
'#............##............#',
'#.####.#####.##.#####.####.#',
'#.####.#####.##.#####.####.#',
'#o..##....... .......##..o#',
'###.##.##.########.##.##.###',
'###.##.##.########.##.##.###',
'#......##....##....##......#',
'#.##########.##.##########.#',
'#.##########.##.##########.#',
'#..........................#',
'############################'
];
const COLS = MAP[0].length, ROWS = MAP.length;
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
const GHOST_COLORS = ['#f00', '#f9a', '#0ff', '#fa0'];
let score = 0, lives = 3, gameState = 'idle', comboGhost = 0;
let best = parseInt(localStorage.getItem('cocoman_best') || '-1');
let dots = [], powers = [], player, ghosts, nextDir = [1, 0], frightTimer = 0;
let tickInterval = null;
function initLevel() {
dots = []; powers = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (MAP[r][c] === '.') dots.push([c, r]);
if (MAP[r][c] === 'o') powers.push([c, r]);
}
}
player = { x: 14, y: 23, dir: [1, 0] };
nextDir = [1, 0];
ghosts = [
{ x: 13, y: 14, dir: [0, -1], color: GHOST_COLORS[0] },
{ x: 14, y: 14, dir: [0, 1], color: GHOST_COLORS[1] },
{ x: 13, y: 15, dir: [1, 0], color: GHOST_COLORS[2] },
{ x: 14, y: 15, dir: [-1, 0], color: GHOST_COLORS[3] }
];
frightTimer = 0; comboGhost = 0;
}
function wrapX(x) {
if (x < 0) return COLS - 1;
if (x >= COLS) return 0;
return x;
}
function cellAt(x, y) {
if (y < 0 || y >= ROWS) return '#';
const row = MAP[Math.floor(y)];
const ch = row[Math.floor(x)];
return ch === undefined ? ' ' : ch;
}
function canMovePlayer(x, y, dx, dy) {
const nx = wrapX(x + dx), ny = y + dy;
const ch = cellAt(nx, ny);
return ch !== '#' && ch !== '=';
}
function canGhostMove(x, y, dx, dy) {
const nx = wrapX(x + dx), ny = y + dy;
return cellAt(nx, ny) !== '#';
}
function moveGhost(g) {
const dirs = [[0,-1],[0,1],[-1,0],[1,0]].filter(([dx,dy]) => {
if (!canGhostMove(g.x, g.y, dx, dy)) return false;
if (dx === -g.dir[0] && dy === -g.dir[1]) return false;
return true;
});
if (!dirs.length) return;
if (frightTimer > 0) {
g.dir = dirs[Math.floor(Math.random() * dirs.length)];
} else {
dirs.sort((a, b) =>
Math.hypot(g.x+a[0]-player.x, g.y+a[1]-player.y) -
Math.hypot(g.x+b[0]-player.x, g.y+b[1]-player.y)
);
g.dir = dirs[0];
}
g.x = wrapX(g.x + g.dir[0]);
g.y += g.dir[1];
}
function tick() {
if (gameState !== 'play') return;
if (canMovePlayer(player.x, player.y, ...nextDir)) player.dir = [...nextDir];
if (canMovePlayer(player.x, player.y, ...player.dir)) {
player.x = wrapX(player.x + player.dir[0]);
player.y += player.dir[1];
}
const di = dots.findIndex(d => d[0] === player.x && d[1] === player.y);
if (di >= 0) { dots.splice(di, 1); score += 10; document.getElementById('scoreEl').textContent = score; }
const pi = powers.findIndex(p => p[0] === player.x && p[1] === player.y);
if (pi >= 0) { powers.splice(pi, 1); score += 50; frightTimer = 22; comboGhost = 0; document.getElementById('scoreEl').textContent = score; }
if (frightTimer > 0) frightTimer--;
ghosts.forEach(moveGhost);
for (const g of ghosts) {
if (g.x === player.x && g.y === player.y) {
if (frightTimer > 0) {
comboGhost++;
score += 200 * Math.pow(2, comboGhost - 1);
document.getElementById('scoreEl').textContent = score;
g.x = 13; g.y = 14; g.dir = [0, -1];
} else {
loseLife();
return;
}
}
}
if (!dots.length && !powers.length) winGame();
}
function loseLife() {
lives--;
document.getElementById('livesEl').textContent = lives;
if (lives <= 0) {
endGame();
} else {
player = { x: 14, y: 23, dir: [1, 0] };
nextDir = [1, 0]; frightTimer = 0;
document.getElementById('msg').textContent = 'Caught! Keep going...';
}
}
function winGame() {
clearInterval(tickInterval); tickInterval = null;
gameState = 'over';
score += lives * 100;
document.getElementById('scoreEl').textContent = score;
document.getElementById('msg').textContent = `You won! Score: ${score}. SPACE = new game`;
saveAndShow();
}
function endGame() {
clearInterval(tickInterval); tickInterval = null;
gameState = 'over';
document.getElementById('msg').textContent = `GAME OVER! Score: ${score}. SPACE = new game`;
saveAndShow();
}
function saveAndShow() {
if (best < 0 || score > best) {
best = score;
localStorage.setItem('cocoman_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function startGame() {
score = 0; lives = 3; gameState = 'play';
document.getElementById('scoreEl').textContent = '0';
document.getElementById('livesEl').textContent = '3';
document.getElementById('msg').textContent = '';
document.getElementById('scoreSubmit').style.display = 'none';
initLevel();
if (tickInterval) clearInterval(tickInterval);
tickInterval = setInterval(tick, 155);
}
document.addEventListener('keydown', e => {
const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
if (e.code === 'Space') { e.preventDefault(); startGame(); return; }
if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') startGame(); nextDir = map[e.code]; }
});
function dpadInput(dx, dy) { if (gameState === 'idle') startGame(); nextDir = [dx, dy]; }
document.getElementById('btn-up').addEventListener('click', () => dpadInput(0, -1));
document.getElementById('btn-down').addEventListener('click', () => dpadInput(0, 1));
document.getElementById('btn-left').addEventListener('click', () => dpadInput(-1, 0));
document.getElementById('btn-right').addEventListener('click', () => dpadInput(1, 0));
if (best >= 0) document.getElementById('bestEl').textContent = best;
initLevel();
let animFrame = 0;
function draw() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (MAP[r][c] === '#') {
ctx.fillStyle = '#00c';
ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
ctx.strokeStyle = '#44f';
ctx.strokeRect(c * CELL + 1, r * CELL + 1, CELL - 2, CELL - 2);
}
}
}
ctx.fillStyle = '#fff';
for (const [dc, dr] of dots) {
ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 2.5, 0, Math.PI * 2); ctx.fill();
}
if (Math.floor(animFrame / 15) % 2) {
ctx.fillStyle = '#FFA500';
for (const [dc, dr] of powers) {
ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 5, 0, Math.PI * 2); ctx.fill();
}
}
for (const g of ghosts) {
ctx.fillStyle = frightTimer > 0 ? '#006' : g.color;
const gx = g.x * CELL + CELL/2, gy = g.y * CELL + CELL/2;
ctx.beginPath();
ctx.arc(gx, gy - 2, CELL/2 - 2, Math.PI, 0);
ctx.lineTo(gx + CELL/2 - 2, gy + CELL/2 - 2);
for (let w = 0; w < 4; w++) {
ctx.lineTo(gx + CELL/2 - 2 - w * (CELL - 4) / 4, gy + CELL/2 - 2 - (w % 2 ? 3 : 0));
}
ctx.lineTo(gx - CELL/2 + 2, gy + CELL/2 - 2); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
if (frightTimer <= 0) {
ctx.fillStyle = '#00c';
ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
}
}
const mouth = Math.abs(Math.sin(animFrame * 0.15)) * 0.4;
const pa = player.dir[0] !== 0 ? Math.atan2(player.dir[1], player.dir[0]) : -Math.PI / 2 * Math.sign(player.dir[1] || 1);
ctx.fillStyle = '#ff0';
ctx.beginPath();
ctx.moveTo(player.x * CELL + CELL/2, player.y * CELL + CELL/2);
ctx.arc(player.x * CELL + CELL/2, player.y * CELL + CELL/2, CELL/2 - 1, pa + mouth, pa + Math.PI * 2 - mouth);
ctx.closePath(); ctx.fill();
animFrame++;
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#000"/>
<g fill="#00f" stroke="#00f" stroke-width="1">
<rect x="5" y="5" width="110" height="8"/>
<rect x="5" y="67" width="110" height="8"/>
<rect x="5" y="5" width="8" height="70"/>
<rect x="107" y="5" width="8" height="70"/>
<rect x="30" y="5" width="8" height="35"/>
<rect x="60" y="5" width="8" height="35"/>
<rect x="90" y="5" width="8" height="35"/>
<rect x="30" y="45" width="8" height="30"/>
<rect x="60" y="45" width="8" height="30"/>
<rect x="90" y="45" width="8" height="30"/>
</g>
<circle cx="20" cy="42" r="8" fill="#ff0"/>
<path d="M20,42 L28,38 L28,46 Z" fill="#000"/>
<circle cx="55" cy="25" r="4" fill="#f00" opacity="0.9"/>
<circle cx="80" cy="55" r="4" fill="#f88" opacity="0.9"/>
<circle cx="19" cy="25" r="2" fill="#fff"/>
<circle cx="45" cy="55" r="2" fill="#fff"/>
<circle cx="75" cy="35" r="2" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 998 B

View file

@ -0,0 +1,793 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ECOINFLOW</title>
<style>
:root {
--pub: #FFA500;
--hab: #8BC34A;
--val: #eeeeee;
--shop: #4CAF50;
--acum: #FF5252;
--check: #FFFF55;
--cbdc: #607D8B;
--bg: #000;
--fg: #eee;
--cell: 64px;
--gap: 5px;
--pad: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100vh; overflow: hidden; }
body {
background: var(--bg); color: var(--fg);
font-family: 'Courier New', ui-monospace, monospace;
height: 100vh; display: flex; flex-direction: column;
align-items: center; user-select: none; -webkit-user-select: none;
}
#topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
#ui { display:flex; gap:16px; padding:8px; font-size:14px; color:#FFA500; flex-wrap:wrap; justify-content:center; }
#ui b { font-size:15px; }
#hudTurn { transition:color 0.3s; }
#boardWrap { position:relative; flex:1; min-height:0; margin:0; display:flex; align-items:center; justify-content:center; width:100%; overflow:hidden; }
#board { display:grid; gap:var(--gap); background:#222; padding:var(--pad); border:1px solid #3a3a3a; position:relative; }
.cell { width:var(--cell); height:var(--cell); background:#2c2c2c; border:1px solid #444; display:flex; align-items:center; justify-content:center; cursor:pointer; position:relative; transition:background 0.15s; }
.cell.empty:hover { background:#3a3a3a; }
.cell.drawable { background:#4a3800; border-color:#9a7200; }
.cell.drawable.empty:hover { background:#5e4700; }
.cell.in-path { background:#524000; }
.cell.cursor { outline:2px solid var(--pub); outline-offset:-2px; z-index:3; }
.node-label { font-size:40px; font-weight:bold; color:#fff; pointer-events:none; line-height:1; text-align:center; background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.45); padding:2px 7px; border-radius:3px; }
.node.validator .node-label,.node.accumulator .node-label { font-size:22px; padding:1px 4px; }
.node { width:82%; height:82%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; font-weight:bold; font-size:11px; color:#000; position:relative; transition:transform 0.2s,opacity 0.2s; }
.node-icon { display:flex; align-items:center; justify-content:center; pointer-events:none; line-height:1; font-size:34px; }
.node.validator .node-icon { font-size:22px; }
.node.inactive { opacity:0.42; }
.node.active { animation:pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%,100%{transform:scale(1);} 50%{transform:scale(1.07);} }
.node.danger { animation:danger 0.42s ease-in-out infinite !important; opacity:1 !important; }
@keyframes danger { 0%,100%{background:var(--hab);box-shadow:0 0 0 2px var(--hab);} 50%{background:#c83030;box-shadow:0 0 0 5px rgba(255,82,82,.9),0 0 16px rgba(255,82,82,.5);} }
.node.acum-danger { animation:acumDanger 0.44s ease-in-out infinite !important; opacity:1 !important; }
@keyframes acumDanger { 0%,100%{filter:drop-shadow(0 0 3px rgba(255,82,82,.45));} 50%{filter:drop-shadow(0 0 12px rgba(255,17,68,1)) drop-shadow(0 0 4px rgba(255,82,82,.9));} }
.node.cbdc-threat { animation:cbdcThreat 0.55s ease-in-out infinite !important; opacity:1 !important; }
@keyframes cbdcThreat { 0%,100%{background:var(--hab);box-shadow:0 0 0 2px var(--hab);} 50%{background:#4a6572;box-shadow:0 0 0 5px rgba(96,125,139,.85),0 0 14px rgba(96,125,139,.5);} }
.node.cbdc-alarm { animation:cbdcAlarm 0.7s ease-in-out infinite; }
@keyframes cbdcAlarm { 0%,100%{box-shadow:0 0 0 2px var(--cbdc);} 50%{box-shadow:0 0 0 6px rgba(96,125,139,.8),0 0 14px rgba(96,125,139,.5);} }
.node.pub { background:var(--pub); border-radius:3px; box-shadow:0 0 0 2px var(--pub),0 0 10px rgba(255,165,0,.18); }
.node.pub::before { content:''; position:absolute; width:2px; height:10px; background:var(--pub); top:-10px; left:50%; transform:translateX(-50%); }
.node.pub::after { content:''; position:absolute; width:6px; height:6px; background:var(--pub); border-radius:50%; top:-16px; left:50%; transform:translateX(-50%); }
.node.pub.reduced { outline:2px dashed #c47800; outline-offset:2px; }
.node.inhabitant { background:var(--hab); border-radius:50%; box-shadow:0 0 0 2px var(--hab),0 0 10px rgba(139,195,74,.18); }
.node.inhabitant.cbdc-controlled { background:#455a64; border-radius:50%; box-shadow:0 0 0 2px #455a64; }
.node.checkpoint { background:transparent; border-radius:3px; border:3px solid var(--check); box-shadow:0 0 6px rgba(255,215,0,.35); opacity:1 !important; }
.node.checkpoint.active { background:transparent !important; border:3px solid var(--check) !important; box-shadow:0 0 0 2px var(--check),0 0 18px rgba(255,229,102,.9) !important; filter:none !important; animation:checkActive 1.8s ease-in-out infinite !important; }
.node.checkpoint .node-label { color:var(--check); text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 8px rgba(0,0,0,.9); }
@keyframes checkActive { 0%,100%{transform:scale(1.1);box-shadow:0 0 0 2px var(--check),0 0 12px rgba(255,229,102,.65);} 50%{transform:scale(1.22);box-shadow:0 0 0 5px var(--check),0 0 26px rgba(255,229,102,1);} }
.node.validator { background:rgba(200,218,230,.55); clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%); width:68%; height:68%; overflow:hidden; filter:drop-shadow(0 0 5px rgba(200,218,230,.7)); }
.node.validator.active { background:var(--val) !important; filter:drop-shadow(0 0 8px rgba(238,238,238,.9)) !important; }
.node.shop { background:var(--shop); clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%); filter:drop-shadow(0 0 4px rgba(76,175,80,.45)); }
.node.accumulator { background:rgba(255,82,82,.18); clip-path:polygon(50% 0,100% 100%,0 100%); overflow:hidden; filter:drop-shadow(0 0 4px rgba(255,82,82,.42)); }
.node.cbdc { background:var(--cbdc); border-radius:3px; color:#cfd8dc; font-size:14px; box-shadow:0 0 0 2px var(--cbdc),0 0 10px rgba(96,125,139,.25); }
#pipes { position:absolute; pointer-events:none; }
#pipes path { stroke:var(--pub); stroke-width:5; stroke-linecap:round; stroke-linejoin:round; opacity:.4; fill:none; }
#pipes path.flowing { opacity:1; stroke-width:6; filter:drop-shadow(0 0 4px rgba(255,165,0,.85)) drop-shadow(0 0 10px rgba(255,165,0,.4)); stroke-dasharray:14 8; animation:pipeDash 1.1s linear infinite; }
#pipes path.clickable { pointer-events:auto; cursor:pointer; }
#pipes path.clickable:hover { stroke:#fff; filter:none; stroke-dasharray:none; animation:none; }
#pipes path.cbdc-line { stroke:var(--cbdc); stroke-width:3; stroke-dasharray:5 4; opacity:.75; pointer-events:none; }
#particleLayer { position:absolute; pointer-events:none; }
.flow-particle { position:absolute; width:7px; height:7px; background:var(--pub); border-radius:50%; box-shadow:0 0 7px var(--pub),0 0 14px rgba(255,165,0,.6),0 0 22px rgba(255,165,0,.3); offset-distance:0%; animation:flowParticle 900ms linear infinite; will-change:offset-distance,opacity; }
@keyframes flowParticle { 0%{offset-distance:0%;opacity:0;} 8%{opacity:1;} 85%{opacity:1;} 100%{offset-distance:100%;opacity:0;} }
@keyframes pipeDash { to { stroke-dashoffset:-22; } }
.pot-float { position:absolute; color:var(--acum); font-size:15px; font-weight:bold; font-family:'Courier New',monospace; letter-spacing:1px; pointer-events:none; text-shadow:0 0 8px rgba(255,82,82,.7); animation:potFloat 1.4s ease-out forwards; z-index:20; white-space:nowrap; }
@keyframes potFloat { 0%{transform:translateY(0) scale(1);opacity:1;} 20%{transform:translateY(-6px) scale(1.15);} 100%{transform:translateY(-48px) scale(.9);opacity:0;} }
.check-pulse { position:absolute; border-radius:50%; border:2px solid var(--check); box-shadow:0 0 10px var(--check); pointer-events:none; animation:checkPulse .8s ease-out forwards; z-index:15; }
@keyframes checkPulse { 0%{transform:translate(-50%,-50%) scale(.3);opacity:1;} 100%{transform:translate(-50%,-50%) scale(2.5);opacity:0;} }
#message { margin:8px 0; min-height:18px; font-size:12px; color:#888; text-align:center; max-width:500px; padding:0 16px; line-height:1.5; }
#message.warning { color:var(--pub); background:rgba(255,165,0,.07); border:1px solid rgba(255,165,0,.2); padding:6px 16px; border-radius:2px; }
#message.alert { color:var(--acum); background:rgba(255,82,82,.07); border:1px solid rgba(255,82,82,.2); padding:6px 16px; border-radius:2px; }
#legend { display:flex; flex-wrap:wrap; justify-content:center; gap:10px 16px; margin:4px 0; padding:8px 16px; max-width:560px; font-size:11px; color:#888; }
#legend .item { display:flex; align-items:center; gap:6px; }
#legend .mini { width:16px; height:16px; display:inline-block; flex-shrink:0; }
#legend .mini.pub { background:var(--pub); border-radius:2px; }
#legend .mini.inhabitant { background:var(--hab); border-radius:50%; }
#legend .mini.validator { background:var(--val); clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%); width:14px; height:14px; margin:1px; }
#legend .mini.shop { background:var(--shop); clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%); }
#legend .mini.accumulator { background:var(--acum); clip-path:polygon(50% 0,100% 100%,0 100%); }
#legend .mini.checkpoint { background:transparent; border:2px solid var(--check); border-radius:2px; }
#legend .mini.cbdc { background:var(--cbdc); border-radius:2px; }
#controls { color:#888; font-size:13px; text-align:center; margin-bottom:8px; display:flex; gap:10px; flex-wrap:wrap; justify-content:center; align-items:center; padding:0 16px; }
#btnAdvanceTurn { background:#0a0a0a; color:var(--fg); border:1px solid var(--pub); padding:8px 18px; font-family:inherit; font-size:12px; cursor:pointer; text-transform:uppercase; letter-spacing:1px; }
#btnAdvanceTurn:hover { background:var(--pub); color:#000; }
button { background:#0a0a0a; color:var(--fg); border:1px solid var(--pub); padding:10px 16px; font-family:inherit; font-size:12px; cursor:pointer; text-transform:uppercase; letter-spacing:1px; }
button:hover { background:var(--pub); color:#000; }
button.primary { background:var(--pub); color:#000; font-weight:bold; }
button.primary:hover { background:#ffb733; }
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.97); display:flex; align-items:center; justify-content:center; z-index:100; padding:20px; }
.overlay.hidden { display:none; }
.overlay .panel { max-width:480px; border:1px solid var(--pub); padding:24px 28px; background:#050505; }
.overlay h2 { color:var(--pub); margin-bottom:14px; font-size:16px; letter-spacing:1px; }
.overlay p { margin:10px 0; line-height:1.55; font-size:13px; color:#bbb; }
.overlay .hint { color:#bbb; font-size:12px; border-left:2px solid #444; padding-left:10px; margin-top:16px; }
.info-panel { max-width:560px !important; max-height:85vh; overflow-y:auto; }
.info-panel h3 { color:var(--pub); font-size:13px; letter-spacing:1px; margin-top:18px; margin-bottom:6px; text-transform:uppercase; }
.info-panel h3:first-of-type { margin-top:4px; }
.info-panel ul { list-style:none; padding-left:0; margin:8px 0; }
.info-panel ul li { font-size:13px; color:#bbb; padding:4px 0 4px 14px; position:relative; line-height:1.5; }
.info-panel ul li::before { content:'>'; color:var(--pub); position:absolute; left:0; }
.info-panel .stat-line { font-family:inherit; color:#888; font-size:12px; margin:6px 0 12px; }
.info-panel .stat-line b { color:var(--pub); }
.overlay .buttons { margin-top:20px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap; }
#scoreSubmit { margin-top:14px; text-align:center; }
#scoreSubmit button { border-color:var(--check); color:var(--check); background:#1a3a1a; padding:6px 16px; font-size:14px; }
#scoreSubmit button:hover { background:var(--check); color:#000; }
#winBreakdown { font-size:11px; color:#555; line-height:1.9; margin:4px 0 10px; border-left:2px solid #222; padding-left:10px; }
#winBreakdown .positive { color:#6a9a3a; }
#winBreakdown .negative { color:var(--acum); }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ECOINFLOW</span>
</div>
<div id="ui">
<span>LEVEL: <b id="hudLevel">1</b>/<b id="hudLevelTotal">8</b></span>
<span>TURN: <b id="hudTurn">0</b></span>
<span>PAR: <b id="hudPar">2</b></span>
<span>SCORE: <b id="hudScore">0</b></span>
<span>BEST: <b id="topBest">-</b></span>
</div>
<div id="boardWrap">
<div id="board"></div>
<svg id="pipes"></svg>
<div id="particleLayer"></div>
</div>
<div id="message"></div>
<div id="legend"></div>
<div id="controls">
<button id="btnAdvanceTurn" onclick="tick()">ADVANCE TURN</button>
<span>ENTER &#8212; advance &nbsp;|&nbsp; Backspace &#8212; undo &nbsp;|&nbsp; I &#8212; info</span>
</div>
<div id="overlayIntro" class="overlay">
<div class="panel">
<h2 id="introTitle">LEVEL 1 &#8212; Your first PUB</h2>
<p id="introText"></p>
<p style="margin-top:10px;font-size:12px;line-height:2.2">
<a href="https://wiki.solarnethub.com/ecoin/overview" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none;margin-right:14px">ECOin &#8599;</a>
<a href="https://wiki.solarnethub.com/socialnet/overview" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none;margin-right:14px">Oasis &#8599;</a>
<a href="https://wiki.solarnethub.com/" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none">SolarNET.HuB &#8599;</a>
</p>
<p class="hint">Click a node to start a pipe, then click adjacent cells to extend it, and finish on another node. Click an existing pipe to remove it. Press <b>ENTER</b> (or ADVANCE TURN button) to process one flow cycle.</p>
<div class="buttons"><button class="primary" onclick="closeIntro()">START</button></div>
</div>
</div>
<div id="overlayWin" class="overlay hidden">
<div class="panel">
<h2>&#10003; LEVEL COMPLETE</h2>
<p id="winText"></p>
<p>Level score: <b id="winLevelPoints" style="color:var(--pub);font-size:18px">0</b></p>
<p>Total score: <b id="winTotalPoints" style="color:var(--check);font-size:18px">0</b></p>
<div id="winBreakdown"></div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="ecoinflow">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<div class="buttons">
<button onclick="restartLevel()">RETRY</button>
<button id="btnNextLevel" class="primary" onclick="goNextLevel()">NEXT LEVEL</button>
</div>
</div>
</div>
<div id="overlayLoss" class="overlay hidden">
<div class="panel">
<h2 style="color:var(--acum)">&#10007; NETWORK DOWN</h2>
<p id="lossText"></p>
<div class="buttons"><button class="primary" onclick="restartLevel()">RETRY</button></div>
</div>
</div>
<div id="overlayInfo" class="overlay hidden">
<div class="panel info-panel">
<h2>INFORMATION</h2>
<div id="infoBody"></div>
<div class="buttons"><button class="primary" onclick="closeInfo()">CLOSE</button></div>
</div>
</div>
<script>
var ecoflowBest = parseInt(localStorage.getItem('ecoinflow_best') || '0');
function updateBest(score) {
if (score > ecoflowBest) { ecoflowBest = score; localStorage.setItem('ecoinflow_best', ecoflowBest); }
document.getElementById('topBest').textContent = ecoflowBest > 0 ? String(ecoflowBest) : '-';
}
updateBest(0);
var LEVELS = [
{ id:1, size:3, par:2,
grid:[['empty','inhabitant','empty'],['pub','empty','inhabitant'],['empty','empty','checkpoint']],
concept:'Your first PUB',
introText:'A PUB is a public server in the Oasis network. It generates ECOin and distributes it to those connected. Your task: connect the PUB to the inhabitants and reach the checkpoint.',
winText:'Network active. Inhabitants receive their flow. This is how a PUB works: it does not accumulate, it distributes.' },
{ id:2, size:4, par:4,
grid:[['inhabitant','empty','inhabitant','empty'],['empty','pub','empty','pub'],['inhabitant','empty','inhabitant','empty'],['empty','empty','checkpoint','empty']],
concept:'Universal Basic Income',
introText:'UBI is the flow that reaches every inhabitant of Oasis, regardless of what they do. It is not charity: it is the network recognizing that your time has value. Each PUB emits UBI to connected inhabitants. None can be left without it.',
winText:'All inhabitants receive UBI. The network is fair when no one is disconnected.' },
{ id:3, size:4, par:5,
grid:[['pub','empty','empty','inhabitant'],['empty','validator','empty','empty'],['inhabitant','empty','validator','empty'],['empty','empty','inhabitant','checkpoint']],
concept:'Validators',
introText:'The ECOin network needs validators: nodes that confirm transactions are legitimate. A validator must accumulate minimum flow before it can pass ECOin through. Plan the connection order: charge validators first, then extend the network through them.',
winText:'Validators active. The blockchain advances. Without validators, there is no network.' },
{ id:4, size:4, par:6,
grid:[['pub','empty','inhabitant','empty'],['inhabitant','accumulator','empty','shop'],['empty','empty','shop','empty'],['inhabitant','empty','pub','checkpoint']],
concept:'Proof of Transaction',
introText:'PoT discourages accumulating ECOin without using it. If flow reaches an accumulator and does not exit, the network penalizes it. You can route around it or connect it to a Shop so the flow keeps circulating. ECOin that does not circulate serves no one.',
winText:'Clean flow. This is how the network maintains its economic health.',
winTextPoT:'PoT triggered. Penalty applied. Next time, keep the flow moving.' },
{ id:5, size:5, par:8,
grid:[['pub','empty','inhabitant','empty','inhabitant'],['empty','validator','empty','shop','empty'],['inhabitant','empty','accumulator','empty','validator'],['empty','shop','empty','inhabitant','empty'],['pub','empty','empty','empty','checkpoint']],
concept:'The Checkpoint',
introText:'The ECOin chain advances through checkpoints: milestones confirming that a number of blocks have been mined and validated. This level integrates all elements seen so far. Manage flow carefully: validators block until loaded, the accumulator penalizes if full. Plan before advancing the turn.',
winText:'Checkpoint reached. The network advances. Every mined block is a step toward digital sovereignty.',
winTextPoT:'Checkpoint reached, but PoT triggered. Penalty applied.' },
{ id:6, size:5, par:10,
grid:[[{type:'pub',capacity:2},'empty','inhabitant','empty','pub'],['inhabitant','validator','empty','shop','inhabitant'],['empty','empty','accumulator','checkpoint','validator'],['pub','shop','empty','inhabitant','empty'],['inhabitant','validator','shop','accumulator','checkpoint']],
concept:'Network Under Pressure',
introText:'The real network operates with limited resources. One PUB is running at reduced capacity (marked with a dashed border). You cannot always feed all nodes at once: learn to prioritize. Inhabitants first, then validators, shops if there is surplus. Two checkpoints to activate.',
winText:'Network stabilized under pressure. Digital sovereignty requires conscious management of real resources.',
winTextPoT:'Network stabilized, but PoT penalized. Optimize the accumulator flow.' },
{ id:7, size:5, par:12,
grid:[['pub','inhabitant','empty','inhabitant','pub'],['inhabitant','cbdc','inhabitant','empty','empty'],['empty','inhabitant','empty','validator','shop'],['pub','empty','shop','empty','inhabitant'],['empty','inhabitant','empty','validator','checkpoint']],
concept:'The Digital Euro',
introText:'The Digital Euro is being implemented. Unlike ECOin, a CBDC (Central Bank Digital Currency) is not sovereignty: it is surveillance. The CBDC node tries to capture adjacent inhabitants not connected to a PUB every 2 turns. Connect your PUBs to vulnerable inhabitants first. Speed matters.',
winText:'Sovereign network. Inhabitants are connected to community PUBs, not surveillance infrastructure. This is what is at stake.',
winTextCBDC:'Some inhabitants were captured by CBDC before you could connect them. The network survived, but not all are free.' },
{ id:8, size:6, par:14,
grid:[['pub','empty','inhabitant','empty','inhabitant','pub'],['empty','validator','empty','shop','empty','inhabitant'],['inhabitant','empty','accumulator','empty','validator','empty'],['empty','shop','empty','inhabitant','empty','shop'],['pub','empty','validator','empty','pub','empty'],['inhabitant','empty','cbdc','inhabitant','empty','checkpoint']],
concept:'Complete Oasis',
introText:'This is Oasis. A decentralized network where ECOin flows from PUBs to inhabitants as universal basic income, validators maintain blockchain integrity, shops sustain the internal economy, and checkpoints consolidate network history. CBDC tries to expand from the edge. There is no central bank. Only the network. Complete the picture.',
winText:'Oasis active. You have built a sovereign network. Now you know how it works. Now you can be part of it.' }
];
function createCell(type) {
return { type:type, active:type==='pub', flowReceivedThisTurn:0, turnosSinFlujo:0, acumulado:0, validadorCargado:0, contadorActivo:0, bloqueado:false, capacity:null, cbdcControlled:false };
}
var state = { levelIndex:0, size:0, board:[], paths:[], drawing:null, turn:0, score:0, potActivations:0, cbdcCaptured:new Set(), pendingEvents:[], status:'intro', cursor:{r:0,c:0} };
function loadLevel(i) {
var lvl = LEVELS[i];
state.levelIndex = i; state.size = lvl.size;
state.board = lvl.grid.map(function(row) { return row.map(function(def) {
if (typeof def === 'string') return createCell(def);
var cell = createCell(def.type);
if (def.capacity !== undefined) cell.capacity = def.capacity;
return cell;
}); });
state.paths = []; state.drawing = null; state.turn = 0;
if (i === 0) state.score = 0;
state.potActivations = 0; state.cbdcCaptured = new Set(); state.pendingEvents = [];
state.status = 'playing'; state.cursor = {r:0,c:0};
document.getElementById('hudLevel').textContent = lvl.id;
document.getElementById('hudLevelTotal').textContent = LEVELS.length;
document.getElementById('hudPar').textContent = lvl.par;
document.getElementById('introTitle').textContent = 'LEVEL ' + lvl.id + ' \u2014 ' + lvl.concept;
document.getElementById('introText').textContent = lvl.introText;
renderLegend(); showMessage(''); resizeBoard(); render();
}
var LEGEND_LABELS = { pub:'PUB', inhabitant:'Inhabitant', validator:'Validator', shop:'Shop', accumulator:'Accumulator', checkpoint:'Checkpoint', cbdc:'CBDC' };
var LEGEND_ORDER = ['pub','inhabitant','validator','shop','accumulator','checkpoint','cbdc'];
function renderLegend() {
var present = new Set();
for (var r=0;r<state.size;r++) for (var c=0;c<state.size;c++) { var t=state.board[r][c].type; if(t!=='empty') present.add(t); }
var el = document.getElementById('legend');
el.innerHTML = '';
LEGEND_ORDER.forEach(function(type) {
if (!present.has(type)) return;
var item = document.createElement('div'); item.className = 'item';
var mini = document.createElement('span'); mini.className = 'mini '+type;
var label = document.createElement('span'); label.textContent = LEGEND_LABELS[type];
item.appendChild(mini); item.appendChild(label); el.appendChild(item);
});
}
function getCell(r,c) { if(r<0||c<0||r>=state.size||c>=state.size) return null; return state.board[r][c]; }
function areAdjacent(a,b) { return Math.abs(a.r-b.r)+Math.abs(a.c-b.c)===1; }
function cellInOtherPath(r,c,exc) {
for(var i=0;i<state.paths.length;i++) { if(i===exc) continue; var p=state.paths[i]; for(var k=1;k<p.length-1;k++) if(p[k].r===r&&p[k].c===c) return true; }
return false;
}
function countConnections(r,c) {
return state.paths.filter(function(p){var a=p[0],b=p[p.length-1]; return(a.r===r&&a.c===c)||(b.r===r&&b.c===c);}).length;
}
function getConnectedNodes(r,c) {
var result=[];
state.paths.forEach(function(path){ var a=path[0],b=path[path.length-1]; if(a.r===r&&a.c===c) result.push(b); else if(b.r===r&&b.c===c) result.push(a); });
return result;
}
function isConnectedToPUB(sr,sc) {
var visited=new Set(), queue=[sr+','+sc]; visited.add(sr+','+sc);
while(queue.length>0) {
var key=queue.shift(), parts=key.split(','), kr=+parts[0], kc=+parts[1], cell=state.board[kr][kc];
if(cell.type==='pub'&&cell.active&&!cell.bloqueado) return true;
getConnectedNodes(kr,kc).forEach(function(n){ var nk=n.r+','+n.c; if(!visited.has(nk)){visited.add(nk);queue.push(nk);} });
}
return false;
}
var PUB_OUTPUT=4;
function computePreviewFlow() {
var flow={};
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) flow[r+','+c]=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c];
if(cell.type!=='pub'||!cell.active||cell.bloqueado) continue;
var outs=getConnectedNodes(r,c); if(!outs.length) continue;
var pubOut=cell.capacity!==null?cell.capacity:PUB_OUTPUT, share=pubOut/outs.length;
outs.forEach(function(n){ previewPropagate(n.r,n.c,share,new Set([r+','+c]),flow); });
}
return flow;
}
function previewPropagate(r,c,amount,visited,flow) {
var cell=state.board[r][c], k=r+','+c;
if(visited.has(k)) return; visited.add(k);
if(cell.type!=='pub') flow[k]=(flow[k]||0)+amount;
if(cell.type!=='validator'||!cell.active||cell.bloqueado) return;
getConnectedNodes(r,c).filter(function(n){return!visited.has(n.r+','+n.c);}).forEach(function(n){ previewPropagate(n.r,n.c,amount,new Set(visited),flow); });
}
function render() { renderBoard(); renderPipes(); renderParticles(); renderHud(); }
function makeEcoinSVG() {
var ns='http://www.w3.org/2000/svg';
var svg=document.createElementNS(ns,'svg');
svg.setAttribute('viewBox','0 0 32 32'); svg.setAttribute('width','68%'); svg.setAttribute('height','68%');
svg.classList.add('node-icon');
var c1=document.createElementNS(ns,'circle'); c1.setAttribute('cx','16'); c1.setAttribute('cy','16'); c1.setAttribute('r','13'); c1.setAttribute('fill','#0d0800'); c1.setAttribute('stroke','#FFA500'); c1.setAttribute('stroke-width','2.5');
var c2=document.createElementNS(ns,'circle'); c2.setAttribute('cx','16'); c2.setAttribute('cy','16'); c2.setAttribute('r','9'); c2.setAttribute('fill','none'); c2.setAttribute('stroke','rgba(255,165,0,0.35)'); c2.setAttribute('stroke-width','1');
var t=document.createElementNS(ns,'text'); t.setAttribute('x','16'); t.setAttribute('y','21'); t.setAttribute('text-anchor','middle'); t.setAttribute('font-size','15'); t.setAttribute('font-weight','bold'); t.setAttribute('fill','#FFA500'); t.setAttribute('font-family','monospace'); t.textContent='E';
svg.appendChild(c1); svg.appendChild(c2); svg.appendChild(t);
return svg;
}
function renderBoard() {
var preview=computePreviewFlow();
var fmtFlow=function(v){return Number.isInteger(v)?String(v):v.toFixed(1);};
var board=document.getElementById('board');
board.innerHTML='';
board.style.gridTemplateColumns='repeat('+state.size+', var(--cell))';
var last=state.drawing?state.drawing.cells[state.drawing.cells.length-1]:null;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
(function(r,c){
var cell=state.board[r][c];
var div=document.createElement('div'); div.className='cell';
if(cell.type==='empty') div.classList.add('empty');
if(state.cursor&&state.cursor.r===r&&state.cursor.c===c) div.classList.add('cursor');
if(last&&areAdjacent(last,{r:r,c:c})) {
var alreadyIn=state.drawing.cells.some(function(p){return p.r===r&&p.c===c;});
var blocked=cell.type==='empty'&&cellInOtherPath(r,c);
if(!alreadyIn&&!blocked) div.classList.add('drawable');
}
if(state.drawing&&state.drawing.cells.some(function(p){return p.r===r&&p.c===c;})) div.classList.add('in-path');
if(cell.type!=='empty') {
var node=document.createElement('div'); node.className='node '+cell.type;
if(cell.cbdcControlled){ node.classList.add('cbdc-controlled'); node.classList.add('inactive'); }
else if(cell.active&&!cell.bloqueado) node.classList.add('active');
else node.classList.add('inactive');
if(cell.type==='inhabitant'&&!cell.cbdcControlled&&cell.turnosSinFlujo>=2){ node.classList.remove('inactive'); node.classList.add('danger'); }
if(cell.type==='inhabitant'&&!cell.cbdcControlled&&!node.classList.contains('danger')) {
var adjToCBDC=[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].some(function(p){ if(p.r<0||p.c<0||p.r>=state.size||p.c>=state.size) return false; return state.board[p.r][p.c].type==='cbdc'; });
if(adjToCBDC&&!isConnectedToPUB(r,c)){ node.classList.remove('inactive'); node.classList.add('cbdc-threat'); }
}
if(cell.type==='accumulator'&&!cell.bloqueado&&cell.acumulado>=4) node.classList.add('acum-danger');
if(cell.type==='cbdc') {
var hasVuln=[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].some(function(p){ if(p.r<0||p.c<0||p.r>=state.size||p.c>=state.size) return false; var adj=state.board[p.r][p.c]; return adj.type==='inhabitant'&&!adj.cbdcControlled&&!isConnectedToPUB(p.r,p.c); });
if(hasVuln){ node.classList.remove('inactive'); node.classList.add('cbdc-alarm'); }
}
if(cell.type==='pub'&&cell.capacity!==null) node.classList.add('reduced');
if(cell.type==='pub'){ node.appendChild(makeEcoinSVG()); }
else if(cell.type==='inhabitant'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\uD83E\uDD65'; node.appendChild(ico); }
else if(cell.type==='validator'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\u2714'; node.appendChild(ico); }
else if(cell.type==='cbdc'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\uD83D\uDC80'; node.appendChild(ico); }
if(cell.type==='checkpoint'){ var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=Math.min(cell.contadorActivo,3); node.appendChild(lbl); }
if(cell.type==='validator') {
if(!cell.active){ var pct=Math.round(Math.min(100,(cell.validadorCargado/3)*100)); node.style.background='linear-gradient(to top,#c8dde8 '+pct+'%,rgba(200,218,230,.55) '+pct+'%)'; }
var pf=preview[r+','+c]||0; var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=fmtFlow(pf); node.appendChild(lbl);
}
if(cell.type==='pub') {
var pubOutput=cell.capacity!==null?cell.capacity:4, conns=countConnections(r,c), share=conns>1?pubOutput/conns:pubOutput;
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=Number.isInteger(share)?share:share.toFixed(1); node.appendChild(lbl);
}
if(cell.type==='inhabitant'&&!cell.cbdcControlled) {
var pf=preview[r+','+c]||0, health=3-cell.turnosSinFlujo;
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=health;
lbl.style.color=pf>=1?'#fff':pf>0?'#FFA500':'#FF5252'; node.appendChild(lbl);
}
if(cell.type==='accumulator'&&!cell.bloqueado) {
var acc=cell.acumulado, pct=Math.round(Math.min(100,(acc/5)*100));
var fillColor=acc>=4.5?'#FF1744':acc>=3?'#FF5252':acc>=1.5?'#E53935':'#B71C1C';
node.style.background='linear-gradient(to top,'+fillColor+' '+pct+'%,rgba(255,82,82,.18) '+pct+'%)';
var pf=preview[r+','+c]||0, projected=Math.min(acc+pf,5);
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=fmtFlow(projected)+'/5'; node.appendChild(lbl);
}
div.appendChild(node);
}
div.setAttribute('role','button');
div.setAttribute('aria-label',cell.type==='empty'?'Empty cell':LEGEND_LABELS[cell.type]||cell.type);
div.addEventListener('click',function(){onCellClick(r,c);});
board.appendChild(div);
})(r,c);
}
}
function cellCenter(r,c) {
var boardEl=document.getElementById('board'), cellEls=boardEl.querySelectorAll('.cell');
if(cellEls.length>0) {
var idx=r*state.size+c, rect=cellEls[idx]?cellEls[idx].getBoundingClientRect():null, boardRect=boardEl.getBoundingClientRect();
if(rect) return {x:rect.left-boardRect.left+rect.width/2,y:rect.top-boardRect.top+rect.height/2};
}
var pad=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--pad'))||8;
var gap=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--gap'))||4;
var cs=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cell'))||72;
return {x:pad+c*(cs+gap)+cs/2,y:pad+r*(cs+gap)+cs/2};
}
function boardOffset() {
var boardEl=document.getElementById('board'), wrapEl=document.getElementById('boardWrap');
if(!boardEl||!wrapEl) return {x:0,y:0};
var br=boardEl.getBoundingClientRect(), wr=wrapEl.getBoundingClientRect();
return {x:br.left-wr.left, y:br.top-wr.top};
}
function renderPipes() {
var svg=document.getElementById('pipes'), boardEl=document.getElementById('board');
var off=boardOffset(), w=boardEl.offsetWidth, h=boardEl.offsetHeight;
svg.setAttribute('width',w); svg.setAttribute('height',h);
svg.style.width=w+'px'; svg.style.height=h+'px';
svg.style.left=off.x+'px'; svg.style.top=off.y+'px';
svg.innerHTML='';
var flowingIdx=computeFlowingPaths();
var cellEl=document.querySelector('#board .cell'), nodeR=cellEl?cellEl.offsetWidth*0.40:26;
function trimPt(from,toward,r){ var dx=toward.x-from.x,dy=toward.y-from.y,len=Math.sqrt(dx*dx+dy*dy); return len>0?{x:from.x+dx/len*r,y:from.y+dy/len*r}:from; }
function makePath(pts){ var d='M '+pts[0].x.toFixed(1)+' '+pts[0].y.toFixed(1); for(var i=1;i<pts.length;i++) d+=' L '+pts[i].x.toFixed(1)+' '+pts[i].y.toFixed(1); return d; }
state.paths.forEach(function(path,idx) {
var pts=path.map(function(p){return cellCenter(p.r,p.c);});
if(pts.length>=2){ pts=pts.slice(); pts[0]=trimPt(pts[0],pts[1],nodeR); pts[pts.length-1]=trimPt(pts[pts.length-1],pts[pts.length-2],nodeR); }
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d',makePath(pts)); el.classList.add('clickable');
if(flowingIdx.has(idx)) el.classList.add('flowing');
el.addEventListener('click',function(e){e.stopPropagation();removePath(idx);});
svg.appendChild(el);
});
state.cbdcCaptured.forEach(function(key){
var parts=key.split(','), cr=+parts[0], cc=+parts[1];
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
if(state.board[r][c].type!=='cbdc') continue;
var from=cellCenter(r,c), to=cellCenter(cr,cc);
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d','M '+from.x.toFixed(1)+' '+from.y.toFixed(1)+' L '+to.x.toFixed(1)+' '+to.y.toFixed(1)); el.classList.add('cbdc-line'); svg.appendChild(el);
}
});
if(state.drawing&&state.drawing.cells.length>0) {
var pts=state.drawing.cells.map(function(p){return cellCenter(p.r,p.c);});
if(pts.length>1){
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d',makePath(pts)); el.setAttribute('stroke-dasharray','8 5'); el.setAttribute('opacity','0.75'); el.setAttribute('stroke-width','4'); svg.appendChild(el);
}
var start=pts[0], circle=document.createElementNS('http://www.w3.org/2000/svg','circle');
circle.setAttribute('cx',start.x.toFixed(1)); circle.setAttribute('cy',start.y.toFixed(1)); circle.setAttribute('r',nodeR);
circle.setAttribute('fill','rgba(255,165,0,.15)'); circle.setAttribute('stroke','#FFA500'); circle.setAttribute('stroke-width','2'); svg.appendChild(circle);
}
}
function computeFlowingPaths() {
var flowing=new Set();
state.paths.forEach(function(p,i){ var a=state.board[p[0].r][p[0].c], b=state.board[p[p.length-1].r][p[p.length-1].c]; var aPub=a.type==='pub'&&a.active&&!a.bloqueado, bPub=b.type==='pub'&&b.active&&!b.bloqueado; if(aPub&&!b.bloqueado) flowing.add(i); if(bPub&&!a.bloqueado) flowing.add(i); });
return flowing;
}
function renderParticles() {
var layer=document.getElementById('particleLayer'); if(!layer) return;
var boardEl=document.getElementById('board'), off=boardOffset();
layer.style.left=off.x+'px'; layer.style.top=off.y+'px';
layer.style.width=boardEl.offsetWidth+'px'; layer.style.height=boardEl.offsetHeight+'px';
layer.querySelectorAll('.flow-particle').forEach(function(el){el.remove();});
if(state.status!=='playing'&&state.status!=='won') return;
var flowingIdx=computeFlowingPaths(), DURATION=900, N=4;
flowingIdx.forEach(function(idx){
var path=state.paths[idx]; if(!path||path.length<2) return;
var pts=path.map(function(p){return cellCenter(p.r,p.c);});
var cellA=state.board[path[0].r][path[0].c], cellB=state.board[path[path.length-1].r][path[path.length-1].c];
if(cellB.type==='pub'&&cellA.type!=='pub') pts=pts.slice().reverse();
var d='M '+pts.map(function(p){return p.x.toFixed(1)+' '+p.y.toFixed(1);}).join(' L ');
for(var k=0;k<N;k++){
var particle=document.createElement('div'); particle.className='flow-particle';
particle.style.offsetPath='path("'+d+'")'; particle.style.animationDuration=DURATION+'ms'; particle.style.animationDelay=(-k*DURATION/N)+'ms';
layer.appendChild(particle);
}
});
}
function fireEvents() {
var layer=document.getElementById('particleLayer'); if(!layer||state.pendingEvents.length===0) return;
state.pendingEvents.forEach(function(evt){
var center=cellCenter(evt.r,evt.c);
if(evt.type==='pot'){ var txt=document.createElement('div'); txt.className='pot-float'; txt.textContent='\u221250'; txt.style.left=(center.x-16)+'px'; txt.style.top=(center.y-10)+'px'; layer.appendChild(txt); txt.addEventListener('animationend',function(){txt.remove();}); }
if(evt.type==='checkpoint'){ var ring=document.createElement('div'); ring.className='check-pulse'; ring.style.left=center.x+'px'; ring.style.top=center.y+'px'; ring.style.width='30px'; ring.style.height='30px'; layer.appendChild(ring); ring.addEventListener('animationend',function(){ring.remove();}); }
});
}
function renderHud() {
document.getElementById('hudScore').textContent=state.score;
var lvl=LEVELS[state.levelIndex], turnEl=document.getElementById('hudTurn');
turnEl.textContent=state.turn;
turnEl.style.color=state.turn>lvl.par?'#FF5252':state.turn===lvl.par?'#FFA500':'var(--pub)';
}
function showMessage(text,type) {
var el=document.getElementById('message'); el.textContent=text||'';
el.className=type==='alert'?'alert':type==='warning'?'warning':'';
}
function buildTurnStatus() {
var parts=[];
var checks=[];
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='checkpoint') checks.push(cell.active?'\u2713':cell.contadorActivo+'/3'); }
if(checks.length>0) parts.push('Checkpoint: '+checks.join(' '));
var habRisk1=0,habRisk2=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='inhabitant'&&!cell.cbdcControlled){ if(cell.turnosSinFlujo===1) habRisk1++; else if(cell.turnosSinFlujo>=2) habRisk2++; } }
if(habRisk2>0) parts.push('\u26a0 '+habRisk2+' inhabitant(s) in danger');
else if(habRisk1>0) parts.push(habRisk1+' inhabitant(s) without flow');
var acumP=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='accumulator'&&!cell.bloqueado&&cell.acumulado>=4) acumP++; }
if(acumP>0) parts.push('\u26a0 Accumulator almost full');
return parts.join(' \u00b7 ');
}
function onCellClick(r,c) {
if(state.status!=='playing') return;
var cell=state.board[r][c];
if(!state.drawing) {
if(cell.type==='empty'){ showMessage('Start drawing by clicking on a node.'); return; }
if(cell.type==='inhabitant'){ showMessage('Inhabitants can only receive connections.'); return; }
state.drawing={cells:[{r:r,c:c}]};
showMessage('Drawing from '+(LEGEND_LABELS[cell.type]||cell.type).toUpperCase()+'. Click adjacent cells and finish on another node.');
render(); return;
}
var cells=state.drawing.cells, last=cells[cells.length-1];
if(cells.length===1&&last.r===r&&last.c===c){ cancelDrawing(); return; }
if(!areAdjacent(last,{r:r,c:c})){ showMessage('Can only extend to adjacent cells.'); return; }
if(cells.some(function(p){return p.r===r&&p.c===c;})){ showMessage('That cell is already in the path.'); return; }
if(cell.type==='empty'&&cellInOtherPath(r,c)){ showMessage('That cell already has a pipe.'); return; }
cells.push({r:r,c:c});
if(cell.type!=='empty'){ finalizeDrawing(); return; }
render();
}
function finalizeDrawing() {
var cells=state.drawing.cells, first=state.board[cells[0].r][cells[0].c], last=state.board[cells[cells.length-1].r][cells[cells.length-1].c];
if(first.type==='empty'||last.type==='empty'||cells.length<2){ cancelDrawing(); return; }
var a=cells[0], b=cells[cells.length-1];
if(countConnections(a.r,a.c)>=4){ showMessage('That node already has 4 connections (maximum).'); cancelDrawing(); return; }
if(last.type==='inhabitant'&&countConnections(b.r,b.c)>=1){ showMessage('This inhabitant is already connected.'); cancelDrawing(); return; }
if(countConnections(b.r,b.c)>=4){ showMessage('That node already has 4 connections (maximum).'); cancelDrawing(); return; }
var dup=state.paths.some(function(p){ var x=p[0],y=p[p.length-1]; return(x.r===a.r&&x.c===a.c&&y.r===b.r&&y.c===b.c)||(x.r===b.r&&x.c===b.c&&y.r===a.r&&y.c===a.c); });
if(dup){ showMessage('A pipe between those nodes already exists.'); cancelDrawing(); return; }
state.paths.push(cells); state.drawing=null; state.score+=2; showMessage('Pipe created. (+2)'); render();
}
function cancelDrawing(){ state.drawing=null; showMessage(''); render(); }
function removePath(idx){ if(state.status!=='playing') return; state.paths.splice(idx,1); showMessage('Pipe removed.'); render(); }
function propagateFrom(r,c,amount,visited) {
var cell=state.board[r][c], k=r+','+c;
if(visited.has(k)) return; visited.add(k);
if(cell.type!=='pub') cell.flowReceivedThisTurn+=amount;
if(cell.type!=='validator'||!cell.active||cell.bloqueado) return;
getConnectedNodes(r,c).filter(function(n){return!visited.has(n.r+','+n.c);}).forEach(function(n){propagateFrom(n.r,n.c,amount,new Set(visited));});
}
function tick() {
if(state.status!=='playing') return;
if(state.paths.length===0) showMessage('No pipes: connect nodes before advancing. Inhabitants will lose flow.','warning');
state.turn++;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) state.board[r][c].flowReceivedThisTurn=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c];
if(cell.type!=='pub'||!cell.active||cell.bloqueado) continue;
var outs=getConnectedNodes(r,c); if(!outs.length) continue;
var pubOutput=cell.capacity!==null?cell.capacity:PUB_OUTPUT, share=pubOutput/outs.length;
outs.forEach(function(n){ var v=new Set(); v.add(r+','+c); propagateFrom(n.r,n.c,share,v); });
}
var caidoDesc=null;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c], f=cell.flowReceivedThisTurn;
switch(cell.type) {
case 'inhabitant':
if(cell.cbdcControlled) break;
if(f>=1){ cell.active=true; cell.turnosSinFlujo=0; }
else{ cell.active=false; cell.turnosSinFlujo++; if(cell.turnosSinFlujo>=3&&!caidoDesc) caidoDesc='Inhabitant at row '+(r+1)+', col '+(c+1)+' has gone 3 turns without ECOin.'; }
break;
case 'validator': cell.validadorCargado+=f; if(cell.validadorCargado>=3) cell.active=true; break;
case 'shop': cell.active=f>=2; break;
case 'accumulator':
cell.acumulado+=f;
if(cell.acumulado>5&&!cell.bloqueado){ cell.bloqueado=true; cell.active=false; state.potActivations++; state.pendingEvents.push({type:'pot',r:r,c:c}); }
break;
case 'checkpoint':
if(f>0){ cell.contadorActivo++; if(cell.contadorActivo>=3){ cell.active=true; if(cell.contadorActivo===3) state.pendingEvents.push({type:'checkpoint',r:r,c:c}); } }
break;
}
}
if(state.turn%2===0) {
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
if(state.board[r][c].type!=='cbdc') continue;
[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].filter(function(p){return p.r>=0&&p.c>=0&&p.r<state.size&&p.c<state.size;}).forEach(function(p){
var adjCell=state.board[p.r][p.c];
if(adjCell.type!=='inhabitant'||adjCell.cbdcControlled) return;
if(isConnectedToPUB(p.r,p.c)) return;
adjCell.cbdcControlled=true; adjCell.active=false; state.cbdcCaptured.add(p.r+','+p.c);
if(!caidoDesc) caidoDesc='Inhabitant at row '+(p.r+1)+', col '+(p.c+1)+' was captured by CBDC. Connect PUBs first.';
});
}
}
if(caidoDesc){ state.status='lost'; document.getElementById('lossText').textContent=caidoDesc+' The network cannot leave anyone disconnected. Review your connections.'; document.getElementById('overlayLoss').classList.remove('hidden'); render(); return; }
var hasCheckpoint=false, allActive=true;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='checkpoint'){ hasCheckpoint=true; if(!cell.active) allActive=false; } }
if(hasCheckpoint&&allActive) {
state.status='won';
var lvl=LEVELS[state.levelIndex], base=100, shopsActive=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) if(state.board[r][c].type==='shop'&&state.board[r][c].active) shopsActive++;
var bonusShops=shopsActive*10, bonusTurns=Math.max(0,lvl.par-state.turn)*5, penalPot=state.potActivations*50, levelScore=base+bonusShops+bonusTurns-penalPot;
state.score+=levelScore;
var winMsg=lvl.winText;
if(state.potActivations>0&&lvl.winTextPoT) winMsg=lvl.winTextPoT;
if(state.cbdcCaptured.size>0&&lvl.winTextCBDC) winMsg=lvl.winTextCBDC;
document.getElementById('winText').textContent=winMsg;
document.getElementById('winLevelPoints').textContent=levelScore;
document.getElementById('winTotalPoints').textContent=state.score;
document.getElementById('scoreInput').value=state.score;
var bd=document.getElementById('winBreakdown'); bd.innerHTML='';
function bdLine(cls,txt){ var s=document.createElement('span'); s.className=cls; s.textContent=txt; bd.appendChild(s); }
bdLine('positive','Base: +'+base);
if(bonusShops>0) bdLine('positive','Active shops ('+shopsActive+'): +'+bonusShops);
if(bonusTurns>0) bdLine('positive','Turns under par ('+(lvl.par-state.turn)+'): +'+bonusTurns);
if(penalPot>0) bdLine('negative','PoT triggered (\u00d7'+state.potActivations+'): \u221250');
var isLastLevel=state.levelIndex+1>=LEVELS.length;
var beatsBest=state.score>ecoflowBest;
updateBest(state.score);
document.getElementById('scoreSubmit').style.display=(isLastLevel&&beatsBest&&state.score>0)?'block':'none';
var nextBtn=document.getElementById('btnNextLevel'); nextBtn.style.display=(state.levelIndex+1<LEVELS.length)?'inline-block':'none';
render(); fireEvents(); state.pendingEvents=[];
setTimeout(function(){document.getElementById('overlayWin').classList.remove('hidden');},1500);
return;
}
var statusMsg=buildTurnStatus();
if(statusMsg){ showMessage(statusMsg,statusMsg.includes('danger')||statusMsg.includes('full')?'alert':null); }
else showMessage('Turn '+state.turn+' \u2014 no issues.');
render(); fireEvents(); state.pendingEvents=[];
}
function restartLevel() {
document.getElementById('overlayWin').classList.add('hidden'); document.getElementById('overlayLoss').classList.add('hidden');
if(state.levelIndex===0) state.score=0;
loadLevel(state.levelIndex);
}
function goNextLevel() {
if(state.levelIndex+1>=LEVELS.length) return;
document.getElementById('overlayWin').classList.add('hidden'); document.getElementById('overlayLoss').classList.add('hidden');
loadLevel(state.levelIndex+1); state.status='intro'; document.getElementById('overlayIntro').classList.remove('hidden');
}
function closeIntro(){ document.getElementById('overlayIntro').classList.add('hidden'); state.status='playing'; }
function closeInfo(){ document.getElementById('overlayInfo').classList.add('hidden'); }
function mk(tag,cls,txt){ var el=document.createElement(tag); if(cls) el.className=cls; if(txt!==undefined) el.textContent=txt; return el; }
function mka(tag,cls,attrs){ var el=document.createElement(tag); if(cls) el.className=cls; if(attrs) Object.keys(attrs).forEach(function(k){el.setAttribute(k,attrs[k]);}); return el; }
function openInfo() {
var lvl=LEVELS[state.levelIndex], body=document.getElementById('infoBody'); body.innerHTML='';
var habOK=0,habRiesgo=0,habCount=0,habCBDC=0,checkpoints=[],valActivos=0,valCargando=[],acumFilling=[],acumBloqueados=0,shopsActive=0,shopsInactive=0,pubsActivos=0;
var fmt=function(n){return(Math.round(n*10)/10).toString();};
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){
var cell=state.board[r][c];
switch(cell.type){
case 'pub': if(cell.active&&!cell.bloqueado) pubsActivos++; break;
case 'inhabitant': habCount++; if(cell.cbdcControlled) habCBDC++; else if(cell.turnosSinFlujo===0) habOK++; else habRiesgo++; break;
case 'checkpoint': checkpoints.push(cell.contadorActivo); break;
case 'validator': if(cell.active) valActivos++; else valCargando.push(cell.validadorCargado); break;
case 'accumulator': if(cell.bloqueado) acumBloqueados++; else acumFilling.push(cell.acumulado); break;
case 'shop': if(cell.active) shopsActive++; else shopsInactive++; break;
}
}
function h3(t){ var el=mk('h3'); el.textContent=t; body.appendChild(el); }
function p(t){ var el=mk('p'); el.textContent=t; body.appendChild(el); }
function ul(items){ var ul=mk('ul'); items.forEach(function(t){ var li=mk('li'); li.textContent=t; ul.appendChild(li); }); body.appendChild(ul); }
h3('Level objective');
p(lvl.introText);
p('Goal: activate all checkpoints (3 turns with flow) without any inhabitant going 3 turns without ECOin.');
h3('Current status');
var stat=mk('div','stat-line');
stat.textContent='Turn '+state.turn+' \u00b7 Par '+lvl.par+' \u00b7 Score '+state.score+' \u00b7 Active PUBs '+pubsActivos+' \u00b7 PoT activations '+state.potActivations;
body.appendChild(stat);
var statusItems=[];
if(habCount>0){ var t='Inhabitants: '+habOK+' with flow'; if(habRiesgo>0) t+=', '+habRiesgo+' without flow (fall at 3 turns)'; if(habCBDC>0) t+=', '+habCBDC+' captured by CBDC'; statusItems.push(t); }
if(checkpoints.length>0) statusItems.push('Checkpoints: '+checkpoints.map(function(p){return p>=3?'active':p+'/3';}).join(' / '));
if(valActivos>0||valCargando.length>0){ var t=(valActivos>0?valActivos+' active':'')+(valCargando.length>0?(valActivos>0?', ':'')+'loading: '+valCargando.map(function(v){return fmt(v)+'/3';}).join(', '):''); statusItems.push('Validators: '+t); }
if(acumFilling.length>0||acumBloqueados>0){ var t=(acumFilling.length>0?'filling: '+acumFilling.map(function(a){return fmt(a)+'/5';}).join(', '):'')+(acumBloqueados>0?(acumFilling.length>0?', ':'')+acumBloqueados+' blocked (PoT)':''); statusItems.push('Accumulators: '+t+' (-50 pts when exceeding 5)'); }
if(shopsActive>0||shopsInactive>0) statusItems.push('Shops: '+shopsActive+' active'+(shopsInactive>0?', '+shopsInactive+' inactive':'')+' (+10 pts each when winning)');
ul(statusItems);
h3('How to play');
ul(['Click a node to start a pipe, click adjacent cells to extend, finish on another node.','Click an existing pipe to remove it.','Press ENTER (or ADVANCE TURN button) to process one flow cycle.','A node can have a maximum of 4 connections. Inhabitants accept only 1.', '+2 score per pipe placed.']);
h3('ECOin rules');
ul(['Each active PUB generates 4 ECOin units/turn (2 if reduced capacity), split equally among connections.','An inhabitant needs at least 1 unit/turn. Three turns without flow = defeat.','A validator blocks flow until it accumulates 3 units. Once active it forwards flow to other connections.','An accumulator absorbs without redistributing. Exceeding 5 units triggers PoT: -50 pts and node blocked.','A shop needs at least 2 units/turn to operate. Active shops when winning: +10 pts each.','A checkpoint activates after receiving flow for 3 consecutive turns. Activating all checkpoints wins the level.','CBDC captures adjacent inhabitants not connected to a PUB every 2 turns = defeat.']);
h3('Scoring');
ul(['+2 per pipe placed','+100 per level completed','+10 per active shop at completion','+5 per turn saved vs. par (only if completed under par)','-50 per accumulator blocked by PoT','Score accumulates across all levels']);
h3('Glossary');
ul(['PUB - Public Server: Oasis network node distributing ECOin to connected inhabitants. Not centralized.','ECOin: Community currency. Its value is anchored to human time. Cannot be issued arbitrarily.','UBI - Universal Basic Income: Flow reaching every inhabitant regardless of activity.','PoT - Proof of Transaction: Discourages accumulating ECOin without using it.','Validator: Confirms transaction legitimacy. Needs minimum flow before it can operate.','Checkpoint: Milestone on the ECOin blockchain. When reached, history is consolidated and immutable.','CBDC - Central Bank Digital Currency: Digital currency by a central bank. Enables tracking every transaction = total financial surveillance.']);
document.getElementById('overlayInfo').classList.remove('hidden');
}
function undoAction() {
if(state.status!=='playing') return;
if(state.drawing){ cancelDrawing(); }
else if(state.paths.length>0){ state.paths.pop(); showMessage('Pipe undone.'); render(); }
else showMessage('Nothing to undo.');
}
function deletePipeAtCursor() {
if(state.status!=='playing') return;
if(state.drawing){ cancelDrawing(); return; }
var r=state.cursor.r, c=state.cursor.c, before=state.paths.length;
state.paths=state.paths.filter(function(path){return!path.some(function(p){return p.r===r&&p.c===c;});});
if(state.paths.length<before){ showMessage('Pipe removed.'); render(); }
}
document.addEventListener('keydown',function(e){
if(e.code==='Enter'){
e.preventDefault();
if(!document.getElementById('overlayInfo').classList.contains('hidden')) closeInfo();
else if(!document.getElementById('overlayIntro').classList.contains('hidden')) closeIntro();
else if(!document.getElementById('overlayWin').classList.contains('hidden')){ var nb=document.getElementById('btnNextLevel'); if(nb&&!nb.disabled) goNextLevel(); else restartLevel(); }
else if(!document.getElementById('overlayLoss').classList.contains('hidden')) restartLevel();
else if(state.status==='playing') tick();
}
if(e.code==='Backspace'&&state.status==='playing'){ e.preventDefault(); undoAction(); }
if(e.code==='Space'&&state.status==='playing'){ e.preventDefault(); deletePipeAtCursor(); }
if(e.code==='Space'&&(state.status==='lost'||state.status==='won')){ e.preventDefault(); restartLevel(); }
if((e.ctrlKey||e.metaKey)&&e.code==='KeyZ'){ e.preventDefault(); undoAction(); }
var DIRS={ArrowUp:[-1,0],ArrowDown:[1,0],ArrowLeft:[0,-1],ArrowRight:[0,1]};
if(DIRS[e.code]!==undefined&&state.status==='playing'){
e.preventDefault();
var dr=DIRS[e.code][0], dc=DIRS[e.code][1], nr=state.cursor.r+dr, nc=state.cursor.c+dc;
if(nr>=0&&nc>=0&&nr<state.size&&nc<state.size){
if(e.shiftKey){ var curCell=state.board[state.cursor.r][state.cursor.c]; if(!state.drawing&&curCell.type!=='empty') onCellClick(state.cursor.r,state.cursor.c); state.cursor={r:nr,c:nc}; if(state.drawing) onCellClick(nr,nc); }
else state.cursor={r:nr,c:nc};
render();
}
}
if((e.key==='i'||e.key==='I'||e.key==='?')&&!e.ctrlKey&&!e.metaKey){
if(document.getElementById('overlayInfo').classList.contains('hidden')&&document.getElementById('overlayIntro').classList.contains('hidden')&&document.getElementById('overlayWin').classList.contains('hidden')&&document.getElementById('overlayLoss').classList.contains('hidden')){ e.preventDefault(); openInfo(); }
}
if(e.code==='Escape'){ if(!document.getElementById('overlayInfo').classList.contains('hidden')) closeInfo(); else if(state.drawing) cancelDrawing(); }
});
function resizeBoard() {
var sz = state.size || LEVELS[state.levelIndex].size;
var gap = 5, pad = 10;
var topbar = document.getElementById('topbar').offsetHeight || 40;
var ui = document.getElementById('ui').offsetHeight || 32;
var msg = document.getElementById('message').offsetHeight || 18;
var legend = document.getElementById('legend').offsetHeight || 0;
var controls = document.getElementById('controls').offsetHeight || 40;
var usedH = topbar + ui + msg + legend + controls + 12;
var avW = window.innerWidth;
var avH = Math.max(80, window.innerHeight - usedH);
var cell = Math.floor(Math.min(
(avW - pad*2 - gap*(sz-1)) / sz,
(avH - pad*2 - gap*(sz-1)) / sz
));
cell = Math.max(32, cell);
document.documentElement.style.setProperty('--cell', cell+'px');
}
window.addEventListener('resize', function() { resizeBoard(); renderPipes(); renderParticles(); });
loadLevel(0);
state.status='intro';
</script>
</body>
</html>

View file

@ -0,0 +1,113 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="4" fill="#111" stroke="#222" stroke-width="1"/>
<!-- Grid cells -->
<rect x="22" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="22" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="22" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<!-- Pipes (orange paths between nodes) -->
<path d="M48 74 L48 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M48 134 L48 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M74 48 L82 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M134 48 L142 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M108 74 L108 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M108 134 L108 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M194 108 L202 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M254 108 L262 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M228 74 L228 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M288 74 L288 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M288 134 L288 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M314 168 L322 168" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M168 134 L168 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<!-- PUB node (orange square) row1 col1 -->
<rect x="34" y="34" width="36" height="36" rx="2" fill="#FFA500" opacity="0.92"/>
<line x1="52" y1="24" x2="52" y2="34" stroke="#FFA500" stroke-width="2"/>
<circle cx="52" cy="21" r="3" fill="#FFA500"/>
<text x="52" y="57" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
<!-- habitante node (green circle) row1 col2 -->
<circle cx="108" cy="48" r="18" fill="#8BC34A" opacity="0.9"/>
<text x="108" y="52" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- validador (diamond) row1 col3 -->
<polygon points="168,30 186,48 168,66 150,48" fill="rgba(200,218,230,0.55)" stroke="#ccc" stroke-width="1"/>
<text x="168" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">VAL</text>
<!-- tienda (hexagon) row1 col4 -->
<polygon points="228,30 241,38 241,58 228,66 215,58 215,38" fill="#4CAF50" opacity="0.9"/>
<text x="228" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- acumulador (triangle) row1 col5 -->
<polygon points="288,30 310,66 266,66" fill="rgba(255,82,82,0.25)" stroke="#FF5252" stroke-width="1"/>
<text x="288" y="58" font-family="monospace" font-size="7" fill="#eee" text-anchor="middle" font-weight="bold">ACU</text>
<!-- CBDC (grey square) row1 col6 -->
<rect x="334" y="34" width="36" height="36" rx="2" fill="#607D8B" opacity="0.9"/>
<text x="352" y="57" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
<!-- row2 nodes -->
<!-- PUB node row2 col1 -->
<rect x="34" y="94" width="36" height="36" rx="2" fill="#FFA500" opacity="0.7"/>
<text x="52" y="117" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
<!-- hab row2 col2 -->
<circle cx="108" cy="108" r="18" fill="#8BC34A" opacity="0.7"/>
<text x="108" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- hab row2 col4 -->
<circle cx="228" cy="108" r="18" fill="#8BC34A" opacity="0.85"/>
<text x="228" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- tienda row2 col5 -->
<polygon points="288,90 301,98 301,118 288,126 275,118 275,98" fill="#4CAF50" opacity="0.75"/>
<text x="288" y="112" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- checkpoint row3 col2 -->
<rect x="90" y="154" width="36" height="36" rx="2" fill="none" stroke="#FFFF55" stroke-width="3"/>
<text x="108" y="177" font-family="monospace" font-size="7" fill="#FFFF55" text-anchor="middle" font-weight="bold">CHK</text>
<!-- hab row3 col3 -->
<circle cx="168" cy="168" r="18" fill="#8BC34A" opacity="0.8"/>
<text x="168" y="172" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- CBDC row3 col4 -->
<rect x="214" y="154" width="36" height="36" rx="2" fill="#607D8B" opacity="0.8"/>
<text x="232" y="177" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
<!-- tienda row3 col6 -->
<polygon points="348,154 361,162 361,182 348,190 335,182 335,162" fill="#4CAF50" opacity="0.8"/>
<text x="348" y="176" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- Flow particles on some pipes -->
<circle cx="48" cy="78" r="3" fill="#FFA500" opacity="0.95">
<animate attributeName="cy" values="74;82" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="91" cy="48" r="3" fill="#FFA500" opacity="0.95">
<animate attributeName="cx" values="74;82" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
</circle>
<!-- Title -->
<text x="200" y="212" font-family="'Courier New', monospace" font-size="14" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">ECOINFLOW</text>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Labyrinth</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; border: 1px solid #333; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
#dpad-row { display: flex; gap: 4px; }
#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
#dpad button:active { background: #444; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
#btn-giveup { margin-left: auto; background: #3a1a1a; border: 1px solid #f44; color: #f44; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 13px; }
#btn-giveup:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">LABYRINTH</span>
<button id="btn-giveup" disabled>Give Up</button>
</div>
<div id="ui">
<span>LEVEL: <b id="levelEl">1</b></span>
<span>MOVES: <b id="movesEl">150</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press SPACE or tap to start</div>
<div id="dpad">
<div id="dpad-row"><button id="btn-up">&#8593;</button></div>
<div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="labyrinth">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const BASE_SIZE = 10;
let CELL = 36;
let level = 1, moves = 0, maxMoves = 150, score = 0, total = 0, gameState = 'idle';
let cols, rows, grid, px, py;
let best = parseInt(localStorage.getItem('labyrinth_best') || '-1');
function seededRand(seed) {
let s = seed;
return () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
}
function generateMaze(c, r, seed) {
const rng = seededRand(seed);
const cells = Array.from({length: r}, () => Array.from({length: c}, () => ({ n: true, s: true, e: true, w: true, visited: false })));
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function carve(x, y) {
cells[y][x].visited = true;
const dirs = shuffle([['n', 0, -1], ['s', 0, 1], ['e', 1, 0], ['w', -1, 0]]);
for (const [d, dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && nx < c && ny >= 0 && ny < r && !cells[ny][nx].visited) {
const opp = { n: 's', s: 'n', e: 'w', w: 'e' };
cells[y][x][d] = false;
cells[ny][nx][opp[d]] = false;
carve(nx, ny);
}
}
}
carve(0, 0);
return cells;
}
function getAvailableSize() {
const usedH = (document.getElementById('topbar').offsetHeight || 40)
+ (document.getElementById('ui').offsetHeight || 30)
+ (document.getElementById('msg').offsetHeight || 22)
+ (document.getElementById('dpad').offsetHeight || 100)
+ (document.getElementById('controls').offsetHeight || 16)
+ 24;
return { w: window.innerWidth - 8, h: Math.max(200, window.innerHeight - usedH) };
}
function startLevel() {
cols = BASE_SIZE + (level - 1) * 2;
rows = Math.floor(cols * 0.7);
maxMoves = cols * rows * 2;
moves = 0;
grid = generateMaze(cols, rows, level * 7919 + 42);
px = Math.floor(cols / 2); py = Math.floor(rows / 2);
const avail = getAvailableSize();
CELL = Math.max(16, Math.floor(Math.min(avail.w / cols, avail.h / rows)));
canvas.width = cols * CELL;
canvas.height = rows * CELL;
document.getElementById('levelEl').textContent = level;
document.getElementById('movesEl').textContent = maxMoves;
document.getElementById('msg').textContent = `Level ${level} — Reach the exit!`;
gameState = 'play';
document.getElementById('btn-giveup').disabled = false;
}
function tryMove(dx, dy) {
if (gameState !== 'play') return;
const cell = grid[py][px];
const dirs = { '0,-1': 'n', '0,1': 's', '1,0': 'e', '-1,0': 'w' };
const key = `${dx},${dy}`;
const wall = dirs[key];
if (!wall || cell[wall]) return;
px += dx; py += dy;
moves++;
document.getElementById('movesEl').textContent = maxMoves - moves;
if (px === cols - 1 && py === rows - 1) {
const remaining = maxMoves - moves;
const levelScore = remaining + level * 50;
total += levelScore;
document.getElementById('scoreEl').textContent = total;
document.getElementById('msg').textContent = `EXIT! +${levelScore} pts. Next level...`;
gameState = 'transit';
setTimeout(() => { level++; startLevel(); }, 1000);
} else if (moves >= maxMoves) {
endGame('moves');
}
}
function endGame(reason) {
gameState = 'over';
if (best < 0 || total > best) {
best = total;
localStorage.setItem('labyrinth_best', best);
document.getElementById('bestEl').textContent = best;
}
const head = reason === 'giveup' ? `Gave up at level ${level}.` : 'Out of moves!';
document.getElementById('msg').textContent = `${head} Score: ${total}. SPACE = new game`;
document.getElementById('scoreInput').value = total;
document.getElementById('scoreSubmit').style.display = 'block';
document.getElementById('btn-giveup').disabled = true;
}
function newGame() {
level = 1; total = 0; score = 0;
document.getElementById('scoreEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
startLevel();
}
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); newGame(); return; }
const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') newGame(); else tryMove(...map[e.code]); }
});
canvas.addEventListener('click', () => { if (gameState === 'idle') newGame(); });
window.addEventListener('resize', () => { if (gameState !== 'idle') startLevel(); });
const dpadStart = () => { if (gameState === 'idle') newGame(); };
document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
document.getElementById('btn-giveup').addEventListener('click', () => { if (gameState === 'play') endGame('giveup'); });
if (best >= 0) document.getElementById('bestEl').textContent = best;
const VIEW = 6;
function draw() {
const W = canvas.width, H = canvas.height;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
if (gameState === 'idle') { requestAnimationFrame(draw); return; }
const viewCols = Math.min(cols, Math.floor(W / CELL));
const viewRows = Math.min(rows, Math.floor(H / CELL));
const offX = Math.max(0, Math.min(px - Math.floor(viewCols / 2), cols - viewCols));
const offY = Math.max(0, Math.min(py - Math.floor(viewRows / 2), rows - viewRows));
for (let r = offY; r < Math.min(offY + viewRows + 1, rows); r++) {
for (let c = offX; c < Math.min(offX + viewCols + 1, cols); c++) {
const x = (c - offX) * CELL, y = (r - offY) * CELL;
const cell = grid[r][c];
ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 2;
if (cell.n) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + CELL, y); ctx.stroke(); }
if (cell.s) { ctx.beginPath(); ctx.moveTo(x, y + CELL); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
if (cell.w) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + CELL); ctx.stroke(); }
if (cell.e) { ctx.beginPath(); ctx.moveTo(x + CELL, y); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
}
}
const ex = (cols - 1 - offX) * CELL + CELL / 2, ey = (rows - 1 - offY) * CELL + CELL / 2;
if (cols - 1 >= offX && cols - 1 < offX + viewCols && rows - 1 >= offY && rows - 1 < offY + viewRows) {
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.arc(ex, ey, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('EXIT', ex, ey);
}
const ppx = (px - offX) * CELL + CELL / 2, ppy = (py - offY) * CELL + CELL / 2;
ctx.fillStyle = '#00ff88';
ctx.beginPath(); ctx.arc(ppx, ppy, 7, 0, Math.PI * 2); ctx.fill();
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#111"/>
<g stroke="#FFA500" stroke-width="2" fill="none">
<rect x="5" y="5" width="110" height="70"/>
<line x1="25" y1="5" x2="25" y2="45"/>
<line x1="45" y1="35" x2="45" y2="75"/>
<line x1="65" y1="5" x2="65" y2="45"/>
<line x1="85" y1="35" x2="85" y2="75"/>
<line x1="5" y1="25" x2="45" y2="25"/>
<line x1="65" y1="25" x2="105" y2="25"/>
<line x1="25" y1="55" x2="65" y2="55"/>
<line x1="85" y1="55" x2="105" y2="55"/>
</g>
<circle cx="15" cy="15" r="4" fill="#00ff88"/>
<circle cx="105" cy="65" r="4" fill="#ff4444"/>
</svg>

After

Width:  |  Height:  |  Size: 672 B

View file

@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Neon Infiltrator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-tap-highlight-color: transparent; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 16px; padding: 6px 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; width: 100%; }
canvas { display: block; width: 100%; height: auto; max-width: 700px; border: 1px solid #333; image-rendering: crisp-edges; }
#msg { font-size: 13px; color: #FFA500; margin: 3px; text-align: center; min-height: 18px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 4px; }
.dpad-row { display: flex; gap: 4px; }
#dpad button { width: 50px; height: 50px; background: #1a0d00; border: 1px solid #FF6600; color: #FF6600; font-size: 20px; cursor: pointer; border-radius: 4px; }
#dpad button:active { background: #3a1a00; }
#controls { color: #666; font-size: 12px; text-align: center; margin-bottom: 3px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
@media (min-width: 768px) { #dpad, #controls { display: none; } }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">NEON INFILTRATOR</span>
</div>
<div id="ui">
<span>DATA: <b id="dataEl">0/0</b></span>
<span>LEVEL: <b id="levelEl">1</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
<span>ENEMIES: <b id="enemyEl">0</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press any arrow key or tap to start</div>
<div id="dpad">
<div class="dpad-row"><button id="btn-up">&#8593;</button></div>
<div class="dpad-row">
<button id="btn-left">&#8592;</button>
<button id="btn-down">&#8595;</button>
<button id="btn-right">&#8594;</button>
</div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="neoninfiltrator">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const CELL = 28;
let MAP_W = 0, MAP_H = 0;
let walls = [], enemies = [], dataItems = [], exitPos = { x: -1, y: -1 };
let player = { x: 1, y: 1 };
let level = 1, dataCollected = 0, totalData = 0;
let score = 0;
let best = parseInt(localStorage.getItem('ni_best') || '-1');
let gameState = 'idle';
let lastPlayerMove = 0, lastEnemyStep = 0;
const MOVE_DELAY = 140, ENEMY_INTERVAL = 500;
const keys = {};
function setMsg(txt) { document.getElementById('msg').textContent = txt; }
function updateUI() {
document.getElementById('dataEl').textContent = dataCollected + '/' + totalData;
document.getElementById('levelEl').textContent = level;
document.getElementById('scoreEl').textContent = score;
document.getElementById('bestEl').textContent = best < 0 ? '-' : best;
document.getElementById('enemyEl').textContent = enemies.length;
}
function resizeCanvas() {
let bw = 18, bh = 14;
if (level >= 5) { bw = 22; bh = 18; }
else if (level >= 3) { bw = 20; bh = 16; }
MAP_W = bw; MAP_H = bh;
canvas.width = MAP_W * CELL;
canvas.height = MAP_H * CELL;
}
function generateLevel() {
resizeCanvas();
walls = Array(MAP_H).fill(null).map(() => Array(MAP_W).fill(false));
for (let i = 0; i < MAP_W; i++) { walls[0][i] = true; walls[MAP_H - 1][i] = true; }
for (let i = 0; i < MAP_H; i++) { walls[i][0] = true; walls[i][MAP_W - 1] = true; }
for (let i = 0; i < Math.floor(MAP_W * MAP_H / 7); i++) {
const x = 2 + Math.floor(Math.random() * (MAP_W - 4));
const y = 2 + Math.floor(Math.random() * (MAP_H - 4));
if (!walls[y][x] && !(Math.abs(x - player.x) < 2 && Math.abs(y - player.y) < 2)) {
if (Math.random() < 0.55) walls[y][x] = true;
}
}
for (let i = 0; i < MAP_W * 2; i++) {
const x = 1 + Math.floor(Math.random() * (MAP_W - 2));
const y = 1 + Math.floor(Math.random() * (MAP_H - 2));
if (walls[y][x] && x > 1 && x < MAP_W - 2 && y > 1 && y < MAP_H - 2) {
if (Math.random() < 0.5) walls[y][x] = false;
}
}
player.x = 1; player.y = 1;
if (walls[player.y][player.x]) {
outer: for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
if (!walls[y][x]) { player.x = x; player.y = y; break outer; }
}
}
}
exitPos = { x: MAP_W - 2, y: MAP_H - 2 };
for (let t = 0; t < 200; t++) {
const ex = MAP_W - 2 - Math.floor(Math.random() * 3);
const ey = MAP_H - 2 - Math.floor(Math.random() * 3);
if (ex >= 1 && ey >= 1 && !walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 6) {
exitPos = { x: ex, y: ey }; break;
}
}
totalData = Math.min(12, 3 + Math.floor(level / 1.5));
dataItems = [];
for (let i = 0; i < totalData; i++) {
let placed = false;
for (let t = 0; t < 100; t++) {
const dx = 1 + Math.floor(Math.random() * (MAP_W - 2));
const dy = 1 + Math.floor(Math.random() * (MAP_H - 2));
if (!walls[dy][dx] && !(dx === player.x && dy === player.y) && !(dx === exitPos.x && dy === exitPos.y) && !dataItems.some(d => d.x === dx && d.y === dy)) {
dataItems.push({ x: dx, y: dy, collected: false }); placed = true; break;
}
}
if (!placed) {
outer2: for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
if (!walls[y][x] && !(x === player.x && y === player.y) && !(x === exitPos.x && y === exitPos.y) && !dataItems.some(d => d.x === x && d.y === y)) {
dataItems.push({ x, y, collected: false }); break outer2;
}
}
}
}
}
dataCollected = 0;
const enemyCount = Math.min(6, 1 + Math.floor(level / 2));
enemies = [];
for (let i = 0; i < enemyCount; i++) {
let placed = false;
for (let t = 0; t < 150; t++) {
const ex = 2 + Math.floor(Math.random() * (MAP_W - 4));
const ey = 2 + Math.floor(Math.random() * (MAP_H - 4));
if (!walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 4 && !(ex === exitPos.x && ey === exitPos.y) && !dataItems.some(d => d.x === ex && d.y === ey)) {
enemies.push({ x: ex, y: ey }); placed = true; break;
}
}
if (!placed) {
outer3: for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
if (!walls[y][x] && (Math.abs(x - player.x) + Math.abs(y - player.y)) > 3 && !enemies.some(e => e.x === x && e.y === y)) {
enemies.push({ x, y }); break outer3;
}
}
}
}
}
updateUI();
setMsg(`Level ${level} — Collect ${totalData} data items and reach the exit`);
}
function checkEnemyCollision() {
for (const e of enemies) {
if (e.x === player.x && e.y === player.y) { endGame(); return true; }
}
return false;
}
function tryMove(dx, dy) {
if (gameState !== 'play') return;
const nx = player.x + dx, ny = player.y + dy;
if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H || walls[ny][nx]) return;
player.x = nx; player.y = ny;
for (const d of dataItems) {
if (!d.collected && d.x === player.x && d.y === player.y) {
d.collected = true; dataCollected++; score += 100;
updateUI(); setMsg(`Data collected! (${dataCollected}/${totalData})`); break;
}
}
if (dataCollected === totalData && exitPos.x === player.x && exitPos.y === player.y) {
const bonus = level * 50;
score += bonus;
updateUI();
setMsg(`Level ${level} complete! +${bonus} bonus. Advancing...`);
gameState = 'transit';
setTimeout(() => { level++; generateLevel(); gameState = 'play'; lastPlayerMove = performance.now(); lastEnemyStep = performance.now(); }, 1400);
return;
}
checkEnemyCollision();
}
function moveEnemies() {
if (gameState !== 'play') return;
for (const e of enemies) {
let dx = 0, dy = 0;
if (Math.random() < 0.6) {
if (Math.abs(e.x - player.x) > Math.abs(e.y - player.y)) dx = e.x > player.x ? -1 : 1;
else dy = e.y > player.y ? -1 : 1;
} else {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
[dx, dy] = dirs[Math.floor(Math.random() * 4)];
}
const nx = e.x + dx, ny = e.y + dy;
if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && !walls[ny][nx] && !(nx === exitPos.x && ny === exitPos.y)) {
e.x = nx; e.y = ny;
} else {
for (const [sx, sy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const bx = e.x + sx, by = e.y + sy;
if (bx >= 0 && bx < MAP_W && by >= 0 && by < MAP_H && !walls[by][bx] && !(bx === exitPos.x && by === exitPos.y)) {
e.x = bx; e.y = by; break;
}
}
}
}
checkEnemyCollision();
updateUI();
}
function endGame() {
gameState = 'over';
if (best < 0 || score > best) { best = score; localStorage.setItem('ni_best', best); }
updateUI();
setMsg(`DETECTED! Score: ${score}. Press SPACE for new game.`);
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function newGame() {
score = 0; level = 1;
document.getElementById('scoreSubmit').style.display = 'none';
generateLevel();
gameState = 'play';
lastPlayerMove = performance.now();
lastEnemyStep = performance.now();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#1a0d00'; ctx.lineWidth = 0.6;
for (let i = 0; i <= MAP_W; i++) {
ctx.beginPath(); ctx.moveTo(i * CELL, 0); ctx.lineTo(i * CELL, canvas.height); ctx.stroke();
}
for (let i = 0; i <= MAP_H; i++) {
ctx.beginPath(); ctx.moveTo(0, i * CELL); ctx.lineTo(canvas.width, i * CELL); ctx.stroke();
}
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
if (walls[y][x]) {
ctx.fillStyle = '#1a0a05';
ctx.fillRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
ctx.strokeStyle = '#FF6600'; ctx.lineWidth = 1;
ctx.strokeRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
}
}
}
for (const d of dataItems) {
if (!d.collected) {
ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF6600';
ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.25, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#FF884D';
ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.12, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
}
ctx.fillStyle = '#FFA500'; ctx.globalAlpha = 0.7;
ctx.fillRect(exitPos.x * CELL + 4, exitPos.y * CELL + 4, CELL - 8, CELL - 8);
ctx.globalAlpha = 1; ctx.fillStyle = '#FFA500';
ctx.font = `${CELL - 6}px monospace`;
ctx.fillText('\u25C8', exitPos.x * CELL + CELL * 0.28, exitPos.y * CELL + CELL * 0.78);
if (dataCollected === totalData && gameState === 'play') {
ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 4; ctx.shadowColor = '#FF6600';
ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center';
ctx.fillText('\u2192 EXIT AVAILABLE \u2190', canvas.width / 2, 20);
ctx.shadowBlur = 0; ctx.textAlign = 'left';
}
for (const e of enemies) {
ctx.fillStyle = '#FF4400'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF2200';
ctx.fillRect(e.x * CELL + 4, e.y * CELL + 4, CELL - 8, CELL - 8);
ctx.shadowBlur = 0;
}
ctx.fillStyle = '#FF6600'; ctx.shadowBlur = 8; ctx.shadowColor = '#FF6600';
ctx.fillRect(player.x * CELL + 6, player.y * CELL + 6, CELL - 12, CELL - 12);
ctx.fillStyle = '#FFB347';
ctx.fillRect(player.x * CELL + 10, player.y * CELL + 10, 5, 5);
ctx.shadowBlur = 0;
}
function gameLoop(now) {
if (gameState === 'play') {
let dx = 0, dy = 0;
if (keys.ArrowUp || keys.w) dy = -1;
if (keys.ArrowDown || keys.s) dy = 1;
if (keys.ArrowLeft || keys.a) dx = -1;
if (keys.ArrowRight || keys.d) dx = 1;
if ((dx !== 0 || dy !== 0) && now - lastPlayerMove >= MOVE_DELAY) {
tryMove(dx, dy); lastPlayerMove = now;
}
if (now - lastEnemyStep >= ENEMY_INTERVAL) { moveEnemies(); lastEnemyStep = now; }
}
draw();
requestAnimationFrame(gameLoop);
}
document.addEventListener('keydown', e => {
const key = e.key;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 's', 'a', 'd', ' '].includes(key)) e.preventDefault();
if (key === ' ') { if (gameState === 'idle' || gameState === 'over') { newGame(); } return; }
keys[key] = true;
if (gameState === 'idle') newGame();
});
document.addEventListener('keyup', e => { keys[e.key] = false; });
const dpadStart = () => { if (gameState === 'idle' || gameState === 'over') newGame(); };
document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
generateLevel();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>

View file

@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#050505">
<rect width="400" height="220" fill="#050505"/>
<line x1="28" y1="10" x2="28" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="56" y1="10" x2="56" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="84" y1="10" x2="84" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="112" y1="10" x2="112" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="140" y1="10" x2="140" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="168" y1="10" x2="168" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="196" y1="10" x2="196" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="224" y1="10" x2="224" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="252" y1="10" x2="252" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="280" y1="10" x2="280" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="308" y1="10" x2="308" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="336" y1="10" x2="336" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="364" y1="10" x2="364" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="38" x2="392" y2="38" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="66" x2="392" y2="66" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="94" x2="392" y2="94" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="122" x2="392" y2="122" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="150" x2="392" y2="150" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="178" x2="392" y2="178" stroke="#1a0d00" stroke-width="0.6"/>
<rect x="0" y="10" width="392" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="364.5" y="37.5" width="27.5" height="140" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="56" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="112" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="140" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="196" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="224" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="252" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="280" y="37.5" width="27.5" height="55" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="308" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="168" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="84" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<circle cx="71" cy="51" r="6" fill="#FFB347">
<animate attributeName="opacity" values="1;0.4;1" dur="1.2s" repeatCount="indefinite"/>
</circle>
<circle cx="71" cy="51" r="3" fill="#FF884D"/>
<circle cx="183" cy="107" r="6" fill="#FFB347">
<animate attributeName="opacity" values="0.4;1;0.4" dur="1.2s" repeatCount="indefinite"/>
</circle>
<circle cx="183" cy="107" r="3" fill="#FF884D"/>
<circle cx="323" cy="79" r="6" fill="#FFB347">
<animate attributeName="opacity" values="1;0.4;1" dur="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="323" cy="79" r="3" fill="#FF884D"/>
<rect x="212" y="129" width="12" height="12" fill="#FFA500" opacity="0.7"/>
<text x="218" y="140" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">&#9672;</text>
<rect x="237" y="46" width="20" height="20" fill="#FF4400">
<animate attributeName="opacity" values="1;0.6;1" dur="0.8s" repeatCount="indefinite"/>
</rect>
<rect x="353" y="101" width="20" height="20" fill="#FF4400">
<animate attributeName="opacity" values="0.6;1;0.6" dur="0.8s" repeatCount="indefinite"/>
</rect>
<rect x="35" y="46" width="18" height="18" fill="#FF6600">
<animate attributeName="opacity" values="1;0.7;1" dur="1s" repeatCount="indefinite"/>
</rect>
<rect x="39" y="50" width="7" height="7" fill="#FFB347"/>
<text x="200" y="212" font-family="'Courier New', monospace" font-size="13" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">NEON INFILTRATOR</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PingPong</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">PINGPONG</span>
</div>
<canvas id="c" width="640" height="400"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8593;&#8595; or W/S — Move paddle &nbsp;|&nbsp; First to 5 wins</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="pingpong">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PADDLE_W = 12, PADDLE_H = 70, PADDLE_OFF = 18;
const BALL_R = 8;
const WIN_SCORE = 5;
let state = 'idle';
let playerScore = 0, aiScore = 0;
let player, ai, ball;
let frames = 0;
function initGame() {
player = { x: PADDLE_OFF, y: H / 2 - PADDLE_H / 2, w: PADDLE_W, h: PADDLE_H, speed: 5 };
ai = { x: W - PADDLE_OFF - PADDLE_W, y: H / 2 - PADDLE_H / 2, w: PADDLE_W, h: PADDLE_H, speed: 3.8 };
resetBall(1);
frames = 0;
}
function resetBall(dir) {
const angle = (Math.random() * 0.5 - 0.25) * Math.PI;
const speed = 5;
ball = {
x: W / 2, y: H / 2,
vx: dir * speed * Math.cos(angle),
vy: speed * Math.sin(angle),
r: BALL_R
};
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') {
e.preventDefault();
if (state === 'idle' || state === 'over') {
playerScore = 0; aiScore = 0;
initGame();
state = 'play';
document.getElementById('msg').textContent = '';
}
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function paddleCollide(ball, pad) {
return ball.x - ball.r < pad.x + pad.w &&
ball.x + ball.r > pad.x &&
ball.y + ball.r > pad.y &&
ball.y - ball.r < pad.y + pad.h;
}
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = '#222';
ctx.lineWidth = 2;
ctx.setLineDash([12, 10]);
ctx.beginPath();
ctx.moveTo(W / 2, 0);
ctx.lineTo(W / 2, H);
ctx.stroke();
ctx.setLineDash([]);
if (state === 'play') {
if ((keys['ArrowUp'] || keys['KeyW']) && player.y > 0) player.y -= player.speed;
if ((keys['ArrowDown'] || keys['KeyS']) && player.y + player.h < H) player.y += player.speed;
const aiCenter = ai.y + ai.h / 2;
const aiSpeed = ai.speed + Math.min(frames * 0.001, 1);
if (aiCenter < ball.y - 4) ai.y = Math.min(H - ai.h, ai.y + aiSpeed);
else if (aiCenter > ball.y + 4) ai.y = Math.max(0, ai.y - aiSpeed);
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = Math.abs(ball.vy); }
if (ball.y + ball.r > H) { ball.y = H - ball.r; ball.vy = -Math.abs(ball.vy); }
if (paddleCollide(ball, player)) {
ball.x = player.x + player.w + ball.r;
const rel = (ball.y - (player.y + player.h / 2)) / (player.h / 2);
const speed = Math.min(Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy) + 0.3, 14);
const angle = rel * 0.75;
ball.vx = speed * Math.cos(angle);
ball.vy = speed * Math.sin(angle);
}
if (paddleCollide(ball, ai)) {
ball.x = ai.x - ball.r;
const rel = (ball.y - (ai.y + ai.h / 2)) / (ai.h / 2);
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
const angle = rel * 0.75;
ball.vx = -speed * Math.cos(angle);
ball.vy = speed * Math.sin(angle);
}
if (ball.x - ball.r < 0) {
aiScore++;
if (aiScore >= WIN_SCORE) { state = 'over'; document.getElementById('msg').textContent = 'AI WINS — Press SPACE to retry'; }
else resetBall(1);
}
if (ball.x + ball.r > W) {
playerScore++;
if (playerScore >= WIN_SCORE) { state = 'over'; document.getElementById('msg').textContent = 'YOU WIN! — Press SPACE to play again'; document.getElementById('scoreInput').value = playerScore * 100; document.getElementById('scoreSubmit').style.display = 'block'; }
else resetBall(-1);
}
frames++;
}
ctx.fillStyle = '#FFA500';
ctx.beginPath(); ctx.roundRect(player.x, player.y, player.w, player.h, 4); ctx.fill();
ctx.fillStyle = '#4CAF50';
ctx.beginPath(); ctx.roundRect(ai.x, ai.y, ai.w, ai.h, 4); ctx.fill();
if (state !== 'idle') {
ctx.fillStyle = '#fff';
ctx.shadowBlur = 8;
ctx.shadowColor = '#fff';
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.font = 'bold 48px monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#FFA500';
ctx.fillText(playerScore, W / 4, 60);
ctx.fillStyle = '#4CAF50';
ctx.fillText(aiScore, W * 3 / 4, 60);
ctx.font = '13px monospace';
ctx.fillStyle = '#555';
ctx.fillText('YOU', W / 4, 78);
ctx.fillText('AI', W * 3 / 4, 78);
if (state === 'idle') {
ctx.font = '22px monospace';
ctx.fillStyle = '#FFA500';
ctx.fillText('PINGPONG', W / 2, H / 2 - 10);
ctx.font = '16px monospace';
ctx.fillStyle = '#888';
ctx.fillText('Press SPACE to start', W / 2, H / 2 + 20);
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<line x1="200" y1="0" x2="200" y2="220" stroke="#333" stroke-width="2" stroke-dasharray="10,10"/>
<rect x="12" y="70" width="14" height="70" rx="4" fill="#FFA500"/>
<rect x="374" y="80" width="14" height="60" rx="4" fill="#4CAF50"/>
<circle cx="260" cy="125" r="10" fill="#fff"/>
<text x="150" y="210" font-family="monospace" font-size="20" fill="#FFA500" text-anchor="middle" font-weight="bold">3</text>
<text x="250" y="210" font-family="monospace" font-size="20" fill="#4CAF50" text-anchor="middle" font-weight="bold">2</text>
<text x="50" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">YOU</text>
<text x="355" y="210" font-family="monospace" font-size="11" fill="#4CAF50" text-anchor="middle">AI</text>
</svg>

After

Width:  |  Height:  |  Size: 901 B

View file

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rock Paper Scissors</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
#score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
#score-bar span b { color: #FFA500; }
#arena { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; max-width: 480px; padding: 0 12px; }
#choices { display: flex; gap: 16px; }
.choice-btn { font-size: 3rem; background: #111; border: 2px solid #333; border-radius: 12px; cursor: pointer; padding: 14px 20px; transition: border-color 0.15s, background 0.15s; line-height: 1; }
.choice-btn:hover { border-color: #FFA500; background: #1a1a00; }
.choice-btn.selected { border-color: #FFA500; background: #2a1a00; }
#battle { display: flex; align-items: center; gap: 24px; font-size: 2.2rem; min-height: 72px; }
#vs { font-size: 1rem; color: #555; }
#result { font-size: 1.3rem; min-height: 32px; text-align: center; }
.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; }
#rounds { display: flex; gap: 8px; margin-top: 4px; }
.round-dot { width: 14px; height: 14px; border-radius: 50%; background: #222; border: 2px solid #444; }
.round-dot.win { background: #4CAF50; border-color: #4CAF50; }
.round-dot.lose { background: #e74c3c; border-color: #e74c3c; }
.round-dot.draw { background: #FFA500; border-color: #FFA500; }
#play-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
#play-btn:hover { background: #ffb700; }
#play-btn:disabled { background: #333; color: #666; cursor: default; }
#final { font-size: 1.4rem; min-height: 36px; text-align: center; margin-top: 8px; }
#hint { color: #555; font-size: 13px; margin-top: 4px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ROCK PAPER SCISSORS</span>
</div>
<h1>Rock &middot; Paper &middot; Scissors</h1>
<div id="score-bar">
<span>YOU: <b id="playerWins">0</b></span>
<span>AI: <b id="aiWins">0</b></span>
<span>DRAWS: <b id="draws">0</b></span>
</div>
<div id="arena">
<div id="choices">
<button class="choice-btn" data-choice="rock" title="Rock">&#9994;</button>
<button class="choice-btn" data-choice="paper" title="Paper">&#9995;</button>
<button class="choice-btn" data-choice="scissors" title="Scissors">✌️</button>
</div>
<div id="battle">
<span id="playerEmoji">?</span>
<span id="vs">VS</span>
<span id="aiEmoji">?</span>
</div>
<div id="result">&nbsp;</div>
<div id="rounds"></div>
<button id="play-btn" disabled>Choose a move!</button>
<div id="final">&nbsp;</div>
<div id="hint">Best of 5 rounds</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="rockpaperscissors">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
</div>
<script>
const EMOJI = { rock: '\u270A', paper: '\u270B', scissors: '✌️' };
const BEATS = { rock: 'scissors', scissors: 'paper', paper: 'rock' };
const NAMES = { rock: 'Rock', paper: 'Paper', scissors: 'Scissors' };
let playerWins = 0, aiWins = 0, draws = 0;
let roundHistory = [];
let chosen = null;
let gameOver = false;
let streak = 0, bestStreak = 0;
const MAX_ROUNDS = 5;
const playerWinsEl = document.getElementById('playerWins');
const aiWinsEl = document.getElementById('aiWins');
const drawsEl = document.getElementById('draws');
const playerEmojiEl = document.getElementById('playerEmoji');
const aiEmojiEl = document.getElementById('aiEmoji');
const resultEl = document.getElementById('result');
const roundsEl = document.getElementById('rounds');
const playBtn = document.getElementById('play-btn');
const finalEl = document.getElementById('final');
function updateRounds() {
while (roundsEl.firstChild) roundsEl.removeChild(roundsEl.firstChild);
for (let i = 0; i < MAX_ROUNDS; i++) {
const dot = document.createElement('div');
dot.className = 'round-dot' + (roundHistory[i] ? ' ' + roundHistory[i] : '');
roundsEl.appendChild(dot);
}
}
document.querySelectorAll('.choice-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (gameOver) return;
document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
chosen = btn.dataset.choice;
playBtn.disabled = false;
playBtn.textContent = 'PLAY!';
});
});
function onPlay() {
if (!chosen || gameOver) return;
const choices = ['rock', 'paper', 'scissors'];
const aiChoice = choices[Math.floor(Math.random() * 3)];
playerEmojiEl.textContent = EMOJI[chosen];
aiEmojiEl.textContent = EMOJI[aiChoice];
let outcome, outcomeClass;
if (chosen === aiChoice) {
outcome = 'DRAW!'; outcomeClass = 'draw';
draws++;
roundHistory.push('draw');
drawsEl.textContent = draws;
} else if (BEATS[chosen] === aiChoice) {
outcome = NAMES[chosen] + ' beats ' + NAMES[aiChoice] + ' \u2014 YOU WIN!'; outcomeClass = 'win';
playerWins++; streak++;
if (streak > bestStreak) bestStreak = streak;
roundHistory.push('win');
playerWinsEl.textContent = playerWins;
} else {
outcome = NAMES[aiChoice] + ' beats ' + NAMES[chosen] + ' \u2014 AI WINS!'; outcomeClass = 'lose';
aiWins++; streak = 0;
roundHistory.push('lose');
aiWinsEl.textContent = aiWins;
}
resultEl.className = outcomeClass;
resultEl.textContent = outcome;
updateRounds();
const total = playerWins + aiWins + draws;
if (playerWins >= 3 || aiWins >= 3 || total >= MAX_ROUNDS) {
gameOver = true;
if (playerWins > aiWins) { finalEl.className = 'win'; finalEl.textContent = 'YOU WIN THE MATCH! Best streak: ' + bestStreak; document.getElementById('scoreInput').value = bestStreak; document.getElementById('scoreSubmit').style.display = 'block'; }
else if (aiWins > playerWins) { finalEl.className = 'lose'; finalEl.textContent = 'AI WINS THE MATCH!'; }
else { finalEl.className = 'draw'; finalEl.textContent = "IT'S A TIE!"; }
playBtn.textContent = 'New Game';
playBtn.disabled = false;
playBtn.onclick = resetGame;
} else {
chosen = null;
document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
playBtn.disabled = true;
playBtn.textContent = 'Choose a move!';
}
}
playBtn.addEventListener('click', onPlay);
function resetGame() {
playerWins = 0; aiWins = 0; draws = 0; roundHistory = []; chosen = null; gameOver = false; streak = 0; bestStreak = 0;
playerWinsEl.textContent = '0'; aiWinsEl.textContent = '0'; drawsEl.textContent = '0';
playerEmojiEl.textContent = '?'; aiEmojiEl.textContent = '?';
resultEl.textContent = '\u00a0'; resultEl.className = '';
finalEl.textContent = '\u00a0'; finalEl.className = '';
document.getElementById('scoreSubmit').style.display = 'none';
document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
playBtn.disabled = true;
playBtn.textContent = 'Choose a move!';
playBtn.onclick = onPlay;
updateRounds();
}
updateRounds();
</script>
</body>
</html>

View file

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<rect x="30" y="55" width="90" height="100" rx="14" fill="#111" stroke="#FFA500" stroke-width="2"/>
<rect x="42" y="38" width="66" height="26" rx="10" fill="#111" stroke="#FFA500" stroke-width="2"/>
<rect x="42" y="56" width="22" height="10" rx="4" fill="#FFA500" opacity="0.3"/>
<rect x="68" y="56" width="22" height="10" rx="4" fill="#FFA500" opacity="0.3"/>
<ellipse cx="75" cy="105" rx="28" ry="34" fill="#FFA500" opacity="0.15"/>
<text x="75" y="115" font-family="monospace" font-size="30" fill="#FFA500" text-anchor="middle"></text>
<text x="75" y="170" font-family="monospace" font-size="10" fill="#FFA500" text-anchor="middle">ROCK</text>
<rect x="155" y="40" width="90" height="115" rx="14" fill="#111" stroke="#4CAF50" stroke-width="2"/>
<text x="200" y="115" font-family="monospace" font-size="30" fill="#4CAF50" text-anchor="middle"></text>
<text x="200" y="170" font-family="monospace" font-size="10" fill="#4CAF50" text-anchor="middle">PAPER</text>
<rect x="280" y="55" width="90" height="100" rx="14" fill="#111" stroke="#FFA500" stroke-width="2"/>
<text x="325" y="115" font-family="monospace" font-size="30" fill="#FFA500" text-anchor="middle">✌️</text>
<text x="325" y="170" font-family="monospace" font-size="10" fill="#FFA500" text-anchor="middle">SCISSORS</text>
<text x="200" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Rock · Paper · Scissors</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Space Invaders</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #4CAF50; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #4CAF50; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">SPACE INVADERS</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>WAVE: <b id="wave">1</b></span>
</div>
<canvas id="c" width="600" height="380"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8592;&#8594; Move &nbsp;|&nbsp; SPACE — Shoot</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="spaceinvaders">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let state = 'idle';
let score = 0, lives = 3, wave = 1;
let player, bullets, aliens, alienBullets;
let alienDir = 1, alienMoveTimer = 0, alienDropped = false;
let alienShootTimer = 0;
let frames = 0;
const ALIEN_COLS = 10, ALIEN_ROWS = 4;
const PLAYER_W = 36, PLAYER_H = 18;
const BULLET_SPEED = 9;
const ALIEN_BULLET_SPEED = 3;
function initGame() {
player = { x: W / 2 - PLAYER_W / 2, y: H - 40, w: PLAYER_W, h: PLAYER_H, speed: 5, shooting: false, cooldown: 0 };
bullets = [];
alienBullets = [];
alienDir = 1;
alienMoveTimer = 0;
alienDropped = false;
alienShootTimer = 0;
frames = 0;
spawnAliens();
}
function spawnAliens() {
aliens = [];
for (let r = 0; r < ALIEN_ROWS; r++) {
for (let c = 0; c < ALIEN_COLS; c++) {
aliens.push({ x: 40 + c * 52, y: 40 + r * 44, w: 32, h: 22, alive: true, type: r < 2 ? 0 : 1 });
}
}
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') e.preventDefault();
if (e.code === 'Space' && (state === 'idle' || state === 'over')) {
score = 0; lives = 3; wave = 1;
document.getElementById('score').textContent = '0';
document.getElementById('lives').textContent = '3';
document.getElementById('wave').textContent = '1';
initGame();
state = 'play';
document.getElementById('msg').textContent = '';
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function drawPlayer(p) {
ctx.fillStyle = '#FFA500';
ctx.fillRect(p.x + p.w / 2 - 4, p.y - 8, 8, 8);
ctx.fillRect(p.x, p.y, p.w, p.h);
ctx.fillStyle = '#FF6600';
ctx.fillRect(p.x + 4, p.y + 4, 6, p.h - 4);
ctx.fillRect(p.x + p.w - 10, p.y + 4, 6, p.h - 4);
}
function drawAlien(a) {
if (!a.alive) return;
ctx.fillStyle = a.type === 0 ? '#4CAF50' : '#FFA500';
const f = Math.floor(frames / 20) % 2;
const x = a.x, y = a.y;
if (a.type === 0) {
ctx.fillRect(x + 4, y, 8, 6); ctx.fillRect(x + 20, y, 8, 6);
ctx.fillRect(x, y + 6, 32, 10);
ctx.fillRect(f === 0 ? x - 4 : x, y + 16, 8, 6);
ctx.fillRect(f === 0 ? x + 28 : x + 24, y + 16, 8, 6);
ctx.fillStyle = '#000';
ctx.fillRect(x + 8, y + 8, 4, 4); ctx.fillRect(x + 20, y + 8, 4, 4);
} else {
ctx.fillRect(x + 8, y, 16, 6);
ctx.fillRect(x, y + 6, 32, 10);
ctx.fillRect(f === 0 ? x + 4 : x, y + 16, 8, 6);
ctx.fillRect(f === 0 ? x + 20 : x + 24, y + 16, 8, 6);
ctx.fillStyle = '#000';
ctx.fillRect(x + 6, y + 8, 6, 4); ctx.fillRect(x + 20, y + 8, 6, 4);
}
}
function alienSpeed() { return 0.07 + wave * 0.06 + (1 - aliens.filter(a => a.alive).length / (ALIEN_COLS * ALIEN_ROWS)) * 0.2; }
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#003300';
ctx.fillRect(0, H - 2, W, 2);
if (state === 'play') {
if (keys['ArrowLeft'] && player.x > 0) player.x -= player.speed;
if (keys['ArrowRight'] && player.x + player.w < W) player.x += player.speed;
if (player.cooldown > 0) player.cooldown--;
if (keys['Space'] && player.cooldown === 0) {
bullets.push({ x: player.x + player.w / 2 - 2, y: player.y, w: 4, h: 10 });
player.cooldown = 12;
}
for (let i = bullets.length - 1; i >= 0; i--) {
bullets[i].y -= BULLET_SPEED;
if (bullets[i].y < 0) { bullets.splice(i, 1); continue; }
let hit = false;
for (const a of aliens) {
if (!a.alive) continue;
if (bullets[i] && bullets[i].x < a.x + a.w && bullets[i].x + bullets[i].w > a.x && bullets[i].y < a.y + a.h && bullets[i].y + bullets[i].h > a.y) {
a.alive = false;
score += a.type === 0 ? 20 : 10;
document.getElementById('score').textContent = score;
bullets.splice(i, 1);
hit = true;
break;
}
}
}
alienMoveTimer += alienSpeed();
if (alienMoveTimer >= 1) {
alienMoveTimer = 0;
let atEdge = false;
for (const a of aliens) {
if (!a.alive) continue;
if ((alienDir > 0 && a.x + a.w + 8 > W) || (alienDir < 0 && a.x - 8 < 0)) { atEdge = true; break; }
}
if (atEdge) {
alienDir *= -1;
for (const a of aliens) { if (a.alive) a.y += 12; }
} else {
for (const a of aliens) { if (a.alive) a.x += alienDir * 6; }
}
}
for (const a of aliens) {
if (a.alive && a.y + a.h >= player.y) {
state = 'over';
document.getElementById('msg').textContent = 'GAME OVER — Press SPACE';
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
}
alienShootTimer++;
if (alienShootTimer > Math.max(60, 130 - wave * 8)) {
alienShootTimer = 0;
const alive = aliens.filter(a => a.alive);
if (alive.length && alienBullets.length < 3) {
const shooter = alive[Math.floor(Math.random() * alive.length)];
alienBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
}
}
for (let i = alienBullets.length - 1; i >= 0; i--) {
alienBullets[i].y += ALIEN_BULLET_SPEED;
if (alienBullets[i].y > H) { alienBullets.splice(i, 1); continue; }
const b = alienBullets[i];
if (b && b.x < player.x + player.w && b.x + b.w > player.x && b.y < player.y + player.h && b.y + b.h > player.y) {
alienBullets.splice(i, 1);
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = 'GAME OVER — Press SPACE'; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
}
}
if (aliens.every(a => !a.alive)) {
wave++;
document.getElementById('wave').textContent = wave;
spawnAliens();
}
frames++;
}
aliens.forEach(drawAlien);
bullets.forEach(b => { ctx.fillStyle = '#fff'; ctx.fillRect(b.x, b.y, b.w, b.h); });
alienBullets.forEach(b => { ctx.fillStyle = '#f44'; ctx.fillRect(b.x, b.y, b.w, b.h); });
drawPlayer(player);
for (let i = 0; i < lives; i++) {
ctx.fillStyle = '#FFA500';
ctx.fillRect(10 + i * 20, H - 16, 14, 8);
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<g fill="#4CAF50">
<rect x="30" y="30" width="8" height="8"/><rect x="46" y="30" width="8" height="8"/><rect x="38" y="38" width="16" height="8"/><rect x="26" y="46" width="32" height="8"/><rect x="22" y="54" width="8" height="8"/><rect x="54" y="54" width="8" height="8"/>
<rect x="90" y="30" width="8" height="8"/><rect x="106" y="30" width="8" height="8"/><rect x="98" y="38" width="16" height="8"/><rect x="86" y="46" width="32" height="8"/><rect x="82" y="54" width="8" height="8"/><rect x="114" y="54" width="8" height="8"/>
<rect x="150" y="30" width="8" height="8"/><rect x="166" y="30" width="8" height="8"/><rect x="158" y="38" width="16" height="8"/><rect x="146" y="46" width="32" height="8"/><rect x="142" y="54" width="8" height="8"/><rect x="174" y="54" width="8" height="8"/>
<rect x="210" y="30" width="8" height="8"/><rect x="226" y="30" width="8" height="8"/><rect x="218" y="38" width="16" height="8"/><rect x="206" y="46" width="32" height="8"/><rect x="202" y="54" width="8" height="8"/><rect x="234" y="54" width="8" height="8"/>
<rect x="270" y="30" width="8" height="8"/><rect x="286" y="30" width="8" height="8"/><rect x="278" y="38" width="16" height="8"/><rect x="266" y="46" width="32" height="8"/><rect x="262" y="54" width="8" height="8"/><rect x="294" y="54" width="8" height="8"/>
<rect x="330" y="30" width="8" height="8"/><rect x="346" y="30" width="8" height="8"/><rect x="338" y="38" width="16" height="8"/><rect x="326" y="46" width="32" height="8"/><rect x="322" y="54" width="8" height="8"/><rect x="354" y="54" width="8" height="8"/>
</g>
<g fill="#FFA500">
<rect x="30" y="100" width="8" height="8"/><rect x="46" y="100" width="8" height="8"/><rect x="38" y="92" width="16" height="8"/><rect x="34" y="108" width="24" height="8"/>
<rect x="90" y="100" width="8" height="8"/><rect x="106" y="100" width="8" height="8"/><rect x="98" y="92" width="16" height="8"/><rect x="94" y="108" width="24" height="8"/>
<rect x="150" y="100" width="8" height="8"/><rect x="166" y="100" width="8" height="8"/><rect x="158" y="92" width="16" height="8"/><rect x="154" y="108" width="24" height="8"/>
<rect x="210" y="100" width="8" height="8"/><rect x="226" y="100" width="8" height="8"/><rect x="218" y="92" width="16" height="8"/><rect x="214" y="108" width="24" height="8"/>
<rect x="270" y="100" width="8" height="8"/><rect x="286" y="100" width="8" height="8"/><rect x="278" y="92" width="16" height="8"/><rect x="274" y="108" width="24" height="8"/>
</g>
<g fill="#FFA500">
<rect x="190" y="180" width="20" height="8"/>
<rect x="184" y="172" width="32" height="8"/>
<rect x="178" y="164" width="44" height="8"/>
<rect x="196" y="156" width="8" height="8"/>
</g>
<rect x="196" y="144" width="4" height="12" fill="#fff"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#4CAF50" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tetris</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#game-wrap { display: flex; flex: 1; align-self: stretch; min-height: 0; gap: 16px; align-items: stretch; padding: 8px; }
canvas { display: block; border: 1px solid #333; }
#c { flex: 1; min-width: 0; }
#sidebar { width: 120px; color: #ccc; font-size: 13px; }
#sidebar h3 { color: #FFA500; margin-bottom: 6px; }
#sidebar p { margin-bottom: 4px; }
#sidebar b { color: #FFA500; }
#next-canvas { border: 1px solid #333; margin: 8px 0; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#touch-row { display: flex; gap: 6px; justify-content: center; margin: 4px; }
#touch-row button { width: 50px; height: 40px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 16px; cursor: pointer; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">TETRIS</span>
</div>
<div id="game-wrap">
<canvas id="c" width="200" height="400"></canvas>
<div id="sidebar">
<h3>NEXT</h3>
<canvas id="next-canvas" width="100" height="80"></canvas>
<p>SCORE<br><b id="scoreEl">0</b></p>
<p>LINES<br><b id="linesEl">0</b></p>
<p>LEVEL<br><b id="levelEl">1</b></p>
<p>BEST<br><b id="bestEl">-</b></p>
<p style="margin-top:10px;color:#888;font-size:11px">&#8592;&#8594; Move<br>&#8593; Rotate<br>&#8595; Soft drop<br>SPACE Hard drop</p>
</div>
</div>
<div id="msg">Press SPACE to start</div>
<div id="touch-row">
<button id="tleft">&#8592;</button>
<button id="trot">&#8635;</button>
<button id="tright">&#8594;</button>
<button id="tdrop">&#8595;&#8595;</button>
</div>
<div id="controls">Arrow keys &nbsp;|&nbsp; SPACE — hard drop / new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="tetris">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const nextCanvas = document.getElementById('next-canvas');
const nctx = nextCanvas.getContext('2d');
const COLS = 10, ROWS = 20, CELL = 20;
const PIECES = [
{ shape: [[1,1,1,1]], color: '#00f0f0' },
{ shape: [[1,1],[1,1]], color: '#ffd700' },
{ shape: [[0,1,1],[1,1,0]], color: '#0f0' },
{ shape: [[1,1,0],[0,1,1]], color: '#f00' },
{ shape: [[1,0,0],[1,1,1]], color: '#00f' },
{ shape: [[0,0,1],[1,1,1]], color: '#fa0' },
{ shape: [[0,1,0],[1,1,1]], color: '#a0f' }
];
let board, score, lines, level, piece, next, gameState, dropTimer, dropInterval;
let best = parseInt(localStorage.getItem('tetris_best') || '-1');
function rotate(shape) {
return shape[0].map((_, i) => shape.map(row => row[i]).reverse());
}
function newPiece(template) {
const t = template || PIECES[Math.floor(Math.random() * PIECES.length)];
return { shape: t.shape.map(r => [...r]), color: t.color, x: Math.floor(COLS / 2) - Math.floor(t.shape[0].length / 2), y: 0 };
}
function fits(shape, x, y) {
for (let r = 0; r < shape.length; r++)
for (let c = 0; c < shape[r].length; c++)
if (shape[r][c]) {
const nx = x + c, ny = y + r;
if (nx < 0 || nx >= COLS || ny >= ROWS) return false;
if (ny >= 0 && board[ny][nx]) return false;
}
return true;
}
function lock() {
for (let r = 0; r < piece.shape.length; r++)
for (let c = 0; c < piece.shape[r].length; c++)
if (piece.shape[r][c] && piece.y + r >= 0) board[piece.y + r][piece.x + c] = piece.color;
let cleared = 0;
for (let r = ROWS - 1; r >= 0; r--) {
if (board[r].every(c => c)) {
board.splice(r, 1);
board.unshift(new Array(COLS).fill(0));
cleared++; r++;
}
}
if (cleared) {
const pts = [0, 100, 300, 500, 800][cleared] * level;
score += pts; lines += cleared;
level = Math.floor(lines / 10) + 1;
dropInterval = Math.max(100, 800 - (level - 1) * 70);
document.getElementById('scoreEl').textContent = score;
document.getElementById('linesEl').textContent = lines;
document.getElementById('levelEl').textContent = level;
}
piece = newPiece(next);
next = PIECES[Math.floor(Math.random() * PIECES.length)];
if (!fits(piece.shape, piece.x, piece.y)) { endGame(); return; }
}
function drop() {
if (!fits(piece.shape, piece.x, piece.y + 1)) { lock(); } else { piece.y++; }
}
function hardDrop() {
while (fits(piece.shape, piece.x, piece.y + 1)) { piece.y++; score += 2; }
lock();
document.getElementById('scoreEl').textContent = score;
}
function startGame() {
board = Array.from({length: ROWS}, () => new Array(COLS).fill(0));
score = 0; lines = 0; level = 1; dropInterval = 800; dropTimer = 0;
gameState = 'play';
next = PIECES[Math.floor(Math.random() * PIECES.length)];
piece = newPiece(PIECES[Math.floor(Math.random() * PIECES.length)]);
document.getElementById('scoreEl').textContent = '0';
document.getElementById('linesEl').textContent = '0';
document.getElementById('levelEl').textContent = '1';
document.getElementById('msg').textContent = '';
document.getElementById('scoreSubmit').style.display = 'none';
}
function endGame() {
gameState = 'over';
if (best < 0 || score > best) {
best = score;
localStorage.setItem('tetris_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('msg').textContent = `GAME OVER! Score: ${score}. SPACE = new game`;
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); if (gameState !== 'play') startGame(); else hardDrop(); return; }
if (gameState !== 'play') return;
if (e.code === 'ArrowLeft') { e.preventDefault(); if (fits(piece.shape, piece.x - 1, piece.y)) piece.x--; }
if (e.code === 'ArrowRight') { e.preventDefault(); if (fits(piece.shape, piece.x + 1, piece.y)) piece.x++; }
if (e.code === 'ArrowDown') { e.preventDefault(); drop(); }
if (e.code === 'ArrowUp' || e.code === 'KeyX') {
e.preventDefault();
const rot = rotate(piece.shape);
if (fits(rot, piece.x, piece.y)) piece.shape = rot;
else if (fits(rot, piece.x - 1, piece.y)) { piece.x--; piece.shape = rot; }
else if (fits(rot, piece.x + 1, piece.y)) { piece.x++; piece.shape = rot; }
}
});
document.getElementById('tleft').addEventListener('click', () => { if (gameState === 'play' && fits(piece.shape, piece.x - 1, piece.y)) piece.x--; });
document.getElementById('tright').addEventListener('click', () => { if (gameState === 'play' && fits(piece.shape, piece.x + 1, piece.y)) piece.x++; });
document.getElementById('trot').addEventListener('click', () => { if (gameState !== 'play') return; const rot = rotate(piece.shape); if (fits(rot, piece.x, piece.y)) piece.shape = rot; });
document.getElementById('tdrop').addEventListener('click', () => { if (gameState === 'play') hardDrop(); });
if (best >= 0) document.getElementById('bestEl').textContent = best;
function drawCell(c, x, y, size, context) {
context = context || ctx;
context.fillStyle = c;
context.fillRect(x + 1, y + 1, size - 2, size - 2);
context.fillStyle = 'rgba(255,255,255,0.15)';
context.fillRect(x + 1, y + 1, size - 2, 4);
context.fillStyle = 'rgba(0,0,0,0.2)';
context.fillRect(x + 1, y + size - 5, size - 2, 4);
}
function getGhostY() {
let gy = piece.y;
while (fits(piece.shape, piece.x, gy + 1)) gy++;
return gy;
}
let last = 0;
function loop(ts) {
const dt = ts - last; last = ts;
if (gameState === 'play') {
dropTimer += dt;
if (dropTimer >= dropInterval) { dropTimer = 0; drop(); }
}
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { ctx.strokeRect(c * CELL, r * CELL, CELL, CELL); }
if (board) {
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (board[r][c]) drawCell(board[r][c], c * CELL, r * CELL, CELL);
}
if (piece && gameState === 'play') {
const gy = getGhostY();
piece.shape.forEach((row, r) => row.forEach((v, c) => {
if (v) {
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.fillRect((piece.x + c) * CELL + 1, (gy + r) * CELL + 1, CELL - 2, CELL - 2);
drawCell(piece.color, (piece.x + c) * CELL, (piece.y + r) * CELL, CELL);
}
}));
}
nctx.fillStyle = '#0a0a0a'; nctx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
if (next) {
const nCell = 18;
const ox = Math.floor((nextCanvas.width - next.shape[0].length * nCell) / 2);
const oy = Math.floor((nextCanvas.height - next.shape.length * nCell) / 2);
next.shape.forEach((row, r) => row.forEach((v, c) => { if (v) drawCell(next.color, ox + c * nCell, oy + r * nCell, nCell, nctx); }));
}
requestAnimationFrame(loop);
}
gameState = 'idle';
requestAnimationFrame(loop);
</script>
</body>
</html>

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#111"/>
<rect x="30" y="5" width="60" height="70" fill="#000" stroke="#333" stroke-width="1"/>
<rect x="30" y="55" width="10" height="10" fill="#00f0f0"/>
<rect x="40" y="55" width="10" height="10" fill="#00f0f0"/>
<rect x="50" y="55" width="10" height="10" fill="#00f0f0"/>
<rect x="60" y="55" width="10" height="10" fill="#00f0f0"/>
<rect x="30" y="65" width="10" height="10" fill="#ff0"/>
<rect x="40" y="65" width="10" height="10" fill="#ff0"/>
<rect x="50" y="65" width="10" height="10" fill="#0f0"/>
<rect x="60" y="65" width="10" height="10" fill="#0f0"/>
<rect x="70" y="65" width="10" height="10" fill="#f00"/>
<rect x="80" y="65" width="10" height="10" fill="#f00"/>
<rect x="60" y="45" width="10" height="10" fill="#f0a"/>
<rect x="60" y="35" width="10" height="10" fill="#f0a"/>
<rect x="70" y="35" width="10" height="10" fill="#f0a"/>
<rect x="80" y="35" width="10" height="10" fill="#f0a"/>
<rect x="40" y="20" width="10" height="10" fill="#fa0"/>
<rect x="50" y="20" width="10" height="10" fill="#fa0"/>
<rect x="50" y="10" width="10" height="10" fill="#fa0"/>
<rect x="60" y="10" width="10" height="10" fill="#fa0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TikTakToe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
#score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
#score-bar span b { color: #FFA500; }
#board { display: grid; grid-template-columns: repeat(3, 90px); grid-template-rows: repeat(3, 90px); gap: 6px; margin: 10px 0; }
.cell { width: 90px; height: 90px; background: #111; border: 2px solid #333; border-radius: 8px; font-size: 2.6rem; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.cell:hover:not(.taken) { border-color: #FFA500; background: #1a1a00; }
.cell.x { color: #FFA500; cursor: default; }
.cell.o { color: #4CAF50; cursor: default; }
.cell.taken { cursor: default; }
.cell.win-cell { border-color: #FFA500; background: #1a1200; }
#status { font-size: 1.2rem; min-height: 32px; text-align: center; margin: 8px 0; }
.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; } .thinking { color: #555; }
#new-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
#new-btn:hover { background: #ffb700; }
#scoreSubmit { display: none; text-align: center; margin: 8px; }
#scoreSubmit form button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">TIKTAKTOE</span>
</div>
<h1>Tic &middot; Tac &middot; Toe</h1>
<div id="score-bar">
<span>YOU (X): <b id="winsEl">0</b></span>
<span>AI (O): <b id="lossesEl">0</b></span>
<span>DRAWS: <b id="drawsEl">0</b></span>
</div>
<div id="board"></div>
<div id="status">&nbsp;</div>
<button id="new-btn">New Game</button>
<div id="scoreSubmit">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="tiktaktoe">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
let board, gameOver, wins, losses, draws;
function init() {
wins = 0; losses = 0; draws = 0;
newGame();
}
function newGame() {
board = Array(9).fill(null);
gameOver = false;
document.getElementById('scoreSubmit').style.display = 'none';
setStatus('\u00a0', '');
renderBoard();
}
function renderBoard() {
const el = document.getElementById('board');
while (el.firstChild) el.removeChild(el.firstChild);
const winner = checkWinner(board);
for (let i = 0; i < 9; i++) {
const cell = document.createElement('div');
cell.className = 'cell' + (board[i] ? ' taken ' + board[i] : '');
if (winner && winner.line.includes(i)) cell.className += ' win-cell';
if (board[i]) {
const t = document.createTextNode(board[i] === 'x' ? 'X' : 'O');
cell.appendChild(t);
}
const idx = i;
cell.addEventListener('click', () => playerMove(idx));
el.appendChild(cell);
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.className = cls;
el.textContent = msg;
}
function checkWinner(b) {
for (const [a, c, d] of LINES) {
if (b[a] && b[a] === b[c] && b[a] === b[d]) return { winner: b[a], line: [a, c, d] };
}
return null;
}
function playerMove(idx) {
if (gameOver || board[idx]) return;
board[idx] = 'x';
renderBoard();
const res = checkWinner(board);
if (res) { endGame('win'); return; }
if (board.every(c => c)) { endGame('draw'); return; }
setStatus('Thinking...', 'thinking');
setTimeout(aiMove, 200);
}
function aiMove() {
const move = bestMove(board);
if (move === -1) { endGame('draw'); return; }
board[move] = 'o';
renderBoard();
const res = checkWinner(board);
if (res) { endGame('lose'); return; }
if (board.every(c => c)) { endGame('draw'); return; }
setStatus('\u00a0', '');
}
function endGame(outcome) {
gameOver = true;
if (outcome === 'win') {
wins++;
document.getElementById('winsEl').textContent = wins;
setStatus('You win!', 'win');
document.getElementById('scoreInput').value = wins;
document.getElementById('scoreSubmit').style.display = 'block';
} else if (outcome === 'lose') {
losses++;
document.getElementById('lossesEl').textContent = losses;
setStatus('AI wins!', 'lose');
} else {
draws++;
document.getElementById('drawsEl').textContent = draws;
setStatus("It's a draw!", 'draw');
}
}
function minimax(b, isMax, alpha, beta) {
const res = checkWinner(b);
if (res) return res.winner === 'o' ? 10 : -10;
if (b.every(c => c)) return 0;
if (isMax) {
let best = -Infinity;
for (let i = 0; i < 9; i++) {
if (!b[i]) {
b[i] = 'o';
best = Math.max(best, minimax(b, false, alpha, beta));
b[i] = null;
alpha = Math.max(alpha, best);
if (beta <= alpha) break;
}
}
return best;
} else {
let best = Infinity;
for (let i = 0; i < 9; i++) {
if (!b[i]) {
b[i] = 'x';
best = Math.min(best, minimax(b, true, alpha, beta));
b[i] = null;
beta = Math.min(beta, best);
if (beta <= alpha) break;
}
}
return best;
}
}
function bestMove(b) {
let best = -Infinity, move = -1;
for (let i = 0; i < 9; i++) {
if (!b[i]) {
b[i] = 'o';
const score = minimax(b, false, -Infinity, Infinity);
b[i] = null;
if (score > best) { best = score; move = i; }
}
}
return move;
}
document.getElementById('new-btn').addEventListener('click', newGame);
init();
</script>
</body>
</html>

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<line x1="147" y1="40" x2="147" y2="180" stroke="#444" stroke-width="3"/>
<line x1="253" y1="40" x2="253" y2="180" stroke="#444" stroke-width="3"/>
<line x1="80" y1="93" x2="320" y2="93" stroke="#444" stroke-width="3"/>
<line x1="80" y1="127" x2="320" y2="127" stroke="#444" stroke-width="3"/>
<text x="113" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<text x="200" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
<text x="287" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<text x="113" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
<text x="200" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<text x="287" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
<text x="113" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
<text x="200" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<text x="287" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<line x1="80" y1="40" x2="320" y2="180" stroke="#FFA500" stroke-width="3" opacity="0.5"/>
<text x="200" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Tic · Tac · Toe</text>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Labyrinth</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; border: 1px solid #333; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
#dpad-row { display: flex; gap: 4px; }
#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
#dpad button:active { background: #444; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
#btn-giveup { margin-left: auto; background: #3a1a1a; border: 1px solid #f44; color: #f44; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 13px; }
#btn-giveup:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">LABYRINTH</span>
<button id="btn-giveup" disabled>Give Up</button>
</div>
<div id="ui">
<span>LEVEL: <b id="levelEl">1</b></span>
<span>MOVES: <b id="movesEl">150</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press SPACE or tap to start</div>
<div id="dpad">
<div id="dpad-row"><button id="btn-up">&#8593;</button></div>
<div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="labyrinth">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const BASE_SIZE = 10;
let CELL = 36;
let level = 1, moves = 0, maxMoves = 150, score = 0, total = 0, gameState = 'idle';
let cols, rows, grid, px, py;
let best = parseInt(localStorage.getItem('labyrinth_best') || '-1');
function seededRand(seed) {
let s = seed;
return () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
}
function generateMaze(c, r, seed) {
const rng = seededRand(seed);
const cells = Array.from({length: r}, () => Array.from({length: c}, () => ({ n: true, s: true, e: true, w: true, visited: false })));
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function carve(x, y) {
cells[y][x].visited = true;
const dirs = shuffle([['n', 0, -1], ['s', 0, 1], ['e', 1, 0], ['w', -1, 0]]);
for (const [d, dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && nx < c && ny >= 0 && ny < r && !cells[ny][nx].visited) {
const opp = { n: 's', s: 'n', e: 'w', w: 'e' };
cells[y][x][d] = false;
cells[ny][nx][opp[d]] = false;
carve(nx, ny);
}
}
}
carve(0, 0);
return cells;
}
function getAvailableSize() {
const usedH = (document.getElementById('topbar').offsetHeight || 40)
+ (document.getElementById('ui').offsetHeight || 30)
+ (document.getElementById('msg').offsetHeight || 22)
+ (document.getElementById('dpad').offsetHeight || 100)
+ (document.getElementById('controls').offsetHeight || 16)
+ 24;
return { w: window.innerWidth - 8, h: Math.max(200, window.innerHeight - usedH) };
}
function startLevel() {
cols = BASE_SIZE + (level - 1) * 2;
rows = Math.floor(cols * 0.7);
maxMoves = cols * rows * 2;
moves = 0;
grid = generateMaze(cols, rows, level * 7919 + 42);
px = Math.floor(cols / 2); py = Math.floor(rows / 2);
const avail = getAvailableSize();
CELL = Math.max(16, Math.floor(Math.min(avail.w / cols, avail.h / rows)));
canvas.width = cols * CELL;
canvas.height = rows * CELL;
document.getElementById('levelEl').textContent = level;
document.getElementById('movesEl').textContent = maxMoves;
document.getElementById('msg').textContent = `Level ${level} — Reach the exit!`;
gameState = 'play';
document.getElementById('btn-giveup').disabled = false;
}
function tryMove(dx, dy) {
if (gameState !== 'play') return;
const cell = grid[py][px];
const dirs = { '0,-1': 'n', '0,1': 's', '1,0': 'e', '-1,0': 'w' };
const key = `${dx},${dy}`;
const wall = dirs[key];
if (!wall || cell[wall]) return;
px += dx; py += dy;
moves++;
document.getElementById('movesEl').textContent = maxMoves - moves;
if (px === cols - 1 && py === rows - 1) {
const remaining = maxMoves - moves;
const levelScore = remaining + level * 50;
total += levelScore;
document.getElementById('scoreEl').textContent = total;
document.getElementById('msg').textContent = `EXIT! +${levelScore} pts. Next level...`;
gameState = 'transit';
setTimeout(() => { level++; startLevel(); }, 1000);
} else if (moves >= maxMoves) {
endGame('moves');
}
}
function endGame(reason) {
gameState = 'over';
if (best < 0 || total > best) {
best = total;
localStorage.setItem('labyrinth_best', best);
document.getElementById('bestEl').textContent = best;
}
const head = reason === 'giveup' ? `Gave up at level ${level}.` : 'Out of moves!';
document.getElementById('msg').textContent = `${head} Score: ${total}. SPACE = new game`;
document.getElementById('scoreInput').value = total;
document.getElementById('scoreSubmit').style.display = 'block';
document.getElementById('btn-giveup').disabled = true;
}
function newGame() {
level = 1; total = 0; score = 0;
document.getElementById('scoreEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
startLevel();
}
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); newGame(); return; }
const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') newGame(); else tryMove(...map[e.code]); }
});
canvas.addEventListener('click', () => { if (gameState === 'idle') newGame(); });
window.addEventListener('resize', () => { if (gameState !== 'idle') startLevel(); });
const dpadStart = () => { if (gameState === 'idle') newGame(); };
document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
document.getElementById('btn-giveup').addEventListener('click', () => { if (gameState === 'play') endGame('giveup'); });
if (best >= 0) document.getElementById('bestEl').textContent = best;
const VIEW = 6;
function draw() {
const W = canvas.width, H = canvas.height;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
if (gameState === 'idle') { requestAnimationFrame(draw); return; }
const viewCols = Math.min(cols, Math.floor(W / CELL));
const viewRows = Math.min(rows, Math.floor(H / CELL));
const offX = Math.max(0, Math.min(px - Math.floor(viewCols / 2), cols - viewCols));
const offY = Math.max(0, Math.min(py - Math.floor(viewRows / 2), rows - viewRows));
for (let r = offY; r < Math.min(offY + viewRows + 1, rows); r++) {
for (let c = offX; c < Math.min(offX + viewCols + 1, cols); c++) {
const x = (c - offX) * CELL, y = (r - offY) * CELL;
const cell = grid[r][c];
ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 2;
if (cell.n) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + CELL, y); ctx.stroke(); }
if (cell.s) { ctx.beginPath(); ctx.moveTo(x, y + CELL); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
if (cell.w) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + CELL); ctx.stroke(); }
if (cell.e) { ctx.beginPath(); ctx.moveTo(x + CELL, y); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
}
}
const ex = (cols - 1 - offX) * CELL + CELL / 2, ey = (rows - 1 - offY) * CELL + CELL / 2;
if (cols - 1 >= offX && cols - 1 < offX + viewCols && rows - 1 >= offY && rows - 1 < offY + viewRows) {
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.arc(ex, ey, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('EXIT', ex, ey);
}
const ppx = (px - offX) * CELL + CELL / 2, ppy = (py - offY) * CELL + CELL / 2;
ctx.fillStyle = '#00ff88';
ctx.beginPath(); ctx.arc(ppx, ppy, 7, 0, Math.PI * 2); ctx.fill();
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#111"/>
<g stroke="#FFA500" stroke-width="2" fill="none">
<rect x="5" y="5" width="110" height="70"/>
<line x1="25" y1="5" x2="25" y2="45"/>
<line x1="45" y1="35" x2="45" y2="75"/>
<line x1="65" y1="5" x2="65" y2="45"/>
<line x1="85" y1="35" x2="85" y2="75"/>
<line x1="5" y1="25" x2="45" y2="25"/>
<line x1="65" y1="25" x2="105" y2="25"/>
<line x1="25" y1="55" x2="65" y2="55"/>
<line x1="85" y1="55" x2="105" y2="55"/>
</g>
<circle cx="15" cy="15" r="4" fill="#00ff88"/>
<circle cx="105" cy="65" r="4" fill="#ff4444"/>
</svg>

After

Width:  |  Height:  |  Size: 672 B

View file

@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Neon Infiltrator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-tap-highlight-color: transparent; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 16px; padding: 6px 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; width: 100%; }
canvas { display: block; width: 100%; height: auto; max-width: 700px; border: 1px solid #333; image-rendering: crisp-edges; }
#msg { font-size: 13px; color: #FFA500; margin: 3px; text-align: center; min-height: 18px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 4px; }
.dpad-row { display: flex; gap: 4px; }
#dpad button { width: 50px; height: 50px; background: #1a0d00; border: 1px solid #FF6600; color: #FF6600; font-size: 20px; cursor: pointer; border-radius: 4px; }
#dpad button:active { background: #3a1a00; }
#controls { color: #666; font-size: 12px; text-align: center; margin-bottom: 3px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
@media (min-width: 768px) { #dpad, #controls { display: none; } }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">NEON INFILTRATOR</span>
</div>
<div id="ui">
<span>DATA: <b id="dataEl">0/0</b></span>
<span>LEVEL: <b id="levelEl">1</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
<span>ENEMIES: <b id="enemyEl">0</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press any arrow key or tap to start</div>
<div id="dpad">
<div class="dpad-row"><button id="btn-up">&#8593;</button></div>
<div class="dpad-row">
<button id="btn-left">&#8592;</button>
<button id="btn-down">&#8595;</button>
<button id="btn-right">&#8594;</button>
</div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="neoninfiltrator">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const CELL = 28;
let MAP_W = 0, MAP_H = 0;
let walls = [], enemies = [], dataItems = [], exitPos = { x: -1, y: -1 };
let player = { x: 1, y: 1 };
let level = 1, dataCollected = 0, totalData = 0;
let score = 0;
let best = parseInt(localStorage.getItem('ni_best') || '-1');
let gameState = 'idle';
let lastPlayerMove = 0, lastEnemyStep = 0;
const MOVE_DELAY = 140, ENEMY_INTERVAL = 500;
const keys = {};
function setMsg(txt) { document.getElementById('msg').textContent = txt; }
function updateUI() {
document.getElementById('dataEl').textContent = dataCollected + '/' + totalData;
document.getElementById('levelEl').textContent = level;
document.getElementById('scoreEl').textContent = score;
document.getElementById('bestEl').textContent = best < 0 ? '-' : best;
document.getElementById('enemyEl').textContent = enemies.length;
}
function resizeCanvas() {
let bw = 18, bh = 14;
if (level >= 5) { bw = 22; bh = 18; }
else if (level >= 3) { bw = 20; bh = 16; }
MAP_W = bw; MAP_H = bh;
canvas.width = MAP_W * CELL;
canvas.height = MAP_H * CELL;
}
function generateLevel() {
resizeCanvas();
walls = Array(MAP_H).fill(null).map(() => Array(MAP_W).fill(false));
for (let i = 0; i < MAP_W; i++) { walls[0][i] = true; walls[MAP_H - 1][i] = true; }
for (let i = 0; i < MAP_H; i++) { walls[i][0] = true; walls[i][MAP_W - 1] = true; }
for (let i = 0; i < Math.floor(MAP_W * MAP_H / 7); i++) {
const x = 2 + Math.floor(Math.random() * (MAP_W - 4));
const y = 2 + Math.floor(Math.random() * (MAP_H - 4));
if (!walls[y][x] && !(Math.abs(x - player.x) < 2 && Math.abs(y - player.y) < 2)) {
if (Math.random() < 0.55) walls[y][x] = true;
}
}
for (let i = 0; i < MAP_W * 2; i++) {
const x = 1 + Math.floor(Math.random() * (MAP_W - 2));
const y = 1 + Math.floor(Math.random() * (MAP_H - 2));
if (walls[y][x] && x > 1 && x < MAP_W - 2 && y > 1 && y < MAP_H - 2) {
if (Math.random() < 0.5) walls[y][x] = false;
}
}
player.x = 1; player.y = 1;
if (walls[player.y][player.x]) {
outer: for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
if (!walls[y][x]) { player.x = x; player.y = y; break outer; }
}
}
}
exitPos = { x: MAP_W - 2, y: MAP_H - 2 };
for (let t = 0; t < 200; t++) {
const ex = MAP_W - 2 - Math.floor(Math.random() * 3);
const ey = MAP_H - 2 - Math.floor(Math.random() * 3);
if (ex >= 1 && ey >= 1 && !walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 6) {
exitPos = { x: ex, y: ey }; break;
}
}
totalData = Math.min(12, 3 + Math.floor(level / 1.5));
dataItems = [];
for (let i = 0; i < totalData; i++) {
let placed = false;
for (let t = 0; t < 100; t++) {
const dx = 1 + Math.floor(Math.random() * (MAP_W - 2));
const dy = 1 + Math.floor(Math.random() * (MAP_H - 2));
if (!walls[dy][dx] && !(dx === player.x && dy === player.y) && !(dx === exitPos.x && dy === exitPos.y) && !dataItems.some(d => d.x === dx && d.y === dy)) {
dataItems.push({ x: dx, y: dy, collected: false }); placed = true; break;
}
}
if (!placed) {
outer2: for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
if (!walls[y][x] && !(x === player.x && y === player.y) && !(x === exitPos.x && y === exitPos.y) && !dataItems.some(d => d.x === x && d.y === y)) {
dataItems.push({ x, y, collected: false }); break outer2;
}
}
}
}
}
dataCollected = 0;
const enemyCount = Math.min(6, 1 + Math.floor(level / 2));
enemies = [];
for (let i = 0; i < enemyCount; i++) {
let placed = false;
for (let t = 0; t < 150; t++) {
const ex = 2 + Math.floor(Math.random() * (MAP_W - 4));
const ey = 2 + Math.floor(Math.random() * (MAP_H - 4));
if (!walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 4 && !(ex === exitPos.x && ey === exitPos.y) && !dataItems.some(d => d.x === ex && d.y === ey)) {
enemies.push({ x: ex, y: ey }); placed = true; break;
}
}
if (!placed) {
outer3: for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
if (!walls[y][x] && (Math.abs(x - player.x) + Math.abs(y - player.y)) > 3 && !enemies.some(e => e.x === x && e.y === y)) {
enemies.push({ x, y }); break outer3;
}
}
}
}
}
updateUI();
setMsg(`Level ${level} — Collect ${totalData} data items and reach the exit`);
}
function checkEnemyCollision() {
for (const e of enemies) {
if (e.x === player.x && e.y === player.y) { endGame(); return true; }
}
return false;
}
function tryMove(dx, dy) {
if (gameState !== 'play') return;
const nx = player.x + dx, ny = player.y + dy;
if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H || walls[ny][nx]) return;
player.x = nx; player.y = ny;
for (const d of dataItems) {
if (!d.collected && d.x === player.x && d.y === player.y) {
d.collected = true; dataCollected++; score += 100;
updateUI(); setMsg(`Data collected! (${dataCollected}/${totalData})`); break;
}
}
if (dataCollected === totalData && exitPos.x === player.x && exitPos.y === player.y) {
const bonus = level * 50;
score += bonus;
updateUI();
setMsg(`Level ${level} complete! +${bonus} bonus. Advancing...`);
gameState = 'transit';
setTimeout(() => { level++; generateLevel(); gameState = 'play'; lastPlayerMove = performance.now(); lastEnemyStep = performance.now(); }, 1400);
return;
}
checkEnemyCollision();
}
function moveEnemies() {
if (gameState !== 'play') return;
for (const e of enemies) {
let dx = 0, dy = 0;
if (Math.random() < 0.6) {
if (Math.abs(e.x - player.x) > Math.abs(e.y - player.y)) dx = e.x > player.x ? -1 : 1;
else dy = e.y > player.y ? -1 : 1;
} else {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
[dx, dy] = dirs[Math.floor(Math.random() * 4)];
}
const nx = e.x + dx, ny = e.y + dy;
if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && !walls[ny][nx] && !(nx === exitPos.x && ny === exitPos.y)) {
e.x = nx; e.y = ny;
} else {
for (const [sx, sy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const bx = e.x + sx, by = e.y + sy;
if (bx >= 0 && bx < MAP_W && by >= 0 && by < MAP_H && !walls[by][bx] && !(bx === exitPos.x && by === exitPos.y)) {
e.x = bx; e.y = by; break;
}
}
}
}
checkEnemyCollision();
updateUI();
}
function endGame() {
gameState = 'over';
if (best < 0 || score > best) { best = score; localStorage.setItem('ni_best', best); }
updateUI();
setMsg(`DETECTED! Score: ${score}. Press SPACE for new game.`);
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function newGame() {
score = 0; level = 1;
document.getElementById('scoreSubmit').style.display = 'none';
generateLevel();
gameState = 'play';
lastPlayerMove = performance.now();
lastEnemyStep = performance.now();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#1a0d00'; ctx.lineWidth = 0.6;
for (let i = 0; i <= MAP_W; i++) {
ctx.beginPath(); ctx.moveTo(i * CELL, 0); ctx.lineTo(i * CELL, canvas.height); ctx.stroke();
}
for (let i = 0; i <= MAP_H; i++) {
ctx.beginPath(); ctx.moveTo(0, i * CELL); ctx.lineTo(canvas.width, i * CELL); ctx.stroke();
}
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
if (walls[y][x]) {
ctx.fillStyle = '#1a0a05';
ctx.fillRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
ctx.strokeStyle = '#FF6600'; ctx.lineWidth = 1;
ctx.strokeRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
}
}
}
for (const d of dataItems) {
if (!d.collected) {
ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF6600';
ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.25, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#FF884D';
ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.12, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
}
ctx.fillStyle = '#FFA500'; ctx.globalAlpha = 0.7;
ctx.fillRect(exitPos.x * CELL + 4, exitPos.y * CELL + 4, CELL - 8, CELL - 8);
ctx.globalAlpha = 1; ctx.fillStyle = '#FFA500';
ctx.font = `${CELL - 6}px monospace`;
ctx.fillText('\u25C8', exitPos.x * CELL + CELL * 0.28, exitPos.y * CELL + CELL * 0.78);
if (dataCollected === totalData && gameState === 'play') {
ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 4; ctx.shadowColor = '#FF6600';
ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center';
ctx.fillText('\u2192 EXIT AVAILABLE \u2190', canvas.width / 2, 20);
ctx.shadowBlur = 0; ctx.textAlign = 'left';
}
for (const e of enemies) {
ctx.fillStyle = '#FF4400'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF2200';
ctx.fillRect(e.x * CELL + 4, e.y * CELL + 4, CELL - 8, CELL - 8);
ctx.shadowBlur = 0;
}
ctx.fillStyle = '#FF6600'; ctx.shadowBlur = 8; ctx.shadowColor = '#FF6600';
ctx.fillRect(player.x * CELL + 6, player.y * CELL + 6, CELL - 12, CELL - 12);
ctx.fillStyle = '#FFB347';
ctx.fillRect(player.x * CELL + 10, player.y * CELL + 10, 5, 5);
ctx.shadowBlur = 0;
}
function gameLoop(now) {
if (gameState === 'play') {
let dx = 0, dy = 0;
if (keys.ArrowUp || keys.w) dy = -1;
if (keys.ArrowDown || keys.s) dy = 1;
if (keys.ArrowLeft || keys.a) dx = -1;
if (keys.ArrowRight || keys.d) dx = 1;
if ((dx !== 0 || dy !== 0) && now - lastPlayerMove >= MOVE_DELAY) {
tryMove(dx, dy); lastPlayerMove = now;
}
if (now - lastEnemyStep >= ENEMY_INTERVAL) { moveEnemies(); lastEnemyStep = now; }
}
draw();
requestAnimationFrame(gameLoop);
}
document.addEventListener('keydown', e => {
const key = e.key;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 's', 'a', 'd', ' '].includes(key)) e.preventDefault();
if (key === ' ') { if (gameState === 'idle' || gameState === 'over') { newGame(); } return; }
keys[key] = true;
if (gameState === 'idle') newGame();
});
document.addEventListener('keyup', e => { keys[e.key] = false; });
const dpadStart = () => { if (gameState === 'idle' || gameState === 'over') newGame(); };
document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
generateLevel();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>

View file

@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#050505">
<rect width="400" height="220" fill="#050505"/>
<line x1="28" y1="10" x2="28" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="56" y1="10" x2="56" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="84" y1="10" x2="84" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="112" y1="10" x2="112" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="140" y1="10" x2="140" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="168" y1="10" x2="168" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="196" y1="10" x2="196" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="224" y1="10" x2="224" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="252" y1="10" x2="252" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="280" y1="10" x2="280" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="308" y1="10" x2="308" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="336" y1="10" x2="336" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="364" y1="10" x2="364" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="38" x2="392" y2="38" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="66" x2="392" y2="66" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="94" x2="392" y2="94" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="122" x2="392" y2="122" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="150" x2="392" y2="150" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="178" x2="392" y2="178" stroke="#1a0d00" stroke-width="0.6"/>
<rect x="0" y="10" width="392" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="364.5" y="37.5" width="27.5" height="140" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="56" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="112" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="140" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="196" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="224" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="252" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="280" y="37.5" width="27.5" height="55" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="308" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="168" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="84" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<circle cx="71" cy="51" r="6" fill="#FFB347">
<animate attributeName="opacity" values="1;0.4;1" dur="1.2s" repeatCount="indefinite"/>
</circle>
<circle cx="71" cy="51" r="3" fill="#FF884D"/>
<circle cx="183" cy="107" r="6" fill="#FFB347">
<animate attributeName="opacity" values="0.4;1;0.4" dur="1.2s" repeatCount="indefinite"/>
</circle>
<circle cx="183" cy="107" r="3" fill="#FF884D"/>
<circle cx="323" cy="79" r="6" fill="#FFB347">
<animate attributeName="opacity" values="1;0.4;1" dur="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="323" cy="79" r="3" fill="#FF884D"/>
<rect x="212" y="129" width="12" height="12" fill="#FFA500" opacity="0.7"/>
<text x="218" y="140" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">&#9672;</text>
<rect x="237" y="46" width="20" height="20" fill="#FF4400">
<animate attributeName="opacity" values="1;0.6;1" dur="0.8s" repeatCount="indefinite"/>
</rect>
<rect x="353" y="101" width="20" height="20" fill="#FF4400">
<animate attributeName="opacity" values="0.6;1;0.6" dur="0.8s" repeatCount="indefinite"/>
</rect>
<rect x="35" y="46" width="18" height="18" fill="#FF6600">
<animate attributeName="opacity" values="1;0.7;1" dur="1s" repeatCount="indefinite"/>
</rect>
<rect x="39" y="50" width="7" height="7" fill="#FFB347"/>
<text x="200" y="212" font-family="'Courier New', monospace" font-size="13" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">NEON INFILTRATOR</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PingPong</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">PINGPONG</span>
</div>
<canvas id="c" width="640" height="400"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8593;&#8595; or W/S — Move paddle &nbsp;|&nbsp; First to 5 wins</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="pingpong">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PADDLE_W = 12, PADDLE_H = 70, PADDLE_OFF = 18;
const BALL_R = 8;
const WIN_SCORE = 5;
let state = 'idle';
let playerScore = 0, aiScore = 0;
let player, ai, ball;
let frames = 0;
function initGame() {
player = { x: PADDLE_OFF, y: H / 2 - PADDLE_H / 2, w: PADDLE_W, h: PADDLE_H, speed: 5 };
ai = { x: W - PADDLE_OFF - PADDLE_W, y: H / 2 - PADDLE_H / 2, w: PADDLE_W, h: PADDLE_H, speed: 3.8 };
resetBall(1);
frames = 0;
}
function resetBall(dir) {
const angle = (Math.random() * 0.5 - 0.25) * Math.PI;
const speed = 5;
ball = {
x: W / 2, y: H / 2,
vx: dir * speed * Math.cos(angle),
vy: speed * Math.sin(angle),
r: BALL_R
};
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') {
e.preventDefault();
if (state === 'idle' || state === 'over') {
playerScore = 0; aiScore = 0;
initGame();
state = 'play';
document.getElementById('msg').textContent = '';
}
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function paddleCollide(ball, pad) {
return ball.x - ball.r < pad.x + pad.w &&
ball.x + ball.r > pad.x &&
ball.y + ball.r > pad.y &&
ball.y - ball.r < pad.y + pad.h;
}
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = '#222';
ctx.lineWidth = 2;
ctx.setLineDash([12, 10]);
ctx.beginPath();
ctx.moveTo(W / 2, 0);
ctx.lineTo(W / 2, H);
ctx.stroke();
ctx.setLineDash([]);
if (state === 'play') {
if ((keys['ArrowUp'] || keys['KeyW']) && player.y > 0) player.y -= player.speed;
if ((keys['ArrowDown'] || keys['KeyS']) && player.y + player.h < H) player.y += player.speed;
const aiCenter = ai.y + ai.h / 2;
const aiSpeed = ai.speed + Math.min(frames * 0.001, 1);
if (aiCenter < ball.y - 4) ai.y = Math.min(H - ai.h, ai.y + aiSpeed);
else if (aiCenter > ball.y + 4) ai.y = Math.max(0, ai.y - aiSpeed);
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = Math.abs(ball.vy); }
if (ball.y + ball.r > H) { ball.y = H - ball.r; ball.vy = -Math.abs(ball.vy); }
if (paddleCollide(ball, player)) {
ball.x = player.x + player.w + ball.r;
const rel = (ball.y - (player.y + player.h / 2)) / (player.h / 2);
const speed = Math.min(Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy) + 0.3, 14);
const angle = rel * 0.75;
ball.vx = speed * Math.cos(angle);
ball.vy = speed * Math.sin(angle);
}
if (paddleCollide(ball, ai)) {
ball.x = ai.x - ball.r;
const rel = (ball.y - (ai.y + ai.h / 2)) / (ai.h / 2);
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
const angle = rel * 0.75;
ball.vx = -speed * Math.cos(angle);
ball.vy = speed * Math.sin(angle);
}
if (ball.x - ball.r < 0) {
aiScore++;
if (aiScore >= WIN_SCORE) { state = 'over'; document.getElementById('msg').textContent = 'AI WINS — Press SPACE to retry'; }
else resetBall(1);
}
if (ball.x + ball.r > W) {
playerScore++;
if (playerScore >= WIN_SCORE) { state = 'over'; document.getElementById('msg').textContent = 'YOU WIN! — Press SPACE to play again'; document.getElementById('scoreInput').value = playerScore * 100; document.getElementById('scoreSubmit').style.display = 'block'; }
else resetBall(-1);
}
frames++;
}
ctx.fillStyle = '#FFA500';
ctx.beginPath(); ctx.roundRect(player.x, player.y, player.w, player.h, 4); ctx.fill();
ctx.fillStyle = '#4CAF50';
ctx.beginPath(); ctx.roundRect(ai.x, ai.y, ai.w, ai.h, 4); ctx.fill();
if (state !== 'idle') {
ctx.fillStyle = '#fff';
ctx.shadowBlur = 8;
ctx.shadowColor = '#fff';
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.font = 'bold 48px monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#FFA500';
ctx.fillText(playerScore, W / 4, 60);
ctx.fillStyle = '#4CAF50';
ctx.fillText(aiScore, W * 3 / 4, 60);
ctx.font = '13px monospace';
ctx.fillStyle = '#555';
ctx.fillText('YOU', W / 4, 78);
ctx.fillText('AI', W * 3 / 4, 78);
if (state === 'idle') {
ctx.font = '22px monospace';
ctx.fillStyle = '#FFA500';
ctx.fillText('PINGPONG', W / 2, H / 2 - 10);
ctx.font = '16px monospace';
ctx.fillStyle = '#888';
ctx.fillText('Press SPACE to start', W / 2, H / 2 + 20);
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<line x1="200" y1="0" x2="200" y2="220" stroke="#333" stroke-width="2" stroke-dasharray="10,10"/>
<rect x="12" y="70" width="14" height="70" rx="4" fill="#FFA500"/>
<rect x="374" y="80" width="14" height="60" rx="4" fill="#4CAF50"/>
<circle cx="260" cy="125" r="10" fill="#fff"/>
<text x="150" y="210" font-family="monospace" font-size="20" fill="#FFA500" text-anchor="middle" font-weight="bold">3</text>
<text x="250" y="210" font-family="monospace" font-size="20" fill="#4CAF50" text-anchor="middle" font-weight="bold">2</text>
<text x="50" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">YOU</text>
<text x="355" y="210" font-family="monospace" font-size="11" fill="#4CAF50" text-anchor="middle">AI</text>
</svg>

After

Width:  |  Height:  |  Size: 901 B

View file

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rock Paper Scissors</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
#score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
#score-bar span b { color: #FFA500; }
#arena { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; max-width: 480px; padding: 0 12px; }
#choices { display: flex; gap: 16px; }
.choice-btn { font-size: 3rem; background: #111; border: 2px solid #333; border-radius: 12px; cursor: pointer; padding: 14px 20px; transition: border-color 0.15s, background 0.15s; line-height: 1; }
.choice-btn:hover { border-color: #FFA500; background: #1a1a00; }
.choice-btn.selected { border-color: #FFA500; background: #2a1a00; }
#battle { display: flex; align-items: center; gap: 24px; font-size: 2.2rem; min-height: 72px; }
#vs { font-size: 1rem; color: #555; }
#result { font-size: 1.3rem; min-height: 32px; text-align: center; }
.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; }
#rounds { display: flex; gap: 8px; margin-top: 4px; }
.round-dot { width: 14px; height: 14px; border-radius: 50%; background: #222; border: 2px solid #444; }
.round-dot.win { background: #4CAF50; border-color: #4CAF50; }
.round-dot.lose { background: #e74c3c; border-color: #e74c3c; }
.round-dot.draw { background: #FFA500; border-color: #FFA500; }
#play-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
#play-btn:hover { background: #ffb700; }
#play-btn:disabled { background: #333; color: #666; cursor: default; }
#final { font-size: 1.4rem; min-height: 36px; text-align: center; margin-top: 8px; }
#hint { color: #555; font-size: 13px; margin-top: 4px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ROCK PAPER SCISSORS</span>
</div>
<h1>Rock &middot; Paper &middot; Scissors</h1>
<div id="score-bar">
<span>YOU: <b id="playerWins">0</b></span>
<span>AI: <b id="aiWins">0</b></span>
<span>DRAWS: <b id="draws">0</b></span>
</div>
<div id="arena">
<div id="choices">
<button class="choice-btn" data-choice="rock" title="Rock">&#9994;</button>
<button class="choice-btn" data-choice="paper" title="Paper">&#9995;</button>
<button class="choice-btn" data-choice="scissors" title="Scissors">✌️</button>
</div>
<div id="battle">
<span id="playerEmoji">?</span>
<span id="vs">VS</span>
<span id="aiEmoji">?</span>
</div>
<div id="result">&nbsp;</div>
<div id="rounds"></div>
<button id="play-btn" disabled>Choose a move!</button>
<div id="final">&nbsp;</div>
<div id="hint">Best of 5 rounds</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="rockpaperscissors">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
</div>
<script>
const EMOJI = { rock: '\u270A', paper: '\u270B', scissors: '✌️' };
const BEATS = { rock: 'scissors', scissors: 'paper', paper: 'rock' };
const NAMES = { rock: 'Rock', paper: 'Paper', scissors: 'Scissors' };
let playerWins = 0, aiWins = 0, draws = 0;
let roundHistory = [];
let chosen = null;
let gameOver = false;
let streak = 0, bestStreak = 0;
const MAX_ROUNDS = 5;
const playerWinsEl = document.getElementById('playerWins');
const aiWinsEl = document.getElementById('aiWins');
const drawsEl = document.getElementById('draws');
const playerEmojiEl = document.getElementById('playerEmoji');
const aiEmojiEl = document.getElementById('aiEmoji');
const resultEl = document.getElementById('result');
const roundsEl = document.getElementById('rounds');
const playBtn = document.getElementById('play-btn');
const finalEl = document.getElementById('final');
function updateRounds() {
while (roundsEl.firstChild) roundsEl.removeChild(roundsEl.firstChild);
for (let i = 0; i < MAX_ROUNDS; i++) {
const dot = document.createElement('div');
dot.className = 'round-dot' + (roundHistory[i] ? ' ' + roundHistory[i] : '');
roundsEl.appendChild(dot);
}
}
document.querySelectorAll('.choice-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (gameOver) return;
document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
chosen = btn.dataset.choice;
playBtn.disabled = false;
playBtn.textContent = 'PLAY!';
});
});
function onPlay() {
if (!chosen || gameOver) return;
const choices = ['rock', 'paper', 'scissors'];
const aiChoice = choices[Math.floor(Math.random() * 3)];
playerEmojiEl.textContent = EMOJI[chosen];
aiEmojiEl.textContent = EMOJI[aiChoice];
let outcome, outcomeClass;
if (chosen === aiChoice) {
outcome = 'DRAW!'; outcomeClass = 'draw';
draws++;
roundHistory.push('draw');
drawsEl.textContent = draws;
} else if (BEATS[chosen] === aiChoice) {
outcome = NAMES[chosen] + ' beats ' + NAMES[aiChoice] + ' \u2014 YOU WIN!'; outcomeClass = 'win';
playerWins++; streak++;
if (streak > bestStreak) bestStreak = streak;
roundHistory.push('win');
playerWinsEl.textContent = playerWins;
} else {
outcome = NAMES[aiChoice] + ' beats ' + NAMES[chosen] + ' \u2014 AI WINS!'; outcomeClass = 'lose';
aiWins++; streak = 0;
roundHistory.push('lose');
aiWinsEl.textContent = aiWins;
}
resultEl.className = outcomeClass;
resultEl.textContent = outcome;
updateRounds();
const total = playerWins + aiWins + draws;
if (playerWins >= 3 || aiWins >= 3 || total >= MAX_ROUNDS) {
gameOver = true;
if (playerWins > aiWins) { finalEl.className = 'win'; finalEl.textContent = 'YOU WIN THE MATCH! Best streak: ' + bestStreak; document.getElementById('scoreInput').value = bestStreak; document.getElementById('scoreSubmit').style.display = 'block'; }
else if (aiWins > playerWins) { finalEl.className = 'lose'; finalEl.textContent = 'AI WINS THE MATCH!'; }
else { finalEl.className = 'draw'; finalEl.textContent = "IT'S A TIE!"; }
playBtn.textContent = 'New Game';
playBtn.disabled = false;
playBtn.onclick = resetGame;
} else {
chosen = null;
document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
playBtn.disabled = true;
playBtn.textContent = 'Choose a move!';
}
}
playBtn.addEventListener('click', onPlay);
function resetGame() {
playerWins = 0; aiWins = 0; draws = 0; roundHistory = []; chosen = null; gameOver = false; streak = 0; bestStreak = 0;
playerWinsEl.textContent = '0'; aiWinsEl.textContent = '0'; drawsEl.textContent = '0';
playerEmojiEl.textContent = '?'; aiEmojiEl.textContent = '?';
resultEl.textContent = '\u00a0'; resultEl.className = '';
finalEl.textContent = '\u00a0'; finalEl.className = '';
document.getElementById('scoreSubmit').style.display = 'none';
document.querySelectorAll('.choice-btn').forEach(b => b.classList.remove('selected'));
playBtn.disabled = true;
playBtn.textContent = 'Choose a move!';
playBtn.onclick = onPlay;
updateRounds();
}
updateRounds();
</script>
</body>
</html>

View file

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<rect x="30" y="55" width="90" height="100" rx="14" fill="#111" stroke="#FFA500" stroke-width="2"/>
<rect x="42" y="38" width="66" height="26" rx="10" fill="#111" stroke="#FFA500" stroke-width="2"/>
<rect x="42" y="56" width="22" height="10" rx="4" fill="#FFA500" opacity="0.3"/>
<rect x="68" y="56" width="22" height="10" rx="4" fill="#FFA500" opacity="0.3"/>
<ellipse cx="75" cy="105" rx="28" ry="34" fill="#FFA500" opacity="0.15"/>
<text x="75" y="115" font-family="monospace" font-size="30" fill="#FFA500" text-anchor="middle"></text>
<text x="75" y="170" font-family="monospace" font-size="10" fill="#FFA500" text-anchor="middle">ROCK</text>
<rect x="155" y="40" width="90" height="115" rx="14" fill="#111" stroke="#4CAF50" stroke-width="2"/>
<text x="200" y="115" font-family="monospace" font-size="30" fill="#4CAF50" text-anchor="middle"></text>
<text x="200" y="170" font-family="monospace" font-size="10" fill="#4CAF50" text-anchor="middle">PAPER</text>
<rect x="280" y="55" width="90" height="100" rx="14" fill="#111" stroke="#FFA500" stroke-width="2"/>
<text x="325" y="115" font-family="monospace" font-size="30" fill="#FFA500" text-anchor="middle">✌️</text>
<text x="325" y="170" font-family="monospace" font-size="10" fill="#FFA500" text-anchor="middle">SCISSORS</text>
<text x="200" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Rock · Paper · Scissors</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Space Invaders</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #4CAF50; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #4CAF50; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">SPACE INVADERS</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>WAVE: <b id="wave">1</b></span>
</div>
<canvas id="c" width="600" height="380"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8592;&#8594; Move &nbsp;|&nbsp; SPACE — Shoot</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="spaceinvaders">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let state = 'idle';
let score = 0, lives = 3, wave = 1;
let player, bullets, aliens, alienBullets;
let alienDir = 1, alienMoveTimer = 0, alienDropped = false;
let alienShootTimer = 0;
let frames = 0;
const ALIEN_COLS = 10, ALIEN_ROWS = 4;
const PLAYER_W = 36, PLAYER_H = 18;
const BULLET_SPEED = 9;
const ALIEN_BULLET_SPEED = 3;
function initGame() {
player = { x: W / 2 - PLAYER_W / 2, y: H - 40, w: PLAYER_W, h: PLAYER_H, speed: 5, shooting: false, cooldown: 0 };
bullets = [];
alienBullets = [];
alienDir = 1;
alienMoveTimer = 0;
alienDropped = false;
alienShootTimer = 0;
frames = 0;
spawnAliens();
}
function spawnAliens() {
aliens = [];
for (let r = 0; r < ALIEN_ROWS; r++) {
for (let c = 0; c < ALIEN_COLS; c++) {
aliens.push({ x: 40 + c * 52, y: 40 + r * 44, w: 32, h: 22, alive: true, type: r < 2 ? 0 : 1 });
}
}
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') e.preventDefault();
if (e.code === 'Space' && (state === 'idle' || state === 'over')) {
score = 0; lives = 3; wave = 1;
document.getElementById('score').textContent = '0';
document.getElementById('lives').textContent = '3';
document.getElementById('wave').textContent = '1';
initGame();
state = 'play';
document.getElementById('msg').textContent = '';
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function drawPlayer(p) {
ctx.fillStyle = '#FFA500';
ctx.fillRect(p.x + p.w / 2 - 4, p.y - 8, 8, 8);
ctx.fillRect(p.x, p.y, p.w, p.h);
ctx.fillStyle = '#FF6600';
ctx.fillRect(p.x + 4, p.y + 4, 6, p.h - 4);
ctx.fillRect(p.x + p.w - 10, p.y + 4, 6, p.h - 4);
}
function drawAlien(a) {
if (!a.alive) return;
ctx.fillStyle = a.type === 0 ? '#4CAF50' : '#FFA500';
const f = Math.floor(frames / 20) % 2;
const x = a.x, y = a.y;
if (a.type === 0) {
ctx.fillRect(x + 4, y, 8, 6); ctx.fillRect(x + 20, y, 8, 6);
ctx.fillRect(x, y + 6, 32, 10);
ctx.fillRect(f === 0 ? x - 4 : x, y + 16, 8, 6);
ctx.fillRect(f === 0 ? x + 28 : x + 24, y + 16, 8, 6);
ctx.fillStyle = '#000';
ctx.fillRect(x + 8, y + 8, 4, 4); ctx.fillRect(x + 20, y + 8, 4, 4);
} else {
ctx.fillRect(x + 8, y, 16, 6);
ctx.fillRect(x, y + 6, 32, 10);
ctx.fillRect(f === 0 ? x + 4 : x, y + 16, 8, 6);
ctx.fillRect(f === 0 ? x + 20 : x + 24, y + 16, 8, 6);
ctx.fillStyle = '#000';
ctx.fillRect(x + 6, y + 8, 6, 4); ctx.fillRect(x + 20, y + 8, 6, 4);
}
}
function alienSpeed() { return 0.07 + wave * 0.06 + (1 - aliens.filter(a => a.alive).length / (ALIEN_COLS * ALIEN_ROWS)) * 0.2; }
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#003300';
ctx.fillRect(0, H - 2, W, 2);
if (state === 'play') {
if (keys['ArrowLeft'] && player.x > 0) player.x -= player.speed;
if (keys['ArrowRight'] && player.x + player.w < W) player.x += player.speed;
if (player.cooldown > 0) player.cooldown--;
if (keys['Space'] && player.cooldown === 0) {
bullets.push({ x: player.x + player.w / 2 - 2, y: player.y, w: 4, h: 10 });
player.cooldown = 12;
}
for (let i = bullets.length - 1; i >= 0; i--) {
bullets[i].y -= BULLET_SPEED;
if (bullets[i].y < 0) { bullets.splice(i, 1); continue; }
let hit = false;
for (const a of aliens) {
if (!a.alive) continue;
if (bullets[i] && bullets[i].x < a.x + a.w && bullets[i].x + bullets[i].w > a.x && bullets[i].y < a.y + a.h && bullets[i].y + bullets[i].h > a.y) {
a.alive = false;
score += a.type === 0 ? 20 : 10;
document.getElementById('score').textContent = score;
bullets.splice(i, 1);
hit = true;
break;
}
}
}
alienMoveTimer += alienSpeed();
if (alienMoveTimer >= 1) {
alienMoveTimer = 0;
let atEdge = false;
for (const a of aliens) {
if (!a.alive) continue;
if ((alienDir > 0 && a.x + a.w + 8 > W) || (alienDir < 0 && a.x - 8 < 0)) { atEdge = true; break; }
}
if (atEdge) {
alienDir *= -1;
for (const a of aliens) { if (a.alive) a.y += 12; }
} else {
for (const a of aliens) { if (a.alive) a.x += alienDir * 6; }
}
}
for (const a of aliens) {
if (a.alive && a.y + a.h >= player.y) {
state = 'over';
document.getElementById('msg').textContent = 'GAME OVER — Press SPACE';
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
}
alienShootTimer++;
if (alienShootTimer > Math.max(60, 130 - wave * 8)) {
alienShootTimer = 0;
const alive = aliens.filter(a => a.alive);
if (alive.length && alienBullets.length < 3) {
const shooter = alive[Math.floor(Math.random() * alive.length)];
alienBullets.push({ x: shooter.x + shooter.w / 2 - 2, y: shooter.y + shooter.h, w: 4, h: 10 });
}
}
for (let i = alienBullets.length - 1; i >= 0; i--) {
alienBullets[i].y += ALIEN_BULLET_SPEED;
if (alienBullets[i].y > H) { alienBullets.splice(i, 1); continue; }
const b = alienBullets[i];
if (b && b.x < player.x + player.w && b.x + b.w > player.x && b.y < player.y + player.h && b.y + b.h > player.y) {
alienBullets.splice(i, 1);
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = 'GAME OVER — Press SPACE'; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
}
}
if (aliens.every(a => !a.alive)) {
wave++;
document.getElementById('wave').textContent = wave;
spawnAliens();
}
frames++;
}
aliens.forEach(drawAlien);
bullets.forEach(b => { ctx.fillStyle = '#fff'; ctx.fillRect(b.x, b.y, b.w, b.h); });
alienBullets.forEach(b => { ctx.fillStyle = '#f44'; ctx.fillRect(b.x, b.y, b.w, b.h); });
drawPlayer(player);
for (let i = 0; i < lives; i++) {
ctx.fillStyle = '#FFA500';
ctx.fillRect(10 + i * 20, H - 16, 14, 8);
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<g fill="#4CAF50">
<rect x="30" y="30" width="8" height="8"/><rect x="46" y="30" width="8" height="8"/><rect x="38" y="38" width="16" height="8"/><rect x="26" y="46" width="32" height="8"/><rect x="22" y="54" width="8" height="8"/><rect x="54" y="54" width="8" height="8"/>
<rect x="90" y="30" width="8" height="8"/><rect x="106" y="30" width="8" height="8"/><rect x="98" y="38" width="16" height="8"/><rect x="86" y="46" width="32" height="8"/><rect x="82" y="54" width="8" height="8"/><rect x="114" y="54" width="8" height="8"/>
<rect x="150" y="30" width="8" height="8"/><rect x="166" y="30" width="8" height="8"/><rect x="158" y="38" width="16" height="8"/><rect x="146" y="46" width="32" height="8"/><rect x="142" y="54" width="8" height="8"/><rect x="174" y="54" width="8" height="8"/>
<rect x="210" y="30" width="8" height="8"/><rect x="226" y="30" width="8" height="8"/><rect x="218" y="38" width="16" height="8"/><rect x="206" y="46" width="32" height="8"/><rect x="202" y="54" width="8" height="8"/><rect x="234" y="54" width="8" height="8"/>
<rect x="270" y="30" width="8" height="8"/><rect x="286" y="30" width="8" height="8"/><rect x="278" y="38" width="16" height="8"/><rect x="266" y="46" width="32" height="8"/><rect x="262" y="54" width="8" height="8"/><rect x="294" y="54" width="8" height="8"/>
<rect x="330" y="30" width="8" height="8"/><rect x="346" y="30" width="8" height="8"/><rect x="338" y="38" width="16" height="8"/><rect x="326" y="46" width="32" height="8"/><rect x="322" y="54" width="8" height="8"/><rect x="354" y="54" width="8" height="8"/>
</g>
<g fill="#FFA500">
<rect x="30" y="100" width="8" height="8"/><rect x="46" y="100" width="8" height="8"/><rect x="38" y="92" width="16" height="8"/><rect x="34" y="108" width="24" height="8"/>
<rect x="90" y="100" width="8" height="8"/><rect x="106" y="100" width="8" height="8"/><rect x="98" y="92" width="16" height="8"/><rect x="94" y="108" width="24" height="8"/>
<rect x="150" y="100" width="8" height="8"/><rect x="166" y="100" width="8" height="8"/><rect x="158" y="92" width="16" height="8"/><rect x="154" y="108" width="24" height="8"/>
<rect x="210" y="100" width="8" height="8"/><rect x="226" y="100" width="8" height="8"/><rect x="218" y="92" width="16" height="8"/><rect x="214" y="108" width="24" height="8"/>
<rect x="270" y="100" width="8" height="8"/><rect x="286" y="100" width="8" height="8"/><rect x="278" y="92" width="16" height="8"/><rect x="274" y="108" width="24" height="8"/>
</g>
<g fill="#FFA500">
<rect x="190" y="180" width="20" height="8"/>
<rect x="184" y="172" width="32" height="8"/>
<rect x="178" y="164" width="44" height="8"/>
<rect x="196" y="156" width="8" height="8"/>
</g>
<rect x="196" y="144" width="4" height="12" fill="#fff"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#4CAF50" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tetris</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#game-wrap { display: flex; flex: 1; align-self: stretch; min-height: 0; gap: 16px; align-items: stretch; padding: 8px; }
canvas { display: block; border: 1px solid #333; }
#c { flex: 1; min-width: 0; }
#sidebar { width: 120px; color: #ccc; font-size: 13px; }
#sidebar h3 { color: #FFA500; margin-bottom: 6px; }
#sidebar p { margin-bottom: 4px; }
#sidebar b { color: #FFA500; }
#next-canvas { border: 1px solid #333; margin: 8px 0; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#touch-row { display: flex; gap: 6px; justify-content: center; margin: 4px; }
#touch-row button { width: 50px; height: 40px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 16px; cursor: pointer; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">TETRIS</span>
</div>
<div id="game-wrap">
<canvas id="c" width="200" height="400"></canvas>
<div id="sidebar">
<h3>NEXT</h3>
<canvas id="next-canvas" width="100" height="80"></canvas>
<p>SCORE<br><b id="scoreEl">0</b></p>
<p>LINES<br><b id="linesEl">0</b></p>
<p>LEVEL<br><b id="levelEl">1</b></p>
<p>BEST<br><b id="bestEl">-</b></p>
<p style="margin-top:10px;color:#888;font-size:11px">&#8592;&#8594; Move<br>&#8593; Rotate<br>&#8595; Soft drop<br>SPACE Hard drop</p>
</div>
</div>
<div id="msg">Press SPACE to start</div>
<div id="touch-row">
<button id="tleft">&#8592;</button>
<button id="trot">&#8635;</button>
<button id="tright">&#8594;</button>
<button id="tdrop">&#8595;&#8595;</button>
</div>
<div id="controls">Arrow keys &nbsp;|&nbsp; SPACE — hard drop / new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="tetris">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const nextCanvas = document.getElementById('next-canvas');
const nctx = nextCanvas.getContext('2d');
const COLS = 10, ROWS = 20, CELL = 20;
const PIECES = [
{ shape: [[1,1,1,1]], color: '#00f0f0' },
{ shape: [[1,1],[1,1]], color: '#ffd700' },
{ shape: [[0,1,1],[1,1,0]], color: '#0f0' },
{ shape: [[1,1,0],[0,1,1]], color: '#f00' },
{ shape: [[1,0,0],[1,1,1]], color: '#00f' },
{ shape: [[0,0,1],[1,1,1]], color: '#fa0' },
{ shape: [[0,1,0],[1,1,1]], color: '#a0f' }
];
let board, score, lines, level, piece, next, gameState, dropTimer, dropInterval;
let best = parseInt(localStorage.getItem('tetris_best') || '-1');
function rotate(shape) {
return shape[0].map((_, i) => shape.map(row => row[i]).reverse());
}
function newPiece(template) {
const t = template || PIECES[Math.floor(Math.random() * PIECES.length)];
return { shape: t.shape.map(r => [...r]), color: t.color, x: Math.floor(COLS / 2) - Math.floor(t.shape[0].length / 2), y: 0 };
}
function fits(shape, x, y) {
for (let r = 0; r < shape.length; r++)
for (let c = 0; c < shape[r].length; c++)
if (shape[r][c]) {
const nx = x + c, ny = y + r;
if (nx < 0 || nx >= COLS || ny >= ROWS) return false;
if (ny >= 0 && board[ny][nx]) return false;
}
return true;
}
function lock() {
for (let r = 0; r < piece.shape.length; r++)
for (let c = 0; c < piece.shape[r].length; c++)
if (piece.shape[r][c] && piece.y + r >= 0) board[piece.y + r][piece.x + c] = piece.color;
let cleared = 0;
for (let r = ROWS - 1; r >= 0; r--) {
if (board[r].every(c => c)) {
board.splice(r, 1);
board.unshift(new Array(COLS).fill(0));
cleared++; r++;
}
}
if (cleared) {
const pts = [0, 100, 300, 500, 800][cleared] * level;
score += pts; lines += cleared;
level = Math.floor(lines / 10) + 1;
dropInterval = Math.max(100, 800 - (level - 1) * 70);
document.getElementById('scoreEl').textContent = score;
document.getElementById('linesEl').textContent = lines;
document.getElementById('levelEl').textContent = level;
}
piece = newPiece(next);
next = PIECES[Math.floor(Math.random() * PIECES.length)];
if (!fits(piece.shape, piece.x, piece.y)) { endGame(); return; }
}
function drop() {
if (!fits(piece.shape, piece.x, piece.y + 1)) { lock(); } else { piece.y++; }
}
function hardDrop() {
while (fits(piece.shape, piece.x, piece.y + 1)) { piece.y++; score += 2; }
lock();
document.getElementById('scoreEl').textContent = score;
}
function startGame() {
board = Array.from({length: ROWS}, () => new Array(COLS).fill(0));
score = 0; lines = 0; level = 1; dropInterval = 800; dropTimer = 0;
gameState = 'play';
next = PIECES[Math.floor(Math.random() * PIECES.length)];
piece = newPiece(PIECES[Math.floor(Math.random() * PIECES.length)]);
document.getElementById('scoreEl').textContent = '0';
document.getElementById('linesEl').textContent = '0';
document.getElementById('levelEl').textContent = '1';
document.getElementById('msg').textContent = '';
document.getElementById('scoreSubmit').style.display = 'none';
}
function endGame() {
gameState = 'over';
if (best < 0 || score > best) {
best = score;
localStorage.setItem('tetris_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('msg').textContent = `GAME OVER! Score: ${score}. SPACE = new game`;
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); if (gameState !== 'play') startGame(); else hardDrop(); return; }
if (gameState !== 'play') return;
if (e.code === 'ArrowLeft') { e.preventDefault(); if (fits(piece.shape, piece.x - 1, piece.y)) piece.x--; }
if (e.code === 'ArrowRight') { e.preventDefault(); if (fits(piece.shape, piece.x + 1, piece.y)) piece.x++; }
if (e.code === 'ArrowDown') { e.preventDefault(); drop(); }
if (e.code === 'ArrowUp' || e.code === 'KeyX') {
e.preventDefault();
const rot = rotate(piece.shape);
if (fits(rot, piece.x, piece.y)) piece.shape = rot;
else if (fits(rot, piece.x - 1, piece.y)) { piece.x--; piece.shape = rot; }
else if (fits(rot, piece.x + 1, piece.y)) { piece.x++; piece.shape = rot; }
}
});
document.getElementById('tleft').addEventListener('click', () => { if (gameState === 'play' && fits(piece.shape, piece.x - 1, piece.y)) piece.x--; });
document.getElementById('tright').addEventListener('click', () => { if (gameState === 'play' && fits(piece.shape, piece.x + 1, piece.y)) piece.x++; });
document.getElementById('trot').addEventListener('click', () => { if (gameState !== 'play') return; const rot = rotate(piece.shape); if (fits(rot, piece.x, piece.y)) piece.shape = rot; });
document.getElementById('tdrop').addEventListener('click', () => { if (gameState === 'play') hardDrop(); });
if (best >= 0) document.getElementById('bestEl').textContent = best;
function drawCell(c, x, y, size, context) {
context = context || ctx;
context.fillStyle = c;
context.fillRect(x + 1, y + 1, size - 2, size - 2);
context.fillStyle = 'rgba(255,255,255,0.15)';
context.fillRect(x + 1, y + 1, size - 2, 4);
context.fillStyle = 'rgba(0,0,0,0.2)';
context.fillRect(x + 1, y + size - 5, size - 2, 4);
}
function getGhostY() {
let gy = piece.y;
while (fits(piece.shape, piece.x, gy + 1)) gy++;
return gy;
}
let last = 0;
function loop(ts) {
const dt = ts - last; last = ts;
if (gameState === 'play') {
dropTimer += dt;
if (dropTimer >= dropInterval) { dropTimer = 0; drop(); }
}
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#111'; ctx.lineWidth = 0.5;
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { ctx.strokeRect(c * CELL, r * CELL, CELL, CELL); }
if (board) {
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (board[r][c]) drawCell(board[r][c], c * CELL, r * CELL, CELL);
}
if (piece && gameState === 'play') {
const gy = getGhostY();
piece.shape.forEach((row, r) => row.forEach((v, c) => {
if (v) {
ctx.fillStyle = 'rgba(255,255,255,0.1)';
ctx.fillRect((piece.x + c) * CELL + 1, (gy + r) * CELL + 1, CELL - 2, CELL - 2);
drawCell(piece.color, (piece.x + c) * CELL, (piece.y + r) * CELL, CELL);
}
}));
}
nctx.fillStyle = '#0a0a0a'; nctx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
if (next) {
const nCell = 18;
const ox = Math.floor((nextCanvas.width - next.shape[0].length * nCell) / 2);
const oy = Math.floor((nextCanvas.height - next.shape.length * nCell) / 2);
next.shape.forEach((row, r) => row.forEach((v, c) => { if (v) drawCell(next.color, ox + c * nCell, oy + r * nCell, nCell, nctx); }));
}
requestAnimationFrame(loop);
}
gameState = 'idle';
requestAnimationFrame(loop);
</script>
</body>
</html>

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#111"/>
<rect x="30" y="5" width="60" height="70" fill="#000" stroke="#333" stroke-width="1"/>
<rect x="30" y="55" width="10" height="10" fill="#00f0f0"/>
<rect x="40" y="55" width="10" height="10" fill="#00f0f0"/>
<rect x="50" y="55" width="10" height="10" fill="#00f0f0"/>
<rect x="60" y="55" width="10" height="10" fill="#00f0f0"/>
<rect x="30" y="65" width="10" height="10" fill="#ff0"/>
<rect x="40" y="65" width="10" height="10" fill="#ff0"/>
<rect x="50" y="65" width="10" height="10" fill="#0f0"/>
<rect x="60" y="65" width="10" height="10" fill="#0f0"/>
<rect x="70" y="65" width="10" height="10" fill="#f00"/>
<rect x="80" y="65" width="10" height="10" fill="#f00"/>
<rect x="60" y="45" width="10" height="10" fill="#f0a"/>
<rect x="60" y="35" width="10" height="10" fill="#f0a"/>
<rect x="70" y="35" width="10" height="10" fill="#f0a"/>
<rect x="80" y="35" width="10" height="10" fill="#f0a"/>
<rect x="40" y="20" width="10" height="10" fill="#fa0"/>
<rect x="50" y="20" width="10" height="10" fill="#fa0"/>
<rect x="50" y="10" width="10" height="10" fill="#fa0"/>
<rect x="60" y="10" width="10" height="10" fill="#fa0"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TikTakToe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; min-height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
h1 { color: #FFA500; font-size: 1.4rem; margin: 18px 0 6px; }
#score-bar { display: flex; gap: 32px; font-size: 15px; color: #aaa; margin-bottom: 14px; }
#score-bar span b { color: #FFA500; }
#board { display: grid; grid-template-columns: repeat(3, 90px); grid-template-rows: repeat(3, 90px); gap: 6px; margin: 10px 0; }
.cell { width: 90px; height: 90px; background: #111; border: 2px solid #333; border-radius: 8px; font-size: 2.6rem; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.cell:hover:not(.taken) { border-color: #FFA500; background: #1a1a00; }
.cell.x { color: #FFA500; cursor: default; }
.cell.o { color: #4CAF50; cursor: default; }
.cell.taken { cursor: default; }
.cell.win-cell { border-color: #FFA500; background: #1a1200; }
#status { font-size: 1.2rem; min-height: 32px; text-align: center; margin: 8px 0; }
.win { color: #4CAF50; } .lose { color: #e74c3c; } .draw { color: #FFA500; } .thinking { color: #555; }
#new-btn { margin-top: 10px; padding: 10px 32px; font-family: monospace; font-size: 1rem; background: #FFA500; color: #000; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
#new-btn:hover { background: #ffb700; }
#scoreSubmit { display: none; text-align: center; margin: 8px; }
#scoreSubmit form button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">TIKTAKTOE</span>
</div>
<h1>Tic &middot; Tac &middot; Toe</h1>
<div id="score-bar">
<span>YOU (X): <b id="winsEl">0</b></span>
<span>AI (O): <b id="lossesEl">0</b></span>
<span>DRAWS: <b id="drawsEl">0</b></span>
</div>
<div id="board"></div>
<div id="status">&nbsp;</div>
<button id="new-btn">New Game</button>
<div id="scoreSubmit">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="tiktaktoe">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
let board, gameOver, wins, losses, draws;
function init() {
wins = 0; losses = 0; draws = 0;
newGame();
}
function newGame() {
board = Array(9).fill(null);
gameOver = false;
document.getElementById('scoreSubmit').style.display = 'none';
setStatus('\u00a0', '');
renderBoard();
}
function renderBoard() {
const el = document.getElementById('board');
while (el.firstChild) el.removeChild(el.firstChild);
const winner = checkWinner(board);
for (let i = 0; i < 9; i++) {
const cell = document.createElement('div');
cell.className = 'cell' + (board[i] ? ' taken ' + board[i] : '');
if (winner && winner.line.includes(i)) cell.className += ' win-cell';
if (board[i]) {
const t = document.createTextNode(board[i] === 'x' ? 'X' : 'O');
cell.appendChild(t);
}
const idx = i;
cell.addEventListener('click', () => playerMove(idx));
el.appendChild(cell);
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.className = cls;
el.textContent = msg;
}
function checkWinner(b) {
for (const [a, c, d] of LINES) {
if (b[a] && b[a] === b[c] && b[a] === b[d]) return { winner: b[a], line: [a, c, d] };
}
return null;
}
function playerMove(idx) {
if (gameOver || board[idx]) return;
board[idx] = 'x';
renderBoard();
const res = checkWinner(board);
if (res) { endGame('win'); return; }
if (board.every(c => c)) { endGame('draw'); return; }
setStatus('Thinking...', 'thinking');
setTimeout(aiMove, 200);
}
function aiMove() {
const move = bestMove(board);
if (move === -1) { endGame('draw'); return; }
board[move] = 'o';
renderBoard();
const res = checkWinner(board);
if (res) { endGame('lose'); return; }
if (board.every(c => c)) { endGame('draw'); return; }
setStatus('\u00a0', '');
}
function endGame(outcome) {
gameOver = true;
if (outcome === 'win') {
wins++;
document.getElementById('winsEl').textContent = wins;
setStatus('You win!', 'win');
document.getElementById('scoreInput').value = wins;
document.getElementById('scoreSubmit').style.display = 'block';
} else if (outcome === 'lose') {
losses++;
document.getElementById('lossesEl').textContent = losses;
setStatus('AI wins!', 'lose');
} else {
draws++;
document.getElementById('drawsEl').textContent = draws;
setStatus("It's a draw!", 'draw');
}
}
function minimax(b, isMax, alpha, beta) {
const res = checkWinner(b);
if (res) return res.winner === 'o' ? 10 : -10;
if (b.every(c => c)) return 0;
if (isMax) {
let best = -Infinity;
for (let i = 0; i < 9; i++) {
if (!b[i]) {
b[i] = 'o';
best = Math.max(best, minimax(b, false, alpha, beta));
b[i] = null;
alpha = Math.max(alpha, best);
if (beta <= alpha) break;
}
}
return best;
} else {
let best = Infinity;
for (let i = 0; i < 9; i++) {
if (!b[i]) {
b[i] = 'x';
best = Math.min(best, minimax(b, true, alpha, beta));
b[i] = null;
beta = Math.min(beta, best);
if (beta <= alpha) break;
}
}
return best;
}
}
function bestMove(b) {
let best = -Infinity, move = -1;
for (let i = 0; i < 9; i++) {
if (!b[i]) {
b[i] = 'o';
const score = minimax(b, false, -Infinity, Infinity);
b[i] = null;
if (score > best) { best = score; move = i; }
}
}
return move;
}
document.getElementById('new-btn').addEventListener('click', newGame);
init();
</script>
</body>
</html>

View file

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<line x1="147" y1="40" x2="147" y2="180" stroke="#444" stroke-width="3"/>
<line x1="253" y1="40" x2="253" y2="180" stroke="#444" stroke-width="3"/>
<line x1="80" y1="93" x2="320" y2="93" stroke="#444" stroke-width="3"/>
<line x1="80" y1="127" x2="320" y2="127" stroke="#444" stroke-width="3"/>
<text x="113" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<text x="200" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
<text x="287" y="88" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<text x="113" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
<text x="200" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<text x="287" y="122" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
<text x="113" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#4CAF50" text-anchor="middle">O</text>
<text x="200" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<text x="287" y="170" font-family="monospace" font-size="36" font-weight="bold" fill="#FFA500" text-anchor="middle">X</text>
<line x1="80" y1="40" x2="320" y2="180" stroke="#FFA500" stroke-width="3" opacity="0.5"/>
<text x="200" y="210" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">Tic · Tac · Toe</text>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,223 @@
const { execFileSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const BASE_MAP = path.join(__dirname, "..", "client", "assets", "images", "worldmap-z2.png");
const CACHE_DIR = path.join(__dirname, "cache");
const TILES_DIR = path.join(__dirname, "tiles");
const MAP_W = 1024;
const MAP_H = 1024;
const latLngToPx = (lat, lng) => {
const latRad = lat * Math.PI / 180;
const x = Math.round((lng + 180) / 360 * MAP_W);
const y = Math.round((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * MAP_H);
return { x: Math.max(12, Math.min(MAP_W - 12, x)), y: Math.max(12, Math.min(MAP_H - 12, y)) };
};
const pxToLatLng = (px, py) => {
const lng = px / MAP_W * 360 - 180;
const n = Math.PI - 2 * Math.PI * py / MAP_H;
const lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
return { lat: Math.round(lat * 100) / 100, lng: Math.round(lng * 100) / 100 };
};
const getMaxTileZoom = () => {
try {
const dirs = fs.readdirSync(TILES_DIR).filter(d => /^\d+$/.test(d) && fs.existsSync(path.join(TILES_DIR, d, '0_0.png')));
return dirs.length ? Math.max(...dirs.map(Number)) : 0;
} catch (_) { return 0; }
};
const getViewportBounds = (centerLat, centerLng, zoom) => {
const effectiveZ = Math.min(zoom, getMaxTileZoom());
const n = Math.pow(2, effectiveZ);
const tileSize = 256;
const worldPx = n * tileSize;
const scale = Math.pow(2, zoom - effectiveZ);
const vw = MAP_W / scale;
const vh = MAP_H / scale;
const latRad = centerLat * Math.PI / 180;
const cx = (centerLng + 180) / 360 * worldPx;
const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
const x0 = cx - vw / 2;
const y0 = cy - vh / 2;
const x1 = cx + vw / 2;
const y1 = cy + vh / 2;
const pxToLng = (px) => px / worldPx * 360 - 180;
const pxToLat = (py) => { const nn = Math.PI - 2 * Math.PI * py / worldPx; return 180 / Math.PI * Math.atan(0.5 * (Math.exp(nn) - Math.exp(-nn))); };
return {
latMin: Math.max(-85, pxToLat(Math.min(y1, worldPx - 1))),
latMax: Math.min(85, pxToLat(Math.max(y0, 0))),
lngMin: Math.max(-180, pxToLng(Math.max(x0, 0))),
lngMax: Math.min(180, pxToLng(Math.min(x1, worldPx - 1)))
};
};
const renderMapWithPins = (markers, mainIdx) => {
const pins = (Array.isArray(markers) ? markers : [])
.filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
.map((m, i) => ({ ...latLngToPx(m.lat, m.lng), main: i === (mainIdx || 0) }));
const hash = crypto.createHash("md5")
.update(pins.map((p) => `${p.x},${p.y},${p.main}`).join(";"))
.digest("hex")
.slice(0, 12);
const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
if (fs.existsSync(outFile)) return `map_${hash}.png`;
const script = `
from PIL import Image, ImageDraw
import sys, json
pins = json.loads(sys.argv[1])
im = Image.open(sys.argv[2]).copy()
draw = ImageDraw.Draw(im)
for p in pins:
x, y, main = p['x'], p['y'], p.get('main', False)
sw = 3 if main else 2
sh = 18 if main else 13
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
draw.polygon([(x, y + 2), (x - sw, y - sh + sw * 2), (x + sw, y - sh + sw * 2)], fill=clr)
draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1)
im.save(sys.argv[3], optimize=True)
`;
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
execFileSync("python3", [
"-c", script,
JSON.stringify(pins),
BASE_MAP,
outFile
], { timeout: 10000 });
} catch (e) {
return null;
}
return `map_${hash}.png`;
};
const renderZoomedMapWithPins = (centerLat, centerLng, zoom, markers, mainIdx) => {
const maxZ = getMaxTileZoom();
if (!maxZ || zoom <= 2) return renderMapWithPins(markers, mainIdx);
const effectiveZ = Math.min(zoom, maxZ);
const scale = Math.pow(2, zoom - effectiveZ);
const n = Math.pow(2, effectiveZ);
const tileSize = 256;
const worldPx = n * tileSize;
const latRad = centerLat * Math.PI / 180;
const cx = (centerLng + 180) / 360 * worldPx;
const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
const vw = MAP_W / scale;
const vh = MAP_H / scale;
const x0 = cx - vw / 2;
const y0 = cy - vh / 2;
const pinData = (Array.isArray(markers) ? markers : [])
.filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
.map((m, i) => {
const latR = m.lat * Math.PI / 180;
const wx = (m.lng + 180) / 360 * worldPx;
const wy = (1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * worldPx;
return { px: (wx - x0) * scale, py: (wy - y0) * scale, main: i === (mainIdx || 0) };
});
const hashInput = `z${zoom}_${Math.round(centerLat * 100)}_${Math.round(centerLng * 100)}_` + pinData.map(p => `${Math.round(p.px)},${Math.round(p.py)},${p.main}`).join(";");
const hash = crypto.createHash("md5").update(hashInput).digest("hex").slice(0, 12);
const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
if (fs.existsSync(outFile)) return `map_${hash}.png`;
const txMin = Math.max(0, Math.floor(x0 / tileSize));
const txMax = Math.min(n - 1, Math.floor((x0 + vw) / tileSize));
const tyMin = Math.max(0, Math.floor(y0 / tileSize));
const tyMax = Math.min(n - 1, Math.floor((y0 + vh) / tileSize));
const tiles = [];
for (let tx = txMin; tx <= txMax; tx++) {
for (let ty = tyMin; ty <= tyMax; ty++) {
const tp = path.join(TILES_DIR, String(effectiveZ), `${tx}_${ty}.png`);
tiles.push({ tx, ty, tp, exists: fs.existsSync(tp) });
}
}
const script = `
from PIL import Image, ImageDraw
import sys, json, os
args = json.loads(sys.argv[1])
out_file = sys.argv[2]
tile_size = 256
tx_min = args['txMin']
ty_min = args['tyMin']
tx_max = args['txMax']
ty_max = args['tyMax']
x0 = args['x0']
y0 = args['y0']
vw = args['vw']
vh = args['vh']
scale = args['scale']
pins = args['pins']
tiles = args['tiles']
canvas_w = (tx_max - tx_min + 1) * tile_size
canvas_h = (ty_max - ty_min + 1) * tile_size
canvas = Image.new('RGB', (canvas_w, canvas_h), (170, 211, 223))
for t in tiles:
if t['exists']:
try:
tile = Image.open(t['tp']).convert('RGB')
canvas.paste(tile, ((t['tx'] - tx_min) * tile_size, (t['ty'] - ty_min) * tile_size))
except:
pass
crop_x = x0 - tx_min * tile_size
crop_y = y0 - ty_min * tile_size
cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y + vh)))
result = cropped.resize((1024, 1024), Image.LANCZOS)
draw = ImageDraw.Draw(result)
for p in pins:
px, py, main = p['px'], p['py'], p.get('main', False)
if -20 <= px <= 1044 and -20 <= py <= 1044:
sw = 3 if main else 2
sh = 18 if main else 13
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
draw.polygon([(px, py + 2), (px - sw, py - sh + sw * 2), (px + sw, py - sh + sw * 2)], fill=clr)
draw.ellipse([px - sw - 1, py - sh - sw, px + sw + 1, py - sh + sw], fill=dark, outline='white', width=1)
result.save(out_file, optimize=True)
`;
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
execFileSync("python3", [
"-c", script,
JSON.stringify({ txMin, tyMin, txMax, tyMax, x0, y0, vw, vh, scale, pins: pinData, tiles }),
outFile
], { timeout: 15000 });
} catch (e) {
return renderMapWithPins(markers, mainIdx);
}
return `map_${hash}.png`;
};
module.exports = { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, getMaxTileZoom, latLngToPx, pxToLatLng, MAP_W, MAP_H };

View file

@ -0,0 +1,223 @@
const { execFileSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const BASE_MAP = path.join(__dirname, "..", "client", "assets", "images", "worldmap-z2.png");
const CACHE_DIR = path.join(__dirname, "cache");
const TILES_DIR = path.join(__dirname, "tiles");
const MAP_W = 1024;
const MAP_H = 1024;
const latLngToPx = (lat, lng) => {
const latRad = lat * Math.PI / 180;
const x = Math.round((lng + 180) / 360 * MAP_W);
const y = Math.round((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * MAP_H);
return { x: Math.max(12, Math.min(MAP_W - 12, x)), y: Math.max(12, Math.min(MAP_H - 12, y)) };
};
const pxToLatLng = (px, py) => {
const lng = px / MAP_W * 360 - 180;
const n = Math.PI - 2 * Math.PI * py / MAP_H;
const lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
return { lat: Math.round(lat * 100) / 100, lng: Math.round(lng * 100) / 100 };
};
const getMaxTileZoom = () => {
try {
const dirs = fs.readdirSync(TILES_DIR).filter(d => /^\d+$/.test(d) && fs.existsSync(path.join(TILES_DIR, d, '0_0.png')));
return dirs.length ? Math.max(...dirs.map(Number)) : 0;
} catch (_) { return 0; }
};
const getViewportBounds = (centerLat, centerLng, zoom) => {
const effectiveZ = Math.min(zoom, getMaxTileZoom());
const n = Math.pow(2, effectiveZ);
const tileSize = 256;
const worldPx = n * tileSize;
const scale = Math.pow(2, zoom - effectiveZ);
const vw = MAP_W / scale;
const vh = MAP_H / scale;
const latRad = centerLat * Math.PI / 180;
const cx = (centerLng + 180) / 360 * worldPx;
const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
const x0 = cx - vw / 2;
const y0 = cy - vh / 2;
const x1 = cx + vw / 2;
const y1 = cy + vh / 2;
const pxToLng = (px) => px / worldPx * 360 - 180;
const pxToLat = (py) => { const nn = Math.PI - 2 * Math.PI * py / worldPx; return 180 / Math.PI * Math.atan(0.5 * (Math.exp(nn) - Math.exp(-nn))); };
return {
latMin: Math.max(-85, pxToLat(Math.min(y1, worldPx - 1))),
latMax: Math.min(85, pxToLat(Math.max(y0, 0))),
lngMin: Math.max(-180, pxToLng(Math.max(x0, 0))),
lngMax: Math.min(180, pxToLng(Math.min(x1, worldPx - 1)))
};
};
const renderMapWithPins = (markers, mainIdx) => {
const pins = (Array.isArray(markers) ? markers : [])
.filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
.map((m, i) => ({ ...latLngToPx(m.lat, m.lng), main: i === (mainIdx || 0) }));
const hash = crypto.createHash("md5")
.update(pins.map((p) => `${p.x},${p.y},${p.main}`).join(";"))
.digest("hex")
.slice(0, 12);
const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
if (fs.existsSync(outFile)) return `map_${hash}.png`;
const script = `
from PIL import Image, ImageDraw
import sys, json
pins = json.loads(sys.argv[1])
im = Image.open(sys.argv[2]).copy()
draw = ImageDraw.Draw(im)
for p in pins:
x, y, main = p['x'], p['y'], p.get('main', False)
sw = 3 if main else 2
sh = 18 if main else 13
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
draw.polygon([(x, y + 2), (x - sw, y - sh + sw * 2), (x + sw, y - sh + sw * 2)], fill=clr)
draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1)
im.save(sys.argv[3], optimize=True)
`;
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
execFileSync("python3", [
"-c", script,
JSON.stringify(pins),
BASE_MAP,
outFile
], { timeout: 10000 });
} catch (e) {
return null;
}
return `map_${hash}.png`;
};
const renderZoomedMapWithPins = (centerLat, centerLng, zoom, markers, mainIdx) => {
const maxZ = getMaxTileZoom();
if (!maxZ || zoom <= 2) return renderMapWithPins(markers, mainIdx);
const effectiveZ = Math.min(zoom, maxZ);
const scale = Math.pow(2, zoom - effectiveZ);
const n = Math.pow(2, effectiveZ);
const tileSize = 256;
const worldPx = n * tileSize;
const latRad = centerLat * Math.PI / 180;
const cx = (centerLng + 180) / 360 * worldPx;
const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
const vw = MAP_W / scale;
const vh = MAP_H / scale;
const x0 = cx - vw / 2;
const y0 = cy - vh / 2;
const pinData = (Array.isArray(markers) ? markers : [])
.filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
.map((m, i) => {
const latR = m.lat * Math.PI / 180;
const wx = (m.lng + 180) / 360 * worldPx;
const wy = (1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * worldPx;
return { px: (wx - x0) * scale, py: (wy - y0) * scale, main: i === (mainIdx || 0) };
});
const hashInput = `z${zoom}_${Math.round(centerLat * 100)}_${Math.round(centerLng * 100)}_` + pinData.map(p => `${Math.round(p.px)},${Math.round(p.py)},${p.main}`).join(";");
const hash = crypto.createHash("md5").update(hashInput).digest("hex").slice(0, 12);
const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
if (fs.existsSync(outFile)) return `map_${hash}.png`;
const txMin = Math.max(0, Math.floor(x0 / tileSize));
const txMax = Math.min(n - 1, Math.floor((x0 + vw) / tileSize));
const tyMin = Math.max(0, Math.floor(y0 / tileSize));
const tyMax = Math.min(n - 1, Math.floor((y0 + vh) / tileSize));
const tiles = [];
for (let tx = txMin; tx <= txMax; tx++) {
for (let ty = tyMin; ty <= tyMax; ty++) {
const tp = path.join(TILES_DIR, String(effectiveZ), `${tx}_${ty}.png`);
tiles.push({ tx, ty, tp, exists: fs.existsSync(tp) });
}
}
const script = `
from PIL import Image, ImageDraw
import sys, json, os
args = json.loads(sys.argv[1])
out_file = sys.argv[2]
tile_size = 256
tx_min = args['txMin']
ty_min = args['tyMin']
tx_max = args['txMax']
ty_max = args['tyMax']
x0 = args['x0']
y0 = args['y0']
vw = args['vw']
vh = args['vh']
scale = args['scale']
pins = args['pins']
tiles = args['tiles']
canvas_w = (tx_max - tx_min + 1) * tile_size
canvas_h = (ty_max - ty_min + 1) * tile_size
canvas = Image.new('RGB', (canvas_w, canvas_h), (170, 211, 223))
for t in tiles:
if t['exists']:
try:
tile = Image.open(t['tp']).convert('RGB')
canvas.paste(tile, ((t['tx'] - tx_min) * tile_size, (t['ty'] - ty_min) * tile_size))
except:
pass
crop_x = x0 - tx_min * tile_size
crop_y = y0 - ty_min * tile_size
cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y + vh)))
result = cropped.resize((1024, 1024), Image.LANCZOS)
draw = ImageDraw.Draw(result)
for p in pins:
px, py, main = p['px'], p['py'], p.get('main', False)
if -20 <= px <= 1044 and -20 <= py <= 1044:
sw = 3 if main else 2
sh = 18 if main else 13
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
draw.polygon([(px, py + 2), (px - sw, py - sh + sw * 2), (px + sw, py - sh + sw * 2)], fill=clr)
draw.ellipse([px - sw - 1, py - sh - sw, px + sw + 1, py - sh + sw], fill=dark, outline='white', width=1)
result.save(out_file, optimize=True)
`;
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
execFileSync("python3", [
"-c", script,
JSON.stringify({ txMin, tyMin, txMax, tyMax, x0, y0, vw, vh, scale, pins: pinData, tiles }),
outFile
], { timeout: 15000 });
} catch (e) {
return renderMapWithPins(markers, mainIdx);
}
return `map_${hash}.png`;
};
module.exports = { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, getMaxTileZoom, latLngToPx, pxToLatLng, MAP_W, MAP_H };

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Some files were not shown because too many files have changed in this diff Show more