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:
+15
-1
@@ -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);
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user