Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c49c3e260 | ||
|
|
817cfbd7fd | ||
|
|
44485cff52 | ||
|
|
a0201fea18 | ||
|
|
c4bac08c02 | ||
|
|
7f0584f0fc |
Binary file not shown.
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"image": "fra-peppino.png",
|
||||||
|
"height": 120,
|
||||||
|
"frames": {
|
||||||
|
"stand": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 50,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 24.0,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"blink": {
|
||||||
|
"x": 52,
|
||||||
|
"y": 0,
|
||||||
|
"w": 50,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 23.9,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"talk": {
|
||||||
|
"x": 104,
|
||||||
|
"y": 0,
|
||||||
|
"w": 50,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 23.7,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"explain": {
|
||||||
|
"x": 156,
|
||||||
|
"y": 0,
|
||||||
|
"w": 61,
|
||||||
|
"h": 121,
|
||||||
|
"ax": 27.6,
|
||||||
|
"ay": 121
|
||||||
|
},
|
||||||
|
"laugh": {
|
||||||
|
"x": 219,
|
||||||
|
"y": 0,
|
||||||
|
"w": 54,
|
||||||
|
"h": 119,
|
||||||
|
"ax": 24.3,
|
||||||
|
"ay": 119
|
||||||
|
},
|
||||||
|
"bless": {
|
||||||
|
"x": 275,
|
||||||
|
"y": 0,
|
||||||
|
"w": 58,
|
||||||
|
"h": 119,
|
||||||
|
"ax": 27.6,
|
||||||
|
"ay": 119
|
||||||
|
},
|
||||||
|
"shrug": {
|
||||||
|
"x": 335,
|
||||||
|
"y": 0,
|
||||||
|
"w": 91,
|
||||||
|
"h": 119,
|
||||||
|
"ax": 43.3,
|
||||||
|
"ay": 119
|
||||||
|
},
|
||||||
|
"point": {
|
||||||
|
"x": 428,
|
||||||
|
"y": 0,
|
||||||
|
"w": 76,
|
||||||
|
"h": 119,
|
||||||
|
"ax": 31.2,
|
||||||
|
"ay": 119
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"image": "mariano.png",
|
||||||
|
"height": 120,
|
||||||
|
"frames": {
|
||||||
|
"stand": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"w": 40,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 17.8,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"blink": {
|
||||||
|
"x": 42,
|
||||||
|
"y": 0,
|
||||||
|
"w": 40,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 17.9,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"talk": {
|
||||||
|
"x": 84,
|
||||||
|
"y": 0,
|
||||||
|
"w": 40,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 17.9,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"explain": {
|
||||||
|
"x": 126,
|
||||||
|
"y": 0,
|
||||||
|
"w": 60,
|
||||||
|
"h": 120,
|
||||||
|
"ax": 20.9,
|
||||||
|
"ay": 120
|
||||||
|
},
|
||||||
|
"throw": {
|
||||||
|
"x": 188,
|
||||||
|
"y": 0,
|
||||||
|
"w": 80,
|
||||||
|
"h": 102,
|
||||||
|
"ax": 51.5,
|
||||||
|
"ay": 102
|
||||||
|
},
|
||||||
|
"laugh": {
|
||||||
|
"x": 270,
|
||||||
|
"y": 0,
|
||||||
|
"w": 41,
|
||||||
|
"h": 119,
|
||||||
|
"ax": 16.9,
|
||||||
|
"ay": 119
|
||||||
|
},
|
||||||
|
"measure": {
|
||||||
|
"x": 313,
|
||||||
|
"y": 0,
|
||||||
|
"w": 72,
|
||||||
|
"h": 91,
|
||||||
|
"ax": 38.0,
|
||||||
|
"ay": 91
|
||||||
|
},
|
||||||
|
"cheer": {
|
||||||
|
"x": 387,
|
||||||
|
"y": 0,
|
||||||
|
"w": 63,
|
||||||
|
"h": 118,
|
||||||
|
"ax": 27.5,
|
||||||
|
"ay": 118
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -883,9 +883,9 @@
|
|||||||
"file": "andrea-e94382adb6.mp3",
|
"file": "andrea-e94382adb6.mp3",
|
||||||
"sec": 5.76
|
"sec": 5.76
|
||||||
},
|
},
|
||||||
"andrea|Senza bocce, e senza un vecchietto che misura col metro, non è una partita.": {
|
"andrea|Una partita? Chiedo a Mariano, il campione.": {
|
||||||
"file": "andrea-d6576896b2.mp3",
|
"file": "andrea-609540460a.mp3",
|
||||||
"sec": 5.36
|
"sec": 3.81
|
||||||
},
|
},
|
||||||
"andrea|Di là, dove guarda l'omino di ferro, c'è il campo da tennis della chiesa.": {
|
"andrea|Di là, dove guarda l'omino di ferro, c'è il campo da tennis della chiesa.": {
|
||||||
"file": "andrea-455ac4031a.mp3",
|
"file": "andrea-455ac4031a.mp3",
|
||||||
@@ -931,6 +931,10 @@
|
|||||||
"file": "andrea-2de291f241.mp3",
|
"file": "andrea-2de291f241.mp3",
|
||||||
"sec": 4.09
|
"sec": 4.09
|
||||||
},
|
},
|
||||||
|
"andrea|Mariano, il campione del pallaio. Gli occhiali in testa, il sorriso di chi ha già vinto.": {
|
||||||
|
"file": "andrea-3e2f9b4424.mp3",
|
||||||
|
"sec": 6.42
|
||||||
|
},
|
||||||
"andrea|Giù a destra, oltre le vigne, c'è la casa del maestro Bacci. Mezzo paese ha imparato a leggere e scrivere da lui.": {
|
"andrea|Giù a destra, oltre le vigne, c'è la casa del maestro Bacci. Mezzo paese ha imparato a leggere e scrivere da lui.": {
|
||||||
"file": "andrea-060513a152.mp3",
|
"file": "andrea-060513a152.mp3",
|
||||||
"sec": 8.65
|
"sec": 8.65
|
||||||
@@ -1083,9 +1087,9 @@
|
|||||||
"file": "andrea-70f150bdec.mp3",
|
"file": "andrea-70f150bdec.mp3",
|
||||||
"sec": 2.67
|
"sec": 2.67
|
||||||
},
|
},
|
||||||
"tozzino|E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.": {
|
"tozzino|E chi sennò? Ha chiamato ora: s'è fermato a Pratolino a mettere le catene. A breve arriva.": {
|
||||||
"file": "tozzino-d51159c364.mp3",
|
"file": "tozzino-9fef6eb47d.mp3",
|
||||||
"sec": 4.08
|
"sec": 6.42
|
||||||
},
|
},
|
||||||
"tozzino|Se non arriva, la vinco a tavolino.": {
|
"tozzino|Se non arriva, la vinco a tavolino.": {
|
||||||
"file": "tozzino-c06cc01612.mp3",
|
"file": "tozzino-c06cc01612.mp3",
|
||||||
@@ -1659,6 +1663,38 @@
|
|||||||
"file": "arcu-5148dffe36.mp3",
|
"file": "arcu-5148dffe36.mp3",
|
||||||
"sec": 1.7
|
"sec": 1.7
|
||||||
},
|
},
|
||||||
|
"andrea|Mariano! Facciamo una partita a bocce?": {
|
||||||
|
"file": "andrea-83b1337b4d.mp3",
|
||||||
|
"sec": 3.31
|
||||||
|
},
|
||||||
|
"andrea|La rivincita?": {
|
||||||
|
"file": "andrea-98e4366a56.mp3",
|
||||||
|
"sec": 0.83
|
||||||
|
},
|
||||||
|
"andrea|Allora, Brento Sanico?": {
|
||||||
|
"file": "andrea-2add39ff10.mp3",
|
||||||
|
"sec": 1.29
|
||||||
|
},
|
||||||
|
"andrea|Ciao Mariano.": {
|
||||||
|
"file": "andrea-76a4a385c7.mp3",
|
||||||
|
"sec": 0.89
|
||||||
|
},
|
||||||
|
"mariano|Con me? Al pallaio sono il campione.": {
|
||||||
|
"file": "mariano-e8bb19cfdf.mp3",
|
||||||
|
"sec": 3.31
|
||||||
|
},
|
||||||
|
"mariano|Facciamo così: se mi batti, ti porto a Brento Sanico.": {
|
||||||
|
"file": "mariano-0eeb942ca2.mp3",
|
||||||
|
"sec": 3.52
|
||||||
|
},
|
||||||
|
"mariano|Il campione non scappa. Tira!": {
|
||||||
|
"file": "mariano-bdf58ba1be.mp3",
|
||||||
|
"sec": 3.26
|
||||||
|
},
|
||||||
|
"mariano|Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.": {
|
||||||
|
"file": "mariano-1c9cd0c325.mp3",
|
||||||
|
"sec": 5.96
|
||||||
|
},
|
||||||
"andrea|Sono bloccato. Mi dai un consiglio?": {
|
"andrea|Sono bloccato. Mi dai un consiglio?": {
|
||||||
"file": "andrea-42b02eb9ea.mp3",
|
"file": "andrea-42b02eb9ea.mp3",
|
||||||
"sec": 3.9
|
"sec": 3.9
|
||||||
@@ -1735,6 +1771,26 @@
|
|||||||
"file": "castello-dc8a873d86.mp3",
|
"file": "castello-dc8a873d86.mp3",
|
||||||
"sec": 4.36
|
"sec": 4.36
|
||||||
},
|
},
|
||||||
|
"mariano|Aspetta, ci vuole il metro...": {
|
||||||
|
"file": "mariano-e8b4ebf3d9.mp3",
|
||||||
|
"sec": 1.62
|
||||||
|
},
|
||||||
|
"mariano|Hai vinto, ti ci porto!": {
|
||||||
|
"file": "mariano-f361945994.mp3",
|
||||||
|
"sec": 1.66
|
||||||
|
},
|
||||||
|
"mariano|Bocciato! Al pallaio comando io.": {
|
||||||
|
"file": "mariano-4b1eea73b2.mp3",
|
||||||
|
"sec": 3.38
|
||||||
|
},
|
||||||
|
"mariano|Quando vuoi la rivincita, sono qui.": {
|
||||||
|
"file": "mariano-e582e766cd.mp3",
|
||||||
|
"sec": 2.29
|
||||||
|
},
|
||||||
|
"mariano|Già stanco? Il pallaio ti aspetta.": {
|
||||||
|
"file": "mariano-39e27158b7.mp3",
|
||||||
|
"sec": 2.83
|
||||||
|
},
|
||||||
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
|
"andrea|Laggiù, appoggiato al muro, c'è un angolo di tela dipinta: una strada bianca, e un omino con la maglia rossa e le braccia alzate.": {
|
||||||
"file": "andrea-6484641c58.mp3",
|
"file": "andrea-6484641c58.mp3",
|
||||||
"sec": 8.67
|
"sec": 8.67
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
@@ -103,6 +103,25 @@
|
|||||||
<div id="inventory" aria-label="Inventario"></div>
|
<div id="inventory" aria-label="Inventario"></div>
|
||||||
</div>
|
</div>
|
||||||
<ol id="choices" hidden></ol>
|
<ol id="choices" hidden></ol>
|
||||||
|
<section id="bocce-controls" hidden aria-label="Comandi delle bocce">
|
||||||
|
<p id="bocce-status" role="status"></p>
|
||||||
|
<div class="bocce-adjust">
|
||||||
|
<button id="bocce-left" type="button" aria-label="Sposta la mira a sinistra">← Mira</button>
|
||||||
|
<label for="bocce-power">Forza <output id="bocce-power-value">50%</output>
|
||||||
|
<input id="bocce-power" type="range" min="0" max="100" step="1" value="50" aria-label="Forza del lancio">
|
||||||
|
</label>
|
||||||
|
<button id="bocce-right" type="button" aria-label="Sposta la mira a destra">Mira →</button>
|
||||||
|
<button id="bocce-throw" type="button">Lancia</button>
|
||||||
|
</div>
|
||||||
|
<details id="bocce-help"><summary>Come si gioca</summary>
|
||||||
|
<p>Tocca il campo o il dettaglio del pallino per mirare. Regola la forza, poi premi Lancia.
|
||||||
|
Il cerchio azzurro indica dove si fermerebbe la boccia senza urti.</p>
|
||||||
|
<p>Puoi anche tenere premuto sul campo o Spazio e rilasciare per tirare.
|
||||||
|
Frecce ← →: mira; ↑ ↓: forza. Esc esce dalla mano.</p>
|
||||||
|
<p>Tre bocce a testa. Tira chi non ha il punto; segna ogni boccia più vicina della migliore avversaria.
|
||||||
|
Vince chi fa più punti nella mano.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p>
|
<p id="rotate">Gira il telefono: in orizzontale si gioca meglio.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "leggenda-del-regolo",
|
"name": "leggenda-del-regolo",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { project, detailPoint, DETAIL, HALF, canThrow, landing, left, closest, gap, score, status } from './bocce.js';
|
||||||
|
import { ambientHash as hash } from './ambient.js';
|
||||||
|
|
||||||
|
const FONT = '"Press Start 2P", monospace';
|
||||||
|
const COLORS = { player: ['#172e58', '#3975c4', '#d9ecff'], cpu: ['#531d1b', '#c24e3b', '#ffe0bd'], jack: ['#70531b', '#e2b743', '#fff8cd'] };
|
||||||
|
function text(ctx, line, x, y, size = 7, color = '#f4e3c2', align = 'left') {
|
||||||
|
ctx.font = `${size}px ${FONT}`; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillStyle = color; ctx.fillText(line, x, y);
|
||||||
|
}
|
||||||
|
function panel(ctx, x, y, w, h) {
|
||||||
|
ctx.fillStyle = 'rgba(22,25,23,0.92)'; ctx.fillRect(x, y, w, h);
|
||||||
|
ctx.strokeStyle = '#897b57'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
||||||
|
}
|
||||||
|
function sphere(ctx, b, x, y, r, top = false) {
|
||||||
|
const [dark, mid, light] = COLORS[b.jack ? 'jack' : b.who];
|
||||||
|
ctx.fillStyle = 'rgba(29,22,13,0.4)'; ctx.beginPath();
|
||||||
|
ctx.ellipse(x + r * 0.4, y + r * 0.12, r * 1.25, r * 0.45, 0.3, 0, Math.PI * 2); ctx.fill();
|
||||||
|
const cy = top ? y : y - r;
|
||||||
|
const shade = ctx.createRadialGradient(x - r * 0.35, cy - r * 0.4, 0, x, cy, r * 1.1);
|
||||||
|
shade.addColorStop(0, light); shade.addColorStop(0.28, mid); shade.addColorStop(1, dark);
|
||||||
|
ctx.fillStyle = shade; ctx.beginPath(); ctx.arc(x, cy, r, 0, Math.PI * 2); ctx.fill();
|
||||||
|
if (!b.jack) {
|
||||||
|
ctx.save(); ctx.beginPath(); ctx.arc(x, cy, r - 0.2, 0, Math.PI * 2); ctx.clip();
|
||||||
|
ctx.strokeStyle = 'rgba(240,230,210,0.65)'; ctx.lineWidth = Math.max(0.6, r * 0.1);
|
||||||
|
// Le incisioni si spostano con la distanza percorsa: si vede il rotolamento.
|
||||||
|
const offset = Math.sin(b.spin ?? 0) * r * 0.8;
|
||||||
|
ctx.beginPath(); ctx.ellipse(x + offset, cy, Math.max(0.5, r * 0.35), r * 1.1, -0.3, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
ctx.fillStyle = light; ctx.fillRect(Math.round(x - r * 0.35), Math.round(cy - r * 0.4), 1, 1);
|
||||||
|
}
|
||||||
|
function cross(ctx, p, radius, color) {
|
||||||
|
ctx.strokeStyle = color; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.45, 0, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(p.x - radius - 3, p.y); ctx.lineTo(p.x + radius + 3, p.y);
|
||||||
|
ctx.moveTo(p.x, p.y - radius * 0.45 - 3); ctx.lineTo(p.x, p.y + radius * 0.45 + 3); ctx.stroke();
|
||||||
|
}
|
||||||
|
const cm = (c, b) => b ? `${(gap(b, c.jack) * 20).toFixed(1)} cm` : '—';
|
||||||
|
|
||||||
|
function detail(ctx, c, def) {
|
||||||
|
const d = DETAIL;
|
||||||
|
panel(ctx, d.x, d.y, d.w, d.h);
|
||||||
|
text(ctx, 'VICINO AL PALLINO', d.x + d.w / 2, d.y + 13, 6, '#eed5a0', 'center');
|
||||||
|
ctx.save(); ctx.beginPath(); ctx.rect(d.x + 5, d.y + 25, d.w - 10, d.h - 49); ctx.clip();
|
||||||
|
ctx.fillStyle = '#b79a6c'; ctx.fillRect(d.x, d.y + 25, d.w, d.h);
|
||||||
|
for (let i = 0; i < 160; i++) {
|
||||||
|
ctx.fillStyle = i % 2 ? '#c9b17d' : '#9c8059';
|
||||||
|
ctx.fillRect(Math.round(d.x + hash(i + 5) * d.w), Math.round(d.y + 25 + hash(i + 190) * (d.h - 49)), 1, 1);
|
||||||
|
}
|
||||||
|
// Le sponde compaiono anche nel dettaglio quando il pallino si avvicina al bordo.
|
||||||
|
for (const w of [-HALF, HALF]) {
|
||||||
|
const p = detailPoint(c, { u: c.jack.u, w });
|
||||||
|
ctx.fillStyle = '#73513b'; ctx.fillRect(p.x - 2, d.y + 25, 4, d.h);
|
||||||
|
}
|
||||||
|
const jack = detailPoint(c, c.jack), leader = score(c).winner;
|
||||||
|
for (const who of ['player', 'cpu']) {
|
||||||
|
const b = closest(c, who);
|
||||||
|
if (!b) continue;
|
||||||
|
const p = detailPoint(c, b);
|
||||||
|
ctx.strokeStyle = who === 'player' ? '#315d92' : '#923827'; ctx.setLineDash([2, 2]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(jack.x, jack.y); ctx.lineTo(p.x, p.y); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
if (leader === who) { ctx.strokeStyle = '#fff2a3'; ctx.beginPath(); ctx.arc(p.x, p.y, b.r * d.scale + 3, 0, Math.PI * 2); ctx.stroke(); }
|
||||||
|
}
|
||||||
|
if (canThrow(c)) {
|
||||||
|
const target = detailPoint(c, c.aim), end = detailPoint(c, landing(c));
|
||||||
|
cross(ctx, target, 6, '#fff4c9');
|
||||||
|
ctx.setLineDash([2, 2]); cross(ctx, end, 5, '#315d92'); ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
for (const b of [...c.balls, c.jack]) {
|
||||||
|
const p = detailPoint(c, b);
|
||||||
|
sphere(ctx, b, p.x, p.y, Math.max(b.jack ? 2 : 3, b.r * d.scale), true);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
text(ctx, 'Tocca qui per mirare', d.x + d.w / 2, d.y + d.h - 12, 6, '#d7ccb4', 'center');
|
||||||
|
for (const [i, who] of ['player', 'cpu'].entries()) {
|
||||||
|
text(ctx, `${def.names[who]}: ${cm(c, closest(c, who))}`, d.x + 2, d.y + d.h + 13 + i * 14, 6, COLORS[who][2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawBocce(ctx, c, def, bg) {
|
||||||
|
ctx.save();
|
||||||
|
if (bg) ctx.drawImage(bg, 0, 0, 640, 360);
|
||||||
|
else { ctx.fillStyle = '#bca16d'; ctx.fillRect(0, 0, 640, 360); }
|
||||||
|
// Polvere breve dietro l'ultima boccia, agganciata al terreno e subito dissolta.
|
||||||
|
for (const [i, t] of c.trails.entries()) {
|
||||||
|
const p = project(t.u, t.w);
|
||||||
|
ctx.fillStyle = `rgba(218,195,145,${0.22 * (1 - t.age / 0.55)})`;
|
||||||
|
ctx.fillRect(Math.round(p.x + (hash(i) - 0.5) * 5), Math.round(p.y), Math.max(1, p.s * 8), 1);
|
||||||
|
}
|
||||||
|
if (canThrow(c)) {
|
||||||
|
const from = project(1, 0), target = project(c.aim.u, c.aim.w), end = landing(c);
|
||||||
|
const to = project(Math.min(99, end.u), Math.max(-HALF, Math.min(HALF, end.w)));
|
||||||
|
ctx.strokeStyle = 'rgba(255,246,210,0.8)'; ctx.lineWidth = 1; ctx.setLineDash([3, 5]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
cross(ctx, target, 5, '#fff4c9'); cross(ctx, to, 7, '#8fc7ff');
|
||||||
|
}
|
||||||
|
for (const b of [c.jack, ...c.balls].sort((a, b) => b.u - a.u)) {
|
||||||
|
const p = project(b.u, b.w);
|
||||||
|
const r = Math.max(b.jack ? 1.7 : 2.5, b.r * 33.9 * p.s * (1 + 0.65 * (1 - p.s)));
|
||||||
|
sphere(ctx, b, p.x, p.y, r);
|
||||||
|
}
|
||||||
|
// Pallino identificabile anche senza distinguere i colori delle squadre.
|
||||||
|
const j = project(c.jack.u, c.jack.w);
|
||||||
|
text(ctx, 'P', j.x, j.y - 13, 6, '#fff7c7', 'center');
|
||||||
|
detail(ctx, c, def);
|
||||||
|
panel(ctx, 18, 306, 180, 43);
|
||||||
|
for (const [i, who] of ['player', 'cpu'].entries()) {
|
||||||
|
const y = 319 + i * 17;
|
||||||
|
text(ctx, def.names[who], 28, y, 7, COLORS[who][2]);
|
||||||
|
for (let n = 0; n < 3; n++) {
|
||||||
|
const x = 139 + n * 18;
|
||||||
|
if (n < left(c, who)) sphere(ctx, { who, spin: 0 }, x, y, 4, true);
|
||||||
|
else { ctx.strokeStyle = '#766e5c'; ctx.strokeRect(x - 3, y - 3, 6, 6); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panel(ctx, 182, 50, 440, 24);
|
||||||
|
text(ctx, status(c, def.names), 402, 63, 6, '#fff0cb', 'center');
|
||||||
|
if (canThrow(c)) {
|
||||||
|
panel(ctx, 446, 274, 176, 37);
|
||||||
|
text(ctx, `FORZA ${Math.round(c.power * 100)}%`, 458, 286, 7);
|
||||||
|
ctx.fillStyle = '#3e3e36'; ctx.fillRect(458, 299, 150, 5);
|
||||||
|
ctx.fillStyle = '#b9d392'; ctx.fillRect(458, 299, Math.round(c.power * 150), 5);
|
||||||
|
}
|
||||||
|
if (c.step === 'result') {
|
||||||
|
panel(ctx, 206, 91, 397, 70);
|
||||||
|
text(ctx, 'MANO CONCLUSA', 404, 108, 9, '#efd49e', 'center');
|
||||||
|
text(ctx, status(c, def.names), 404, 132, 10, c.result.winner === 'player' ? '#b9e5b3' : '#f1d9be', 'center');
|
||||||
|
text(ctx, 'Le distanze restano visibili fino a quando esci.', 404, 150, 6, '#d6c7af', 'center');
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Mano amichevole da tre bocce: mira libera, forza ripetibile, gioca chi non ha il punto.
|
||||||
|
// Il campo usa coordinate u (lunghezza) e w (larghezza). Un'unità rappresenta 20 cm.
|
||||||
|
// È una versione da circolo: sponde elastiche, senza falli o dichiarazioni arbitrali.
|
||||||
|
export const LANE = 100, HALF = 10, FRICTION = 8, BALL = 0.27, JACK = 0.1, BALLS = 3;
|
||||||
|
export const SWING = 1.6;
|
||||||
|
export const FIXED_STEP = 1 / 240;
|
||||||
|
const REST = 0.75, EPS = 0.005;
|
||||||
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
|
function random(c) { c.seed = (Math.imul(c.seed, 1664525) + 1013904223) >>> 0; return c.seed / 4294967296; }
|
||||||
|
const deviation = c => random(c) + random(c) + random(c) - 1.5;
|
||||||
|
const distance = (a, b) => Math.hypot(a.u - b.u, a.w - b.w);
|
||||||
|
export const gap = (a, b) => Math.max(0, distance(a, b) - a.r - b.r);
|
||||||
|
export const left = (c, who) => Math.max(0, BALLS - c.balls.filter(b => b.who === who).length);
|
||||||
|
export const thrower = c => c.current;
|
||||||
|
export const canThrow = c => c.step === 'aim' && c.current === 'player' && left(c, 'player') > 0;
|
||||||
|
export const powerAt = time => clamp(time / SWING, 0, 1);
|
||||||
|
export const rangeFor = power => 8 + 96 * power;
|
||||||
|
export const powerFor = distance => clamp((distance - 8) / 96, 0, 1);
|
||||||
|
export const moving = c => [c.jack, ...c.balls].some(b => Math.hypot(b.vu, b.vw) > EPS);
|
||||||
|
export const closest = (c, who) => c.balls.filter(b => b.who === who).sort((a, b) => gap(a, c.jack) - gap(b, c.jack))[0];
|
||||||
|
|
||||||
|
export function newBocce(seed = Date.now() >>> 0) {
|
||||||
|
const c = { step: 'aim', t: 0, age: 0, seed, balls: [], turn: 0, current: 'player', power: 0.5,
|
||||||
|
holding: false, holdTime: 0, accumulator: 0, events: [], trails: [], travel: 0, lastThrower: null };
|
||||||
|
c.jack = { u: 45 + random(c) * 18, w: (random(c) - 0.5) * 5, vu: 0, vw: 0, r: JACK, mass: 0.1, jack: true, spin: 0 };
|
||||||
|
c.aim = { u: c.jack.u, w: c.jack.w };
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
export function setPower(c, value) {
|
||||||
|
if (!canThrow(c) || c.holding || !Number.isFinite(value)) return false;
|
||||||
|
c.power = clamp(value, 0, 1); return true;
|
||||||
|
}
|
||||||
|
export function aim(c, u, w) {
|
||||||
|
if (!canThrow(c) || c.holding || !Number.isFinite(u) || !Number.isFinite(w)) return false;
|
||||||
|
c.aim = { u: clamp(u, 10, LANE - 2), w: clamp(w, -HALF + BALL, HALF - BALL) }; return true;
|
||||||
|
}
|
||||||
|
export function landing(c) {
|
||||||
|
const d = Math.hypot(c.aim.u, c.aim.w), range = rangeFor(c.power);
|
||||||
|
return { u: BALL + 0.02 + range * c.aim.u / d, w: range * c.aim.w / d };
|
||||||
|
}
|
||||||
|
function launch(c, who, u, w, range) {
|
||||||
|
if (left(c, who) <= 0) return false;
|
||||||
|
const d = Math.hypot(u, w), v = Math.sqrt(2 * FRICTION * range);
|
||||||
|
c.balls.push({ who, number: BALLS - left(c, who) + 1, u: BALL + 0.02, w: 0,
|
||||||
|
vu: v * u / d, vw: v * w / d, r: BALL, mass: 1, spin: 0 });
|
||||||
|
c.step = 'rolling'; c.t = c.accumulator = c.travel = 0; c.lastThrower = who;
|
||||||
|
c.events.push({ kind: 'bocce-roll', power: c.power });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
export function shoot(c) {
|
||||||
|
if (!canThrow(c) || c.holding) return false;
|
||||||
|
return launch(c, 'player', c.aim.u, c.aim.w, rangeFor(c.power));
|
||||||
|
}
|
||||||
|
export function press(c) {
|
||||||
|
if (!canThrow(c) || c.holding) return false;
|
||||||
|
c.previousPower = c.power; c.holding = true; c.holdTime = c.power = 0; return true;
|
||||||
|
}
|
||||||
|
export function cancel(c) {
|
||||||
|
if (!c.holding) return false;
|
||||||
|
c.holding = false; c.power = c.previousPower; c.holdTime = 0; return true;
|
||||||
|
}
|
||||||
|
export function release(c) {
|
||||||
|
if (!c.holding) return false;
|
||||||
|
// Un tocco breve serve a mirare, non spreca una boccia.
|
||||||
|
if (c.holdTime < 0.14) return cancel(c);
|
||||||
|
c.holding = false; return shoot(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Impulso normale fra masse diverse: niente accelerazioni create dagli urti, e
|
||||||
|
// separazione geometrica anche quando due pezzi sono già fermi o sovrapposti.
|
||||||
|
export function collide(a, b) {
|
||||||
|
const du = b.u - a.u, dw = b.w - a.w, d = Math.hypot(du, dw), r = a.r + b.r;
|
||||||
|
if (d >= r) return 0;
|
||||||
|
const nu = d > 1e-9 ? du / d : 1, nw = d > 1e-9 ? dw / d : 0;
|
||||||
|
const ia = 1 / (a.mass ?? (a.jack ? 0.1 : 1)), ib = 1 / (b.mass ?? (b.jack ? 0.1 : 1));
|
||||||
|
const overlap = r - d + 0.00001;
|
||||||
|
a.u -= nu * overlap * ia / (ia + ib); a.w -= nw * overlap * ia / (ia + ib);
|
||||||
|
b.u += nu * overlap * ib / (ia + ib); b.w += nw * overlap * ib / (ia + ib);
|
||||||
|
const closing = (a.vu - b.vu) * nu + (a.vw - b.vw) * nw;
|
||||||
|
if (closing <= 0) return 0;
|
||||||
|
const impulse = 1.68 * closing / (ia + ib);
|
||||||
|
a.vu -= impulse * ia * nu; a.vw -= impulse * ia * nw;
|
||||||
|
b.vu += impulse * ib * nu; b.vw += impulse * ib * nw;
|
||||||
|
return closing;
|
||||||
|
}
|
||||||
|
function bounds(b) {
|
||||||
|
let impact = 0;
|
||||||
|
for (const [pos, vel, low, high] of [['u', 'vu', b.r, LANE - b.r], ['w', 'vw', -HALF + b.r, HALF - b.r]]) {
|
||||||
|
if (b[pos] < low || b[pos] > high) {
|
||||||
|
const outward = b[pos] < low ? b[vel] < 0 : b[vel] > 0;
|
||||||
|
b[pos] = clamp(b[pos], low, high);
|
||||||
|
if (outward) { impact = Math.max(impact, Math.abs(b[vel])); b[vel] *= -0.32; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return impact;
|
||||||
|
}
|
||||||
|
function step(c, dt) {
|
||||||
|
const all = [c.jack, ...c.balls];
|
||||||
|
let hit = 0, rail = 0;
|
||||||
|
for (const b of all) {
|
||||||
|
const v = Math.hypot(b.vu, b.vw);
|
||||||
|
if (v <= EPS) { b.vu = b.vw = 0; continue; }
|
||||||
|
const stop = Math.min(dt, v / FRICTION), travel = v * stop - 0.5 * FRICTION * stop * stop;
|
||||||
|
b.u += b.vu / v * travel; b.w += b.vw / v * travel;
|
||||||
|
b.spin = (b.spin ?? 0) + travel / b.r;
|
||||||
|
const next = Math.max(0, v - FRICTION * dt) / v;
|
||||||
|
b.vu *= next; b.vw *= next;
|
||||||
|
rail = Math.max(rail, bounds(b));
|
||||||
|
}
|
||||||
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
|
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) hit = Math.max(hit, collide(all[i], all[j]));
|
||||||
|
all.forEach(bounds);
|
||||||
|
}
|
||||||
|
if (hit > 0.8 || rail > 1) {
|
||||||
|
// Un solo evento per urto, limitato anche se il chiamante non consuma l'audio.
|
||||||
|
if (c.events.length < 12) c.events.push({ kind: hit > rail ? 'bocce-hit' : 'bocce-rail', power: Math.min(1, Math.max(hit, rail) / 30) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function score(c) {
|
||||||
|
const a = closest(c, 'player'), b = closest(c, 'cpu');
|
||||||
|
if (!a && !b) return { winner: null, points: 0 };
|
||||||
|
const p = a ? gap(a, c.jack) : Infinity, q = b ? gap(b, c.jack) : Infinity;
|
||||||
|
if (Math.abs(p - q) < EPS) return { winner: null, points: 0 };
|
||||||
|
const winner = p < q ? 'player' : 'cpu', limit = Math.min(p, q) === p ? q : p;
|
||||||
|
return { winner, points: c.balls.filter(ball => ball.who === winner && gap(ball, c.jack) < limit - EPS).length };
|
||||||
|
}
|
||||||
|
function nextTurn(c) {
|
||||||
|
if (!left(c, 'player') && !left(c, 'cpu')) { c.step = 'result'; c.result = score(c); return; }
|
||||||
|
const winner = score(c).winner;
|
||||||
|
const behind = winner ? (winner === 'player' ? 'cpu' : 'player') : c.lastThrower;
|
||||||
|
c.current = left(c, behind) ? behind : behind === 'player' ? 'cpu' : 'player';
|
||||||
|
c.step = 'aim';
|
||||||
|
}
|
||||||
|
function cpuThrow(c) {
|
||||||
|
// Mariano cerca un accosto libero; con un avversario attaccato al pallino può
|
||||||
|
// tentare di spostarlo. Usa gli stessi lanci e la stessa fisica del giocatore.
|
||||||
|
const other = closest(c, 'player'), attack = other && gap(other, c.jack) < 0.6 && random(c) < 0.45;
|
||||||
|
const target = attack ? other : c.jack;
|
||||||
|
const u = clamp(target.u + deviation(c) * (attack ? 0.5 : 2.2), 10, LANE - 2);
|
||||||
|
const w = clamp(target.w + deviation(c) * (attack ? 0.18 : 0.8), -HALF + BALL, HALF - BALL);
|
||||||
|
launch(c, 'cpu', u, w, Math.hypot(u, w) + (attack ? 14 : -0.35));
|
||||||
|
}
|
||||||
|
export function update(c, dt) {
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||||
|
dt = Math.min(dt, 0.25); c.t += dt;
|
||||||
|
if (c.holding) { c.holdTime += dt; c.power = powerAt(c.holdTime); }
|
||||||
|
if (c.step === 'rolling') {
|
||||||
|
c.accumulator += dt;
|
||||||
|
while (c.accumulator >= FIXED_STEP) { step(c, FIXED_STEP); c.accumulator -= FIXED_STEP; }
|
||||||
|
const b = c.balls.at(-1);
|
||||||
|
if (b && Math.hypot(b.vu, b.vw) > 1) {
|
||||||
|
c.trails.push({ u: b.u, w: b.w, age: 0 });
|
||||||
|
if (c.trails.length > 18) c.trails.shift();
|
||||||
|
}
|
||||||
|
if (!moving(c)) { c.step = 'settle'; c.t = 0; }
|
||||||
|
} else if (c.step === 'settle' && c.t >= REST) {
|
||||||
|
c.turn++; c.t = 0; nextTurn(c);
|
||||||
|
} else if (c.step === 'aim' && c.current === 'cpu' && c.t >= 1.25) cpuThrow(c);
|
||||||
|
for (const t of c.trails) t.age += dt;
|
||||||
|
c.trails = c.trails.filter(t => t.age < 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prospettiva calibrata sulle sponde dello sfondo esistente.
|
||||||
|
const NEAR = { y: 360, x: 355, half: 339 }, FAR = { y: 187, x: 298, half: 37.5 };
|
||||||
|
const S_FAR = FAR.half / NEAR.half, Q = (1 / S_FAR - 1) / LANE, HORIZON = (FAR.y - NEAR.y * S_FAR) / (1 - S_FAR);
|
||||||
|
export function project(u, w) {
|
||||||
|
const s = 1 / (1 + Q * u), k = (s - S_FAR) / (1 - S_FAR);
|
||||||
|
return { x: FAR.x + (NEAR.x - FAR.x) * k + w / HALF * NEAR.half * s, y: HORIZON + (NEAR.y - HORIZON) * s, s };
|
||||||
|
}
|
||||||
|
export function unproject(x, y) {
|
||||||
|
const s = clamp((y - HORIZON) / (NEAR.y - HORIZON), S_FAR, 1);
|
||||||
|
const u = (1 / s - 1) / Q, k = (s - S_FAR) / (1 - S_FAR);
|
||||||
|
return { u, w: (x - FAR.x - (NEAR.x - FAR.x) * k) / (NEAR.half * s) * HALF };
|
||||||
|
}
|
||||||
|
export const DETAIL = { x: 18, y: 86, w: 150, h: 170, scale: 11 };
|
||||||
|
export function detailPoint(c, b) { return { x: DETAIL.x + DETAIL.w / 2 + (b.w - c.jack.w) * DETAIL.scale, y: DETAIL.y + 93 - (b.u - c.jack.u) * DETAIL.scale }; }
|
||||||
|
export function aimScreen(c, [x, y]) {
|
||||||
|
const d = DETAIL;
|
||||||
|
if (x >= d.x && x <= d.x + d.w && y >= d.y + 24 && y <= d.y + d.h - 18)
|
||||||
|
return aim(c, c.jack.u + (d.y + 93 - y) / d.scale, c.jack.w + (x - d.x - d.w / 2) / d.scale);
|
||||||
|
if (y < 185 || y > 360) return false;
|
||||||
|
const p = unproject(x, y); return aim(c, p.u, p.w);
|
||||||
|
}
|
||||||
|
export function status(c, names) {
|
||||||
|
if (c.step === 'result') return c.result.winner ? `${names[c.result.winner]}: ${c.result.points} ${c.result.points === 1 ? 'punto' : 'punti'}.` : 'Parità: nessun punto.';
|
||||||
|
if (c.step === 'rolling') return 'Boccia in movimento…';
|
||||||
|
if (c.step === 'settle') return 'Misuriamo il punto…';
|
||||||
|
const leader = score(c).winner;
|
||||||
|
return `${names[c.current]} al tiro. ${leader ? `Il punto è di ${names[leader]}.` : 'Il punto è da prendere.'}`;
|
||||||
|
}
|
||||||
+23
-3
@@ -1,6 +1,7 @@
|
|||||||
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi.
|
// Il cuore dell'avventura, senza DOM: stato, comandi, coda delle azioni, dialoghi.
|
||||||
// La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano.
|
// La storia (scene, oggetti, dialoghi) arriva tutta dai dati: qui non c'è nessun enigma scritto a mano.
|
||||||
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
import { findPath, dist, inBox, boxDistance, nearestInside } from './geometry.js';
|
||||||
|
import { newBocce, update as bocceUpdate, press as boccePress, release as bocceRelease, cancel as bocceCancel, aimScreen } from './bocce.js';
|
||||||
|
|
||||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give', 'drop'];
|
||||||
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
|
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai', drop: 'Butta' };
|
||||||
@@ -229,7 +230,7 @@ export class Game {
|
|||||||
closeupTap() {
|
closeupTap() {
|
||||||
const c = this.closeup;
|
const c = this.closeup;
|
||||||
if (!c || this.speech) return false;
|
if (!c || this.speech) return false;
|
||||||
if (this.story.closeups[c.id].type === 'torch') return false;
|
if (this.story.closeups[c.id].type) return false; // torcia e bocce non si sfogliano
|
||||||
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
|
if (c.step === 'cover') Object.assign(c, { step: 'title', t: 0 });
|
||||||
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
|
else if (c.step === 'title') Object.assign(c, { step: 'turn', t: 0 });
|
||||||
else return false;
|
else return false;
|
||||||
@@ -247,15 +248,26 @@ export class Game {
|
|||||||
if (c.step === 'done') this.queue.unshift(...def.complete);
|
if (c.step === 'done') this.queue.unshift(...def.complete);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Le bocce: a mano finita, la battuta di chi ha vinto; uscendo prima, quella di chi lascia.
|
||||||
|
if (def?.type === 'bocce') {
|
||||||
|
this.queue.unshift(...(c.step === 'result' ? def[c.result?.winner === 'player' ? 'win' : c.result?.winner === 'cpu' ? 'lose' : 'draw'] ?? [] : def.quit ?? []));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
// Chi è arrivato all'ultima pagina, chiudendo, la commenta (`closing`).
|
||||||
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
|
if (c.step === 'end') this.queue.unshift(...(this.story.closeups?.[c.id]?.closing ?? []));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le bocce: si tiene premuto per caricare la forza e si lascia per tirare (mouse, dito o spazio).
|
||||||
|
closeupPress() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && !this.speech && boccePress(c); }
|
||||||
|
closeupRelease() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceRelease(c); }
|
||||||
|
closeupCancel() { const c = this.closeup; return !!c && this.story.closeups[c.id].type === 'bocce' && bocceCancel(c); }
|
||||||
|
|
||||||
// Solo luce, nessun clic sugli oggetti dietro al primo piano. Il progresso è transitorio:
|
// 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.
|
// uscire o ricaricare durante la ricerca permette di ripeterla, senza anticipare il finale.
|
||||||
closeupAim([x, y]) {
|
closeupAim([x, y]) {
|
||||||
const c = this.closeup;
|
const c = this.closeup;
|
||||||
|
if (c && this.story.closeups[c.id].type === 'bocce') return aimScreen(c, [x, y]);
|
||||||
if (!c || this.story.closeups[c.id].type !== 'torch' || !Number.isFinite(x) || !Number.isFinite(y)) return false;
|
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))];
|
c.beam = [Math.max(70, Math.min(570, x)), Math.max(65, Math.min(295, y))];
|
||||||
return true;
|
return true;
|
||||||
@@ -378,6 +390,11 @@ export class Game {
|
|||||||
const c = this.closeup;
|
const c = this.closeup;
|
||||||
if (c) {
|
if (c) {
|
||||||
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
|
if (this.story.closeups[c.id].type === 'torch') { this.updateTorch(dt); return; }
|
||||||
|
if (this.story.closeups[c.id].type === 'bocce') {
|
||||||
|
bocceUpdate(c, dt);
|
||||||
|
// Il risultato resta leggibile finché il giocatore torna al pallaio.
|
||||||
|
return;
|
||||||
|
}
|
||||||
c.t += dt;
|
c.t += dt;
|
||||||
if (c.step === 'turn' && c.t >= PAGE_TURN) Object.assign(c, { step: 'end', t: 0 });
|
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 ?? [])];
|
if (c.step === 'end' && !c.lines && c.t >= CLOSEUP_PAUSE) c.lines = [...(this.story.closeups?.[c.id]?.comment ?? [])];
|
||||||
@@ -409,9 +426,12 @@ export class Game {
|
|||||||
else if (e.node) this.dialogue.node = e.node;
|
else if (e.node) this.dialogue.node = e.node;
|
||||||
else if (e.endDialogue) this.dialogue = null;
|
else if (e.endDialogue) this.dialogue = null;
|
||||||
else if (e.end) s.ended = true;
|
else if (e.end) s.ended = true;
|
||||||
else if (e.closeup) this.closeup = this.story.closeups[e.closeup].type === 'torch'
|
else if (e.closeup) {
|
||||||
? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
|
const type = this.story.closeups[e.closeup].type;
|
||||||
|
this.closeup = type === 'torch' ? { id: e.closeup, step: 'search', t: 0, age: 0, found: 0, dwell: 0, beam: [320, 180] }
|
||||||
|
: type === 'bocce' ? { id: e.closeup, ...newBocce(Math.floor(Math.random() * 4294967296)) }
|
||||||
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
: { id: e.closeup, step: 'cover', t: 0, age: 0 };
|
||||||
|
}
|
||||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { check, PAGE_TURN } from './game.js';
|
import { check, PAGE_TURN } from './game.js';
|
||||||
import { ambientEffects, ambientHash } from './ambient.js';
|
import { ambientEffects, ambientHash } from './ambient.js';
|
||||||
import { drawTorch } from './torch.js';
|
import { drawTorch } from './torch.js';
|
||||||
|
import { drawBocce } from './bocce-render.js';
|
||||||
import { versioned } from '../version.js';
|
import { versioned } from '../version.js';
|
||||||
export const WIDTH = 640, HEIGHT = 360;
|
export const WIDTH = 640, HEIGHT = 360;
|
||||||
|
|
||||||
@@ -669,6 +670,11 @@ function drawCloseup(ctx, game, time) {
|
|||||||
drawTorch(ctx, c, def, backgrounds.get(def.picture));
|
drawTorch(ctx, c, def, backgrounds.get(def.picture));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (def.type === 'bocce') {
|
||||||
|
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||||
|
drawBocce(ctx, c, def, backgrounds.get(def.picture), time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
|
ctx.fillStyle = `rgba(8, 5, 12, ${Math.min(0.78, c.age / 0.35 * 0.78)})`;
|
||||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
|||||||
@@ -98,9 +98,12 @@ export class Soundscape {
|
|||||||
source.start(at); source.stop(at + duration + 0.03);
|
source.start(at); source.stop(at + duration + 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
play(kind, { pan = (this.random() - 0.5) * 1.3 } = {}) {
|
play(kind, { pan = (this.random() - 0.5) * 1.3, strength = 0.5 } = {}) {
|
||||||
if (!this.audible) return;
|
if (!this.audible) return;
|
||||||
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
|
if (kind === 'page') this.noiseSound(0.42, 2800, 0.24, { paper: true });
|
||||||
|
else if (kind === 'bocce-roll') this.noiseSound(0.7, 650, 0.06 + strength * 0.08, { pan });
|
||||||
|
else if (kind === 'bocce-hit') { this.tone(760, 320, 0.075, 0.04 + strength * 0.07, 0, pan); this.noiseSound(0.09, 1600, 0.04, { pan }); }
|
||||||
|
else if (kind === 'bocce-rail') this.noiseSound(0.16, 380, 0.04 + strength * 0.08, { pan });
|
||||||
else if (kind === 'wood') this.noiseSound(0.3, 750, 0.13, { pan });
|
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 === 'drip') this.tone(1150, 540, 0.18, 0.055, 0, pan);
|
||||||
else if (kind === 'bird') {
|
else if (kind === 'bird') {
|
||||||
|
|||||||
+47
-4
@@ -8,6 +8,7 @@ import { Music, trackFor } from './engine/music.js';
|
|||||||
import { Voices } from './engine/voices.js';
|
import { Voices } from './engine/voices.js';
|
||||||
import { Soundscape } from './engine/soundscape.js';
|
import { Soundscape } from './engine/soundscape.js';
|
||||||
import { soundscapeFor } from './story/soundscapes.js';
|
import { soundscapeFor } from './story/soundscapes.js';
|
||||||
|
import { aim as bocceAim, shoot as bocceShoot, setPower as boccePower, canThrow as bocceReady, status as bocceStatus } from './engine/bocce.js';
|
||||||
import { VERSION, versioned } from './version.js';
|
import { VERSION, versioned } from './version.js';
|
||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
@@ -265,6 +266,7 @@ $('spots-button').addEventListener('click', showSpots);
|
|||||||
function openPause() {
|
function openPause() {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
running = false;
|
running = false;
|
||||||
|
game.closeupCancel();
|
||||||
music.hold = true;
|
music.hold = true;
|
||||||
voices.pause();
|
voices.pause();
|
||||||
ambience.configure({ active: false });
|
ambience.configure({ active: false });
|
||||||
@@ -398,6 +400,8 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
if (game.closeup) {
|
if (game.closeup) {
|
||||||
press = { closeup: true };
|
press = { closeup: true };
|
||||||
game.closeupAim(scenePoint(e));
|
game.closeupAim(scenePoint(e));
|
||||||
|
// Alle bocce si tiene premuto per caricare la forza (la battuta in corso, un tocco la salta).
|
||||||
|
if (!game.skip()) game.closeupPress();
|
||||||
canvas.setPointerCapture(e.pointerId);
|
canvas.setPointerCapture(e.pointerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -406,7 +410,7 @@ canvas.addEventListener('pointerdown', e => {
|
|||||||
});
|
});
|
||||||
canvas.addEventListener('pointerup', e => {
|
canvas.addEventListener('pointerup', e => {
|
||||||
if (!press) return;
|
if (!press) return;
|
||||||
if (press.closeup) { press = null; if (running && !game.skip()) game.closeupTap(); return; }
|
if (press.closeup) { press = null; if (running && !game.closeupRelease() && !game.skip()) game.closeupTap(); return; }
|
||||||
clearTimeout(press.timer);
|
clearTimeout(press.timer);
|
||||||
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
|
// Il secondo clic vicino al primo, entro poco, è un doppio clic: stesso comando, al doppio della velocità.
|
||||||
if (!press.long) {
|
if (!press.long) {
|
||||||
@@ -417,7 +421,8 @@ canvas.addEventListener('pointerup', e => {
|
|||||||
}
|
}
|
||||||
press = null;
|
press = null;
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
|
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; game?.closeupCancel(); });
|
||||||
|
canvas.addEventListener('lostpointercapture', () => { if (press?.closeup) { press = null; game?.closeupCancel(); } });
|
||||||
canvas.addEventListener('contextmenu', e => {
|
canvas.addEventListener('contextmenu', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
if (running && !game.closeup) command.clickScene(scenePoint(e), { look: true });
|
||||||
@@ -429,6 +434,15 @@ canvas.addEventListener('pointermove', e => {
|
|||||||
});
|
});
|
||||||
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
||||||
|
|
||||||
|
// Comandi delle bocce separati dai verbi dell'avventura: forza regolabile e lancio
|
||||||
|
// con un solo clic, anche per chi non vuole usare la pressione temporizzata.
|
||||||
|
const bocceState = () => running && game?.closeup?.id === 'bocce' ? game.closeup : null;
|
||||||
|
$('bocce-power').addEventListener('input', e => { const c = bocceState(); if (c) boccePower(c, Number(e.target.value) / 100); });
|
||||||
|
$('bocce-throw').addEventListener('click', () => { const c = bocceState(); if (c && !game.speech) bocceShoot(c); });
|
||||||
|
for (const [id, move] of [['bocce-left', -0.3], ['bocce-right', 0.3]])
|
||||||
|
$(id).addEventListener('click', () => { const c = bocceState(); if (c) bocceAim(c, c.aim.u, c.aim.w + move); });
|
||||||
|
addEventListener('blur', () => game?.closeupCancel());
|
||||||
|
|
||||||
// --- Verbi.
|
// --- Verbi.
|
||||||
for (const verb of VERBS) {
|
for (const verb of VERBS) {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
@@ -514,6 +528,17 @@ window.addEventListener('keydown', e => {
|
|||||||
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
|
if (!$('map').hidden) { if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') $('map').hidden = true; return; }
|
||||||
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
|
// Nel primo piano: Invio, spazio o freccia sfogliano (o saltano la battuta), Esc chiude il libro.
|
||||||
if (running && game.closeup) {
|
if (running && game.closeup) {
|
||||||
|
if (game.closeup.id === 'bocce') {
|
||||||
|
if (e.target.closest?.('#bocce-controls') && ['BUTTON', 'SUMMARY'].includes(e.target.tagName)) return;
|
||||||
|
const c = game.closeup;
|
||||||
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') bocceAim(c, c.aim.u, c.aim.w + (e.key === 'ArrowLeft' ? -0.3 : 0.3));
|
||||||
|
else boccePower(c, c.power + (e.key === 'ArrowUp' ? 0.01 : -0.01));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (c.step === 'result' && [' ', 'Enter'].includes(e.key)) { e.preventDefault(); game.closeupClose(); return; }
|
||||||
|
}
|
||||||
if (story.closeups[game.closeup.id].type === 'torch' && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
|
if (story.closeups[game.closeup.id].type === 'torch' && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
|
||||||
// Invio cerca il prossimo dettaglio anche per chi non può usare il puntatore.
|
// Invio cerca il prossimo dettaglio anche per chi non può usare il puntatore.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -529,6 +554,7 @@ window.addEventListener('keydown', e => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') game.closeupClose();
|
if (e.key === 'Escape') game.closeupClose();
|
||||||
|
else if ([' ', 'Enter'].includes(e.key) && story.closeups[game.closeup.id].type === 'bocce') { e.preventDefault(); if (!e.repeat && !game.skip()) game.closeupPress(); }
|
||||||
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
|
else if ([' ', 'Enter', 'ArrowRight'].includes(e.key)) { e.preventDefault(); if (!game.skip()) game.closeupTap(); }
|
||||||
else if (e.key === 'm' || e.key === 'M') toggleMute();
|
else if (e.key === 'm' || e.key === 'M') toggleMute();
|
||||||
return;
|
return;
|
||||||
@@ -546,6 +572,9 @@ window.addEventListener('keydown', e => {
|
|||||||
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
|
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Alle bocce, lasciando spazio o Invio parte il tiro.
|
||||||
|
window.addEventListener('keyup', e => { if (running && [' ', 'Enter'].includes(e.key)) game?.closeupRelease(); });
|
||||||
|
|
||||||
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
|
// --- Il primo piano: si chiude col pulsante; la pagina che gira fruscia (rumore filtrato, niente file audio).
|
||||||
$('closeup-close').addEventListener('click', () => game?.closeupClose());
|
$('closeup-close').addEventListener('click', () => game?.closeupClose());
|
||||||
let turning = null, reveal = null;
|
let turning = null, reveal = null;
|
||||||
@@ -584,16 +613,30 @@ function updateUi() {
|
|||||||
updateInventory();
|
updateInventory();
|
||||||
updateChoices();
|
updateChoices();
|
||||||
updateSpeech();
|
updateSpeech();
|
||||||
|
const bocce = c?.id === 'bocce';
|
||||||
|
$('bocce-controls').hidden = !bocce;
|
||||||
|
$('bocce-controls').inert = !running;
|
||||||
|
if (bocce) {
|
||||||
|
$('panel').hidden = $('choices').hidden = true;
|
||||||
|
const line = bocceStatus(c, story.closeups.bocce.names);
|
||||||
|
if ($('bocce-status').textContent !== line) $('bocce-status').textContent = line;
|
||||||
|
$('bocce-power').value = Math.round(c.power * 100);
|
||||||
|
$('bocce-power-value').value = `${Math.round(c.power * 100)}%`;
|
||||||
|
for (const id of ['bocce-power', 'bocce-left', 'bocce-right', 'bocce-throw']) $(id).disabled = !bocceReady(c) || c.holding || !!game.speech;
|
||||||
|
$('bocce-throw').textContent = c.step === 'result' ? 'Mano conclusa' : c.step === 'rolling' ? 'In movimento…' : c.current === 'cpu' ? 'Tira Mariano…' : 'Lancia';
|
||||||
|
for (const event of c.events.splice(0)) ambience.play(event.kind, { pan: 0, strength: event.power });
|
||||||
|
}
|
||||||
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
// Il pulsante della mappa c'è solo in partita, fuori dai dialoghi, e quando c'è almeno un altro posto dove andare.
|
||||||
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
|
$('map-button').hidden = !running || !!game.dialogue || !!game.closeup || game.mapPlaces().length < 2;
|
||||||
$('pause-button').hidden = !running;
|
$('pause-button').hidden = !running;
|
||||||
$('spots-button').hidden = !running || !!game.closeup;
|
$('spots-button').hidden = !running || !!game.closeup;
|
||||||
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
|
$('notebook-button').hidden = !running || !story.notebook || !!game.closeup;
|
||||||
$('closeup-close').hidden = !running || !game.closeup;
|
$('closeup-close').hidden = !running || !game.closeup;
|
||||||
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : 'Chiudi';
|
$('closeup-close').textContent = torch ? c.step === 'done' ? 'Continua' : 'Torna fuori' : bocce ? c.step === 'result' ? 'Torna al pallaio' : 'Lascia stare' : 'Chiudi';
|
||||||
$('panel').inert = !!c;
|
$('panel').inert = !!c;
|
||||||
canvas.classList.toggle('torch', !!torch);
|
canvas.classList.toggle('torch', !!torch);
|
||||||
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : "La scena dell'avventura");
|
canvas.classList.toggle('bocce', bocce);
|
||||||
|
canvas.setAttribute('aria-label', torch ? 'Fessura: sposta la luce con mouse, dito o frecce. Invio cerca il prossimo dettaglio; Esc esce.' : bocce ? 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' : "La scena dell'avventura");
|
||||||
// La pagina che comincia a girare fa il suo fruscio.
|
// La pagina che comincia a girare fa il suo fruscio.
|
||||||
const step = game.closeup?.step ?? null;
|
const step = game.closeup?.step ?? null;
|
||||||
if (step === 'turn' && turning !== 'turn') ambience.play('page');
|
if (step === 'turn' && turning !== 'turn') ambience.play('page');
|
||||||
|
|||||||
+51
-3
@@ -107,6 +107,23 @@ export const story = {
|
|||||||
// Primi piani interattivi: libro dell'oratorio e ricerca con la torcia nella fessura.
|
// Primi piani interattivi: libro dell'oratorio e ricerca con la torcia nella fessura.
|
||||||
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
|
// Le immagini (Codex, assets/closeups/) hanno l'etichetta e le pagine vuote: titolo e «FINE» li scrive il codice.
|
||||||
closeups: {
|
closeups: {
|
||||||
|
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
|
||||||
|
bocce: {
|
||||||
|
type: 'bocce',
|
||||||
|
picture: 'assets/closeups/pallaio.png',
|
||||||
|
names: { player: 'Andrea', cpu: 'Mariano' },
|
||||||
|
win: [
|
||||||
|
{ say: 'Aspetta, ci vuole il metro...', who: 'mariano', pose: 'measure' },
|
||||||
|
{ say: 'Hai vinto, ti ci porto!', who: 'mariano', pose: 'laugh' },
|
||||||
|
{ set: 'bocce_vinte' },
|
||||||
|
],
|
||||||
|
lose: [
|
||||||
|
{ say: 'Bocciato! Al pallaio comando io.', who: 'mariano', pose: 'cheer' },
|
||||||
|
{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' },
|
||||||
|
],
|
||||||
|
quit: [{ say: 'Già stanco? Il pallaio ti aspetta.', who: 'mariano', pose: 'laugh' }],
|
||||||
|
draw: [{ say: 'Quando vuoi la rivincita, sono qui.', who: 'mariano' }],
|
||||||
|
},
|
||||||
fessura: {
|
fessura: {
|
||||||
type: 'torch',
|
type: 'torch',
|
||||||
picture: 'assets/closeups/palio-2007.jpg',
|
picture: 'assets/closeups/palio-2007.jpg',
|
||||||
@@ -155,6 +172,7 @@ export const story = {
|
|||||||
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
|
arcu: { name: 'L\'Arcu', color: '#7fb2ff', sprite: 'assets/characters/arcu.json' },
|
||||||
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
|
tozzino: { name: 'Il Tozzino', color: '#c8f5b0', sprite: 'assets/characters/tozzino.json' },
|
||||||
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
|
barondeduke: { name: 'Il BaronDeDuke', color: '#e0c090', sprite: 'assets/characters/barondeduke.json' },
|
||||||
|
mariano: { name: 'Mariano', color: '#ffb0a3', sprite: 'assets/characters/mariano.json' },
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
squama: {
|
squama: {
|
||||||
@@ -571,6 +589,7 @@ export const story = {
|
|||||||
name: 'Dentro la Pieve',
|
name: 'Dentro la Pieve',
|
||||||
map: [56, 39],
|
map: [56, 39],
|
||||||
background: 'assets/scenes/pieve-interno.png',
|
background: 'assets/scenes/pieve-interno.png',
|
||||||
|
music: 'assets/audio/pieve.mp3', // l'organo soave della chiesa (tools/organo.py, 25 ottobre)
|
||||||
effects: [{ type: 'beam', phase: ['pomeriggio', 'tramonto'], poly: [[22, 122], [32, 118], [250, 360], [150, 360]] }, { type: 'dust', phase: ['pomeriggio', 'tramonto'], area: [60, 200, 90, 110], count: 9 }],
|
effects: [{ type: 'beam', phase: ['pomeriggio', 'tramonto'], poly: [[22, 122], [32, 118], [250, 360], [150, 360]] }, { type: 'dust', phase: ['pomeriggio', 'tramonto'], area: [60, 200, 90, 110], count: 9 }],
|
||||||
// Penombra, e le sei candele dell'altare accese (Andrea, 24 ottobre).
|
// Penombra, e le sei candele dell'altare accese (Andrea, 24 ottobre).
|
||||||
indoor: true,
|
indoor: true,
|
||||||
@@ -1423,7 +1442,9 @@ export const story = {
|
|||||||
name: 'Il tennis della chiesa',
|
name: 'Il tennis della chiesa',
|
||||||
map: [66, 51],
|
map: [66, 51],
|
||||||
background: 'assets/scenes/tennis-chiesa.png',
|
background: 'assets/scenes/tennis-chiesa.png',
|
||||||
speakers: { tosca: [292, 196] },
|
speakers: { tosca: [292, 196], mariano: [500, 234] },
|
||||||
|
// Mariano, il campione del pallaio (sprite di Codex dalla foto di Andrea, 25 ottobre).
|
||||||
|
people: [{ who: 'mariano', at: [500, 290], facing: -1, idle: 'stand', talk: ['talk', 'explain'], blink: 'blink', every: 4.4 }],
|
||||||
walk: [[0, 360], [0, 300], [200, 290], [420, 268], [640, 258], [640, 360]],
|
walk: [[0, 360], [0, 300], [200, 290], [420, 268], [640, 258], [640, 360]],
|
||||||
depth: [[258, 0.7], [360, 1.15]],
|
depth: [[258, 0.7], [360, 1.15]],
|
||||||
entry: [320, 350],
|
entry: [320, 350],
|
||||||
@@ -1432,7 +1453,7 @@ export const story = {
|
|||||||
id: 'pallaio', name: 'il pallaio', box: [300, 248, 340, 40], at: [400, 300], face: 'up',
|
id: 'pallaio', name: 'il pallaio', box: [300, 248, 340, 40], at: [400, 300], face: 'up',
|
||||||
actions: {
|
actions: {
|
||||||
look: [{ say: 'Il pallaio, dove si gioca a bocce. Qui un punto si discute più di un rigore.' }],
|
look: [{ say: 'Il pallaio, dove si gioca a bocce. Qui un punto si discute più di un rigore.' }],
|
||||||
use: [{ say: 'Senza bocce, e senza un vecchietto che misura col metro, non è una partita.' }],
|
use: [{ say: 'Una partita? Chiedo a Mariano, il campione.' }, { dialogue: 'mariano' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1463,6 +1484,13 @@ export const story = {
|
|||||||
{
|
{
|
||||||
id: 'al_cancello', name: 'dal cancello verde, verso il posticino della Carza', box: [470, 140, 120, 130], at: [515, 280], exit: { scene: 'carza', at: [440, 290], face: 'left' },
|
id: 'al_cancello', name: 'dal cancello verde, verso il posticino della Carza', box: [470, 140, 120, 130], at: [515, 280], exit: { scene: 'carza', at: [440, 290], face: 'left' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'mariano', name: 'Mariano', box: [482, 238, 36, 52], at: [455, 300], face: 'right', default: 'talk',
|
||||||
|
actions: {
|
||||||
|
look: [{ say: 'Mariano, il campione del pallaio. Gli occhiali in testa, il sorriso di chi ha già vinto.' }],
|
||||||
|
talk: [{ dialogue: 'mariano' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Fra la Fortezza e i cipressi: la stradina fra le vigne, la casa del maestro Bacci, e lassù il Trebbio.
|
// Fra la Fortezza e i cipressi: la stradina fra le vigne, la casa del maestro Bacci, e lassù il Trebbio.
|
||||||
@@ -1690,7 +1718,7 @@ export const story = {
|
|||||||
{ say: 'Tozzino! Che fai lì davanti al cancello?' },
|
{ say: 'Tozzino! Che fai lì davanti al cancello?' },
|
||||||
{ say: 'Aspetto Federer. Oggi si fa una partita.', who: 'tozzino', pose: 'explain' },
|
{ say: 'Aspetto Federer. Oggi si fa una partita.', who: 'tozzino', pose: 'explain' },
|
||||||
{ say: 'Federer? Roger Federer?' },
|
{ say: 'Federer? Roger Federer?' },
|
||||||
{ say: 'E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.', who: 'tozzino', pose: 'watch' },
|
{ say: 'E chi sennò? Ha chiamato ora: s\'è fermato a Pratolino a mettere le catene. A breve arriva.', who: 'tozzino', pose: 'watch' },
|
||||||
{ say: 'Se non arriva, la vinco a tavolino.', who: 'tozzino', pose: 'look_far' },
|
{ say: 'Se non arriva, la vinco a tavolino.', who: 'tozzino', pose: 'look_far' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -2094,6 +2122,26 @@ export const story = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Mariano al pallaio: una mano a bocce, solo per gloria; se Andrea vince, la promessa di Brento Sanico.
|
||||||
|
mariano: {
|
||||||
|
nodes: {
|
||||||
|
start: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
text: 'Mariano! Facciamo una partita a bocce?', when: '!bocce_vinte', next: 'end',
|
||||||
|
then: [
|
||||||
|
{ say: 'Con me? Al pallaio sono il campione.', who: 'mariano', pose: 'explain' },
|
||||||
|
{ say: 'Facciamo così: se mi batti, ti porto a Brento Sanico.', who: 'mariano' },
|
||||||
|
{ closeup: 'bocce' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ text: 'La rivincita?', when: 'bocce_vinte', next: 'end', then: [{ say: 'Il campione non scappa. Tira!', who: 'mariano', pose: 'throw' }, { closeup: 'bocce' }] },
|
||||||
|
{ text: 'Allora, Brento Sanico?', when: 'bocce_vinte', then: [{ say: 'Promesso: ti ci porto. Ma prima ritrova il palio, che senza festa non si parte.', who: 'mariano', pose: 'explain' }] },
|
||||||
|
{ text: 'Ciao Mariano.', next: 'end' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
|
// Lo "psicologo": il castello ascolta e risponde con un indizio su quello che manca.
|
||||||
castello: {
|
castello: {
|
||||||
nodes: {
|
nodes: {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
// La versione del gioco: la cambia scripts/bump.sh, insieme a package.json.
|
||||||
export const VERSION = '0.1.5';
|
export const VERSION = '0.1.6';
|
||||||
|
|
||||||
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
// Immagini, musiche e voci con ?v=<versione>: a ogni versione l'indirizzo cambia e il browser le riscarica,
|
||||||
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
// mentre fra una versione e l'altra le tiene in cache a lungo (nginx.conf).
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ body { display: flex; justify-content: center; overflow-x: hidden; -webkit-tap-h
|
|||||||
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
#stage { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #000; user-select: none; touch-action: manipulation; }
|
||||||
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
#scene { width: 100%; height: 100%; display: block; image-rendering: pixelated; cursor: crosshair; }
|
||||||
#scene.torch { touch-action: none; }
|
#scene.torch { touch-action: none; }
|
||||||
|
#scene.bocce { touch-action: none; }
|
||||||
|
#bocce-controls { background: #242b28; padding: 10px 14px; border-top: 2px solid #897b57; font-size: 11px; }
|
||||||
|
#bocce-status { color: #f3dcac; margin: 0 0 10px; line-height: 1.5; min-height: 1.5em; }
|
||||||
|
.bocce-adjust { display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||||
|
.bocce-adjust label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 120px; max-width: 280px; flex: 1; }
|
||||||
|
#bocce-power { width: 100%; accent-color: #b9d392; min-height: 32px; margin: 0; }
|
||||||
|
.bocce-adjust button { min-height: 44px; padding: 8px 12px; border: 1px solid #897b57; border-radius: 4px; background: #38453c; color: #f2e1bf; cursor: pointer; font-size: 11px; }
|
||||||
|
#bocce-throw { background: #ead09a; color: #202b25; min-width: 100px; }
|
||||||
|
.bocce-adjust button:disabled { opacity: .4; cursor: default; }
|
||||||
|
#bocce-help { margin-top: 9px; color: #c5c6b6; font-size: 10px; }
|
||||||
|
#bocce-help summary { cursor: pointer; padding: 6px 0; }
|
||||||
|
#bocce-help p { max-width: 76em; line-height: 1.7; }
|
||||||
|
@media (max-width: 600px), (orientation: landscape) and (max-height: 520px) {
|
||||||
|
#bocce-controls { padding: 8px; font-size: 10px; overflow-y: auto; min-height: 0; }
|
||||||
|
.bocce-adjust { flex-wrap: wrap; gap: 6px; }
|
||||||
|
.bocce-adjust label { order: -1; flex-basis: 100%; max-width: none; }
|
||||||
|
.bocce-adjust button { flex: 1; font-size: 9px; padding: 6px; }
|
||||||
|
#bocce-throw { flex-basis: 100%; }
|
||||||
|
#bocce-help { font-size: 9px; }
|
||||||
|
}
|
||||||
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
|
.effects-control { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; font-size: clamp(9px, 1.2vw, 13px); }
|
||||||
#effects-volume { width: 130px; accent-color: var(--accent); }
|
#effects-volume { width: 130px; accent-color: var(--accent); }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { newBocce, press, release, update, powerAt, score, thrower, left, project, SWING, LANE, BALLS } from '../src/engine/bocce.js';
|
||||||
|
import { story } from '../src/story/story.js';
|
||||||
|
import { Game } from '../src/engine/game.js';
|
||||||
|
import { settle } from './helpers.mjs';
|
||||||
|
|
||||||
|
const run = (c, seconds, dt = 1 / 60) => { for (let t = 0; t < seconds; t += dt) update(c, dt); };
|
||||||
|
// Tiene premuto finché la forza porta la boccia a `u`, poi lascia (nella prima salita della barra).
|
||||||
|
function throwTo(c, u) {
|
||||||
|
assert.ok(press(c));
|
||||||
|
const want = (u - 8) / (LANE - 4);
|
||||||
|
while (c.power < want && c.holdTime < SWING / 2) update(c, 1 / 240);
|
||||||
|
assert.ok(release(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('la forza sale finché si tiene premuto, fino al massimo: niente tempismo da azzeccare', () => {
|
||||||
|
assert.equal(powerAt(0), 0);
|
||||||
|
assert.ok(Math.abs(powerAt(SWING / 2) - 0.5) < 1e-9);
|
||||||
|
assert.equal(powerAt(SWING), 1);
|
||||||
|
assert.equal(powerAt(SWING * 3), 1, 'oltre il massimo resta piena, non ricomincia');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una boccia va lontano quanto dice la forza, verso il pallino', () => {
|
||||||
|
const c = newBocce(7);
|
||||||
|
c.jack.u = 95; c.jack.w = 0; // il pallino lontano, così non lo tocca
|
||||||
|
throwTo(c, 50);
|
||||||
|
for (let i = 0; i < 600 && c.step === 'rolling'; i++) update(c, 1 / 60); // fino a boccia ferma, prima che tiri il campione
|
||||||
|
const b = c.balls[0];
|
||||||
|
assert.ok(Math.abs(b.u - 50) < 2.5, `si ferma a ${b.u.toFixed(1)}, non a 50`);
|
||||||
|
assert.ok(Math.abs(b.w) < 4, 'va più o meno dritta');
|
||||||
|
run(c, 1);
|
||||||
|
assert.equal(thrower(c), 'cpu', 'dopo la pausa, tira il campione');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una boccia che arriva addosso al pallino lo sposta', () => {
|
||||||
|
const c = newBocce(3);
|
||||||
|
c.jack.u = 40; c.jack.w = 0;
|
||||||
|
c.balls.push({ who: 'player', u: 30, w: 0, vu: 30, vw: 0, r: 1.25 });
|
||||||
|
c.step = 'rolling';
|
||||||
|
run(c, 3);
|
||||||
|
assert.ok(c.jack.u > 41, `il pallino resta a ${c.jack.u.toFixed(1)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('il punto: vince la boccia più vicina, e conta quelle più vicine della migliore avversaria', () => {
|
||||||
|
const c = newBocce(1);
|
||||||
|
c.jack.u = 60; c.jack.w = 0;
|
||||||
|
const put = (who, u) => c.balls.push({ who, u, w: 0, vu: 0, vw: 0, r: 1.25 });
|
||||||
|
put('player', 61); put('player', 63); put('player', 80);
|
||||||
|
put('cpu', 64); put('cpu', 70); put('cpu', 30);
|
||||||
|
assert.deepEqual(score(c), { winner: 'player', points: 2 });
|
||||||
|
c.balls[0].u = 75; c.balls[1].u = 76;
|
||||||
|
assert.equal(score(c).winner, 'cpu');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('una mano intera: tre bocce a testa, a turno, poi il risultato', () => {
|
||||||
|
const c = newBocce(42);
|
||||||
|
for (let i = 0; i < 200 && c.step !== 'result'; i++) {
|
||||||
|
if (c.step === 'aim' && thrower(c) === 'player') throwTo(c, c.jack.u);
|
||||||
|
run(c, 0.5);
|
||||||
|
}
|
||||||
|
assert.equal(c.step, 'result');
|
||||||
|
assert.equal(left(c, 'player'), 0);
|
||||||
|
assert.equal(left(c, 'cpu'), 0);
|
||||||
|
assert.equal(c.balls.length, BALLS * 2);
|
||||||
|
assert.ok(['player', 'cpu'].includes(c.result.winner));
|
||||||
|
assert.equal(press(c), false, 'a mano finita non si tira più');
|
||||||
|
assert.ok(['player', 'cpu'].includes(c.result.winner) || c.result.winner === null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la prospettiva: lontano è più in alto e più piccolo, al centro della pista', () => {
|
||||||
|
const near = project(0, 0), far = project(LANE, 0);
|
||||||
|
assert.ok(far.y < near.y && far.s < near.s);
|
||||||
|
assert.ok(project(50, 5).x > project(50, -5).x);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('al pallaio: Mariano sfida, la mano si gioca nel primo piano; vincere vale la gloria e la promessa', () => {
|
||||||
|
const g = new Game(story);
|
||||||
|
settle(g);
|
||||||
|
g.go('tennis_chiesa'); settle(g);
|
||||||
|
assert.ok(g.act('talk', 'mariano'));
|
||||||
|
settle(g);
|
||||||
|
g.choose(g.options().findIndex(o => o.text.startsWith('Mariano! Facciamo')));
|
||||||
|
const before = settle(g).map(s => s.text);
|
||||||
|
assert.ok(before.some(t => t.includes('Brento Sanico')), 'la posta la dice prima');
|
||||||
|
assert.equal(g.closeup?.id, 'bocce');
|
||||||
|
assert.equal(g.act('look', 'pallaio'), false, 'durante la partita la scena non risponde');
|
||||||
|
// Si gioca la mano (le bocce di Andrea mirano al pallino); a mano finita il risultato si forza a favore.
|
||||||
|
const c = g.closeup;
|
||||||
|
for (let i = 0; i < 400 && c.step !== 'result'; i++) {
|
||||||
|
if (c.step === 'aim' && thrower(c) === 'player') {
|
||||||
|
g.closeupPress();
|
||||||
|
while (c.power < (c.jack.u - 8) / (LANE - 4) && c.holdTime < SWING / 2) g.update(1 / 240);
|
||||||
|
g.closeupRelease();
|
||||||
|
}
|
||||||
|
g.update(0.25);
|
||||||
|
}
|
||||||
|
assert.equal(c.step, 'result');
|
||||||
|
c.result = { winner: 'player', points: 1 };
|
||||||
|
for (let i = 0; i < 20; i++) g.update(0.25);
|
||||||
|
assert.equal(g.closeup, c, 'il risultato resta a schermo finché non si torna al pallaio');
|
||||||
|
assert.ok(g.closeupClose());
|
||||||
|
const after = settle(g).map(s => `${s.who}: ${s.text}`);
|
||||||
|
assert.ok(after.includes('mariano: Hai vinto, ti ci porto!'));
|
||||||
|
assert.ok(g.state.flags.bocce_vinte);
|
||||||
|
// Vinto, si chiede la rivincita o Brento Sanico; lasciare la partita a metà non segna niente.
|
||||||
|
g.act('talk', 'mariano'); settle(g);
|
||||||
|
assert.ok(g.options().some(o => o.text === 'La rivincita?') && g.options().some(o => o.text === 'Allora, Brento Sanico?'));
|
||||||
|
g.choose(g.options().findIndex(o => o.text === 'La rivincita?'));
|
||||||
|
settle(g);
|
||||||
|
assert.equal(g.closeup?.id, 'bocce');
|
||||||
|
assert.ok(g.closeupClose());
|
||||||
|
assert.ok(settle(g).some(s => s.text.startsWith('Già stanco?')));
|
||||||
|
});
|
||||||
@@ -362,6 +362,34 @@ try {
|
|||||||
await book.close();
|
await book.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le bocce al pallaio: col mouse tenuto premuto la forza sale, lasciando la boccia parte; la scena dietro è ferma.
|
||||||
|
{
|
||||||
|
const bocce = await open({ width: 1280, height: 860 });
|
||||||
|
await bocce.click('#splash');
|
||||||
|
await bocce.click('#new-game');
|
||||||
|
await bocce.click('#rione [data-rione="campomigliaio"]');
|
||||||
|
await bocce.click('#skip-intro');
|
||||||
|
await settle(bocce);
|
||||||
|
await bocce.evaluate(() => game.go('tennis_chiesa', [430, 310]));
|
||||||
|
await settle(bocce);
|
||||||
|
await clickScene(bocce, await hotspotCenter(bocce, 'mariano'));
|
||||||
|
await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 });
|
||||||
|
await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click();
|
||||||
|
await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 });
|
||||||
|
const r = await bocce.locator('#scene').boundingBox();
|
||||||
|
await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2);
|
||||||
|
await bocce.mouse.down();
|
||||||
|
await bocce.waitForTimeout(500);
|
||||||
|
assert.ok(await bocce.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale');
|
||||||
|
await bocce.screenshot({ path: `${OUT}/03c-bocce-forza.png` });
|
||||||
|
await bocce.mouse.up();
|
||||||
|
assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte');
|
||||||
|
await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 });
|
||||||
|
await bocce.keyboard.press('Escape');
|
||||||
|
await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 });
|
||||||
|
await bocce.close();
|
||||||
|
}
|
||||||
|
|
||||||
const phone = await open({ width: 390, height: 844 });
|
const phone = await open({ width: 390, height: 844 });
|
||||||
await phone.click('#splash'); // un clic salta il cartello
|
await phone.click('#splash'); // un clic salta il cartello
|
||||||
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
|
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ test('la storia è coerente: scene, oggetti, dialoghi e punti calpestabili esist
|
|||||||
if (e.closeup) {
|
if (e.closeup) {
|
||||||
const c = story.closeups?.[e.closeup];
|
const c = story.closeups?.[e.closeup];
|
||||||
assert.ok(c, `${where}: primo piano ${e.closeup}`);
|
assert.ok(c, `${where}: primo piano ${e.closeup}`);
|
||||||
for (const src of c.type === 'torch' ? [c.picture] : [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
|
for (const src of c.picture ? [c.picture] : [c.closed, c.open]) assert.ok(existsSync(new URL('../' + src, import.meta.url)), `${where}: manca ${src}`);
|
||||||
}
|
}
|
||||||
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
|
if (e.who) assert.ok(story.characters[e.who], `${where}: personaggio ${e.who}`);
|
||||||
}
|
}
|
||||||
@@ -519,7 +519,7 @@ test('gli amici in giro: l\'Arcu al circolino, il Tozzino al cancello del tennis
|
|||||||
assert.deepEqual(ask('Come va alla Casa dell\'Armando?'), ['andrea: Come va alla Casa dell\'Armando?', 'arcu: Mah... un si fa una sega...']);
|
assert.deepEqual(ask('Come va alla Casa dell\'Armando?'), ['andrea: Come va alla Casa dell\'Armando?', 'arcu: Mah... un si fa una sega...']);
|
||||||
ask('Ci si becca da i\' Trappola, ai\' Barrettino!');
|
ask('Ci si becca da i\' Trappola, ai\' Barrettino!');
|
||||||
assert.equal(g.dialogue, null);
|
assert.equal(g.dialogue, null);
|
||||||
assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: è imbottigliato in Bolognese.'));
|
assert.ok(talk('carza', 'tozzino').includes('tozzino: E chi sennò? Ha chiamato ora: s\'è fermato a Pratolino a mettere le catene. A breve arriva.'));
|
||||||
const baron = talk('polisportiva', 'barondeduke');
|
const baron = talk('polisportiva', 'barondeduke');
|
||||||
assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».'));
|
assert.ok(baron.includes('barondeduke: Avevo due donne e due jack. Entra la terza donna, e penso: «Male male si divide».'));
|
||||||
assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.');
|
assert.equal(baron.at(-1), 'barondeduke: L\'altro aveva colore.');
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""La musica dentro la Pieve (assets/audio/pieve.mp3), sintetizzata da zero: un organo morbido (canne di flauto,
|
||||||
|
poche armoniche) con una melodia lenta originale, sul registro principale, sopra otto accordi in fa maggiore. Dura un giro intero e ricomincia
|
||||||
|
sullo stesso accordo, così il loop non si sente; il riverbero della chiesa lo aggiunge ffmpeg.
|
||||||
|
python3 tools/organo.py # scrive assets/audio/pieve.mp3 (serve ffmpeg)
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
from array import array
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SR = 22050
|
||||||
|
BEAT = 3.0 # secondi per nota della melodia: due note per accordo
|
||||||
|
CHORD = 2 * BEAT
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "assets" / "audio" / "pieve.mp3"
|
||||||
|
|
||||||
|
# Accordi (basso + tre voci) e melodia, due note per accordo. Il giro chiude su fa, come comincia.
|
||||||
|
CHORDS = [
|
||||||
|
("F2", ["F3", "A3", "C4"]), ("D2", ["D3", "F3", "A3"]), ("Bb1", ["Bb2", "D3", "F3"]), ("C2", ["C3", "E3", "G3"]),
|
||||||
|
("A1", ["F3", "A3", "C4"]), ("G2", ["G3", "Bb3", "D4"]), ("C2", ["C3", "F3", "G3"]), ("F2", ["F3", "A3", "C4"]),
|
||||||
|
]
|
||||||
|
MELODY = ["A4", "C5", "D5", "C5", "Bb4", "A4", "G4", "G4", "A4", "C5", "D5", "Bb4", "A4", "G4", "F4", "F4"]
|
||||||
|
DUR = CHORD * len(CHORDS)
|
||||||
|
buf = [0.0] * int(SR * DUR)
|
||||||
|
|
||||||
|
|
||||||
|
def hz(note: str) -> float:
|
||||||
|
names = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
||||||
|
semis = names[note[0]] + (1 if "#" in note else -1 if note[1] == "b" else 0)
|
||||||
|
return 440 * 2 ** ((semis + 12 * (int(note[-1]) + 1) - 69) / 12)
|
||||||
|
|
||||||
|
|
||||||
|
def pipe(start: float, length: float, freq: float, vol: float, attack: float, release: float,
|
||||||
|
partials=((1, 1.0), (2, 0.35), (3, 0.12), (4, 0.06)), vibrato: float = 0.0, wrap: bool = False) -> None:
|
||||||
|
"""Una canna d'organo: poche armoniche, attacco e rilascio lenti. Con `wrap` la coda che esce dalla fine
|
||||||
|
rientra all'inizio: il loop resta continuo."""
|
||||||
|
n0, n = int(start * SR), int((length + release) * SR)
|
||||||
|
for i in range(n):
|
||||||
|
t = i / SR
|
||||||
|
env = min(1.0, t / attack) if t < length else max(0.0, 1 - (t - length) / release)
|
||||||
|
if env <= 0:
|
||||||
|
continue
|
||||||
|
f = freq * (1 + vibrato * math.sin(2 * math.pi * 5.2 * t))
|
||||||
|
s = sum(a * math.sin(2 * math.pi * f * k * t) for k, a in partials)
|
||||||
|
j = n0 + i
|
||||||
|
if j >= len(buf):
|
||||||
|
if not wrap:
|
||||||
|
break
|
||||||
|
j -= len(buf)
|
||||||
|
buf[j] += vol * env * s
|
||||||
|
|
||||||
|
|
||||||
|
for c, (bass, voices) in enumerate(CHORDS):
|
||||||
|
at = c * CHORD
|
||||||
|
pipe(at, CHORD, hz(bass), 0.10, 1.0, 1.6, partials=((1, 1.0), (2, 0.5), (3, 0.15)), wrap=True)
|
||||||
|
for v in voices:
|
||||||
|
pipe(at, CHORD, hz(v), 0.045, 1.2, 1.8, wrap=True)
|
||||||
|
for m, note in enumerate(MELODY):
|
||||||
|
# La melodia su un altro registro dello stesso organo, il principale: più armoniche, nessun vibrato, note staccate.
|
||||||
|
# (Su un flauto a onda pura col vibrato sembrava un theremin: Andrea, 25 ottobre.)
|
||||||
|
pipe(m * BEAT, BEAT * 0.86, hz(note), 0.04, 0.12, 0.7,
|
||||||
|
partials=((1, 1.0), (2, 0.55), (3, 0.32), (4, 0.18), (5, 0.1), (6, 0.06)), wrap=True)
|
||||||
|
|
||||||
|
peak = max(abs(x) for x in buf) or 1
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
w = Path(tmp) / "pieve.wav"
|
||||||
|
with wave.open(str(w), "wb") as f:
|
||||||
|
f.setnchannels(1)
|
||||||
|
f.setsampwidth(2)
|
||||||
|
f.setframerate(SR)
|
||||||
|
f.writeframes(array("h", (int(x / peak * 0.8 * 32767) for x in buf)).tobytes())
|
||||||
|
# Il riverbero della navata: echi lunghi e morbidi, senza alti.
|
||||||
|
af = "aecho=0.8:0.75:140|310|520|830:0.35|0.26|0.18|0.12,lowpass=f=5000,volume=1.45"
|
||||||
|
subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(w), "-af", af, "-c:a", "libmp3lame", "-b:a", "96k", str(OUT)], check=True)
|
||||||
|
print(f"-> {OUT} ({DUR:.0f} s)")
|
||||||
+2
-1
@@ -9,5 +9,6 @@
|
|||||||
"tosca": { "speaker": "Gitta Nikolina" },
|
"tosca": { "speaker": "Gitta Nikolina" },
|
||||||
"arcu": { "speaker": "Luis Moray" },
|
"arcu": { "speaker": "Luis Moray" },
|
||||||
"tozzino": { "speaker": "Xavier Hayasaka", "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" },
|
"tozzino": { "speaker": "Xavier Hayasaka", "filtro": "asetrate=24000*1.12,aresample=24000,atempo=0.92" },
|
||||||
"barondeduke": { "speaker": "Craig Gutsy" }
|
"barondeduke": { "speaker": "Craig Gutsy" },
|
||||||
|
"mariano": { "speaker": "Gilberto Mathias" }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user