941 lines
27 KiB
JavaScript
941 lines
27 KiB
JavaScript
"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();
|