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:
@@ -0,0 +1,64 @@
|
||||
// La "frase" in costruzione sotto la scena (Usa squama con…) e cosa fa ogni clic.
|
||||
// Senza verbo scelto vale il clic intelligente: l'azione più sensata per quel punto.
|
||||
import { VERB_LABELS } from './game.js';
|
||||
|
||||
export class Command {
|
||||
constructor(game) {
|
||||
this.game = game;
|
||||
this.verb = null;
|
||||
this.item = null;
|
||||
}
|
||||
|
||||
reset() { this.verb = null; this.item = null; }
|
||||
|
||||
pickVerb(verb) {
|
||||
this.verb = this.verb === verb ? null : verb;
|
||||
this.item = null;
|
||||
}
|
||||
|
||||
// Testo della riga sotto la scena, con l'eventuale bersaglio sotto il puntatore.
|
||||
sentence(target = null) {
|
||||
const g = this.game, name = target && g.targetName(target);
|
||||
if (this.item) return `${VERB_LABELS[this.verb]} ${g.itemName(this.item)} ${this.verb === 'give' ? 'a' : 'con'}${name ? ' ' + name : '…'}`;
|
||||
if (this.verb) return `${VERB_LABELS[this.verb]}${name ? ' ' + name : '…'}`;
|
||||
if (!target) return '';
|
||||
const isItem = !!g.story.items[target];
|
||||
return isItem ? `Usa ${name} con…` : `${VERB_LABELS[g.defaultVerb(target)]} ${name}`;
|
||||
}
|
||||
|
||||
// Clic sulla scena: `look` per il clic destro o la pressione lunga.
|
||||
clickScene(point, { look = false } = {}) {
|
||||
const g = this.game;
|
||||
if (g.skip()) return 'skip';
|
||||
if (g.dialogue || g.busy) return null;
|
||||
const h = g.hotspotAt(point);
|
||||
let done;
|
||||
if (look && h) done = g.act('look', h.id);
|
||||
else if (!h) done = g.walkTo(point);
|
||||
else if (this.item) done = g.act(this.verb, h.id, this.item);
|
||||
else if (this.verb) done = g.act(this.verb, h.id);
|
||||
else done = g.act(g.defaultVerb(h.id), h.id);
|
||||
this.reset();
|
||||
return done ? (h ? 'act' : 'walk') : null;
|
||||
}
|
||||
|
||||
clickItem(id, { look = false } = {}) {
|
||||
const g = this.game;
|
||||
if (g.skip()) return 'skip';
|
||||
if (g.dialogue || g.busy) return null;
|
||||
if (look) { this.reset(); return g.act('look', id) && 'act'; }
|
||||
if (this.item) {
|
||||
const ok = this.item !== id && g.act('use', id, this.item);
|
||||
this.reset();
|
||||
return ok ? 'act' : null;
|
||||
}
|
||||
if (!this.verb || this.verb === 'use' || this.verb === 'give') {
|
||||
this.verb ??= 'use';
|
||||
this.item = id;
|
||||
return 'select';
|
||||
}
|
||||
const ok = g.act(this.verb, id);
|
||||
this.reset();
|
||||
return ok && 'act';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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.
|
||||
import { findPath, dist, inBox, nearestInside } from './geometry.js';
|
||||
|
||||
export const VERBS = ['walk', 'look', 'take', 'use', 'talk', 'open', 'give'];
|
||||
export const VERB_LABELS = { walk: 'Vai a', look: 'Guarda', take: 'Prendi', use: 'Usa', talk: 'Parla con', open: 'Apri', give: 'Dai' };
|
||||
const DEFAULT_LINES = {
|
||||
walk: null,
|
||||
look: 'Niente di speciale.',
|
||||
take: 'Non credo di poterlo prendere.',
|
||||
use: 'Non so proprio come usarlo.',
|
||||
talk: 'Non mi sembra un gran chiacchierone.',
|
||||
open: 'Non si apre.',
|
||||
give: 'Non credo che lo voglia.',
|
||||
with: 'Non funziona.',
|
||||
};
|
||||
const SPEED = 95;
|
||||
// Solo profilo, come i cicli di camminata: 'up' e 'down' lasciano il verso com'è.
|
||||
const FACING = { left: -1, right: 1 };
|
||||
|
||||
// Condizioni: 'flag', '!flag', 'has:oggetto', '!has:oggetto', oppure un elenco (tutte vere).
|
||||
export function check(cond, state) {
|
||||
if (cond == null) return true;
|
||||
if (Array.isArray(cond)) return cond.every(c => check(c, state));
|
||||
const not = cond.startsWith('!'), key = not ? cond.slice(1) : cond;
|
||||
const value = key.startsWith('has:') ? state.inventory.includes(key.slice(4)) : !!state.flags[key];
|
||||
return not ? !value : value;
|
||||
}
|
||||
|
||||
export const sayTime = text => Math.max(1.6, 0.9 + text.length * 0.055);
|
||||
|
||||
export function newState(story) {
|
||||
const { scene, at } = story.start;
|
||||
return { scene, x: at[0], y: at[1], facing: 1, inventory: [], flags: {}, used: {}, ended: false };
|
||||
}
|
||||
|
||||
// Ripristina un salvataggio scartando tutto quello che la storia attuale non conosce più.
|
||||
export function restoreState(story, data) {
|
||||
const fresh = newState(story);
|
||||
if (!data || typeof data !== 'object' || !story.scenes[data.scene]) return fresh;
|
||||
const scene = story.scenes[data.scene];
|
||||
const [x, y] = Number.isFinite(data.x) && Number.isFinite(data.y) ? nearestInside(scene.walk, [data.x, data.y]) : scene.entry ?? story.start.at;
|
||||
const inventory = Array.isArray(data.inventory) ? [...new Set(data.inventory.filter(i => story.items[i]))] : [];
|
||||
const pick = obj => Object.fromEntries(Object.entries(obj && typeof obj === 'object' ? obj : {}).filter(([, v]) => v === true));
|
||||
return { scene: data.scene, x, y, facing: data.facing === -1 ? -1 : 1, inventory, flags: pick(data.flags), used: pick(data.used), ended: data.ended === true };
|
||||
}
|
||||
|
||||
export class Game {
|
||||
constructor(story, { state, onSave } = {}) {
|
||||
this.story = story;
|
||||
this.state = state ? restoreState(story, state) : newState(story);
|
||||
this.onSave = onSave ?? (() => {});
|
||||
this.queue = [];
|
||||
this.task = null;
|
||||
this.path = [];
|
||||
this.speech = null;
|
||||
this.dialogue = null;
|
||||
this.walking = false;
|
||||
this.time = 0;
|
||||
}
|
||||
|
||||
get scene() { return this.story.scenes[this.state.scene]; }
|
||||
// Occupato: c'è qualcosa in corso che il giocatore non può interrompere. La camminata verso un punto
|
||||
// o verso un oggetto si può sempre cambiare con un altro clic: la coda che la seguiva viene scartata.
|
||||
get busy() { return !!this.speech || (this.task ? !this.task.interruptible : this.queue.length > 0); }
|
||||
get idle() { return !this.busy && !this.task && !this.dialogue; }
|
||||
|
||||
snapshot() {
|
||||
const { scene, x, y, facing, inventory, flags, used, ended } = this.state;
|
||||
return { scene, x: Math.round(x), y: Math.round(y), facing, inventory: [...inventory], flags: { ...flags }, used: { ...used }, ended };
|
||||
}
|
||||
|
||||
hotspots() { return (this.scene.hotspots ?? []).filter(h => check(h.when, this.state)); }
|
||||
hotspot(id) { return this.hotspots().find(h => h.id === id); }
|
||||
hotspotAt(p) {
|
||||
const list = this.hotspots();
|
||||
for (let i = list.length - 1; i >= 0; i--) if (inBox(list[i].box, p)) return list[i];
|
||||
return null;
|
||||
}
|
||||
itemName(id) { return this.story.items[id]?.name ?? id; }
|
||||
targetName(id) { return this.story.items[id] ? this.itemName(id) : this.hotspot(id)?.name ?? id; }
|
||||
defaultVerb(target) {
|
||||
if (this.story.items[target]) return 'look';
|
||||
const h = this.hotspot(target);
|
||||
return h?.default ?? (h?.exit ? 'walk' : 'look');
|
||||
}
|
||||
|
||||
// Quale elenco di effetti produce "verbo + bersaglio (+ oggetto)": il punto unico della logica d'avventura.
|
||||
resolve(verb, target, item = null) {
|
||||
const items = this.story.items, h = items[target] ? null : this.hotspot(target);
|
||||
const def = h ?? items[target];
|
||||
if (!def) return [];
|
||||
if (item) {
|
||||
const table = verb === 'give' ? 'give' : 'with';
|
||||
const found = def[table]?.[item] ?? items[item]?.with?.[target];
|
||||
if (found) return found;
|
||||
return [{ say: verb === 'give' ? DEFAULT_LINES.give : DEFAULT_LINES.with }];
|
||||
}
|
||||
if (def.actions?.[verb]) return def.actions[verb];
|
||||
if (verb === 'walk') return h?.exit ? [{ go: h.exit.scene, at: h.exit.at, face: h.exit.face }] : [];
|
||||
if (verb === 'take' && !h) return [{ say: 'Ce l\'ho già.' }];
|
||||
return DEFAULT_LINES[verb] ? [{ say: DEFAULT_LINES[verb] }] : [];
|
||||
}
|
||||
|
||||
// Comandi dal giocatore. Restituiscono false se in quel momento vanno ignorati.
|
||||
walkTo(p) {
|
||||
if (this.busy || this.dialogue || this.state.ended) return false;
|
||||
this.queue = [];
|
||||
this.task = { walk: p, interruptible: true };
|
||||
this.startWalk(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
act(verb, target, item = null) {
|
||||
if (this.busy || this.dialogue || this.state.ended) return false;
|
||||
const effects = this.resolve(verb, target, item);
|
||||
const h = this.story.items[target] ? null : this.hotspot(target);
|
||||
const steps = [];
|
||||
if (h) {
|
||||
const at = h.at ?? [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3]];
|
||||
steps.push({ walk: at, interruptible: true });
|
||||
if (h.face) steps.push({ face: h.face });
|
||||
}
|
||||
this.task = null;
|
||||
this.path = [];
|
||||
this.walking = false;
|
||||
this.queue = [...steps, ...effects];
|
||||
return true;
|
||||
}
|
||||
|
||||
skip() {
|
||||
if (this.speech) { this.speech = null; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
options() {
|
||||
if (!this.dialogue || this.busy) return [];
|
||||
const node = this.story.dialogues[this.dialogue.id].nodes[this.dialogue.node];
|
||||
return node.options.filter(o => check(o.when, this.state) && !(o.once && this.state.used[o.id]));
|
||||
}
|
||||
|
||||
choose(index) {
|
||||
const option = this.options()[index];
|
||||
if (!option) return false;
|
||||
if (option.once) this.state.used[option.id] = true;
|
||||
const next = option.next ?? this.dialogue.node;
|
||||
this.queue.push({ say: option.say ?? option.text, who: 'andrea' }, ...(option.then ?? []), next === 'end' ? { endDialogue: true } : { node: next });
|
||||
return true;
|
||||
}
|
||||
|
||||
startWalk(p) {
|
||||
this.path = findPath(this.scene.walk, [this.state.x, this.state.y], p);
|
||||
this.walking = this.path.length > 0;
|
||||
}
|
||||
|
||||
scaleAt(y) {
|
||||
const d = this.scene.depth;
|
||||
if (!d) return 1;
|
||||
const [[y0, s0], [y1, s1]] = d, t = Math.max(0, Math.min(1, (y - y0) / (y1 - y0)));
|
||||
return s0 + (s1 - s0) * t;
|
||||
}
|
||||
|
||||
speakerPoint(who) {
|
||||
if (!who || who === 'andrea') return [this.state.x, this.state.y - 62 * this.scaleAt(this.state.y)];
|
||||
const h = this.hotspot(who) ?? (this.scene.hotspots ?? []).find(x => x.id === who);
|
||||
if (!h) return [this.state.x, this.state.y - 62];
|
||||
return h.talkAt ?? [h.box[0] + h.box[2] / 2, h.box[1] - 4];
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
this.time += dt;
|
||||
if (this.speech) {
|
||||
this.speech.left -= dt;
|
||||
if (this.speech.left > 0) return;
|
||||
this.speech = null;
|
||||
}
|
||||
if (this.walking) {
|
||||
let step = SPEED * this.scaleAt(this.state.y) * dt;
|
||||
while (step > 0 && this.path.length) {
|
||||
const target = this.path[0], d = dist([this.state.x, this.state.y], target);
|
||||
if (Math.abs(target[0] - this.state.x) > 0.5) this.state.facing = target[0] > this.state.x ? 1 : -1;
|
||||
if (d <= step) {
|
||||
[this.state.x, this.state.y] = target;
|
||||
this.path.shift();
|
||||
step -= d;
|
||||
} else {
|
||||
this.state.x += (target[0] - this.state.x) / d * step;
|
||||
this.state.y += (target[1] - this.state.y) / d * step;
|
||||
step = 0;
|
||||
}
|
||||
}
|
||||
if (this.path.length) return;
|
||||
this.walking = false;
|
||||
this.task = null;
|
||||
}
|
||||
// Esegue gli effetti istantanei di fila e si ferma al primo che richiede tempo.
|
||||
while (!this.speech && !this.walking && this.queue.length) this.run(this.queue.shift());
|
||||
}
|
||||
|
||||
run(e) {
|
||||
const s = this.state;
|
||||
if (e.say != null) this.speech = { text: e.say, who: e.who ?? 'andrea', left: sayTime(e.say) };
|
||||
else if (e.walk) { this.task = e; this.startWalk(e.walk); }
|
||||
// `go` prima di `face`: un cambio scena porta con sé il verso d'arrivo.
|
||||
else if (e.go) this.go(e.go, e.at, e.face);
|
||||
else if (e.face) s.facing = FACING[e.face] ?? s.facing;
|
||||
else if (e.give) { if (!s.inventory.includes(e.give)) s.inventory.push(e.give); }
|
||||
else if (e.drop) s.inventory = s.inventory.filter(i => i !== e.drop);
|
||||
else if (e.set) s.flags[e.set] = true;
|
||||
else if (e.unset) delete s.flags[e.unset];
|
||||
else if (e.if !== undefined) this.queue.unshift(...(check(e.if, s) ? e.then ?? [] : e.else ?? []));
|
||||
else if (e.dialogue) this.dialogue = { id: e.dialogue, node: 'start' };
|
||||
else if (e.node) this.dialogue.node = e.node;
|
||||
else if (e.endDialogue) this.dialogue = null;
|
||||
else if (e.end) { s.ended = true; this.onSave(this.snapshot()); }
|
||||
else throw new Error('Effetto sconosciuto: ' + JSON.stringify(e));
|
||||
}
|
||||
|
||||
go(scene, at, face) {
|
||||
if (!this.story.scenes[scene]) throw new Error('Scena sconosciuta: ' + scene);
|
||||
this.state.scene = scene;
|
||||
[this.state.x, this.state.y] = at ?? this.scene.entry;
|
||||
this.state.facing = FACING[face] ?? this.state.facing;
|
||||
this.path = [];
|
||||
this.walking = false;
|
||||
this.queue.unshift(...(this.scene.enter ?? []));
|
||||
this.onSave(this.snapshot());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Area calpestabile: un poligono semplice (anche concavo) in coordinate di scena.
|
||||
// Il percorso passa per i vertici concavi (grafo di visibilità + A*), così il personaggio
|
||||
// gira attorno agli angoli invece di attraversare i muri.
|
||||
const EPS = 1e-6;
|
||||
|
||||
export const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
|
||||
function onSegment(p, a, b, tol = 0.5) {
|
||||
const [px, py] = p, [ax, ay] = a, [bx, by] = b;
|
||||
const len2 = (bx - ax) ** 2 + (by - ay) ** 2;
|
||||
const t = len2 ? Math.max(0, Math.min(1, ((px - ax) * (bx - ax) + (py - ay) * (by - ay)) / len2)) : 0;
|
||||
return dist(p, [ax + t * (bx - ax), ay + t * (by - ay)]) <= tol;
|
||||
}
|
||||
|
||||
// Dentro o sul bordo (con tolleranza di mezzo pixel).
|
||||
export function inside(poly, p) {
|
||||
let hit = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[i], b = poly[j];
|
||||
if (onSegment(p, a, b)) return true;
|
||||
if ((a[1] > p[1]) !== (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0]) hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
|
||||
// Incrocio proprio: i due segmenti si tagliano in un punto interno a entrambi.
|
||||
function crosses(p1, p2, q1, q2) {
|
||||
const d1 = cross(q1, q2, p1), d2 = cross(q1, q2, p2), d3 = cross(p1, p2, q1), d4 = cross(p1, p2, q2);
|
||||
return ((d1 > EPS && d2 < -EPS) || (d1 < -EPS && d2 > EPS)) && ((d3 > EPS && d4 < -EPS) || (d3 < -EPS && d4 > EPS));
|
||||
}
|
||||
|
||||
export function visible(poly, a, b) {
|
||||
if (!inside(poly, a) || !inside(poly, b)) return false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++)
|
||||
if (crosses(a, b, poly[i], poly[j])) return false;
|
||||
// Un segmento che passa per due vertici può uscire senza incrociare nessun lato: si campiona.
|
||||
for (let k = 1; k < 8; k++) {
|
||||
const t = k / 8;
|
||||
if (!inside(poly, [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function nearestInside(poly, p) {
|
||||
if (inside(poly, p)) return [p[0], p[1]];
|
||||
let best = null, bestD = Infinity;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const [ax, ay] = poly[j], [bx, by] = poly[i];
|
||||
const len2 = (bx - ax) ** 2 + (by - ay) ** 2;
|
||||
const t = len2 ? Math.max(0, Math.min(1, ((p[0] - ax) * (bx - ax) + (p[1] - ay) * (by - ay)) / len2)) : 0;
|
||||
const q = [ax + t * (bx - ax), ay + t * (by - ay)], d = dist(p, q);
|
||||
if (d < bestD) { bestD = d; best = q; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function signedArea(poly) {
|
||||
let s = 0;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) s += poly[j][0] * poly[i][1] - poly[i][0] * poly[j][1];
|
||||
return s / 2;
|
||||
}
|
||||
|
||||
// I vertici concavi (svolta contraria al verso del poligono) sono gli unici dove un percorso minimo può piegare.
|
||||
export function concaveVertices(poly) {
|
||||
const sign = Math.sign(signedArea(poly)), n = poly.length;
|
||||
return poly.filter((v, i) => sign * cross(poly[(i - 1 + n) % n], v, poly[(i + 1) % n]) < -EPS);
|
||||
}
|
||||
|
||||
export function findPath(poly, from, to) {
|
||||
const start = nearestInside(poly, from), goal = nearestInside(poly, to);
|
||||
if (visible(poly, start, goal)) return [goal];
|
||||
const nodes = [start, goal, ...concaveVertices(poly)];
|
||||
const g = new Map([[0, 0]]), prev = new Map(), open = new Set([0]), done = new Set();
|
||||
while (open.size) {
|
||||
let cur = -1, curF = Infinity;
|
||||
for (const i of open) {
|
||||
const f = g.get(i) + dist(nodes[i], goal);
|
||||
if (f < curF) { curF = f; cur = i; }
|
||||
}
|
||||
if (cur === 1) break;
|
||||
open.delete(cur);
|
||||
done.add(cur);
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (i === cur || done.has(i) || !visible(poly, nodes[cur], nodes[i])) continue;
|
||||
const cost = g.get(cur) + dist(nodes[cur], nodes[i]);
|
||||
if (cost < (g.get(i) ?? Infinity)) { g.set(i, cost); prev.set(i, cur); open.add(i); }
|
||||
}
|
||||
}
|
||||
if (!prev.has(1)) return [start];
|
||||
const path = [];
|
||||
for (let i = 1; i !== 0; i = prev.get(i)) path.unshift(nodes[i]);
|
||||
return path;
|
||||
}
|
||||
|
||||
export const inBox = ([x, y, w, h], p) => p[0] >= x && p[0] <= x + w && p[1] >= y && p[1] <= y + h;
|
||||
@@ -0,0 +1,72 @@
|
||||
// Disegno della scena su un canvas 640×360, ingrandito dal CSS senza sfocatura.
|
||||
// Le forme provvisorie (`art`) verranno sostituite da sfondi e sprite in pixel art.
|
||||
export const WIDTH = 640, HEIGHT = 360;
|
||||
|
||||
function drawShape(ctx, s) {
|
||||
if (s.rect) {
|
||||
ctx.fillStyle = s.fill;
|
||||
ctx.fillRect(...s.rect);
|
||||
} else if (s.poly) {
|
||||
ctx.fillStyle = s.fill;
|
||||
ctx.beginPath();
|
||||
s.poly.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else if (s.lines) {
|
||||
// Righe di riempimento: fughe del selciato o sbarre di un cancello.
|
||||
const { from: [x0, y0], to: [x1, y1], step, vertical } = s.lines;
|
||||
ctx.fillStyle = s.stroke;
|
||||
if (vertical) for (let x = x0; x <= x1; x += step) ctx.fillRect(x, y0, 2, y1 - y0);
|
||||
else for (let y = y0; y <= y1; y += step) ctx.fillRect(x0, y, x1 - x0, 1);
|
||||
} else if (s.text) {
|
||||
ctx.fillStyle = s.fill;
|
||||
ctx.font = 'bold 11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(s.text, ...s.at);
|
||||
}
|
||||
}
|
||||
|
||||
// Andrea provvisorio, disegnato a blocchi: maglia blu (il rione di Campomigliaio), gambe che si alternano.
|
||||
const BODY = [
|
||||
// [x, y, w, h, colore] con l'origine ai piedi, rivolto a destra.
|
||||
[-6, -60, 12, 4, '#4a2e1c'], [-6, -56, 12, 12, '#e8b48c'], [3, -53, 2, 2, '#2a1a10'], [-7, -60, 4, 10, '#4a2e1c'],
|
||||
[-8, -44, 16, 20, '#2f5fb3'], [-4, -42, 8, 3, '#f4efe4'],
|
||||
];
|
||||
|
||||
export function drawAndrea(ctx, x, y, scale, facing, walkPhase) {
|
||||
ctx.save();
|
||||
ctx.translate(Math.round(x), Math.round(y));
|
||||
ctx.scale(scale * facing, scale);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
||||
ctx.fillRect(-10, -2, 20, 4);
|
||||
const swing = walkPhase == null ? 0 : Math.round(Math.sin(walkPhase) * 5);
|
||||
ctx.fillStyle = '#3a3f52';
|
||||
ctx.fillRect(-6 + swing, -24, 5, 22);
|
||||
ctx.fillRect(1 - swing, -24, 5, 22);
|
||||
ctx.fillStyle = '#e8e3d6';
|
||||
ctx.fillRect(-7 + swing, -3, 7, 3);
|
||||
ctx.fillRect(0 - swing, -3, 7, 3);
|
||||
for (const [bx, by, w, h, c] of BODY) {
|
||||
ctx.fillStyle = c;
|
||||
ctx.fillRect(bx, by, w, h);
|
||||
}
|
||||
ctx.fillStyle = '#e8b48c';
|
||||
ctx.fillRect(-2 - swing, -40, 4, 16);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export function render(ctx, game, { hover } = {}) {
|
||||
const scene = game.scene, s = game.state;
|
||||
ctx.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
for (const shape of scene.art ?? []) drawShape(ctx, shape);
|
||||
for (const h of game.hotspots()) for (const shape of h.art ?? []) drawShape(ctx, shape);
|
||||
const phase = game.walking ? (s.x + s.y) / 6 : null;
|
||||
drawAndrea(ctx, s.x, s.y, game.scaleAt(s.y), s.facing, phase);
|
||||
if (hover) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.strokeRect(...hover.box);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Salvataggio nel browser: ogni accesso può fallire (navigazione privata, spazio pieno), e allora si gioca senza.
|
||||
const KEY = 'leggenda-regolo-save';
|
||||
|
||||
export function readSave(storage) {
|
||||
try {
|
||||
const raw = storage.getItem(KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSave(storage, data) {
|
||||
try {
|
||||
storage.setItem(KEY, JSON.stringify(data));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSave(storage) {
|
||||
try {
|
||||
storage.removeItem(KEY);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user