Musica, vecchietti con sprite, nuove scene del paese e generatore degli sfondi

Lavoro della sessione precedente: colonna sonora (titolo, paese, ricordi) con muto e volume,
vecchietta e vecchietto sulla panchina, scene di Pieve, oratorio, Via de' Medici, Villa Adami,
salita, campeggio, Bosco ai Frati e viale dei cipressi, panorama dell'introduzione,
tools/genera.py per gli sfondi dalle foto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UdeKd62opy9PKgpQXtVctF
This commit is contained in:
2026-10-23 12:59:57 +02:00
co-authored by Claude Opus 5.5
parent 5eeb634ceb
commit 33e2598718
45 changed files with 1556 additions and 78 deletions
+15 -1
View File
@@ -76,6 +76,7 @@ export class Game {
hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); }
hotspot(id) { return this.hotspots().find(h => h.id === id); }
people() { return (this.scene.people ?? []).filter(p => check(p.when, this.state)); }
hotspotAt(p) {
const list = this.hotspots();
for (let i = list.length - 1; i >= 0; i--) if (inBox(list[i].box, p)) return list[i];
@@ -166,6 +167,19 @@ export class Game {
return this.back ? 'back' : 'stand';
}
// Il fotogramma di un personaggio della scena (scene.people): quando parla alterna `talk`, o tiene la posa
// chiesta dalla battuta ({ say, who, pose }); da fermo ogni `every` secondi fa `blink` per un attimo.
personPose(p) {
const idle = p.idle ?? 'sit';
if (this.speech?.who === p.who) {
if (this.speech.pose) return this.speech.pose;
const talk = p.talk ?? ['talk'];
return talk[Math.floor(this.time / 0.3) % talk.length];
}
const every = p.every ?? 4, t = (this.time + (p.offset ?? 0)) % every;
return p.blink && t > every - 0.25 ? p.blink : idle;
}
scaleAt(y) {
const d = this.scene.depth;
if (!d) return 1;
@@ -218,7 +232,7 @@ export class Game {
run(e) {
const s = this.state;
if (e.say != null) this.speech = { text: e.say, who: e.who ?? 'andrea', left: sayTime(e.say) };
if (e.say != null) this.speech = { text: e.say, who: e.who ?? 'andrea', pose: e.pose, left: sayTime(e.say) };
else if (e.walk) { this.task = e; this.startWalk(e.walk); }
// `go` prima di `face`: un cambio scena porta con sé il verso d'arrivo.
else if (e.go) this.go(e.go, e.at, e.face);
+68
View File
@@ -0,0 +1,68 @@
// Colonna sonora: un brano alla volta, in loop, con dissolvenza quando cambia.
// Quale brano suona lo decidono i dati (story.music e scene.music). Si prova a partire subito; se il browser non lo
// permette prima di un gesto del giocatore, il brano riparte al primo clic o tasto (unlock).
// Il brano del momento: titolo, fine, oppure quello della scena (o quello comune del gioco).
export function trackFor(story, { screen, scene }) {
const m = story.music ?? {};
if (screen === 'title' || screen === 'intro') return m.title ?? null;
if (screen === 'ending') return m.ending ?? m.game ?? null;
return story.scenes[scene]?.music ?? m.game ?? null;
}
const FADE = 1.2;
export class Music {
constructor({ muted = false, volume = 0.6 } = {}) {
this.muted = muted;
this.volume = volume;
this.blocked = null;
this.want = null;
this.current = null; // { src, audio }
this.old = [];
}
// Chiamata a ogni fotogramma con il brano voluto: avvia, cambia e sfuma.
update(src, dt) {
this.want = src;
if (src !== this.current?.src) {
if (this.current) this.old.push(this.current.audio);
this.current = src ? { src, audio: this.start(src) } : null;
}
// Il brano in corso sale (o scende) verso il volume scelto; quelli vecchi sfumano fino a zero.
const step = dt / FADE, target = this.muted ? 0 : this.volume;
if (this.current) {
const a = this.current.audio;
a.volume = a.volume < target ? Math.min(target, a.volume + step) : Math.max(target, a.volume - step);
}
for (const a of this.old) a.volume = Math.max(0, a.volume - step);
this.old = this.old.filter(a => a.volume > 0 || (a.pause(), false));
}
start(src) {
const audio = new Audio(src);
audio.loop = true;
audio.volume = 0;
audio.play().catch(() => { this.blocked = audio; }); // senza permesso si riprova al primo gesto
return audio;
}
unlock() {
const a = this.blocked;
this.blocked = null;
if (a && a === this.current?.audio) a.play().catch(() => {});
}
toggle() {
this.muted = !this.muted;
return this.muted;
}
setVolume(v) {
this.volume = Math.max(0, Math.min(1, v));
if (this.volume > 0) this.muted = false;
return this.volume;
}
settings() { return { volume: this.volume, muted: this.muted }; }
}
+12 -5
View File
@@ -84,10 +84,10 @@ export function loadAssets(story) {
return Promise.all([...scenes, ...people]);
}
// Il personaggio alto PERSON pixel a grandezza 1, con i piedi in (x, y); rivolto a sinistra si specchia.
// Il personaggio alto PERSON pixel a grandezza 1 (per `size` 1), con i piedi in (x, y); rivolto a sinistra si specchia.
const PERSON = 62;
function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing) {
const f = atlas.frames[frame] ?? atlas.frames.stand, k = PERSON * scale / atlas.height;
function drawSprite(ctx, { atlas, img }, frame, x, y, scale, facing, size = 1) {
const f = atlas.frames[frame] ?? atlas.frames.stand ?? Object.values(atlas.frames)[0], k = PERSON * scale * size / atlas.height;
ctx.save();
ctx.translate(Math.round(x), Math.round(y));
ctx.fillStyle = 'rgba(0,0,0,0.22)';
@@ -109,9 +109,16 @@ export function render(ctx, game, { hover } = {}) {
if (bg) ctx.drawImage(bg, 0, 0, WIDTH, HEIGHT);
for (const shape of scene.art ?? []) drawShape(ctx, shape);
for (const h of game.hotspots()) for (const shape of h.art ?? []) drawShape(ctx, shape);
// Andrea e i personaggi della scena, dal più lontano al più vicino.
const andrea = sprites.get('andrea');
if (andrea) drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing);
else drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
const drawAndreaNow = () => andrea ? drawSprite(ctx, andrea, game.pose(), s.x, s.y, game.scaleAt(s.y), s.facing)
: drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, game.walking ? game.stride / 6 : null);
const queue = [{ y: s.y, draw: drawAndreaNow }];
for (const p of game.people()) {
const sheet = sprites.get(p.who);
if (sheet) queue.push({ y: p.at[1], draw: () => drawSprite(ctx, sheet, game.personPose(p), ...p.at, game.scaleAt(p.at[1]), p.facing ?? 1, p.size) });
}
for (const q of queue.sort((a, b) => a.y - b.y)) q.draw();
if (hover) {
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.setLineDash([3, 3]);
+21
View File
@@ -27,3 +27,24 @@ export function clearSave(storage) {
return false;
}
}
// Volume e muto della musica: si ricordano anche senza una partita salvata.
const AUDIO_KEY = 'leggenda-regolo-audio';
export function readAudio(storage) {
try {
const a = JSON.parse(storage.getItem(AUDIO_KEY));
return a && typeof a.volume === 'number' ? { volume: a.volume, muted: !!a.muted } : null;
} catch {
return null;
}
}
export function writeAudio(storage, settings) {
try {
storage.setItem(AUDIO_KEY, JSON.stringify(settings));
return true;
} catch {
return false;
}
}