Initial import of project

This commit is contained in:
sylvain committed 2026-09-01 14:37:28 +10:00
commit 59bece496b
7 files changed
+1562

No files matched your search

+940
View File
@@ -0,0 +1,940 @@
"use strict";
const CONFIG = {
title: "Times Table Invaders",
virtualWidth: 90,
virtualHeight: 120,
groundHeight: 16,
maxDt: 0.05,
leaderboardKey: "tti.leaderboard.v1",
leaderboardSize: 3,
starCount: 90,
fadedTitleAlpha: 0.07,
laserDuration: 0.18,
explosion: { min: 18, max: 26, ringMs: 450 },
phases: [
{ at: 0, tables: [2, 10], spawnEvery: 4.0, fallSpeed: 10 },
{ at: 60, tables: [2, 4, 5, 10], spawnEvery: 3.5, fallSpeed: 12 },
{ at: 150, tables: [2, 3, 4, 5, 6, 7, 8, 9, 10], spawnEvery: 3.0, fallSpeed: 14 },
{ at: 300, tables: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], spawnEvery: 2.6, fallSpeed: 16 }
],
ramp: { minSpawnEvery: 1.4, maxFallSpeed: 40, step: 20, speedPerStep: 0.8 }
};
const CANNON = {
baseW: 16,
baseH: 7,
turretR: 4.5,
barrelW: 3.6,
barrelH: 9,
rimW: 4.8,
rimH: 1.2,
recoilOffset: 2.2
};
const UFO = {
radius: 4.5,
spawnY: -6,
minGap: 10.5,
wobbleAmpMin: 1.2,
wobbleAmpMax: 2.8,
wobbleSpeedMin: 1.2,
wobbleSpeedMax: 2.6
};
const State = Object.freeze({
WELCOME: "welcome",
PLAYING: "playing",
GAME_OVER: "gameover"
});
const game = {
state: State.WELCOME,
score: 0,
elapsed: 0,
animTime: 0,
spawnTimer: 0,
ufos: [],
stars: [],
houses: [],
particles: [],
rings: [],
shotQueue: [],
laser: null,
cannon: { recoil: 0, angle: -Math.PI / 2 },
leaderboard: [],
pendingSave: false
};
const els = {
canvas: document.getElementById("scene"),
welcome: document.getElementById("welcome-overlay"),
gameover: document.getElementById("gameover-overlay"),
startBtn: document.getElementById("start-btn"),
restartBtn: document.getElementById("restart-btn"),
answer: document.getElementById("answer"),
fireBtn: document.getElementById("fire-btn"),
score: document.getElementById("score"),
finalScore: document.getElementById("final-score-value"),
top3Block: document.getElementById("top3-block"),
muteBtn: document.getElementById("mute-btn"),
nameInput: document.getElementById("player-name"),
saveScoreBtn: document.getElementById("save-score-btn"),
leaderboardBody: document.querySelector("#welcome-leaderboard tbody"),
leaderboardEmpty: document.getElementById("leaderboard-empty"),
gameoverLeaderboardBody: document.querySelector("#gameover-leaderboard tbody"),
gameoverLeaderboardEmpty: document.getElementById("gameover-leaderboard-empty")
};
const ctx = els.canvas.getContext("2d");
let viewScale = 1;
const AudioFX = {
muted: true,
audioCtx: null,
ensure() {
if (!this.audioCtx) {
const Ctor = window.AudioContext || window.webkitAudioContext;
if (!Ctor) return null;
this.audioCtx = new Ctor();
}
if (this.audioCtx.state === "suspended") this.audioCtx.resume();
return this.audioCtx;
},
laser() {
if (this.muted) return;
const ac = this.ensure();
if (!ac) return;
const t = ac.currentTime;
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(880, t);
osc.frequency.exponentialRampToValueAtTime(140, t + 0.16);
gain.gain.setValueAtTime(0.2, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
osc.connect(gain);
gain.connect(ac.destination);
osc.start(t);
osc.stop(t + 0.22);
},
explosion() {
if (this.muted) return;
const ac = this.ensure();
if (!ac) return;
const t = ac.currentTime;
const dur = 0.45;
const buffer = ac.createBuffer(1, Math.floor(ac.sampleRate * dur), ac.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 2);
}
const src = ac.createBufferSource();
src.buffer = buffer;
const filter = ac.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(1600, t);
filter.frequency.exponentialRampToValueAtTime(80, t + dur);
const gain = ac.createGain();
gain.gain.setValueAtTime(0.5, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(filter);
filter.connect(gain);
gain.connect(ac.destination);
src.start(t);
},
gameOver() {
if (this.muted) return;
const ac = this.ensure();
if (!ac) return;
const notes = [329.63, 261.63, 196.0, 130.81];
const step = 0.3;
const t0 = ac.currentTime;
notes.forEach((freq, i) => {
const t = t0 + i * step;
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(freq, t);
gain.gain.setValueAtTime(0.0001, t);
gain.gain.linearRampToValueAtTime(0.3, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, t + step * 1.8);
osc.connect(gain);
gain.connect(ac.destination);
osc.start(t);
osc.stop(t + step * 2);
});
}
};
function resizeCanvas() {
const rect = els.canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
if (w === els.canvas.width && h === els.canvas.height) return;
els.canvas.width = w;
els.canvas.height = h;
viewScale = w / CONFIG.virtualWidth;
}
function show(el) {
el.classList.remove("hidden");
}
function hide(el) {
el.classList.add("hidden");
}
function loadLeaderboard() {
try {
const raw = localStorage.getItem(CONFIG.leaderboardKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((e) => e && typeof e.name === "string" && Number.isFinite(e.score))
.map((e) => ({ name: e.name.slice(0, 16), score: Math.floor(e.score) }))
.sort((a, b) => b.score - a.score)
.slice(0, CONFIG.leaderboardSize);
} catch (err) {
return [];
}
}
function saveLeaderboard(entries) {
try {
localStorage.setItem(CONFIG.leaderboardKey, JSON.stringify(entries));
} catch (err) {
/* storage unavailable: game keeps working without persistence */
}
}
function renderLeaderboard(body, emptyEl) {
body.textContent = "";
if (game.leaderboard.length === 0) {
show(emptyEl);
return;
}
hide(emptyEl);
game.leaderboard.forEach((entry, index) => {
const tr = document.createElement("tr");
const rankTd = document.createElement("td");
rankTd.textContent = String(index + 1);
const nameTd = document.createElement("td");
nameTd.textContent = entry.name;
const scoreTd = document.createElement("td");
scoreTd.textContent = String(entry.score);
tr.append(rankTd, nameTd, scoreTd);
body.appendChild(tr);
});
}
function renderAllLeaderboards() {
renderLeaderboard(els.leaderboardBody, els.leaderboardEmpty);
renderLeaderboard(els.gameoverLeaderboardBody, els.gameoverLeaderboardEmpty);
}
function updateScoreDisplay() {
els.score.textContent = String(game.score);
}
// A score makes the top 3 if it is positive and either the board is not full
// or it beats the current lowest entry (strictly).
function qualifiesForLeaderboard(score) {
if (score <= 0) return false;
if (game.leaderboard.length < CONFIG.leaderboardSize) return true;
const lowest = game.leaderboard[game.leaderboard.length - 1].score;
return score > lowest;
}
// Insert the finished score at its correct rank, evicting the lowest entry so
// the board never exceeds leaderboardSize, then persist and dismiss the popup.
// Guarded by state + pendingSave so a rapid double Enter/click saves once only.
function saveScore() {
if (game.state !== State.GAME_OVER || !game.pendingSave) return;
const name = els.nameInput.value.trim().slice(0, 16);
if (!name) {
els.nameInput.focus();
return;
}
const list = game.leaderboard.concat({ name, score: game.score });
list.sort((a, b) => b.score - a.score);
const top = list.slice(0, CONFIG.leaderboardSize);
game.leaderboard = top;
saveLeaderboard(top);
renderAllLeaderboards();
game.pendingSave = false;
hide(els.top3Block);
}
function setControlsEnabled(enabled) {
els.answer.disabled = !enabled;
els.fireBtn.disabled = !enabled;
}
function enterWelcome() {
show(els.welcome);
hide(els.gameover);
setControlsEnabled(false);
renderAllLeaderboards();
}
function enterPlaying() {
hide(els.welcome);
hide(els.gameover);
setControlsEnabled(true);
els.answer.focus();
}
function enterGameOver() {
setControlsEnabled(false);
els.finalScore.textContent = String(game.score);
if (qualifiesForLeaderboard(game.score)) {
game.pendingSave = true;
els.nameInput.value = "";
show(els.top3Block);
els.nameInput.focus();
} else {
game.pendingSave = false;
hide(els.top3Block);
}
renderAllLeaderboards();
show(els.gameover);
}
const stateEnter = {
[State.WELCOME]: enterWelcome,
[State.PLAYING]: enterPlaying,
[State.GAME_OVER]: enterGameOver
};
function setState(next) {
if (next === game.state) return;
game.state = next;
stateEnter[next]();
}
function startGame() {
generateScenery();
game.score = 0;
game.elapsed = 0;
game.spawnTimer = 0;
game.ufos.length = 0;
game.particles.length = 0;
game.rings.length = 0;
game.shotQueue.length = 0;
game.laser = null;
game.cannon.recoil = 0;
game.cannon.angle = -Math.PI / 2;
game.pendingSave = false;
updateScoreDisplay();
setState(State.PLAYING);
}
function endGame() {
AudioFX.gameOver();
setState(State.GAME_OVER);
}
function submitAnswer() {
if (game.state !== State.PLAYING) return;
const raw = els.answer.value;
els.answer.value = "";
els.answer.focus();
game.cannon.recoil = 1;
const value = parseInt(raw, 10);
const matches = Number.isFinite(value)
? game.ufos.filter((u) => u.result === value).sort((a, b) => b.y - a.y)
: [];
if (matches.length > 0) {
for (const ufo of matches) game.shotQueue.push(ufo);
} else if (!game.laser) {
game.cannon.angle = -Math.PI / 2;
const tip = getCannonTip();
game.laser = { origin: { x: tip.x, y: tip.y }, target: null, t: 0 };
AudioFX.laser();
}
}
let firePulseTimer = null;
function pulseFireButton() {
els.fireBtn.classList.add("btn-pressed");
if (firePulseTimer !== null) clearTimeout(firePulseTimer);
firePulseTimer = setTimeout(() => {
els.fireBtn.classList.remove("btn-pressed");
firePulseTimer = null;
}, 130);
}
function wireInput() {
els.answer.addEventListener("input", () => {
els.answer.value = els.answer.value.replace(/\D/g, "").slice(0, 3);
});
els.answer.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
submitAnswer();
pulseFireButton();
}
});
els.fireBtn.addEventListener("click", submitAnswer);
}
function wireOverlays() {
els.startBtn.addEventListener("click", startGame);
els.restartBtn.addEventListener("click", startGame);
els.saveScoreBtn.addEventListener("click", saveScore);
els.nameInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
saveScore();
}
});
els.muteBtn.addEventListener("click", () => {
AudioFX.muted = !AudioFX.muted;
els.muteBtn.classList.toggle("muted", AudioFX.muted);
els.muteBtn.setAttribute("aria-pressed", String(AudioFX.muted));
});
}
let lastTime = null;
function frame(timeMs) {
if (lastTime === null) lastTime = timeMs;
const dt = Math.min(CONFIG.maxDt, Math.max(0, (timeMs - lastTime) / 1000));
lastTime = timeMs;
update(dt);
draw();
requestAnimationFrame(frame);
}
function update(dt) {
game.animTime += dt;
updateEffects(dt);
if (game.state !== State.PLAYING) return;
game.elapsed += dt;
processShotQueue();
game.spawnTimer -= dt;
if (game.spawnTimer <= 0) {
const phase = currentPhase();
const diff = currentDifficulty(phase);
const spawned = spawnUfo();
game.spawnTimer = spawned ? diff.spawnEvery * (0.8 + Math.random() * 0.4) : 0.6;
}
const groundTop = CONFIG.virtualHeight - CONFIG.groundHeight;
for (const ufo of game.ufos) {
ufo.y += ufo.speed * dt;
ufo.x = ufo.baseX + Math.sin(game.elapsed * ufo.wobbleSpeed + ufo.wobblePhase) * ufo.wobbleAmp;
}
for (const ufo of game.ufos) {
if (ufo.y + UFO.radius >= groundTop) {
endGame();
return;
}
}
}
function updateEffects(dt) {
if (game.cannon.recoil > 0) {
game.cannon.recoil = Math.max(0, game.cannon.recoil - dt * 4);
}
if (game.laser) {
game.laser.t += dt;
if (game.laser.t >= CONFIG.laserDuration) {
const target = game.laser.target;
game.laser = null;
if (target && game.state === State.PLAYING && game.ufos.includes(target)) {
explodeUfo(target);
}
}
} else {
const rest = -Math.PI / 2;
const diff = rest - game.cannon.angle;
if (Math.abs(diff) > 0.001) {
game.cannon.angle += diff * Math.min(1, dt * 10);
} else {
game.cannon.angle = rest;
}
}
for (let i = game.particles.length - 1; i >= 0; i--) {
const pt = game.particles[i];
pt.life -= dt;
if (pt.life <= 0) {
game.particles.splice(i, 1);
continue;
}
pt.x += pt.vx * dt;
pt.y += pt.vy * dt;
}
for (let i = game.rings.length - 1; i >= 0; i--) {
const ring = game.rings[i];
ring.t += dt;
if (ring.t >= ring.duration) game.rings.splice(i, 1);
}
}
function processShotQueue() {
if (game.laser) return;
let target = null;
while (game.shotQueue.length > 0) {
const candidate = game.shotQueue.shift();
if (game.ufos.includes(candidate)) {
target = candidate;
break;
}
}
if (!target) return;
const pivot = getCannonPivot();
game.cannon.angle = Math.atan2(target.y - pivot.y, target.x - pivot.x);
const tip = getCannonTip();
game.laser = { origin: { x: tip.x, y: tip.y }, target, t: 0 };
game.cannon.recoil = 1;
AudioFX.laser();
}
const EXPLOSION_COLORS = ["#ffd76a", "#ff9d5c", "#8ee7ff", "#ffffff", "#b18cff"];
function explodeUfo(ufo) {
const idx = game.ufos.indexOf(ufo);
if (idx !== -1) game.ufos.splice(idx, 1);
AudioFX.explosion();
game.score += 1;
updateScoreDisplay();
const count = CONFIG.explosion.min + Math.floor(Math.random() * (CONFIG.explosion.max - CONFIG.explosion.min + 1));
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 6 + Math.random() * 16;
const life = 0.35 + Math.random() * 0.35;
game.particles.push({
x: ufo.x,
y: ufo.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: 0.3 + Math.random() * 0.6,
color: pick(EXPLOSION_COLORS),
life,
maxLife: life
});
}
game.rings.push({ x: ufo.x, y: ufo.y, t: 0, duration: CONFIG.explosion.ringMs / 1000 });
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function cartoonFont(size) {
return 'bold ' + size + 'px "Comic Sans MS", "Chalkboard SE", "Marker Felt", "Segoe UI", sans-serif';
}
function traceRoundRect(x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function generateStars() {
const skyBottom = CONFIG.virtualHeight - CONFIG.groundHeight;
game.stars = [];
for (let i = 0; i < CONFIG.starCount; i++) {
const r = 0.25 + Math.random() * 0.45;
game.stars.push({
x: Math.random() * CONFIG.virtualWidth,
y: 1 + Math.random() * (skyBottom - 3),
r,
baseAlpha: 0.35 + Math.random() * 0.5,
phase: Math.random() * Math.PI * 2,
speed: 0.6 + Math.random() * 2.2,
bright: r > 0.58
});
}
}
const HOUSE_BODIES = ["#e8d5b5", "#d9b38c", "#c98f75", "#b56576"];
const HOUSE_ROOFS = ["#8d5b4f", "#7f4a3c", "#9c6644", "#6d597a"];
function generateHouses() {
const count = 3 + Math.floor(Math.random() * 3);
const houses = [];
let attempts = 0;
while (houses.length < count && attempts < 80) {
attempts++;
const w = 8 + Math.random() * 5;
const x = 2 + Math.random() * (CONFIG.virtualWidth - 4 - w);
if (houses.some((o) => Math.abs(o.x - x) < o.w / 2 + w / 2 + 3)) continue;
houses.push({
x,
w,
h: 6 + Math.random() * 4,
roofH: 3 + Math.random() * 2,
body: pick(HOUSE_BODIES),
roof: pick(HOUSE_ROOFS)
});
}
game.houses = houses;
}
function generateScenery() {
generateStars();
generateHouses();
}
function currentPhase() {
let phase = CONFIG.phases[0];
for (const p of CONFIG.phases) {
if (game.elapsed >= p.at) phase = p;
}
return phase;
}
// Post-phase-4 gradual ramp: once the final phase begins, fall speed eases up
// toward CONFIG.ramp.maxFallSpeed and the spawn interval eases down toward
// CONFIG.ramp.minSpawnEvery. The pace is set by speedPerStep per `step` seconds,
// so one full ramp window = (cap - base) / speedPerStep * step.
function currentDifficulty(phase) {
const last = CONFIG.phases[CONFIG.phases.length - 1];
if (phase !== last) return { fallSpeed: phase.fallSpeed, spawnEvery: phase.spawnEvery };
const into = Math.max(0, game.elapsed - last.at);
const total = ((CONFIG.ramp.maxFallSpeed - last.fallSpeed) / CONFIG.ramp.speedPerStep) * CONFIG.ramp.step;
const p = total > 0 ? Math.min(1, into / total) : 1;
return {
fallSpeed: Math.min(last.fallSpeed + (CONFIG.ramp.maxFallSpeed - last.fallSpeed) * p, CONFIG.ramp.maxFallSpeed),
spawnEvery: Math.max(CONFIG.ramp.minSpawnEvery, last.spawnEvery - (last.spawnEvery - CONFIG.ramp.minSpawnEvery) * p)
};
}
function spawnUfo() {
const phase = currentPhase();
const diff = currentDifficulty(phase);
const table = pick(phase.tables);
const other = 2 + Math.floor(Math.random() * 11);
const pair = Math.random() < 0.5 ? [table, other] : [other, table];
const a = pair[0];
const b = pair[1];
const margin = UFO.radius + 1;
for (let attempt = 0; attempt < 8; attempt++) {
const amp = UFO.wobbleAmpMin + Math.random() * (UFO.wobbleAmpMax - UFO.wobbleAmpMin);
const lo = margin + amp;
const hi = CONFIG.virtualWidth - margin - amp;
const baseX = lo + Math.random() * (hi - lo);
if (game.ufos.some((u) => u.y < 20 && Math.abs(u.baseX - baseX) < UFO.minGap)) continue;
game.ufos.push({
a,
b,
result: a * b,
baseX,
x: baseX,
y: UFO.spawnY,
speed: diff.fallSpeed,
wobbleAmp: amp,
wobblePhase: Math.random() * Math.PI * 2,
wobbleSpeed: UFO.wobbleSpeedMin + Math.random() * (UFO.wobbleSpeedMax - UFO.wobbleSpeedMin)
});
return true;
}
return false;
}
function drawSky() {
const w = CONFIG.virtualWidth;
const h = CONFIG.virtualHeight;
const sky = ctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#070b21");
sky.addColorStop(1, "#1c2452");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, w, h);
}
function drawFadedTitle() {
const maxW = CONFIG.virtualWidth - 8;
const lines = ["TIMES TABLE", "INVADERS"];
ctx.save();
ctx.globalAlpha = CONFIG.fadedTitleAlpha;
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let y = 24;
for (const line of lines) {
let size = 9;
ctx.font = cartoonFont(size);
const tw = ctx.measureText(line).width;
if (tw > maxW) size *= maxW / tw;
ctx.font = cartoonFont(size);
ctx.fillText(line, CONFIG.virtualWidth / 2, y);
y += size * 1.4;
}
ctx.restore();
}
function drawStars() {
const t = game.animTime;
ctx.fillStyle = "#dfe6ff";
for (const s of game.stars) {
const twinkle = 0.5 + 0.5 * Math.sin(t * s.speed + s.phase);
ctx.globalAlpha = s.baseAlpha * (0.3 + 0.7 * twinkle);
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
if (s.bright) {
const len = s.r * (1.6 + 1.4 * twinkle);
ctx.strokeStyle = "#dfe6ff";
ctx.lineWidth = 0.12;
ctx.beginPath();
ctx.moveTo(s.x - len, s.y);
ctx.lineTo(s.x + len, s.y);
ctx.moveTo(s.x, s.y - len);
ctx.lineTo(s.x, s.y + len);
ctx.stroke();
}
}
ctx.globalAlpha = 1;
}
function drawGround() {
const w = CONFIG.virtualWidth;
const h = CONFIG.virtualHeight;
const top = h - CONFIG.groundHeight;
const grass = ctx.createLinearGradient(0, top, 0, h);
grass.addColorStop(0, "#3fae5a");
grass.addColorStop(1, "#237a38");
ctx.fillStyle = grass;
ctx.fillRect(0, top, w, CONFIG.groundHeight);
ctx.fillStyle = "rgba(255, 255, 255, 0.14)";
ctx.fillRect(0, top, w, 0.5);
}
function drawHouse(house) {
const groundTop = CONFIG.virtualHeight - CONFIG.groundHeight;
const bodyTop = groundTop - house.h;
ctx.fillStyle = house.body;
ctx.fillRect(house.x, bodyTop, house.w, house.h);
ctx.fillStyle = house.roof;
ctx.beginPath();
ctx.moveTo(house.x - 1, bodyTop);
ctx.lineTo(house.x + house.w / 2, bodyTop - house.roofH);
ctx.lineTo(house.x + house.w + 1, bodyTop);
ctx.closePath();
ctx.fill();
const dw = Math.min(2.4, house.w * 0.3);
const dh = Math.min(3.6, house.h * 0.55);
ctx.fillStyle = "#5d4127";
ctx.fillRect(house.x + house.w / 2 - dw / 2, groundTop - dh, dw, dh);
const ww = Math.min(2, house.w * 0.26);
ctx.fillStyle = "#ffe9a8";
ctx.fillRect(house.x + house.w * 0.14, bodyTop + house.h * 0.28, ww, ww);
}
function getCannonPivot() {
const baseTop = CONFIG.virtualHeight - CANNON.baseH;
return { x: CONFIG.virtualWidth / 2, y: baseTop - CANNON.turretR * 0.55 };
}
function getCannonTip() {
const pivot = getCannonPivot();
const len = CANNON.barrelH - game.cannon.recoil * CANNON.recoilOffset;
return {
x: pivot.x + Math.cos(game.cannon.angle) * len,
y: pivot.y + Math.sin(game.cannon.angle) * len
};
}
function drawCannon() {
const pivot = getCannonPivot();
const h = CONFIG.virtualHeight;
const baseTop = h - CANNON.baseH;
const recoil = game.cannon.recoil * CANNON.recoilOffset;
ctx.fillStyle = "#3d4668";
ctx.beginPath();
ctx.moveTo(pivot.x - CANNON.baseW / 2, h);
ctx.lineTo(pivot.x - CANNON.baseW / 2 + 2, baseTop);
ctx.lineTo(pivot.x + CANNON.baseW / 2 - 2, baseTop);
ctx.lineTo(pivot.x + CANNON.baseW / 2, h);
ctx.closePath();
ctx.fill();
ctx.save();
ctx.translate(pivot.x, pivot.y);
ctx.rotate(game.cannon.angle + Math.PI / 2);
const barrel = ctx.createLinearGradient(-CANNON.barrelW / 2, 0, CANNON.barrelW / 2, 0);
barrel.addColorStop(0, "#7d8ac0");
barrel.addColorStop(0.5, "#c3cdf2");
barrel.addColorStop(1, "#66739f");
ctx.fillStyle = barrel;
traceRoundRect(-CANNON.barrelW / 2, -CANNON.barrelH + recoil + CANNON.rimH, CANNON.barrelW, CANNON.barrelH - CANNON.rimH, 1.2);
ctx.fill();
ctx.fillStyle = "#aab6e0";
traceRoundRect(-CANNON.rimW / 2, -CANNON.barrelH + recoil, CANNON.rimW, CANNON.rimH, 0.5);
ctx.fill();
ctx.restore();
ctx.fillStyle = "#57648f";
ctx.beginPath();
ctx.arc(pivot.x, pivot.y, CANNON.turretR, 0, Math.PI * 2);
ctx.fill();
}
function drawUfo(ufo) {
const x = ufo.x;
const y = ufo.y;
const r = UFO.radius;
const domeR = r * 0.58;
const dome = ctx.createLinearGradient(0, y - domeR, 0, y);
dome.addColorStop(0, "rgba(170, 235, 255, 0.95)");
dome.addColorStop(1, "rgba(70, 130, 210, 0.5)");
ctx.fillStyle = dome;
ctx.beginPath();
ctx.arc(x, y - r * 0.08, domeR, Math.PI, 0);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(255, 255, 255, 0.75)";
ctx.lineWidth = 0.3;
ctx.beginPath();
ctx.arc(x - domeR * 0.1, y - r * 0.08, domeR * 0.6, Math.PI * 1.15, Math.PI * 1.75);
ctx.stroke();
const body = ctx.createLinearGradient(0, y - r * 0.3, 0, y + r * 0.42);
body.addColorStop(0, "#e6ecff");
body.addColorStop(0.55, "#9fb0e8");
body.addColorStop(1, "#5a699e");
ctx.fillStyle = body;
ctx.beginPath();
ctx.ellipse(x, y + r * 0.06, r, r * 0.36, 0, 0, Math.PI * 2);
ctx.fill();
const t = game.animTime;
for (let i = -1; i <= 1; i++) {
const on = Math.sin(t * 4 + ufo.wobblePhase + i * 2.1) > 0;
ctx.fillStyle = on ? "#ffd76a" : "#6d6236";
ctx.beginPath();
ctx.arc(x + i * r * 0.52, y + r * 0.14, 0.45, 0, Math.PI * 2);
ctx.fill();
}
const label = ufo.a + " \u00d7 " + ufo.b;
ctx.font = cartoonFont(2.3);
const tw = ctx.measureText(label).width;
const pw = Math.max(tw + 2.4, r * 1.1);
const ph = 3.1;
const py = y + r * 0.42 + 0.9;
traceRoundRect(x - pw / 2, py, pw, ph, 1.4);
ctx.fillStyle = "rgba(8, 12, 30, 0.85)";
ctx.fill();
ctx.strokeStyle = "rgba(150, 180, 255, 0.65)";
ctx.lineWidth = 0.18;
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, x, py + ph / 2 + 0.08);
}
function drawLaser() {
const laser = game.laser;
if (!laser) return;
let end;
if (laser.target && game.ufos.includes(laser.target)) {
end = { x: laser.target.x, y: laser.target.y };
} else {
end = { x: laser.origin.x, y: -2 };
}
const fade = Math.max(0, 1 - laser.t / CONFIG.laserDuration);
ctx.save();
ctx.lineCap = "round";
ctx.strokeStyle = "rgba(120, 220, 255, " + 0.3 * fade + ")";
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(laser.origin.x, laser.origin.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
ctx.strokeStyle = "rgba(255, 255, 255, " + 0.95 * fade + ")";
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(laser.origin.x, laser.origin.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
const fr = 0.6 + 1.4 * fade;
const flash = ctx.createRadialGradient(laser.origin.x, laser.origin.y, 0, laser.origin.x, laser.origin.y, fr);
flash.addColorStop(0, "rgba(255, 255, 255, " + 0.9 * fade + ")");
flash.addColorStop(1, "rgba(140, 220, 255, 0)");
ctx.fillStyle = flash;
ctx.beginPath();
ctx.arc(laser.origin.x, laser.origin.y, fr, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawEffects() {
for (const ring of game.rings) {
const p = ring.t / ring.duration;
ctx.strokeStyle = "rgba(180, 230, 255, " + 0.7 * (1 - p) + ")";
ctx.lineWidth = 0.5 * (1 - p) + 0.1;
ctx.beginPath();
ctx.arc(ring.x, ring.y, 1 + p * 7, 0, Math.PI * 2);
ctx.stroke();
}
for (const pt of game.particles) {
const p = Math.max(0, pt.life / pt.maxLife);
ctx.globalAlpha = p;
ctx.fillStyle = pt.color;
ctx.beginPath();
ctx.arc(pt.x, pt.y, pt.r * (0.5 + 0.5 * p), 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function draw() {
const w = CONFIG.virtualWidth;
const h = CONFIG.virtualHeight;
ctx.setTransform(viewScale, 0, 0, viewScale, 0, 0);
drawSky();
drawFadedTitle();
drawStars();
drawGround();
for (const house of game.houses) drawHouse(house);
for (const ufo of game.ufos) drawUfo(ufo);
drawLaser();
drawCannon();
drawEffects();
}
function wireResize() {
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(resizeCanvas).observe(els.canvas);
}
window.addEventListener("resize", resizeCanvas);
}
function init() {
resizeCanvas();
generateScenery();
wireResize();
wireInput();
wireOverlays();
game.leaderboard = loadLeaderboard();
updateScoreDisplay();
enterWelcome();
requestAnimationFrame(frame);
}
init();