diff -ruN a/index.html b/index.html --- a/index.html +++ b/index.html @@ -2,6 +2,10 @@ + - - - - - - - - - - - - - - @@ -85,32 +64,6 @@ keyboard (ESC) and touch alike; wired in src/scenes/game.js. --> - - - - - - - - - -
diff -ruN a/src/config.js b/src/config.js --- a/src/config.js +++ b/src/config.js @@ -7,9 +7,9 @@ export const GAME_W = 1280; export const GAME_H = 720; -// Fairy-tale palette (RGB). Anna's piumino "carta da zucchero" drives the menu mood. +// Fairy-tale palette (RGB). the pastel puffer jacket "carta da zucchero" drives the menu mood. export const PALETTE = { - sky: [167, 199, 231], // azzurro/lilla — Anna's puffer jacket + sky: [167, 199, 231], // azzurro/lilla — the puffer jacket lilac: [199, 186, 232], cream: [255, 248, 240], gold: [212, 175, 55], // royal gold accents diff -ruN a/src/i18n/en.js b/src/i18n/en.js --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -5,7 +5,7 @@ // The three rules in it.js apply verbatim (no square brackets in k.text strings, no emoji in // pixel-font strings, placeholders are `{name}`). English needs no gendered care. // -// Proper nouns stay put: "Anna", "Pixel Princess Platformer". "Coccoline" — the in-joke currency +// Proper nouns stay put: "Pixel Princess Platformer". "Coccoline" — the in-joke currency // this whole game bills her in — DOES get translated ("Cuddles"): it is the punchline of the // receipt, and a punchline nobody can read is not a punchline. @@ -24,7 +24,7 @@ "menu.chooseHeroine": "Choose your heroine", // --- Heroines (src/config.js CHARACTERS) --------------------------------- - "char.anna.name": "Anna", + "char.anna.name": "Rose", "char.anna.tagline": "The Heroine", "char.anna.desc": "Sweet and brave, she wins every heart at first sight.", "char.sognatrice.name": "Dreamer", @@ -69,7 +69,7 @@ // --- Finale -------------------------------------------------------------- "finale.finalTime": "Final time {time}", "finale.heroineTitle": "The Perfect Princess", - "finale.title": "For Anna", + "finale.title": "Congratulations!", "finale.message": "You crossed enchanted forests, coral depths,\n" + "eastern rooftops and snowy peaks...\n" + diff -ruN a/src/i18n/it.js b/src/i18n/it.js --- a/src/i18n/it.js +++ b/src/i18n/it.js @@ -9,7 +9,7 @@ // they may appear only in DOM strings, or where the k.text() call already passes // font: "sans-serif" (the HUD counters, "★ Classifica", the invincibility label). // 3. The Italian stays FEMININE ("Sii la prima", "Bentornata", "Sei sicura"): the game is a gift -// for Anna. English is naturally neutral, so en.js needs no equivalent care. +// for the heroine. English is naturally neutral, so en.js needs no equivalent care. // // Placeholders are `{name}` and are interpolated by t(key, vars). @@ -28,7 +28,7 @@ "menu.chooseHeroine": "Scegli la tua eroina", // --- Heroines (src/config.js CHARACTERS) --------------------------------- - "char.anna.name": "Anna", + "char.anna.name": "Rosa", "char.anna.tagline": "La Protagonista", "char.anna.desc": "Dolce e coraggiosa, conquista ogni cuore al primo sguardo.", "char.sognatrice.name": "Sognatrice", @@ -73,7 +73,7 @@ // --- Finale -------------------------------------------------------------- "finale.finalTime": "Tempo finale {time}", "finale.heroineTitle": "Principessa Perfetta", - "finale.title": "Per Anna", + "finale.title": "Complimenti!", "finale.message": "Hai attraversato foreste incantate, abissi di corallo,\n" + "tetti d'oriente e cime innevate...\n" + diff -ruN a/src/main.js b/src/main.js --- a/src/main.js +++ b/src/main.js @@ -7,11 +7,9 @@ import { initI18n, onLangChange } from "./i18n/index.js"; import { bindTouchButtons, getInput } from "./controls.js"; import { bindAudioToggle, paintAudioToggle } from "./ui/audioToggle.js"; -import { bindShareButton } from "./ui/shareButton.js"; import { installAudioUnlock } from "./audioUnlock.js"; import { installViewportResync } from "./viewportResync.js"; import { installBackgroundFreeze } from "./backgroundFreeze.js"; -import { bindInstallHint } from "./ui/installHint.js"; import { bindAudioDebug } from "./ui/audioDebug.js"; import { bindFpsOverlay } from "./ui/fpsOverlay.js"; import { registerLoadingScene } from "./scenes/loading.js"; @@ -38,10 +36,6 @@ // Wire the global audio on/off button once; it applies any saved mute preference. bindAudioToggle(); -// Wire the menu's "Sfida un amico" button. A DOM button, not a Kaplay one: navigator.share() -// needs transient user activation, which a click dispatched from the rAF loop no longer has. -bindShareButton(); - // Unlock the WebAudio context on the first real DOM gesture (iOS Safari needs this — see // src/audioUnlock.js). Without it, neither music nor SFX ever play on iPhone. installAudioUnlock(); @@ -56,9 +50,6 @@ // racking up hours, no battery drain, and the time-attack clock stops). See src/backgroundFreeze.js. installBackgroundFreeze(); -// "Add to Home" hint on iOS Safari (true fullscreen lives only in the installed PWA). -bindInstallHint(); - // On-screen WebAudio diagnostics — only renders when the URL carries ?audiodebug=1. Lets a // real iPhone report its AudioContext state when iOS audio can't be reproduced in emulation. bindAudioDebug(); @@ -77,44 +68,6 @@ // Start on the loading screen; it calls k.go("menu") once assets are ready. k.go("loading"); -// Register the service worker for offline play / PWA install (src/sw.js → /sw.js at the -// site root, scope "/"). Production only: on localhost it would cache files between the -// Playwright test runs (and dev edits), serving stale content — so the dev/test loop stays -// service-worker-free, exactly like the window.__pj dev handle below. -if ( - "serviceWorker" in navigator && - location.hostname !== "localhost" && - location.hostname !== "127.0.0.1" -) { - // Keep an installed PWA on the latest deploy. If a worker already controls this page, a later - // `controllerchange` means a NEW deploy's worker took over (sw.js does skipWaiting + clients.claim - // so it activates at once) → reload once so the running session switches to the fresh code. The - // "already controlled" guard skips the reload on the very first install (nothing to replace); the - // update check runs at load / on foreground, so any reload lands near startup, not mid-level. - let reloading = false; - if (navigator.serviceWorker.controller) { - navigator.serviceWorker.addEventListener("controllerchange", () => { - if (reloading) return; - reloading = true; - location.reload(); - }); - } - window.addEventListener("load", () => { - navigator.serviceWorker - .register("./sw.js") - .then((reg) => { - reg.update?.(); // check for a newer worker now… - // …and again whenever the installed app is brought back to the foreground. - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") reg.update?.(); - }); - }) - .catch(() => { - // Offline support just won't be available this session — never break the game over it. - }); - }); -} - // Dev-only test handle (localhost). Lets automated tests/dev tools introspect the // engine AND drive it: `input` is the live virtual-input object (set .left/.right/.jump // to play headlessly without synthetic key events); `getRunTime` reads the time-attack clock diff -ruN a/src/scenes/finale.js b/src/scenes/finale.js --- a/src/scenes/finale.js +++ b/src/scenes/finale.js @@ -14,7 +14,6 @@ import { showReceipt, hideReceipt } from "../ui/receipt.js"; import { hideInsertCoin } from "../ui/insertCoin.js"; import { hideGameOver } from "../ui/gameOver.js"; -import { openLeaderboard, hideLeaderboard, isLeaderboardOpen } from "../ui/leaderboard.js"; import { hidePause } from "../ui/pauseMenu.js"; import { hideSettings } from "../ui/settings.js"; import { fadeToScene } from "../ui/transition.js"; @@ -34,7 +33,6 @@ hideInsertCoin(); hideGameOver(); hideReceipt(); - hideLeaderboard(); hidePause(); hideSettings(); resetHitStop(); // arrive at full speed even if a stomp's hit-stop was cut short by the goal @@ -138,38 +136,12 @@ k.z(21), ]); - // --- The closing sequence: read → CLASSIFICA → scontrino --------------------------------- - // The leaderboard comes FIRST and unmissably. It used to be last (receipt → "Chiudi" → board), - // which meant anyone who closed the app on the receipt never saw it at all — the player simply - // reported "there's no prompt to enter the leaderboard". Now it is the gate: a modal invitation - // she can only leave by sending her time or tapping the small "Salta". Either way `onDone` - // chains the Coccoline receipt (with its WhatsApp payoff) behind it, so nothing is lost. - // - // ...and the gate is now UNSKIPPABLE, which it quietly wasn't. The invitation was a bare - // `k.wait(6, …)`, so a finished run's record was lost to any of three everyday paths: - // 1. Enter/Space/Esc (armed from frame 0) or the big "Torna al menu" button pressed inside - // those six seconds — the scene dies and takes the pending timer with it. - // 2. Tapping "★ Classifica" first, which opened the board with no `inviteMode`/`onDone`: - // the receipt never chained, and the timer later re-opened the overlay on top of itself. - // 3. `k.wait` rides Kaplay's update loop, which a paused tree halts — backgrounding the PWA - // mid-letter (backgroundFreeze) froze the countdown indefinitely. - // So there is now ONE idempotent `offerLeaderboard()` and every path runs through it: the first - // attempt to leave OPENS the invitation instead of skipping it, a wall-clock timer backstops the - // engine one, and the `invited` flag makes a double invitation impossible. - let invited = false; // the classifica has been offered — once per visit, by whoever got there first - let leaving = false; // the scene is on its way out; the backstop must not pop over the menu - let backstop = null; // wall-clock twin of the k.wait timer (assigned at the bottom) - - const showBill = () => showReceipt(getCoccolineRun(), getCoccoline(), getRunTime()); - const offerLeaderboard = () => { - if (invited || leaving) return; - invited = true; - openLeaderboard({ - score: getScore(), - timeMs: getRunTime(), - inviteMode: true, - onDone: showBill, // a sent time or a tapped "Salta" — the receipt follows either way - }); + // --- The closing sequence: read the letter → Coccoline receipt --- + let shown = false; // the receipt has been shown — once per visit, by whoever got there first + const showBill = () => { + if (shown) return; + shown = true; + showReceipt(getCoccolineRun(), getCoccoline(), getRunTime()); }; // --- Return-to-menu button (also Enter / Space / Esc) --- @@ -191,67 +163,17 @@ k.setCursor("default"); }); const toMenu = () => { - // A DOM overlay above the canvas swallows CLICKS but not KEYS: Enter to confirm a nickname, - // or Esc, would otherwise fire this and jump to the menu right past the leaderboard step. - if (isLeaderboardOpen()) return; - // The board is a gate, not a six-second window: the first attempt to leave opens it. Once - // she's actually done with it (time sent, or "Salta" tapped) the next press leaves as usual. - if (!invited) { - sfx("select"); - offerLeaderboard(); - return; - } - leaving = true; - clearTimeout(backstop); + // A DOM overlay above the canvas swallows CLICKS but not KEYS: Enter or Esc would otherwise + // jump to the menu from behind the receipt. + if (shown && !document.getElementById("receipt-overlay")?.hidden) return; sfx("select"); fadeToScene(() => k.go("menu")); }; btn.onClick(toMenu); k.onKeyPress(["enter", "space", "escape"], toMenu); - // Leaderboard re-entry: re-opens the global classifica once the invitation has run (a run - // already filed shows as such, so this can't create a duplicate row) — and stands in AS the - // invitation for anyone who reaches for it first. Top-LEFT: the top-right is the audio toggle, - // and the pause button (top-left) is hidden in this non-playing scene, so the corner is free. - const lbBtn = k.add([ - k.rect(248, 56, { radius: 12 }), - k.pos(148, 52), - k.anchor("center"), - k.area(), - k.color(...PALETTE.cream), - k.z(30), - ]); - lbBtn.add([ - k.text(t("finale.leaderboard"), { size: 22, font: "sans-serif" }), - k.anchor("center"), - k.color(...PALETTE.deepBlue), - ]); - lbBtn.onHover(() => { - lbBtn.scale = k.vec2(1.05); - k.setCursor("pointer"); - }); - lbBtn.onHoverEnd(() => { - lbBtn.scale = k.vec2(1); - k.setCursor("default"); - }); - lbBtn.onClick(() => { - sfx("select"); - // Tapped before the invitation ran, THIS *is* the invitation — otherwise opening the board - // by hand was a dead end that filed the time but never chained the receipt. - if (!invited) return offerLeaderboard(); - openLeaderboard({ score: getScore(), timeMs: getRunTime() }); - }); - - // The delay still lets the heartfelt letter breathe before anything covers it — shorter than - // the old 10s, because the letter is no longer the only thing waiting on the player. Offline - // the board degrades to a friendly "non disponibile" (see leaderboard.js), so the chain never - // dead-ends; the top-left "★ Classifica" button re-opens it afterwards. - const INVITE_DELAY = 6; // s — enough to read the letter, soon enough to still feel like the payoff - k.wait(INVITE_DELAY, offerLeaderboard); - // Wall-clock backstop for case 3 above: setTimeout keeps counting while the tree is frozen, so - // the invitation survives a trip to the background. `invited` makes the loser of the race a - // no-op; toMenu clears it so it can never fire over the menu. - backstop = setTimeout(offerLeaderboard, (INVITE_DELAY + 1) * 1000); + // The delay lets the letter breathe before the receipt covers it. + k.wait(6, showBill); }); } diff -ruN a/src/scenes/game.js b/src/scenes/game.js --- a/src/scenes/game.js +++ b/src/scenes/game.js @@ -46,7 +46,6 @@ import { t } from "../i18n/index.js"; import { showInsertCoin, hideInsertCoin } from "../ui/insertCoin.js"; import { showGameOver, hideGameOver } from "../ui/gameOver.js"; -import { hideLeaderboard } from "../ui/leaderboard.js"; import { showPause, hidePause } from "../ui/pauseMenu.js"; import { showSettings, hideSettings } from "../ui/settings.js"; import { hideReceipt } from "../ui/receipt.js"; @@ -88,7 +87,6 @@ hideInsertCoin(); hideGameOver(); hideReceipt(); - hideLeaderboard(); hidePause(); hideSettings(); k.getTreeRoot().paused = false; diff -ruN a/src/scenes/menu.js b/src/scenes/menu.js --- a/src/scenes/menu.js +++ b/src/scenes/menu.js @@ -17,7 +17,6 @@ import { fadeToScene } from "../ui/transition.js"; import { hideInsertCoin } from "../ui/insertCoin.js"; import { hideGameOver } from "../ui/gameOver.js"; -import { openLeaderboardReadOnly, hideLeaderboard } from "../ui/leaderboard.js"; import { hidePause } from "../ui/pauseMenu.js"; import { showSettings, hideSettings } from "../ui/settings.js"; import { hideReceipt } from "../ui/receipt.js"; @@ -62,7 +61,6 @@ hideInsertCoin(); hideGameOver(); hideReceipt(); - hideLeaderboard(); hidePause(); hideSettings(); resetHitStop(); // never inherit a frozen 0.15× timeScale from a hit-stop cut short by leaving game @@ -111,7 +109,7 @@ k.opacity(0.85), ]); - // Decorative heroine preview (the saved character if resuming, else Anna), idly breathing + // Decorative heroine preview (the saved character if resuming, else Rose), idly breathing // on the right so the title screen feels alive. On the start layer, so it hides the moment // the chooser opens; off to the side, clear of the centred title + buttons. const previewChar = CHARACTERS.find((c) => c.id === getSelectedCharacter()) || CHARACTERS[0]; @@ -183,23 +181,10 @@ }); } - // Leaderboard (read-only standings) + Settings (volume) — bottom of the start screen, - // hidden once the chooser opens. + // Settings (volume, language) — bottom of the start screen, hidden once the chooser opens. makeButton(startLayer, { x: GAME_W / 2, - y: GAME_H - 120, - w: 220, - h: 56, - label: t("menu.leaderboard"), - onClick: () => { - sfx("select"); - openLeaderboardReadOnly(); - }, - base: PALETTE.cream, - }); - makeButton(startLayer, { - x: GAME_W / 2, - y: GAME_H - 56, + y: GAME_H - 80, w: 220, h: 56, label: t("menu.settings"), diff -ruN a/src/ui/receipt.js b/src/ui/receipt.js --- a/src/ui/receipt.js +++ b/src/ui/receipt.js @@ -1,33 +1,13 @@ -// receipt.js — finale "Scontrino" + WhatsApp payoff. -// -// Shows this adventure's Coccoline bill (plus the lifetime grand total as a separate -// line) on a paper-receipt card and a "Paga il Debito!" button that opens WhatsApp with -// the amounts substituted into the share text. Zero data leak: no fixed phone number — -// it opens the generic share sheet so Anna picks the chat. +// receipt.js — finale "Scontrino": this adventure's Coccoline bill, the lifetime total and +// the play time on a paper-receipt card. import { formatDuration } from "../format.js"; import { t } from "../i18n/index.js"; -import { gameUrl } from "./shareButton.js"; - -// Share text from the spec, extended with the lifetime + time lines; encoded at click time. -// The link is appended last: the message boasted a finishing time at someone who had no way to -// try beating it. `gameUrl()` reads the canonical origin off the og:url meta, so there is still -// exactly one place where the public URL is written down. -function whatsappUrl(run, lifetime, timeMs) { - const text = t("receipt.wa", { - time: formatDuration(timeMs), - run, - lifetime, - url: gameUrl(), - }); - return `https://api.whatsapp.com/send?text=${encodeURIComponent(text)}`; -} let overlay = null; let amountEl = null; let lifetimeEl = null; let timeEl = null; -let payBtn = null; let closeBtn = null; function els() { @@ -35,7 +15,6 @@ amountEl ||= document.getElementById("receipt-amount"); lifetimeEl ||= document.getElementById("receipt-lifetime"); timeEl ||= document.getElementById("receipt-time"); - payBtn ||= document.getElementById("receipt-pay"); closeBtn ||= document.getElementById("receipt-close"); } @@ -57,11 +36,6 @@ if (lifetimeEl) lifetimeEl.textContent = String(lifetime); if (timeEl) timeEl.textContent = formatDuration(timeMs); overlay.hidden = false; - if (payBtn) { - payBtn.onclick = () => { - window.open(whatsappUrl(run, lifetime, timeMs), "_blank", "noopener,noreferrer"); - }; - } if (closeBtn) { closeBtn.onclick = () => { hideReceipt();