Prototipo del motore: scena, camminata, verbi, inventario, dialoghi, salvataggio
Motore guidato dai dati con due scene di prova (Piazza e salita alla Fortezza), percorsi su area concava, clic intelligente e pressione lunga, test della soluzione completa in node e nel browser con Playwright. Nel documento la leggenda del nonno. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+169
@@ -0,0 +1,169 @@
|
||||
// Avvio nel browser: collega motore, disegno e interfaccia HTML (verbi, inventario, dialoghi, fumetti).
|
||||
import { story } from './story/story.js';
|
||||
import { Game, VERBS, VERB_LABELS } from './engine/game.js';
|
||||
import { Command } from './engine/command.js';
|
||||
import { render, WIDTH, HEIGHT } from './engine/render.js';
|
||||
import { readSave, writeSave, clearSave } from './engine/storage.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const canvas = $('scene'), ctx = canvas.getContext('2d');
|
||||
const storage = (() => { try { return window.localStorage; } catch { return null; } })();
|
||||
const LONG_PRESS = 450;
|
||||
const debug = new URLSearchParams(location.search).has('debug');
|
||||
|
||||
let game = null, command = null, hoverTarget = null, mouse = null, running = false;
|
||||
|
||||
function start(state) {
|
||||
game = new Game(story, { state, onSave: data => storage && writeSave(storage, data) });
|
||||
command = new Command(game);
|
||||
if (!state) game.go(story.start.scene, story.start.at);
|
||||
$('title').hidden = true;
|
||||
$('ending').hidden = true;
|
||||
running = true;
|
||||
window.game = game; // per i test nel browser e per curiosare dalla console
|
||||
}
|
||||
|
||||
function showTitle() {
|
||||
running = false;
|
||||
const saved = storage && readSave(storage);
|
||||
$('continue').hidden = !saved || saved.ended;
|
||||
$('title').hidden = false;
|
||||
}
|
||||
|
||||
// --- Puntatore: clic, clic destro e pressione lunga = guarda.
|
||||
const scenePoint = e => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return [(e.clientX - r.left) / r.width * WIDTH, (e.clientY - r.top) / r.height * HEIGHT];
|
||||
};
|
||||
let press = null;
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
if (!running || e.button > 0) return;
|
||||
const point = scenePoint(e);
|
||||
press = { point, long: false, timer: setTimeout(() => { press.long = true; command.clickScene(point, { look: true }); }, LONG_PRESS) };
|
||||
});
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (!press) return;
|
||||
clearTimeout(press.timer);
|
||||
if (!press.long) command.clickScene(scenePoint(e));
|
||||
press = null;
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { if (press) clearTimeout(press.timer); press = null; });
|
||||
canvas.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
if (running) command.clickScene(scenePoint(e), { look: true });
|
||||
});
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (!running) return;
|
||||
mouse = e.pointerType === 'mouse' ? scenePoint(e) : null;
|
||||
});
|
||||
canvas.addEventListener('pointerleave', () => { mouse = null; });
|
||||
|
||||
// --- Verbi.
|
||||
for (const verb of VERBS) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.dataset.verb = verb;
|
||||
b.textContent = VERB_LABELS[verb];
|
||||
b.addEventListener('click', () => running && command.pickVerb(verb));
|
||||
$('verbs').appendChild(b);
|
||||
}
|
||||
|
||||
// --- Inventario: si ridisegna solo quando cambia.
|
||||
let inventoryKey = '', hoverItem = null;
|
||||
function updateInventory() {
|
||||
const key = game.state.inventory.join(',') + '|' + command.item;
|
||||
if (key === inventoryKey) return;
|
||||
inventoryKey = key;
|
||||
const box = $('inventory');
|
||||
box.replaceChildren(...game.state.inventory.map(id => {
|
||||
const b = document.createElement('button'), item = story.items[id], dot = document.createElement('i');
|
||||
b.type = 'button';
|
||||
b.dataset.item = id;
|
||||
b.setAttribute('aria-pressed', String(command.item === id));
|
||||
dot.style.background = item.icon?.fill ?? '#ccc';
|
||||
b.append(dot, item.name);
|
||||
let timer = null, long = false;
|
||||
b.addEventListener('pointerdown', () => { long = false; timer = setTimeout(() => { long = true; command.clickItem(id, { look: true }); }, LONG_PRESS); });
|
||||
b.addEventListener('pointerup', () => clearTimeout(timer));
|
||||
b.addEventListener('click', () => { if (!long && running) command.clickItem(id); });
|
||||
b.addEventListener('contextmenu', e => { e.preventDefault(); clearTimeout(timer); if (running) command.clickItem(id, { look: true }); });
|
||||
b.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') hoverItem = id; });
|
||||
b.addEventListener('pointerleave', () => { if (hoverItem === id) hoverItem = null; });
|
||||
return b;
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Dialoghi: scelte numerate, anche da tastiera.
|
||||
let choicesKey = '';
|
||||
function updateChoices() {
|
||||
const options = game.options(), inDialogue = !!game.dialogue;
|
||||
$('panel').hidden = inDialogue;
|
||||
$('choices').hidden = !inDialogue;
|
||||
const key = inDialogue + '|' + options.map(o => o.text).join('|');
|
||||
if (key === choicesKey) return;
|
||||
choicesKey = key;
|
||||
$('choices').replaceChildren(...options.map((o, i) => {
|
||||
const li = document.createElement('li');
|
||||
li.tabIndex = 0;
|
||||
li.textContent = o.text;
|
||||
li.addEventListener('click', () => game.choose(i));
|
||||
li.addEventListener('keydown', e => { if (e.key === 'Enter') game.choose(i); });
|
||||
return li;
|
||||
}));
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', e => {
|
||||
if (!running) return;
|
||||
if ([' ', '.', 'Enter'].includes(e.key) && game.speech) { e.preventDefault(); game.skip(); return; }
|
||||
if (e.key === 'Escape') command.reset();
|
||||
const n = Number(e.key);
|
||||
if (game.dialogue && n >= 1 && n <= 9) game.choose(n - 1);
|
||||
});
|
||||
|
||||
// --- Fumetto sopra chi parla, tenuto dentro i bordi della scena.
|
||||
function updateSpeech() {
|
||||
const box = $('speech'), sp = game.speech;
|
||||
if (!sp) { if (box.textContent) box.textContent = ''; return; }
|
||||
const [x, y] = game.speakerPoint(sp.who);
|
||||
box.textContent = sp.text;
|
||||
box.style.color = story.characters[sp.who]?.color ?? '#fff';
|
||||
box.style.left = Math.min(77, Math.max(23, x / WIDTH * 100)) + '%';
|
||||
box.style.top = Math.max(18, y / HEIGHT * 100) + '%';
|
||||
}
|
||||
|
||||
function updateUi() {
|
||||
// Il bersaglio si ricalcola a ogni fotogramma: la scena cambia anche se il mouse sta fermo.
|
||||
hoverTarget = (game.state.inventory.includes(hoverItem) && hoverItem) || (mouse && game.hotspotAt(mouse)?.id) || null;
|
||||
for (const b of $('verbs').children) b.setAttribute('aria-pressed', String(command.verb === b.dataset.verb));
|
||||
const text = !running || game.speech || game.dialogue ? '' : command.sentence(hoverTarget);
|
||||
if ($('sentence').textContent !== text) $('sentence').textContent = text;
|
||||
updateInventory();
|
||||
updateChoices();
|
||||
updateSpeech();
|
||||
if (game.state.ended && !game.speech && !game.queue.length) {
|
||||
running = false;
|
||||
$('ending').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
let last = performance.now();
|
||||
function frame(now) {
|
||||
const dt = Math.min(0.1, (now - last) / 1000);
|
||||
last = now;
|
||||
if (game) {
|
||||
if (running) game.update(dt);
|
||||
render(ctx, game, { hover: debug && hoverTarget ? game.hotspot(hoverTarget) : null });
|
||||
updateUi();
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
$('new-game').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
|
||||
$('continue').addEventListener('click', () => start(readSave(storage)));
|
||||
$('restart').addEventListener('click', () => { if (storage) clearSave(storage); start(null); });
|
||||
|
||||
// Dietro il titolo si vede già la piazza.
|
||||
game = new Game(story);
|
||||
command = new Command(game);
|
||||
showTitle();
|
||||
requestAnimationFrame(frame);
|
||||
Reference in New Issue
Block a user