Initial import of project
This commit is contained in:
commit
59bece496b
7 files changed
+1562
No files matched your search
@@ -0,0 +1,11 @@
|
||||
1. Please remove the 1 time table from all the UFOs. - DONE
|
||||
2. Rotate the cannon so it points in the UFO direction when it shoots.
|
||||
3. When the welcome overlay is displayed, disable the input and the fire button. Same when the game over overlay is displayed.
|
||||
4. When Enter is pressed to submit a number, animate the fire button as if the fire button was pressed
|
||||
5. Add some sound effects: a laser bean sound when the canon fires, an explosion when an UFO is destroyed, a game over music. Add a Mute toggle next to the score.
|
||||
6. Show the High Scores on the game over overlay.
|
||||
7. In the footer, the number input and Fire button should be centered. The score should be right aligned with the border of the game area. The mute toggle should be left aligned with the border of the game area.
|
||||
8. Simplify the welcome overlay instructions. Just keep:
|
||||
- Type a multiplication result and hit Fire (or Enter)
|
||||
- Don't let any UFO land on Earth!
|
||||
9. On the overlays where the high scores is displayed, please make the high score appear in a separate area below rather than in the same area as the rest.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Implementation Plan — Times Table Invaders
|
||||
|
||||
## 1. Game name
|
||||
**Times Table Invaders** — a Space Invaders homage that conveys both multiplication practice and descending aliens. (Alternatives: "Alien Arithmetic", "UFO Down!", "Cosmic Times".) Used in `<title>`, the welcome overlay, and the faded sky title.
|
||||
|
||||
## 2. Deliverables
|
||||
```
|
||||
/workspace/
|
||||
├── README.md
|
||||
├── PLAN.md ← this plan
|
||||
├── index.html ← single entry point (links style.css + game.js)
|
||||
├── style.css ← layout, HUD, overlays, responsive rules
|
||||
└── game.js ← all logic; vanilla JS, classic <script> tag (no modules/build)
|
||||
```
|
||||
No dependencies, no build step; must work when `index.html` is opened directly (`file://`).
|
||||
|
||||
## 3. Technical decisions
|
||||
- **Scene on `<canvas>`** (stars, faded title, UFOs, ground, houses, cannon, laser, explosions): full cartoon control, easy particle effects, one render loop.
|
||||
- **HUD + overlays in DOM**: input / Fire button / score below the scene; welcome and game-over overlays.
|
||||
- **Fixed 3:4 portrait aspect** for the scene, centered and height-capped on desktop → responsive from phones to desktops with no layout branches.
|
||||
- **Virtual coordinate system** (~90 × 120 units) + one uniform scale + devicePixelRatio-aware backing store → resizing is trivial; all game logic is resolution-independent.
|
||||
- **rAF loop with delta time clamped to 50 ms** (prevents jumps after tab switches).
|
||||
- **localStorage** key `tti.leaderboard.v1` = JSON array of top-3 `{name, score}`; wrapped in try/catch for private browsing.
|
||||
|
||||
## 4. Architecture (game.js)
|
||||
- `CONFIG` object with every tunable: phase table, spawn/speed ramps, sizes, timings.
|
||||
- One `Game` state object: `ufos[]`, `stars[]`, `houses[]`, `particles[]`, laser/cannon animation state, score, elapsed time; states `WELCOME | PLAYING | GAME_OVER`.
|
||||
- Plain-function systems, each with update + draw: starfield, spawner/mover, difficulty, shot queue, input, leaderboard, renderer.
|
||||
|
||||
## 5. Game mechanics (precise)
|
||||
- **UFO generation**: pick table `b` from the currently unlocked set, multiplier `m` ∈ 2..12 (table 1 excluded entirely); display `b × m` or `m × b` (random order); store product. Radius ≈ 9 units; text sized to stay readable at a 360 px phone width.
|
||||
- **Motion**: `y += speed·dt` with per-UFO speed = phase base × rand(0.9–1.1); wobble `x = baseX + A·sin(ωt+φ)`, A ∈ [1.5, 3] units (the "ondulating trajectory").
|
||||
- **No overlap**: on spawn, reject x positions within `r₁+r₂+2` of any live UFO; up to 8 retries, else defer the spawn. Multiplications always stay readable.
|
||||
- **Shooting**: on submit value `v`, all UFOs with product == v enter a shot queue (bottom-most first, ~200 ms apart = "rapid succession"); +1 score each. No match → cannon still fires as miss feedback.
|
||||
- **Game over**: any UFO's bottom reaches the ground line → freeze scene, show overlay.
|
||||
|
||||
## 6. Difficulty progression (elapsed time; all values tunable in CONFIG)
|
||||
| Phase | At | Tables added | Spawn interval | Fall speed |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 0 s | 2, 10 | 4.0 s | 10 u/s |
|
||||
| 2 | 60 s | +4, 5 | 3.5 s | 12 u/s |
|
||||
| 3 | 150 s | +3, 7, 8, 9 (+6*) | 3.0 s | 14 u/s |
|
||||
| 4 | 300 s | +11, 12 | 2.6 s | 16 u/s |
|
||||
|
||||
After phase 4: continuous gradual ramp — spawn interval eases down to a 1.4 s floor, fall speed rises toward ~40 u/s cap.
|
||||
\* Table 1 is excluded from all phases (user request). The README also omits table 6; assumption: **6 joins phase 3**.
|
||||
|
||||
## 7. UI spec
|
||||
- **Sky**: night gradient; ~90 stars regenerated each game with pulsating alpha (random phase/speed per star); faded game title centered in the sky (~7% white).
|
||||
- **Ground** (bottom strip of canvas): 3–5 simple cartoon houses randomized per game; cannon in the foreground, centered.
|
||||
- **Cannon animation**: on shot → recoil + muzzle flash; laser beam (bright core + wide glow) from barrel tip to target for ~150 ms per hit; explosion = 18–26 fading particles + expanding ring (~450 ms), UFO disappears quickly.
|
||||
- **HUD below the scene**: `[ input ] [ Fire ] [ score ]`. Input: `inputmode=numeric`, digits-only filter, hard cap at 3 digits (extras ignored), auto-focused on load/start/after each submit, Enter or Fire submits, field cleared after submit. Score counter sits next to the Fire button.
|
||||
- **Responsive**: scene width = `min(100%, height-derived)`; touch-friendly targets (≥44 px); works 360×640 → 1920×1080.
|
||||
|
||||
## 8. Leaderboard & screens
|
||||
- **Top 3** in localStorage. On game over, if score > 0 and qualifies (fewer than 3 entries or strictly greater than the lowest) → congratulation popup with a name field (≤16 chars, required) → insert at correct rank, evict lowest, persist.
|
||||
- **Welcome overlay**: game title, 2–3 line instructions, leaderboard table (rank / name / score, with "no scores yet" empty state), Start button → regenerates stars/houses, resets state, focuses input, starts the loop.
|
||||
|
||||
## 9. Implementation steps
|
||||
1. Write this plan to `/workspace/PLAN.md`.
|
||||
2. Scaffold `index.html` + `style.css` + `game.js` (layout, canvas, HUD, both overlays); verify layout in a browser.
|
||||
3. Engine: virtual coords, DPR-aware resize handling, rAF loop with clamped dt, state machine.
|
||||
4. Background: starfield + pulsation, faded title, ground/houses, cannon idle drawing.
|
||||
5. UFOs: phase-based spawning, wobble motion, overlap-safe placement, cartoon UFO rendering with `a × b`.
|
||||
6. Shooting: input rules, match detection, sequential shot queue, laser/recoil/flash, explosion particles, score updates.
|
||||
7. Post-phase-4 gradual speed/spawn ramp.
|
||||
8. Game-over flow + leaderboard persistence + name-entry popup.
|
||||
9. Welcome overlay wiring (Start, leaderboard display).
|
||||
10. Responsive pass + edge cases (empty input, rapid double-Enter, resize mid-game, `file://`), then run the QA checklist.
|
||||
|
||||
## 10. QA checklist (manual)
|
||||
- Opens from `file://`, no console errors, vanilla JS only
|
||||
- Correct answer destroys matching UFO(s); multiple matches all destroyed in sequence; +1 each
|
||||
- Wrong/empty input: cannon fires, nothing hit, field cleared and refocused
|
||||
- Digits-only enforcement; >3 digits ignored; Enter and Fire both submit
|
||||
- Early game shows only 2/10 tables; later phases add tables; late game gets faster
|
||||
- UFOs never overlap illegibly; wobble visible; stars pulse and differ per game; faded title present
|
||||
- Cannon animates on shot; explosions quick
|
||||
- A landed UFO ends the game
|
||||
- Top-3 popup asks for a name; leaderboard ranks/saves/evicts correctly and persists across reloads
|
||||
- Playable at phone and desktop sizes
|
||||
|
||||
## Flagged assumptions
|
||||
1. Name: **Times Table Invaders** (alternatives listed in §1).
|
||||
2. Table 6 included in phase 3 (the README's phase list omits it); table 1 excluded from all phases.
|
||||
3. No audio — out of scope (possible WebAudio stretch goal).
|
||||
@@ -0,0 +1,24 @@
|
||||
I want to design a game that will help the player improve with time tables.
|
||||
|
||||
Plateform
|
||||
The game will run in a browser and should consist in a single html file. Satelite files can be used (for instance for scripts and styles). Only vanila javascript must be used.
|
||||
|
||||
Game design
|
||||
cartoon style UFOs are trying to land on Earth. Inside the UFO is a multiplication made of two integers from 1 to 12. On Earth, a canon can shoot. The player inputs a number. If the number corresponds to the result of one of the multiplications from an UFO, the canon shoots the UFO and the UFO explodes. If the number corresponds to several UFOs, all of them are shut down in rapid succession. Every destroyed UFO increments the score by 1. The player looses when one of the UFOs lands on Earth.
|
||||
|
||||
Game progression
|
||||
At first, UFOs appear at a low pace from the sky and descend slowly. They contain predominently easy multiplications (from the 2 and 10 time tables). Then medium multiplications are added to the mix (from the 4 and 5 time tables), then hard ones (3, 7, 8, 9 time tables) and finally very hard ones (11 and 12). Once all the time tables are in the mix, UFOs start very gradually appearing faster and descending faster.
|
||||
|
||||
UI
|
||||
The main element of the screen will be a sky view. The sky will have small stars in the background that shine with a variable pulsating intensity. Stars will be randomly generated at each game. Also in the sky background will be the faded title of the game. UFOs appear at the top and descend toward the ground in a slighlty ondulating trajectory. When they are hit by the canon laser bean, they explode and disapear quickly.
|
||||
Below the sky will be the ground, with small houses in the background and a canon in the foreground. The canon will be animated when it shoots a laser beam.
|
||||
Below the ground will be a text input and a shoot button. The text input will automatically get the focus. It will only accept numbers. It will be wide enough to show a maximum of 3 digits. When the user enters more than 3 digits, extra digits are ignored. Hitting the Enter key submits the value. So does pressing the Fire button. After submitting the value, the text field is cleared. If the value corresponds to a multiplication, the canon shoots down the corresponding UFO(s).
|
||||
Nest to the Fire button is a score counter.
|
||||
The design should be responsive to work on any screen size from smartphone up to desktops.
|
||||
There should never be two overlapping UFOs, or at least if they overlap, it should be only slightly so that the multiplication they contain is still perfectly readable.
|
||||
|
||||
Leaderboard
|
||||
The game will keep track of the three high scores in the browser local storage. When the game ends, if the user reaches the top 3, a popup congratulates him and asks for his or her name. The score is then added to the leaderboard at the right rank. The former lowest score from the leader board is erased.
|
||||
|
||||
Welcome screen
|
||||
Before the game start, a welcome overlay gives the game name, very brief instructions and a Start button, and shows the leaderboard (name, rank and score).
|
||||
@@ -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();
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Times Table Invaders</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<div id="scene-wrap">
|
||||
<canvas id="scene"></canvas>
|
||||
|
||||
<div id="welcome-overlay" class="overlay">
|
||||
<div class="overlay-card">
|
||||
<h1 class="game-title">Times Table Invaders</h1>
|
||||
<p class="tagline">The UFOs are coming!</p>
|
||||
<ul class="instructions">
|
||||
<li>Type a multiplication result and hit <strong>Fire</strong> (or Enter)</li>
|
||||
<li>Don't let any UFO land on Earth!</li>
|
||||
</ul>
|
||||
<section class="leaderboard-block">
|
||||
<h2>High Scores</h2>
|
||||
<table id="welcome-leaderboard" class="leaderboard">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p id="leaderboard-empty" class="empty-note hidden">No scores yet — be the first!</p>
|
||||
</section>
|
||||
<button id="start-btn" class="btn btn-primary" type="button">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="gameover-overlay" class="overlay hidden">
|
||||
<div class="overlay-card">
|
||||
<h1 class="game-title small">A UFO Landed!</h1>
|
||||
<p class="final-score">Score: <span id="final-score-value">0</span></p>
|
||||
|
||||
<div id="top3-block" class="hidden">
|
||||
<p class="congrats">You made the top 3!</p>
|
||||
<label for="player-name">Your name</label>
|
||||
<input id="player-name" type="text" maxlength="16" autocomplete="off" autocapitalize="words" spellcheck="false" />
|
||||
<button id="save-score-btn" class="btn" type="button">Save score</button>
|
||||
</div>
|
||||
|
||||
<section class="leaderboard-block">
|
||||
<h2>High Scores</h2>
|
||||
<table id="gameover-leaderboard" class="leaderboard">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Rank</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p id="gameover-leaderboard-empty" class="empty-note hidden">No scores yet — be the first!</p>
|
||||
</section>
|
||||
|
||||
<button id="restart-btn" class="btn btn-primary" type="button">Play again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hud">
|
||||
<button id="mute-btn" class="btn btn-mute muted" type="button" aria-pressed="true" title="Toggle sound" aria-label="Toggle sound">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor" />
|
||||
<g class="mute-waves" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round">
|
||||
<path d="M16.5 8.5a5 5 0 0 1 0 7" />
|
||||
<path d="M19 6a8.5 8.5 0 0 1 0 12" />
|
||||
</g>
|
||||
<g class="mute-x" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="16" y1="9" x2="21" y2="15" />
|
||||
<line x1="21" y1="9" x2="16" y2="15" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="hud-center">
|
||||
<input id="answer" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" autocomplete="off" autocapitalize="none" spellcheck="false" aria-label="Result of the multiplication" placeholder="?" />
|
||||
<button id="fire-btn" class="btn btn-fire" type="button">Fire</button>
|
||||
</div>
|
||||
<div id="score" aria-live="polite">0</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
CONTAINER_NAME="opencode-mountly"
|
||||
IMAGE_NAME="opencode-mountly-dev"
|
||||
BASE_IMAGE="fedora:44"
|
||||
|
||||
MISSING_TOOLS=(
|
||||
"nodejs"
|
||||
"npm"
|
||||
"cargo"
|
||||
"flatpak"
|
||||
"which"
|
||||
"git"
|
||||
"webkit2gtk4.1"
|
||||
"webkit2gtk4.1-devel"
|
||||
"gtk3-devel"
|
||||
"libsoup3-devel"
|
||||
"openssl-devel"
|
||||
"flatpak-builder"
|
||||
)
|
||||
|
||||
# 1. Check if Podman is installed
|
||||
if ! command -v podman &> /dev/null; then
|
||||
echo -e "${RED}Error: Podman is not installed.${NC}"
|
||||
echo "Install it with: sudo dnf install podman"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Check if we are inside a directory (optional but recommended)
|
||||
if [ ! -d "$(pwd)" ]; then
|
||||
echo -e "${RED}Error: Current directory does not exist.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Building custom Podman image with dependencies...${NC}"
|
||||
TEMP_DOCKERFILE=$(mktemp)
|
||||
cat > "$TEMP_DOCKERFILE" << EOF
|
||||
FROM $BASE_IMAGE
|
||||
|
||||
# Install system dependencies
|
||||
RUN dnf install -y ${MISSING_TOOLS[*]} \
|
||||
&& dnf clean all
|
||||
|
||||
# Install OpenCode
|
||||
RUN curl -fsSL https://opencode.ai/install | bash
|
||||
ENV PATH="/root/.opencode/bin:$PATH"
|
||||
|
||||
# Set up workspace
|
||||
WORKDIR /workspace
|
||||
|
||||
# Default command
|
||||
CMD ["opencode"]
|
||||
EOF
|
||||
|
||||
# Build the image
|
||||
echo -e "${YELLOW}Downloading Fedora base image and building...${NC}"
|
||||
podman build -t "localhost/$IMAGE_NAME" -f "$TEMP_DOCKERFILE" .
|
||||
|
||||
# Check if build succeeded
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}Error: Image build failed.${NC}"
|
||||
rm "$TEMP_DOCKERFILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove temporary file
|
||||
rm "$TEMP_DOCKERFILE"
|
||||
|
||||
echo -e "${GREEN}Custom image 'localhost/$IMAGE_NAME' built successfully.${NC}"
|
||||
|
||||
# Define the config file path on the host
|
||||
HOST_CONFIG="$HOME/.var/app/ai.opencode.opencode/config/opencode/opencode.jsonc"
|
||||
|
||||
# Check if the config file exists
|
||||
if [ ! -f "$HOST_CONFIG" ]; then
|
||||
echo -e "${YELLOW}Warning: Config file not found at $HOST_CONFIG${NC}"
|
||||
echo "OpenCode will start with default settings."
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Starting OpenCode in Podman...${NC}"
|
||||
echo -e "${YELLOW}Working Directory: $(pwd)${NC}"
|
||||
echo -e "${YELLOW}Image: $IMAGE_NAME${NC}"
|
||||
echo -e "${YELLOW}Volume Mount: ./ -> /workspace (SELinux enabled :Z)${NC}"
|
||||
echo -e "${YELLOW}Config File: $HOST_CONFIG -> /tmp/.config/opencode/opencode.jsonc${NC}"
|
||||
echo "----------------------------------------"
|
||||
|
||||
# 3. Run the Podman command
|
||||
podman run -it --rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-v "$(pwd)":/workspace:Z \
|
||||
-w /workspace \
|
||||
-e HOME=/tmp \
|
||||
-v "$HOST_CONFIG":/tmp/.config/opencode/opencode.jsonc:Z \
|
||||
"localhost/$IMAGE_NAME"
|
||||
|
||||
exit_code=$?
|
||||
|
||||
# 4. Check exit status
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
echo -e "${GREEN}OpenCode exited successfully.${NC}"
|
||||
else
|
||||
echo -e "${RED}OpenCode exited with error code: $exit_code${NC}"
|
||||
fi
|
||||
|
||||
exit $exit_code
|
||||
@@ -0,0 +1,292 @@
|
||||
:root {
|
||||
--bg-top: #0b1026;
|
||||
--bg-bottom: #171d42;
|
||||
--panel: #1c2347;
|
||||
--panel-border: #39457f;
|
||||
--text: #eef1ff;
|
||||
--muted: #9aa3d1;
|
||||
--accent: #5eead4;
|
||||
--fire: #ff6b4a;
|
||||
--chrome-reserve: 140px;
|
||||
--scene-w: min(100%, calc((100vh - var(--chrome-reserve)) * 0.75));
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
:root {
|
||||
--scene-w: min(100%, calc((100dvh - var(--chrome-reserve)) * 0.75));
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Comic Sans MS", "Chalkboard SE", "Marker Felt", "Segoe UI", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
background: linear-gradient(var(--bg-top), var(--bg-bottom));
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#scene-wrap {
|
||||
position: relative;
|
||||
width: var(--scene-w);
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: #0a0f2c;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
#scene {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(8, 11, 32, 0.85);
|
||||
}
|
||||
|
||||
.overlay-card {
|
||||
width: min(100%, 420px);
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--panel);
|
||||
border: 2px solid var(--panel-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: clamp(1.5rem, 6vw, 2.2rem);
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.game-title.small {
|
||||
font-size: clamp(1.3rem, 5vw, 1.8rem);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 0 0 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin: 0 auto 14px;
|
||||
padding-left: 20px;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.leaderboard-block {
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.leaderboard-block h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.leaderboard {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.leaderboard th,
|
||||
.leaderboard td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.leaderboard th {
|
||||
color: var(--muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.leaderboard tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: var(--panel-border);
|
||||
min-height: 44px;
|
||||
padding: 10px 22px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 3px 0 rgba(0, 0, 0, 0.35);
|
||||
touch-action: manipulation;
|
||||
transition: transform 0.05s ease, filter 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:not(:disabled):hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.btn:not(:disabled):active,
|
||||
.btn-pressed {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #08241f;
|
||||
}
|
||||
|
||||
.btn-fire {
|
||||
background: var(--fire);
|
||||
}
|
||||
|
||||
.btn-mute {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-self: start;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
#mute-btn .mute-x {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#mute-btn.muted .mute-x {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#mute-btn.muted .mute-waves {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#hud {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: var(--scene-w);
|
||||
}
|
||||
|
||||
.hud-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#answer {
|
||||
font: inherit;
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 2px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
min-height: 44px;
|
||||
width: 6ch;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
#answer:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#answer:disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
#score {
|
||||
justify-self: end;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
min-width: 3.5ch;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 2px solid var(--panel-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.final-score {
|
||||
margin: 8px 0 16px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
#top3-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.congrats {
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#player-name {
|
||||
font: inherit;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 2px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#player-name:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
Reference in new issue
Block a user