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
@@ -71,6 +71,9 @@ sfondi-confronto: ## Rifà il confronto foto/immagini in assets/generated/index.
|
||||
$(PYTHON) tools/genera.py --gallery-only
|
||||
@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)
|
||||
@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)
|
||||
|
||||
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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
|
||||
`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.
|
||||
- **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.
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
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 function newState(story) {
|
||||
@@ -313,7 +316,7 @@ export class Game {
|
||||
mapPlaces() {
|
||||
return Object.entries(this.story.scenes)
|
||||
.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() {
|
||||
const seen = new Set(this.mapPlaces().map(p => p.id)), roads = new Map();
|
||||
|
||||
@@ -88,29 +88,56 @@ for (const b of document.querySelectorAll('.open-credits')) b.addEventListener('
|
||||
$('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).
|
||||
// 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() {
|
||||
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]));
|
||||
svg.replaceChildren(...game.mapRoads().map(([a, b]) => {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
[['x1', at[a][0]], ['y1', at[a][1]], ['x2', at[b][0]], ['y2', at[b][1]]].forEach(([k, v]) => line.setAttribute(k, v));
|
||||
return line;
|
||||
}));
|
||||
board.replaceChildren(svg, ...places.map(p => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.dataset.scene = p.id;
|
||||
b.textContent = p.name;
|
||||
b.classList.toggle('here', p.here);
|
||||
b.style.left = p.at[0] + '%';
|
||||
b.style.top = p.at[1] + '%';
|
||||
b.addEventListener('click', () => { if (game.travel(p.id)) $('map').hidden = true; });
|
||||
return b;
|
||||
const board = $('map-board'), svg = $('map-roads'), places = game.mapPlaces();
|
||||
const bands = MAP_BANDS.map(() => []);
|
||||
for (const p of places) bands[MAP_BANDS.findIndex(top => p.at[1] < top)].push(p);
|
||||
board.replaceChildren(svg, ...bands.filter(b => b.length).map(band => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'map-band';
|
||||
row.append(...band.sort((a, b) => a.at[0] - b.at[0]).map(p => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.dataset.scene = p.id;
|
||||
b.title = p.name;
|
||||
b.classList.toggle('here', p.here);
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
if (p.mini) img.src = p.mini;
|
||||
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;
|
||||
// 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' });
|
||||
}
|
||||
// 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);
|
||||
|
||||
// --- «?»: per qualche secondo si vede tutto quello che si può toccare (sul telefono non c'è il mouse da passarci sopra).
|
||||
|
||||
@@ -54,7 +54,7 @@ export const story = {
|
||||
{ role: 'Musica', names: ['Andrea Manzi, con Suno'] },
|
||||
{ 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.
|
||||
{ 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: 'Voci', names: ['XTTS-v2 di Coqui'] },
|
||||
{ role: 'Carattere', names: ['Press Start 2P di CodeMan38 (licenza OFL)'] },
|
||||
|
||||
@@ -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)); }
|
||||
#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); }
|
||||
/* Sui telefoni la pianta resta grande quanto serve per leggerla e si scorre col dito. */
|
||||
#map-scroll { width: 92%; flex: 1; min-height: 0; margin: .3em 0; overflow: auto; -webkit-overflow-scrolling: touch; }
|
||||
#map-board { position: relative; width: 100%; height: 100%; min-width: 900px; min-height: 380px;
|
||||
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-roads { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
#map-roads line { stroke: #b9a9c2; stroke-width: 2.5; stroke-dasharray: 5 4; vector-effect: non-scaling-stroke; opacity: .7; }
|
||||
#map-board button { position: absolute; transform: translate(-50%, -50%); font: 9px var(--font); padding: .35em .45em;
|
||||
color: var(--bg); background: var(--ink); border: 2px solid #000; border-radius: 6px; cursor: pointer; white-space: nowrap; }
|
||||
#map-board button:hover { background: var(--accent); }
|
||||
#map-board button.here { color: var(--ink); background: #2f5fb3; cursor: default; }
|
||||
#map-board button.here::after { content: ' · sei qui'; font-weight: normal; }
|
||||
/* La mappa a gradini: una fascia per livello del paese, schede con miniatura e nome; si scorre se non ci sta. */
|
||||
#map-scroll { width: 94%; flex: 1; min-height: 0; margin: .3em 0; overflow: auto; -webkit-overflow-scrolling: touch; border-radius: 8px;
|
||||
background: radial-gradient(circle at 50% 60%, #3a2d42, #231a29); }
|
||||
#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; left: 0; top: 0; pointer-events: none; }
|
||||
#map-roads line { stroke: #b9a9c2; stroke-width: 2; stroke-dasharray: 4 4; opacity: .55; }
|
||||
.map-band { position: relative; display: flex; flex-wrap: wrap; justify-content: space-around; align-items: flex-start; gap: 6px 4px;
|
||||
padding-bottom: 4px; border-bottom: 1px solid rgba(185, 169, 194, .12); }
|
||||
.map-band:last-child { border-bottom: 0; }
|
||||
#map-board button { position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; width: 84px; padding: 3px; font: 7px var(--font);
|
||||
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. */
|
||||
#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); }
|
||||
@@ -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 button { font-size: 10px; padding: .45em 1em; }
|
||||
.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; }
|
||||
#choices { min-height: 0; padding: 6px 8px 6px 2em; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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()
|
||||