Suoni d'ambiente sintetizzati (vento, acqua, uccelli, grilli, campane) col loro volume in Pausa; la fessura in primo piano: si cerca col fascio della torcia il quadro e gli occhi (lavoro di Codex). La foto del palio leggibile da nginx, e un test che lo controlla per tutti i file serviti
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
This commit is contained in:
+41
-2
@@ -229,6 +229,7 @@ export class Game {
|
||||
closeupTap() {
|
||||
const c = this.closeup;
|
||||
if (!c || this.speech) return false;
|
||||
if (this.story.closeups[c.id].type === 'torch') return false;
|
||||
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
|
||||
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
|
||||
else return false;
|
||||
@@ -241,11 +242,46 @@ export class Game {
|
||||
if (!c) return false;
|
||||
this.closeup = null;
|
||||
this.speech = null;
|
||||
const def = this.story.closeups?.[c.id];
|
||||
if (def?.type === 'torch') {
|
||||
if (c.step === 'done') this.queue.unshift(...def.complete);
|
||||
return true;
|
||||
}
|
||||
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
||||
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
|
||||
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
|
||||
closeupAim([x, y]) {
|
||||
const c = this.closeup;
|
||||
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
||||
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
|
||||
return true;
|
||||
}
|
||||
|
||||
updateTorch(dt) {
|
||||
const c = this.closeup, def = this.story.closeups[c.id];
|
||||
c.t += dt;
|
||||
if (c.lines?.length) { this.run(c.lines.shift()); return; }
|
||||
if (c.step === 'reveal') {
|
||||
if (c.t < 1.4) return;
|
||||
c.found++;
|
||||
c.step = c.found === def.targets.length ? 'done' : 'search';
|
||||
c.t = c.dwell = 0;
|
||||
return;
|
||||
}
|
||||
const target = def.targets[c.found];
|
||||
if (c.step !== 'search' || !target) return;
|
||||
c.dwell = Math.hypot(c.beam[0] - target.at[0], c.beam[1] - target.at[1]) < target.radius ? c.dwell + dt : 0;
|
||||
if (c.dwell >= 0.45) {
|
||||
c.step = 'reveal';
|
||||
c.t = 0;
|
||||
c.lines = [...(target.lines ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
options() {
|
||||
if (!this.dialogue || this.busy) return [];
|
||||
const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node];
|
||||
@@ -341,6 +377,7 @@ export class Game {
|
||||
// Nel primo piano il tempo scorre solo per la pagina che gira e per la pausa prima del commento.
|
||||
const c = this.closeup;
|
||||
if (c) {
|
||||
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
|
||||
c.t += dt;
|
||||
if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 });
|
||||
if (c.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])];
|
||||
@@ -348,7 +385,7 @@ export class Game {
|
||||
return;
|
||||
}
|
||||
// Esegue gli effetti istantanei di fila e si ferma al primo che richiede tempo.
|
||||
while (!this.speech && !this.walking && this.queue.length) this.run(this.queue.shift());
|
||||
while (!this.speech && !this.walking && !this.closeup && this.queue.length) this.run(this.queue.shift());
|
||||
this.commit();
|
||||
}
|
||||
|
||||
@@ -372,7 +409,9 @@ export class Game {
|
||||
else if (e.node) this.dialogue.node = e.node;
|
||||
else if (e.endDialogue) this.dialogue = null;
|
||||
else if (e.end) s.ended = true;
|
||||
else if (e.closeup) this.closeup = { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
||||
else if (e.closeup) this.closeup = this.story.closeups[e.closeup].type === 'torch'
|
||||
? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
|
||||
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
|
||||
import { check, PAGE_TURN } from './game.js';
|
||||
import { ambientEffects, ambientHash } from './ambient.js';
|
||||
import { drawTorch } from './torch.js';
|
||||
import { versioned } from '../version.js';
|
||||
export const WIDTH = 640, HEIGHT = 360;
|
||||
|
||||
@@ -663,6 +664,11 @@ const twoLines = title => { const i = title.lastIndexOf(' '); return i > 0 ? [ti
|
||||
function drawCloseup(ctx, game, time) {
|
||||
const c = game.closeup, def = game.story.closeups?.[c.id];
|
||||
if (!def) return;
|
||||
if (def.type === 'torch') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawTorch(ctx, c, def, backgrounds.get(def.picture));
|
||||
return;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Web Audio: vento/acqua filtrati e piccoli eventi sintetizzati. Nessun download,
|
||||
// nessun timer in sottofondo; il ciclo del gioco decide quando suonare e quando fermarsi.
|
||||
import { soundscapes } from '../story/soundscapes.js';
|
||||
|
||||
export class Soundscape {
|
||||
constructor({ volume = 0.45, createContext = () => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)(), random = Math.random } = {}) {
|
||||
this.volume = volume;
|
||||
this.createContext = createContext;
|
||||
this.random = random;
|
||||
this.context = null;
|
||||
this.nodes = new Set();
|
||||
this.active = false;
|
||||
this.muted = false;
|
||||
this.key = null;
|
||||
this.elapsed = 0;
|
||||
this.next = 4;
|
||||
this.bed = null;
|
||||
}
|
||||
|
||||
unlock() {
|
||||
try {
|
||||
if (!this.context) {
|
||||
this.context = this.createContext();
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = 0;
|
||||
this.master.connect(this.context.destination);
|
||||
const rate = this.context.sampleRate;
|
||||
this.noise = this.context.createBuffer(1, rate * 4, rate);
|
||||
const data = this.noise.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i++) data[i] = this.random() * 2 - 1;
|
||||
}
|
||||
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
|
||||
} catch {
|
||||
// Anche un'inizializzazione parziale fallita deve lasciare giocabile l'avventura.
|
||||
this.master?.disconnect(); this.context = null; this.master = null; this.noise = null;
|
||||
}
|
||||
}
|
||||
|
||||
get audible() { return !!this.context && this.active && !this.muted && this.volume > 0; }
|
||||
|
||||
configure({ active = this.active, muted = this.muted, volume = this.volume, duck = 1 } = {}) {
|
||||
this.active = active;
|
||||
this.muted = muted;
|
||||
this.volume = Math.max(0, Math.min(1, volume));
|
||||
if (!this.context) return;
|
||||
const gain = this.audible ? this.volume * duck : 0;
|
||||
this.master.gain.cancelScheduledValues(this.context.currentTime);
|
||||
if (!gain) {
|
||||
this.master.gain.setValueAtTime(0, this.context.currentTime);
|
||||
this.clear();
|
||||
} else this.master.gain.setTargetAtTime(gain, this.context.currentTime, 0.12);
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const node of [...this.nodes]) node.stop();
|
||||
this.bed = null;
|
||||
}
|
||||
|
||||
// Tutti i nodi, inclusi filtro e gain, vengono scollegati alla fine del suono.
|
||||
connect(source, gain, filter = null, pan = 0) {
|
||||
const panner = this.context.createStereoPanner?.();
|
||||
if (panner) panner.pan.value = pan;
|
||||
const chain = [source, filter, gain, panner, this.master].filter(Boolean);
|
||||
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
|
||||
const node = { stop: () => {
|
||||
if (!this.nodes.delete(node)) return;
|
||||
try { source.stop(); } catch { /* già finito */ }
|
||||
chain.slice(0, -1).forEach(n => n.disconnect());
|
||||
} };
|
||||
source.onended = node.stop;
|
||||
this.nodes.add(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
noiseSound(duration, frequency, level, { loop = false, pan = 0, paper = false } = {}) {
|
||||
const c = this.context, now = c.currentTime;
|
||||
const source = c.createBufferSource(), filter = c.createBiquadFilter(), gain = c.createGain();
|
||||
source.buffer = this.noise; source.loop = loop;
|
||||
filter.type = paper ? 'bandpass' : 'lowpass'; filter.frequency.value = frequency; filter.Q.value = 0.5;
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(level, now + (loop ? 1.3 : duration * 0.15));
|
||||
if (!loop) gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||||
const node = this.connect(source, gain, filter, pan);
|
||||
source.start();
|
||||
if (!loop) source.stop(now + duration);
|
||||
return { node, gain, level };
|
||||
}
|
||||
|
||||
tone(frequency, end, duration, level, delay = 0, pan = 0) {
|
||||
const c = this.context, at = c.currentTime + delay, source = c.createOscillator(), gain = c.createGain();
|
||||
source.type = 'sine';
|
||||
source.frequency.setValueAtTime(frequency, at);
|
||||
source.frequency.exponentialRampToValueAtTime(end, at + duration);
|
||||
gain.gain.setValueAtTime(0, at);
|
||||
gain.gain.linearRampToValueAtTime(level, at + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, at + duration);
|
||||
this.connect(source, gain, null, pan);
|
||||
source.start(at); source.stop(at + duration + 0.03);
|
||||
}
|
||||
|
||||
play(kind, { pan = (this.random() - 0.5) * 1.3 } = {}) {
|
||||
if (!this.audible) return;
|
||||
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
|
||||
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
|
||||
else if (kind === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
|
||||
else if (kind === 'bird') {
|
||||
for (let i = 0; i < 3; i++) this.tone(2100 + this.random() * 600, 3500, 0.13, 0.035, i * 0.22, pan);
|
||||
} else if (kind === 'cricket') {
|
||||
for (let i = 0; i < 5; i++) this.tone(3900, 4200, 0.055, 0.016, i * 0.12, pan);
|
||||
} else if (kind === 'bell' || kind === 'glass') {
|
||||
const base = kind === 'bell' ? 440 : 1420, duration = kind === 'bell' ? 3.8 : 0.65;
|
||||
for (const [ratio, level] of [[1, 0.035], [2.76, 0.012], [4.07, 0.006]])
|
||||
this.tone(base * ratio, base * ratio, duration, level, 0, pan);
|
||||
}
|
||||
}
|
||||
|
||||
update(profile, phase, dt) {
|
||||
if (!this.audible) return;
|
||||
const key = `${profile}:${phase}`, def = soundscapes[profile];
|
||||
if (!def) return;
|
||||
if (this.key !== key) {
|
||||
this.clear(); this.key = key; this.elapsed = 0; this.next = 4 + this.random() * 5;
|
||||
}
|
||||
if (!this.bed && def.bed) this.bed = this.noiseSound(0, def.bed === 'water' ? 1700 : 340, def.level, { loop: true });
|
||||
this.elapsed += dt;
|
||||
if (this.bed && this.elapsed > 1.5) this.bed.gain.gain.setTargetAtTime(def.level * (0.8 + 0.2 * Math.sin(this.elapsed * 0.7)), this.context.currentTime, 0.6);
|
||||
if (this.elapsed < this.next) return;
|
||||
const events = phase === 'sera' ? def.night : def.day;
|
||||
this.play(events[Math.floor(this.random() * events.length)]);
|
||||
this.next = this.elapsed + def.gap[0] + this.random() * (def.gap[1] - def.gap[0]);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@ export function readAudio(storage) {
|
||||
try {
|
||||
const a = JSON.parse(storage.getItem(AUDIO_KEY));
|
||||
if (!a || typeof a.volume !== 'number') return null;
|
||||
return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }) };
|
||||
return { volume: a.volume, muted: !!a.muted, ...(typeof a.voices === 'number' && { voices: a.voices }),
|
||||
...(typeof a.effects === 'number' && Number.isFinite(a.effects) && { effects: Math.max(0, Math.min(1, a.effects)) }) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Primo piano della fessura, disegnato alla risoluzione del gioco. Il quadro è la tela
|
||||
// della foto del 2007 fornita da Andrea; il ritaglio viene eseguito solo nel disegno.
|
||||
import { ambientHash as hash } from './ambient.js';
|
||||
|
||||
export function drawTorch(ctx, c, def, picture) {
|
||||
const [x, y] = c.beam;
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#090c10';
|
||||
ctx.fillRect(0, 0, 640, 360);
|
||||
// Muratura irregolare, muschio e giunti profondi.
|
||||
for (let row = 0; row < 7; row++) for (let col = 0; col < 10; col++) {
|
||||
const n = row * 10 + col, px = col * 73 - (row % 2) * 36, py = 24 + row * 47;
|
||||
ctx.fillStyle = ['#4b4940', '#555247', '#3e443c', '#615b4b'][Math.floor(hash(n + 1) * 4)];
|
||||
ctx.fillRect(px + 2, py + 2, 68, 42);
|
||||
ctx.fillStyle = '#73705b';
|
||||
ctx.fillRect(px + 5, py + 3, 61, 2);
|
||||
ctx.fillStyle = '#282e29';
|
||||
ctx.fillRect(px + 3, py + 39, 65, 4);
|
||||
for (let k = 0; k < 6; k++) {
|
||||
ctx.fillStyle = k % 2 ? '#424b35' : '#77715a';
|
||||
ctx.fillRect(px + 7 + Math.floor(hash(n * 9 + k) * 54), py + 7 + Math.floor(hash(n * 13 + k) * 28), 3, 2);
|
||||
}
|
||||
}
|
||||
// Il vano si perde dietro il muro; il quadro è appoggiato a terra, senza cornice dorata.
|
||||
ctx.fillStyle = '#111617';
|
||||
ctx.beginPath();
|
||||
[[250, 84], [470, 67], [533, 135], [527, 288], [247, 300], [271, 231]].forEach(([a, b], i) => i ? ctx.lineTo(a, b) : ctx.moveTo(a, b));
|
||||
ctx.closePath(); ctx.fill();
|
||||
if (c.found >= 1) {
|
||||
ctx.save();
|
||||
ctx.translate(350, 224); ctx.rotate(-0.09);
|
||||
ctx.fillStyle = '#242320'; ctx.fillRect(-48, -79, 98, 144);
|
||||
if (picture) ctx.drawImage(picture, ...def.crop, -46, -77, 92, 138);
|
||||
else {
|
||||
// Anche se l'immagine non arriva il ritrovamento resta comprensibile e giocabile.
|
||||
ctx.fillStyle = '#a6a67c'; ctx.fillRect(-46, -77, 92, 138);
|
||||
ctx.fillStyle = '#e4d8ac'; ctx.beginPath(); ctx.moveTo(-43, 54); ctx.lineTo(0, -50); ctx.lineTo(10, -50); ctx.lineTo(25, 54); ctx.fill();
|
||||
ctx.fillStyle = '#a83e2f'; ctx.fillRect(-6, 0, 12, 21); ctx.fillRect(-17, 0, 34, 5);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
const eyes = c.found === 2 && c.step === 'reveal';
|
||||
if (eyes) {
|
||||
ctx.fillStyle = '#e5bd51';
|
||||
for (const ex of [447, 470]) {
|
||||
ctx.fillRect(ex, 134, 13, 8); ctx.fillRect(ex + 3, 132, 7, 12);
|
||||
ctx.fillStyle = '#151912'; ctx.fillRect(ex + 6, 133, 2, 10);
|
||||
ctx.fillStyle = '#fff1ad'; ctx.fillRect(ex + 3, 135, 2, 2);
|
||||
ctx.fillStyle = '#e5bd51';
|
||||
}
|
||||
}
|
||||
// Oscuramento radiale: il centro caldo lascia leggibile il dettaglio, fuori è quasi nero.
|
||||
const light = ctx.createRadialGradient(x, y, 16, x, y, 112);
|
||||
light.addColorStop(0, 'rgba(8,10,15,0)');
|
||||
light.addColorStop(0.42, 'rgba(8,10,15,0.12)');
|
||||
light.addColorStop(0.75, 'rgba(4,6,10,0.8)');
|
||||
light.addColorStop(1, 'rgba(3,5,9,0.985)');
|
||||
ctx.fillStyle = light; ctx.fillRect(0, 0, 640, 360);
|
||||
// Qualche granello di polvere attraversa il fascio, senza lampeggiamenti.
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const px = (hash(i + 100) * 640 + c.age * (2 + hash(i) * 3)) % 640;
|
||||
const py = (hash(i + 200) * 360 + c.age * 2) % 360;
|
||||
const distance = Math.hypot(px - x, py - y);
|
||||
if (distance < 83) { ctx.fillStyle = `rgba(233,216,165,${0.35 * (1 - distance / 83)})`; ctx.fillRect(Math.round(px), Math.round(py), 1, 1); }
|
||||
}
|
||||
// Pietra vicina ai bordi: il giocatore guarda attraverso una fessura.
|
||||
ctx.fillStyle = '#181b1c';
|
||||
for (const points of [[[0, 0], [77, 0], [60, 97], [85, 178], [53, 278], [95, 360], [0, 360]],
|
||||
[[640, 0], [568, 0], [594, 104], [564, 194], [583, 265], [552, 360], [640, 360]]]) {
|
||||
ctx.beginPath(); points.forEach(([a, b], i) => i ? ctx.lineTo(a, b) : ctx.moveTo(a, b)); ctx.closePath(); ctx.fill();
|
||||
}
|
||||
// Torcia impugnata sul bordo inferiore, il puntatore resta libero per cercare.
|
||||
ctx.save();
|
||||
ctx.translate(430, 328); ctx.rotate((x - 320) / 1100);
|
||||
const rect = (color, ...box) => { ctx.fillStyle = color; ctx.fillRect(...box); };
|
||||
rect('#372722', 7, -2, 30, 44); rect('#9b6849', 10, 0, 24, 42);
|
||||
rect('#c18c61', 11, 0, 11, 38); rect('#50382e', 29, 10, 5, 32);
|
||||
// Corpo rosso e bordo metallico, come l'icona della torcia nell'inventario.
|
||||
rect('#171c20', -16, -28, 28, 62); rect('#762e27', -13, -18, 21, 50);
|
||||
rect('#a74330', -11, -17, 7, 46); rect('#4a2925', 4, -17, 4, 49);
|
||||
for (let i = 0; i < 5; i++) rect('#482924', -12, 10 + i * 4, 20, 1);
|
||||
rect('#343b3b', -19, -29, 34, 12); rect('#93998a', -17, -29, 30, 3);
|
||||
rect('#d8ce9f', -15, -31, 26, 3); rect('#777c71', -17, -24, 3, 6);
|
||||
rect('#d0a078', 4, -5, 14, 8); rect('#a37350', 4, 2, 14, 2);
|
||||
for (let i = 0; i < 3; i++) { rect('#bf8b60', 4, 6 + i * 8, 17, 6); rect('#71503c', 5, 12 + i * 8, 16, 2); }
|
||||
rect('#dfb58b', 6, -4, 5, 3);
|
||||
ctx.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
Reference in New Issue
Block a user