Mappa a gradini con le miniature; Campomigliaio dalle foto dell'Arcu; Marco Arcuri nei crediti

- la mappa: una scheda per posto con la miniatura del suo sfondo e il nome, in quattro fasce come il paese
  a gradini, le strade disegnate fra le schede; sta in una schermata sul desktop
- tools/miniature.py (make miniature): le miniature 128x72 a 64 colori in assets/scenes/mini/
- tre sfondi di Campomigliaio generati da Codex dalle foto di Marco Arcuri (ponte, Circolo SMS, strada),
  pronti a 640x360 ma non ancora collegati
- Marco Arcuri «l'Arcu» nei crediti, fra le fotografie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApqNBvNbNCwXqR2RLo8QJh
This commit is contained in:
2026-10-24 14:15:02 +02:00
co-authored by Claude Opus 5.5
parent b0586e3061
commit b9d2f1161c
48 changed files with 121 additions and 30 deletions
+3
View File
@@ -71,6 +71,9 @@ sfondi-confronto: ## Rifà il confronto foto/immagini in assets/generated/index.
$(PYTHON) tools/genera.py --gallery-only $(PYTHON) tools/genera.py --gallery-only
@xdg-open assets/generated/index.html >/dev/null 2>&1 || open assets/generated/index.html @xdg-open assets/generated/index.html >/dev/null 2>&1 || open assets/generated/index.html
miniature: ## Le miniature della mappa (assets/scenes/mini/) dagli sfondi
$(PYTHON) tools/miniature.py
background: ## Porta uno sfondo a 640x360 nel gioco (SRC=..., DST=..., CROP=x,y,w,h, COLORS=64) background: ## Porta uno sfondo a 640x360 nel gioco (SRC=..., DST=..., CROP=x,y,w,h, COLORS=64)
@test -n "$(SRC)" || { echo "uso: make background SRC=assets/generated/nome.png [CROP=x,y,w,h]"; exit 2; } @test -n "$(SRC)" || { echo "uso: make background SRC=assets/generated/nome.png [CROP=x,y,w,h]"; exit 2; }
$(PYTHON) tools/prepare_background.py $(SRC) $(DST) $(if $(CROP),--crop $(CROP)) --colors $(COLORS) $(PYTHON) tools/prepare_background.py $(SRC) $(DST) $(if $(CROP),--crop $(CROP)) --colors $(COLORS)
Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+6
View File
@@ -95,6 +95,12 @@ I prompt fissi (stile, scene, foglio sprite) sono in `prompts/STILE.md`, escluso
descritto sul manifesto in piazza) e due occhi gialli che spariscono. La soluzione (40 passi, tragitti calcolati da descritto sul manifesto in piazza) e due occhi gialli che spariscono. La soluzione (40 passi, tragitti calcolati da
`route`) passa per indovinello, oratorio e catena del ghiacciolo; il Trebbio ha un indizio per ogni tappa della torcia. `route`) passa per indovinello, oratorio e catena del ghiacciolo; il Trebbio ha un indizio per ogni tappa della torcia.
Da fare: il disegno della fessura è provvisorio (forme in `art`); un taccuino con l'obiettivo corrente. Da fare: il disegno della fessura è provvisorio (forme in `art`); un taccuino con l'obiettivo corrente.
- **Mappa a gradini** (24 ottobre): una scheda per posto, con la miniatura dello sfondo (`make miniature`,
`assets/scenes/mini/`) e il nome, in quattro fasce dalla Fortezza ai campi; le strade fra le schede. Sta in una
schermata sul desktop; sul telefono si scorre in verticale.
- **Campomigliaio**: tre foto dell'Arcu (Marco Arcuri, ora nei crediti) trasformate da Codex: il ponte a due arcate sul
torrente, il Circolo SMS, la strada principale. Pronte in `assets/scenes/campomigliaio-{ponte,circolo,strada}.png`,
**ancora da collegare** (camminata, hotspot, uscite, posto sulla mappa).
- **Pronuncia** (`tools/pronuncia.json`): le parole che XTTS legge male, scritte come vanno lette; vale solo per la voce. - **Pronuncia** (`tools/pronuncia.json`): le parole che XTTS legge male, scritte come vanno lette; vale solo per la voce.
Per ora «celo» → «ce l'ho». Le 32 voci nuove del 24 ottobre non sono state controllate con Whisper (qui non installato). Per ora «celo» → «ce l'ho». Le 32 voci nuove del 24 ottobre non sono state controllate con Whisper (qui non installato).
- **Sessione del 23-24 ottobre**: il paese è quasi tutto in gioco (circa 40 scene, sfondi di Codex dalle foto di - **Sessione del 23-24 ottobre**: il paese è quasi tutto in gioco (circa 40 scene, sfondi di Codex dalle foto di
+4 -1
View File
@@ -32,6 +32,9 @@ export function check(cond, state) {
// Un oggetto è «piccolo» per il dito sotto i 24×24 pixel di scena: col tocco ha la precedenza sui vicini grandi. // Un oggetto è «piccolo» per il dito sotto i 24×24 pixel di scena: col tocco ha la precedenza sui vicini grandi.
const SMALL_AREA = 24 * 24; const SMALL_AREA = 24 * 24;
// La miniatura di uno sfondo per la mappa (tools/miniature.py): assets/scenes/x.png -> assets/scenes/mini/x.png.
export const miniature = src => src?.replace(/([^/]+)$/, 'mini/$1') ?? null;
export const sayTime = text => Math.max(1.6, 0.9 + text.length * 0.055); export const sayTime = text => Math.max(1.6, 0.9 + text.length * 0.055);
export function newState(story) { export function newState(story) {
@@ -313,7 +316,7 @@ export class Game {
mapPlaces() { mapPlaces() {
return Object.entries(this.story.scenes) return Object.entries(this.story.scenes)
.filter(([id, sc]) => sc.map && this.state.flags['visto:' + id]) .filter(([id, sc]) => sc.map && this.state.flags['visto:' + id])
.map(([id, sc]) => ({ id, name: sc.name, at: sc.map, here: id === this.state.scene })); .map(([id, sc]) => ({ id, name: sc.name, at: sc.map, here: id === this.state.scene, mini: miniature(sc.background) }));
} }
mapRoads() { mapRoads() {
const seen = new Set(this.mapPlaces().map(p => p.id)), roads = new Map(); const seen = new Set(this.mapPlaces().map(p => p.id)), roads = new Map();
+44 -17
View File
@@ -88,29 +88,56 @@ for (const b of document.querySelectorAll('.open-credits')) b.addEventListener('
$('close-credits').addEventListener('click', () => { $('credits').hidden = true; }); $('close-credits').addEventListener('click', () => { $('credits').hidden = true; });
// --- Mappa: i posti visti e le strade fra loro; un clic su un posto ci porta (game.travel). // --- Mappa: i posti visti e le strade fra loro; un clic su un posto ci porta (game.travel).
// La mappa: i posti visti come schede con la miniatura, a gradini come il paese (le quattro fasce della pianta,
// dalla Fortezza ai campi), e in ogni gradino da sinistra a destra. Le strade si disegnano dopo, fra le schede.
const MAP_BANDS = [25, 50, 75, Infinity];
function openMap() { function openMap() {
if (!running || game.dialogue) return; if (!running || game.dialogue) return;
const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces(), at = Object.fromEntries(places.map(p => [p.id, p.at])); const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces();
svg.replaceChildren(...game.mapRoads().map(([a, b]) => { const bands = MAP_BANDS.map(() => []);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p);
[['x1', at[a][0]], ['y1', at[a][1]], ['x2', at[b][0]], ['y2', at[b][1]]].forEach(([k, v]) => line.setAttribute(k, v)); board.replaceChildren(svg, ...bands.filter(b => b.length).map(band => {
return line; const row = document.createElement('div');
})); row.className = 'map-band';
board.replaceChildren(svg, ...places.map(p => { row.append(...band.sort((a, b) => a.at[0] - b.at[0]).map(p => {
const b = document.createElement('button'); const b = document.createElement('button');
b.type = 'button'; b.type = 'button';
b.dataset.scene = p.id; b.dataset.scene = p.id;
b.textContent = p.name; b.title = p.name;
b.classList.toggle('here', p.here); b.classList.toggle('here', p.here);
b.style.left = p.at[0] + '%'; const img = document.createElement('img');
b.style.top = p.at[1] + '%'; img.alt = '';
b.addEventListener('click', () => { if (game.travel(p.id)) $('map').hidden = true; }); if (p.mini) img.src = p.mini;
return b; const name = document.createElement('span');
name.textContent = p.name;
b.append(img, name);
b.addEventListener('click', () => { if (game.travel(p.id)) $('map').hidden = true; });
return b;
}));
return row;
})); }));
$('map').hidden = false; $('map').hidden = false;
// Sul telefono la pianta scorre: si parte col posto dove si è, al centro. drawRoads();
// Sul telefono la mappa scorre: si parte col posto dove si è, al centro.
board.querySelector('.here')?.scrollIntoView({ block: 'center', inline: 'center' }); board.querySelector('.here')?.scrollIntoView({ block: 'center', inline: 'center' });
} }
// Le strade fra le schede, misurate dopo l'impaginazione (e di nuovo se la finestra cambia misura).
function drawRoads() {
const board = $('map-board'), svg = $('map-roads'), origin = board.getBoundingClientRect();
const center = id => {
const r = board.querySelector(`[data-scene="${id}"] img`)?.getBoundingClientRect();
return r && [r.left + r.width / 2 - origin.left, r.top + r.height / 2 - origin.top];
};
svg.setAttribute('viewBox', `0 0 ${board.scrollWidth} ${board.scrollHeight}`);
svg.style.width = board.scrollWidth + 'px';
svg.style.height = board.scrollHeight + 'px';
svg.replaceChildren(...game.mapRoads().map(([a, b]) => [center(a), center(b)]).filter(([a, b]) => a && b).map(([a, b]) => {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
[['x1', a[0]], ['y1', a[1]], ['x2', b[0]], ['y2', b[1]]].forEach(([k, v]) => line.setAttribute(k, v));
return line;
}));
}
addEventListener('resize', () => { if (!$('map').hidden) drawRoads(); });
$('map-button').addEventListener('click', openMap); $('map-button').addEventListener('click', openMap);
// --- «?»: per qualche secondo si vede tutto quello che si può toccare (sul telefono non c'è il mouse da passarci sopra). // --- «?»: per qualche secondo si vede tutto quello che si può toccare (sul telefono non c'è il mouse da passarci sopra).
+1 -1
View File
@@ -54,7 +54,7 @@ export const story = {
{ role: 'Musica', names: ['Andrea Manzi, con Suno'] }, { role: 'Musica', names: ['Andrea Manzi, con Suno'] },
{ role: 'Sfondi', names: ['dalle fotografie di San Piero di Andrea'] }, { role: 'Sfondi', names: ['dalle fotografie di San Piero di Andrea'] },
// Chi ci ha mandato foto, ricordi o idee: una riga per contributo, si allunga man mano. // Chi ci ha mandato foto, ricordi o idee: una riga per contributo, si allunga man mano.
{ role: 'Fotografie', names: ['Mariano Mirannalti (l\'interno della Pieve)'] }, { role: 'Fotografie', names: ['Mariano Mirannalti (l\'interno della Pieve)', 'Marco Arcuri «l\'Arcu» (Campomigliaio)'] },
{ role: 'Opere d\'arte nel gioco', names: ['Simone Rocchi (l\'omino di ferro del tennis della chiesa)'] }, { role: 'Opere d\'arte nel gioco', names: ['Simone Rocchi (l\'omino di ferro del tennis della chiesa)'] },
{ role: 'Voci', names: ['XTTS-v2 di Coqui'] }, { role: 'Voci', names: ['XTTS-v2 di Coqui'] },
{ role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] }, { role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] },
+20 -11
View File
@@ -146,17 +146,23 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
.load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); } .load-error { margin: .6em 0 0; padding: .4em .8em; color: #fff; background: #7a1f1f; border-radius: 6px; font-size: max(9px, calc(var(--u) * 1.4)); }
#map { z-index: 3; justify-content: flex-start; padding-top: 2.5%; background: rgba(15, 10, 20, .94); } #map { z-index: 3; justify-content: flex-start; padding-top: 2.5%; background: rgba(15, 10, 20, .94); }
#map h2 { font-size: clamp(12.96px, 2.16vw, 23.04px); } #map h2 { font-size: clamp(12.96px, 2.16vw, 23.04px); }
/* Sui telefoni la pianta resta grande quanto serve per leggerla e si scorre col dito. */ /* La mappa a gradini: una fascia per livello del paese, schede con miniatura e nome; si scorre se non ci sta. */
#map-scroll { width: 92%; flex: 1; min-height: 0; margin: .3em 0; overflow: auto; -webkit-overflow-scrolling: touch; } #map-scroll { width: 94%; flex: 1; min-height: 0; margin: .3em 0; overflow: auto; -webkit-overflow-scrolling: touch; border-radius: 8px;
#map-board { position: relative; width: 100%; height: 100%; min-width: 900px; min-height: 380px; background: radial-gradient(circle at 50% 60%, #3a2d42, #231a29); }
background: repeating-linear-gradient(transparent 0 calc(25% - 1px), rgba(185, 169, 194, .12) calc(25% - 1px) 25%), radial-gradient(circle at 50% 60%, #3a2d42, #231a29); border-radius: 8px; } #map-board { position: relative; display: flex; flex-direction: column; justify-content: space-around; gap: 4px; min-height: 100%; padding: 6px; box-sizing: border-box; }
#map-roads { position: absolute; inset: 0; width: 100%; height: 100%; } #map-roads { position: absolute; left: 0; top: 0; pointer-events: none; }
#map-roads line { stroke: #b9a9c2; stroke-width: 2.5; stroke-dasharray: 5 4; vector-effect: non-scaling-stroke; opacity: .7; } #map-roads line { stroke: #b9a9c2; stroke-width: 2; stroke-dasharray: 4 4; opacity: .55; }
#map-board button { position: absolute; transform: translate(-50%, -50%); font: 9px var(--font); padding: .35em .45em; .map-band { position: relative; display: flex; flex-wrap: wrap; justify-content: space-around; align-items: flex-start; gap: 6px 4px;
color: var(--bg); background: var(--ink); border: 2px solid #000; border-radius: 6px; cursor: pointer; white-space: nowrap; } padding-bottom: 4px; border-bottom: 1px solid rgba(185, 169, 194, .12); }
#map-board button:hover { background: var(--accent); } .map-band:last-child { border-bottom: 0; }
#map-board button.here { color: var(--ink); background: #2f5fb3; cursor: default; } #map-board button { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; width: 84px; padding: 3px; font: 7px var(--font);
#map-board button.here::after { content: ' · sei qui'; font-weight: normal; } color: var(--ink); background: #2a2031; border: 2px solid #4b3a55; border-radius: 6px; cursor: pointer; line-height: 1.2; }
#map-board button img { width: 76px; aspect-ratio: 16 / 9; image-rendering: pixelated; border-radius: 3px; background: #1b1420; }
#map-board button span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-align: center; height: 4.2em; line-height: 1.4; }
#map-board button:hover, #map-board button:focus-visible { border-color: var(--accent); }
#map-board button.here { border-color: #5b8ff0; background: #2f5fb3; cursor: default; }
#map-board button.here::before { content: 'sei qui'; position: absolute; top: 1px; left: 50%; transform: translateX(-50%); padding: 1px 4px;
font-size: 6px; color: var(--bg); background: var(--accent); border-radius: 3px; white-space: nowrap; }
/* I crediti crescono con chi contribuisce: scorre solo l'elenco, dedica e Chiudi restano sempre in vista. */ /* I crediti crescono con chi contribuisce: scorre solo l'elenco, dedica e Chiudi restano sempre in vista. */
#credits { justify-content: flex-start; overflow: hidden; padding-top: 4%; background: rgba(15, 10, 20, .96); } #credits { justify-content: flex-start; overflow: hidden; padding-top: 4%; background: rgba(15, 10, 20, .96); }
#credits .credits-dedication { margin: .2em 0 .5em !important; font-size: clamp(10px, 1.6vw, 17px); } #credits .credits-dedication { margin: .2em 0 .5em !important; font-size: clamp(10px, 1.6vw, 17px); }
@@ -198,6 +204,9 @@ body.full #sound { position: fixed; z-index: 13; top: max(8px, env(safe-area-ins
.buttons { gap: 6px; margin-top: .2em; } .buttons { gap: 6px; margin-top: .2em; }
.buttons button { font-size: 10px; padding: .45em 1em; } .buttons button { font-size: 10px; padding: .45em 1em; }
.pause-buttons { min-width: 12em; } .pause-buttons { min-width: 12em; }
/* Mappa: schede più piccole, ne stanno cinque o sei per riga. */
#map-board button { width: 68px; }
#map-board button img { width: 60px; }
#pause .hint { font-size: 8px; } #pause .hint { font-size: 8px; }
#choices { min-height: 0; padding: 6px 8px 6px 2em; } #choices { min-height: 0; padding: 6px 8px 6px 2em; }
} }
+8
View File
@@ -383,3 +383,11 @@ test('il Trebbio ha un indizio per ogni tappa della torcia, e la torcia usata tr
early.act('use', 'fessura', 'torcia'); settle(early); early.act('use', 'fessura', 'torcia'); settle(early);
assert.ok(!early.state.ended && !early.state.flags.palio_trovato, 'senza le impronte la fessura è solo buia'); assert.ok(!early.state.ended && !early.state.flags.palio_trovato, 'senza le impronte la fessura è solo buia');
}); });
test('ogni posto della mappa ha la sua miniatura (make miniature)', () => {
for (const p of new Game(story, { state: { scene: 'piazza', x: 250, y: 320, facing: 1, inventory: [], used: {}, ended: false,
flags: Object.fromEntries(Object.keys(story.scenes).map(id => ['visto:' + id, true])) } }).mapPlaces()) {
assert.ok(p.mini, `${p.id} senza sfondo`);
assert.ok(existsSync(new URL('../' + p.mini, import.meta.url)), `manca ${p.mini}: make miniature`);
}
});
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Le miniature della mappa: ogni sfondo di assets/scenes/ rimpicciolito a 128x72 in assets/scenes/mini/.
python3 tools/miniature.py (make miniature; rifà solo quelle più vecchie del loro sfondo)
Si riduce con la media dei pixel (non a punti): a quella misura la pixel art si legge meglio.
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
SCENES, MINI = ROOT / 'assets' / 'scenes', ROOT / 'assets' / 'scenes' / 'mini'
SIZE = (128, 72)
def main():
MINI.mkdir(exist_ok=True)
done = 0
for src in sorted(SCENES.glob('*.png')):
dst = MINI / src.name
if dst.exists() and dst.stat().st_mtime >= src.stat().st_mtime:
continue
img = Image.open(src).convert('RGB')
# Le panoramiche (più larghe di 16:9) si prendono dal centro.
w, h = img.size
cw = min(w, round(h * 16 / 9))
img = img.crop(((w - cw) // 2, 0, (w - cw) // 2 + cw, h)).resize(SIZE, Image.Resampling.BOX)
# 64 colori come gli sfondi: il file pesa un terzo.
img.quantize(64, dither=Image.Dither.NONE).save(dst, optimize=True)
done += 1
print(f'{done} miniature in {MINI.relative_to(ROOT)}')
if __name__ == '__main__':
main()