Scopa agli 11 all'oratorio, e il tressette rivisto dopo la revisione critica di Codex
Scopa: toscane o napoletane (mattoni o denari), presa obbligatoria, la carta uguale prima delle somme, scelta fra più prese toccando la tavola, coi pulsanti o con frecce e Invio; scope (non all'ultima carta), carte, mattoni, settebello, primiera; tre livelli tarati (92, 64, 52%); test e prova nel browser. Tressette: fuori ai 21 presa per presa, vittoria subito definitiva (Esc non la cancella più), accuse dette al proprio turno e col seme che manca del tris, striscio e busso meno rigidi, il socio risponde al busso, tiene il 3 e il 2, non esce d'Asso scoperto, legge il volo; tastiera col fuoco sulla carta scelta, tavola descritta a parole, riepilogo che aspetta Andrea. Stesso Esc corretto anche nella scopa. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1dxvtsZx8NE9fwjXUNDBr
This commit is contained in:
@@ -5,11 +5,13 @@ import * as bocce from './bocce.js';
|
||||
import * as biliardo from './biliardo.js';
|
||||
import * as briscola from './briscola.js';
|
||||
import * as tressette from './tressette.js';
|
||||
import * as scopa from './scopa.js';
|
||||
|
||||
// I minigiochi in primo piano: comandi opzionali per il tiro o per la scelta di una carta.
|
||||
export const MINIGAMES = {
|
||||
briscola: { create: (seed, level, deck) => briscola.newBriscola(seed, { level, deck }), update: briscola.update, play: briscola.play },
|
||||
tressette: { create: (seed, level, deck) => tressette.newTressette(seed, { level, deck }), update: tressette.update, play: tressette.play },
|
||||
scopa: { create: (seed, level, deck) => scopa.newScopa(seed, { level, deck }), update: scopa.update, play: scopa.play },
|
||||
bocce: { create: (seed, level) => bocce.newBocce(seed, { level }), update: bocce.update, press: bocce.press, release: bocce.release, cancel: bocce.cancel, aim: bocce.aimScreen },
|
||||
biliardo: { create: (seed, level) => biliardo.newBiliardo(seed, { level }), update: biliardo.update, press: biliardo.press, release: biliardo.release, cancel: biliardo.cancel, aim: biliardo.aimScreen },
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { drawBocce } from './bocce-render.js';
|
||||
import { drawBiliardo } from './biliardo-render.js';
|
||||
import { drawBriscola } from './briscola-render.js';
|
||||
import { drawTressette } from './tressette-render.js';
|
||||
import { drawScopa } from './scopa-render.js';
|
||||
import { versioned } from '../version.js';
|
||||
export const WIDTH = 640, HEIGHT = 360;
|
||||
|
||||
@@ -670,6 +671,7 @@ function drawCloseup(ctx, game, time) {
|
||||
if (!def) return;
|
||||
if (def.type === 'briscola') { drawBriscola(ctx, c, def); return; }
|
||||
if (def.type === 'tressette') { drawTressette(ctx, c, def); return; }
|
||||
if (def.type === 'scopa') { drawScopa(ctx, c, def); return; }
|
||||
if (def.type === 'torch') {
|
||||
if (!backgrounds.has(def.picture)) loadBackground(def.picture);
|
||||
drawTorch(ctx, c, def, backgrounds.get(def.picture));
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { canPlay, TARGET } from './scopa.js';
|
||||
import { drawCard, drawTable } from './briscola-render.js';
|
||||
import { text, panel } from './bocce-render.js';
|
||||
|
||||
// Coordinate condivise da disegno e tocco: la mano di Andrea (grande) e le carte in tavola.
|
||||
export const HAND = { y: 262, w: 64, h: 92, gap: 72 };
|
||||
export const handX = (n, i) => Math.round(320 - (n * HAND.gap - 8) / 2 + i * HAND.gap);
|
||||
const TABLE = { w: 42, h: 60, gap: 47, row: 8 };
|
||||
export function tableXY(n, i) {
|
||||
const row = Math.floor(i / TABLE.row), inRow = Math.min(TABLE.row, n - row * TABLE.row);
|
||||
return [Math.round(320 - (inRow * TABLE.gap - 5) / 2 + (i % TABLE.row) * TABLE.gap), n > TABLE.row ? 104 + row * 66 : 132];
|
||||
}
|
||||
export function cardAt(c, [x, y]) {
|
||||
if (!canPlay(c)) return -1;
|
||||
return c.hands.player.findIndex((_, i) => x >= handX(c.hands.player.length, i) && x < handX(c.hands.player.length, i) + HAND.w && y >= HAND.y - 8 && y < HAND.y + HAND.h);
|
||||
}
|
||||
export function tableAt(c, [x, y]) {
|
||||
return c.table.findIndex((_, i) => { const [tx, ty] = tableXY(c.table.length, i); return x >= tx && x < tx + TABLE.w && y >= ty && y < ty + TABLE.h; });
|
||||
}
|
||||
const gold = '#ffe09b', paper = '#fff0cd', mine = '#9fe3ff', theirs = '#ffb0a3';
|
||||
const frame = (ctx, x, y, w, h, color = '#ffdb76') => { ctx.fillStyle = color; ctx.fillRect(x - 2, y - 2, w + 4, h + 4); };
|
||||
|
||||
function board(ctx, c, names) {
|
||||
// Un giocatore per riga: i nomi lunghi non si accavallano.
|
||||
panel(ctx, 26, 34, 150, 50);
|
||||
for (const [who, y, color] of [['player', 45, mine], ['cpu', 59, theirs]]) {
|
||||
text(ctx, names[who], 34, y, 7, color); text(ctx, String(c.score[who]), 168, y, 7, color, 'right');
|
||||
}
|
||||
text(ctx, `Smazzata ${c.round} · ai ${TARGET}`, 101, 74, 5, paper, 'center');
|
||||
// Il mazzo a sinistra, le prese a destra con le scope.
|
||||
if (c.stock.length) for (let i = Math.min(4, Math.ceil(c.stock.length / 8)); i >= 0; i--) drawCard(ctx, null, 40 + i, 150 - i, 42, 60, true);
|
||||
text(ctx, `Mazzo: ${c.stock.length}`, 61, 222, 6, paper, 'center');
|
||||
for (const [who, y] of [['cpu', 92], ['player', 196]]) {
|
||||
if (c.taken[who].length) for (let i = 0; i < Math.min(4, c.taken[who].length / 4); i++) drawCard(ctx, null, 560 + i, y - i, 42, 60, true);
|
||||
if (c.scope[who]) text(ctx, `Scope: ${c.scope[who]}`, 581, y + 70, 6, gold, 'center');
|
||||
}
|
||||
}
|
||||
function table(ctx, c) {
|
||||
const take = c.step === 'take' && c.last.captured.length, shown = take ? [...c.table, ...c.last.captured] : c.table;
|
||||
const marked = new Set((take ? c.last.captured : c.step === 'choose' ? c.choice.options[c.choice.selected] : []).map(a => a.id));
|
||||
shown.forEach((card, i) => {
|
||||
const [x, y] = tableXY(shown.length, i);
|
||||
if (marked.has(card.id)) frame(ctx, x, y, TABLE.w, TABLE.h);
|
||||
else if (c.step === 'take' && !take && card.id === c.last.card.id) frame(ctx, x, y, TABLE.w, TABLE.h, '#9fe3ff');
|
||||
drawCard(ctx, card, x, y, TABLE.w, TABLE.h);
|
||||
});
|
||||
if (take) {
|
||||
drawCard(ctx, c.last.card, 299, 200, TABLE.w, TABLE.h);
|
||||
if (c.last.scopa) { panel(ctx, 260, 98, 120, 22); text(ctx, 'SCOPA!', 320, 109, 9, gold, 'center'); }
|
||||
}
|
||||
}
|
||||
function hands(ctx, c) {
|
||||
c.hands.cpu.forEach((_, i) => drawCard(ctx, null, 320 - (c.hands.cpu.length * 48 - 6) / 2 + i * 48, 38, 42, 60, true));
|
||||
const n = c.hands.player.length, playing = canPlay(c) || c.step === 'choose';
|
||||
c.hands.player.forEach((card, i) => {
|
||||
const x = handX(n, i), chosen = c.step === 'choose' ? c.choice.index === i : canPlay(c) && c.selected === i;
|
||||
const up = chosen ? 8 : 0;
|
||||
if (chosen && playing) frame(ctx, x, HAND.y - up, HAND.w, HAND.h);
|
||||
drawCard(ctx, card, x, HAND.y - up, HAND.w, HAND.h);
|
||||
});
|
||||
}
|
||||
function summary(ctx, c, names, title) {
|
||||
const s = c.summary;
|
||||
panel(ctx, 140, 80, 360, 200);
|
||||
text(ctx, title, 320, 96, 8, gold, 'center');
|
||||
text(ctx, names.player, 380, 116, 6, mine, 'center'); text(ctx, names.cpu, 455, 116, 6, theirs, 'center');
|
||||
s.rows.forEach((r, i) => {
|
||||
const y = 136 + i * 20, show = v => r.key === 'settebello' ? (v ? 'sì' : '—') : String(v);
|
||||
text(ctx, r.label, 160, y, 7, paper);
|
||||
text(ctx, show(r.player) + (r.winner === 'player' ? ' +1' : ''), 380, y, 7, r.winner === 'player' ? gold : paper, 'center');
|
||||
text(ctx, show(r.cpu) + (r.winner === 'cpu' ? ' +1' : ''), 455, y, 7, r.winner === 'cpu' ? gold : paper, 'center');
|
||||
});
|
||||
text(ctx, `Smazzata: ${s.points.player} a ${s.points.cpu}. Siamo ${c.score.player} a ${c.score.cpu}`, 320, 246, 7, paper, 'center');
|
||||
text(ctx, c.step === 'result' ? 'Esc o Invio per tornare all’oratorio' : 'Invio o un tocco per la prossima smazzata', 320, 266, 5, paper, 'center');
|
||||
}
|
||||
export function drawScopa(ctx, c, def) {
|
||||
const names = def?.names ?? { player: 'Andrea', cpu: 'Il giocatore' };
|
||||
ctx.save(); ctx.imageSmoothingEnabled = false;
|
||||
drawTable(ctx, 'SCOPA ALL’ORATORIO');
|
||||
board(ctx, c, names); table(ctx, c); hands(ctx, c);
|
||||
if (c.step === 'smazzata') summary(ctx, c, names, `Fine della smazzata ${c.round}`);
|
||||
if (c.step === 'result') summary(ctx, c, names, c.result.winner === 'player' ? 'Hai vinto!' : `Vince ${names.cpu}`);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Scopa in due, ai 11: regole e avversari senza DOM, come la briscola. Carte, mattoni (o denari), settebello,
|
||||
// primiera e scope. Con le toscane il seme «buono» è mattoni, con le napoletane denari.
|
||||
import { DECKS, cardName } from './briscola.js';
|
||||
|
||||
export { cardName };
|
||||
export const LEVELS = ['bona', 'seria', 'male'];
|
||||
export const TARGET = 11;
|
||||
export const COIN = { toscane: 'mattoni', napoletane: 'denari' };
|
||||
// Valore per le prese: Asso 1, poi i numeri, figure 8, 9 e 10. Primiera: 7 vale 21, 6 18, Asso 16, 5 15, 4 14,
|
||||
// 3 13, 2 12, le figure 10.
|
||||
const PRIME = [16, 12, 13, 14, 15, 18, 21, 10, 10, 10];
|
||||
const build = ({ suits, ranks }) => Object.freeze(suits.flatMap((suit, s) => ranks.map((rank, r) =>
|
||||
Object.freeze({ id: s * 10 + r, suit, rank, value: r + 1, prime: PRIME[r] }))));
|
||||
export const DECK_CARDS = Object.fromEntries(Object.entries(DECKS).map(([name, deck]) => [name, build(deck)]));
|
||||
export const other = who => who === 'player' ? 'cpu' : 'player';
|
||||
const lcg = n => (Math.imul(n, 1664525) + 1013904223) >>> 0;
|
||||
function rng(seed) { let n = seed >>> 0; return () => (n = lcg(n)) / 4294967296; }
|
||||
function shuffle(cards, random) {
|
||||
for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
|
||||
return cards;
|
||||
}
|
||||
|
||||
// Le prese possibili con `card`: se in tavola c'è una carta dello stesso valore si prende quella (una sola, a
|
||||
// scelta se sono più d'una); altrimenti ogni gruppo di carte che somma il suo valore. Se una presa c'è, è obbligatoria.
|
||||
export function captures(table, card) {
|
||||
const same = table.filter(a => a.value === card.value);
|
||||
if (same.length) return same.map(a => [a]);
|
||||
const out = [];
|
||||
for (let mask = 1; mask < 1 << table.length; mask++) {
|
||||
const pick = table.filter((_, i) => mask & 1 << i);
|
||||
if (pick.length > 1 && pick.reduce((n, a) => n + a.value, 0) === card.value) out.push(pick);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const isSettebello = (card, deck) => card.suit === COIN[deck] && card.value === 7;
|
||||
// Primiera: la carta migliore di ogni seme; chi non ha tutti e quattro i semi non la fa.
|
||||
export function primiera(cards, deck) {
|
||||
const suits = DECKS[deck].suits;
|
||||
if (!suits.every(s => cards.some(a => a.suit === s))) return 0;
|
||||
return suits.reduce((n, s) => n + Math.max(...cards.filter(a => a.suit === s).map(a => a.prime)), 0);
|
||||
}
|
||||
// I punti di una smazzata: carte (più di 20), mattoni o denari (più di 5), settebello, primiera, una per scopa.
|
||||
export function tally(taken, scope, deck) {
|
||||
const coin = COIN[deck], rows = [], who = (a, b) => a > b ? 'player' : b > a ? 'cpu' : null;
|
||||
const count = w => taken[w].length, coins = w => taken[w].filter(a => a.suit === coin).length, prime = w => primiera(taken[w], deck);
|
||||
rows.push({ key: 'carte', label: 'Carte', player: count('player'), cpu: count('cpu'), winner: who(count('player'), count('cpu')) });
|
||||
rows.push({ key: 'coin', label: coin === 'mattoni' ? 'Mattoni' : 'Denari', player: coins('player'), cpu: coins('cpu'), winner: who(coins('player'), coins('cpu')) });
|
||||
const seven = taken.player.some(a => isSettebello(a, deck)) ? 'player' : 'cpu';
|
||||
rows.push({ key: 'settebello', label: 'Settebello', player: +(seven === 'player'), cpu: +(seven === 'cpu'), winner: seven });
|
||||
rows.push({ key: 'primiera', label: 'Primiera', player: prime('player'), cpu: prime('cpu'), winner: who(prime('player'), prime('cpu')) });
|
||||
const points = { player: 0, cpu: 0 };
|
||||
for (const r of rows) if (r.winner) points[r.winner]++;
|
||||
rows.push({ key: 'scope', label: 'Scope', player: scope.player, cpu: scope.cpu, winner: null });
|
||||
points.player += scope.player; points.cpu += scope.cpu;
|
||||
return { rows, points };
|
||||
}
|
||||
|
||||
function deal(c) {
|
||||
// Con tre o quattro re in tavola si rimescola.
|
||||
let stock;
|
||||
do { c.seed = lcg(c.seed); stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed)); }
|
||||
while (stock.slice(0, 4).filter(a => a.value === 10).length >= 3);
|
||||
c.table = stock.splice(0, 4); c.stock = stock;
|
||||
c.hands = { player: [], cpu: [] }; c.taken = { player: [], cpu: [] }; c.scope = { player: 0, cpu: 0 };
|
||||
c.current = other(c.dealer); c.lastTaker = null; c.round++;
|
||||
refill(c); c.events.push({ kind: 'card-shuffle' });
|
||||
}
|
||||
// Tre carte a testa, prima a chi gioca per primo.
|
||||
function refill(c) {
|
||||
for (let i = 0; i < 3; i++) for (const who of [other(c.dealer), c.dealer]) c.hands[who].push(c.stock.shift());
|
||||
c.step = 'play'; c.t = 0; c.selected = 0; c.choice = null;
|
||||
}
|
||||
export function newScopa(seed = Date.now() >>> 0, { level = 'seria', deck = 'toscane' } = {}) {
|
||||
const c = { level: LEVELS.includes(level) ? level : 'seria', deck: DECKS[deck] ? deck : 'toscane', score: { player: 0, cpu: 0 },
|
||||
round: 0, seed: seed >>> 0, aiSeed: (seed ^ 0x27d4eb2f) >>> 0, age: 0, events: [], last: null, summary: null };
|
||||
c.dealer = rng(seed)() < 0.5 ? 'player' : 'cpu';
|
||||
deal(c); return c;
|
||||
}
|
||||
export const canPlay = c => c.step === 'play' && c.current === 'player';
|
||||
export function select(c, index) {
|
||||
if (c.step === 'choose' && Number.isInteger(index) && c.choice.options[index]) { c.choice.selected = index; return true; }
|
||||
if (!canPlay(c) || !Number.isInteger(index) || !c.hands.player[index]) return false;
|
||||
c.selected = index; return true;
|
||||
}
|
||||
export function nudge(c, direction) {
|
||||
if (c.step === 'choose') { const n = c.choice.options.length; return select(c, (c.choice.selected + direction + n) % n); }
|
||||
const n = c.hands.player.length;
|
||||
return select(c, (c.selected + direction + n) % n);
|
||||
}
|
||||
// Andrea gioca una carta: se le prese possibili sono più d'una, sceglie lui quale (passo «choose»).
|
||||
export function play(c, index = c.selected, who = 'player', option = null) {
|
||||
if (c.step !== 'play' || c.current !== who || !Number.isInteger(index) || !c.hands[who][index]) return false;
|
||||
const options = captures(c.table, c.hands[who][index]);
|
||||
if (options.length > 1 && option === null) {
|
||||
if (who !== 'player') return false;
|
||||
c.step = 'choose'; c.choice = { index, options, selected: 0 }; return true;
|
||||
}
|
||||
return execute(c, who, index, options.length ? options[option ?? 0] : null);
|
||||
}
|
||||
export function take(c, k = c.choice?.selected) {
|
||||
if (c.step !== 'choose' || !c.choice.options[k]) return false;
|
||||
const { index, options } = c.choice; c.step = 'play'; c.choice = null;
|
||||
return execute(c, 'player', index, options[k]);
|
||||
}
|
||||
export function cancel(c) { if (c.step !== 'choose') return false; c.step = 'play'; c.choice = null; return true; }
|
||||
function execute(c, who, index, captured) {
|
||||
if (!captured && captures(c.table, c.hands[who][index]).length) return false;
|
||||
const [card] = c.hands[who].splice(index, 1);
|
||||
if (captured) {
|
||||
const ids = new Set(captured.map(a => a.id));
|
||||
c.table = c.table.filter(a => !ids.has(a.id)); c.taken[who].push(card, ...captured); c.lastTaker = who;
|
||||
} else c.table.push(card);
|
||||
// L'ultima carta della smazzata non fa scopa.
|
||||
const lastPlay = !c.stock.length && !c.hands.player.length && !c.hands.cpu.length;
|
||||
const scopa = !!captured && !c.table.length && !lastPlay;
|
||||
if (scopa) c.scope[who]++;
|
||||
c.last = { who, card, captured: captured ?? [], scopa };
|
||||
c.events.push({ kind: captured ? 'card-tap' : 'card-slide', who }, ...(scopa ? [{ kind: 'card-busso', who }] : []));
|
||||
c.step = 'take'; c.t = 0;
|
||||
if (who === 'player') c.selected = Math.min(c.selected, Math.max(0, c.hands.player.length - 1));
|
||||
return true;
|
||||
}
|
||||
function after(c) {
|
||||
c.current = other(c.last.who); c.t = 0; c.step = 'play';
|
||||
if (c.hands.player.length || c.hands.cpu.length) return;
|
||||
if (c.stock.length) { refill(c); return; }
|
||||
// Fine smazzata: quel che resta in tavola va all'ultimo che ha preso.
|
||||
if (c.lastTaker) c.taken[c.lastTaker].push(...c.table);
|
||||
c.table = [];
|
||||
const t = tally(c.taken, c.scope, c.deck);
|
||||
c.score.player += t.points.player; c.score.cpu += t.points.cpu;
|
||||
c.summary = { ...t, score: { ...c.score } }; c.t = 0;
|
||||
// Il risultato è subito definitivo: uscire dal riepilogo non può cancellare una vittoria.
|
||||
if (finished(c)) { c.step = 'result'; c.result = { winner: c.score.player > c.score.cpu ? 'player' : 'cpu', ...c.score }; }
|
||||
else c.step = 'smazzata';
|
||||
}
|
||||
// Si va agli 11, contati a fine smazzata: vince chi ne ha di più; pari (o nessuno agli 11), se ne fa un'altra.
|
||||
export const finished = c => Math.max(c.score.player, c.score.cpu) >= TARGET && c.score.player !== c.score.cpu;
|
||||
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
|
||||
export function advance(c) {
|
||||
if (c.step !== 'smazzata') return false;
|
||||
c.dealer = other(c.dealer); deal(c);
|
||||
return true;
|
||||
}
|
||||
|
||||
// La sola porta d'ingresso dell'avversario: la sua mano, la tavola e le carte prese (si son viste tutte).
|
||||
export function observation(c, who = 'cpu') {
|
||||
return { who, deck: c.deck, hand: [...c.hands[who]], table: [...c.table], taken: { player: [...c.taken.player], cpu: [...c.taken.cpu] },
|
||||
opponentCount: c.hands[other(who)].length, stock: c.stock.length, scope: { ...c.scope } };
|
||||
}
|
||||
// Le carte che non si sono viste: in mano all'altro o nel mazzo.
|
||||
export function unseen(v) {
|
||||
const seen = new Set([...v.hand, ...v.table, ...v.taken.player, ...v.taken.cpu].map(a => a.id));
|
||||
return DECK_CARDS[v.deck].filter(a => !seen.has(a.id));
|
||||
}
|
||||
// Quanto vale una mossa: le carte prese (settebello, sette, mattoni, primiera, scopa) meno il rischio di lasciare
|
||||
// una scopa all'altro. «Seria» il rischio lo stima a occhio; «A fassi male» conta quante carte di quel valore
|
||||
// possono essere ancora in mano all'altro.
|
||||
export function score(v, card, captured, counting) {
|
||||
const coin = COIN[v.deck], last = !v.stock && v.hand.length === 1 && !v.opponentCount;
|
||||
const left = captured ? v.table.filter(a => !captured.includes(a)) : [...v.table, card];
|
||||
const worth = a => 1 + (a.suit === coin ? 1.5 : 0) + (isSettebello(a, v.deck) ? 12 : 0) + (a.value === 7 ? 3 : a.value === 6 ? 1.2 : a.value === 1 ? 0.8 : 0);
|
||||
let value = captured ? [card, ...captured].reduce((n, a) => n + worth(a), 0) + (!left.length && !last ? 20 : 0) : -worth(card) * 0.6;
|
||||
const sum = left.reduce((n, a) => n + a.value, 0);
|
||||
if (left.length && sum <= 10 && v.opponentCount) {
|
||||
let p = 0.5;
|
||||
if (counting) {
|
||||
const pool = unseen(v), same = pool.filter(a => a.value === sum).length;
|
||||
p = pool.length ? 1 - (1 - same / pool.length) ** v.opponentCount : 0;
|
||||
}
|
||||
value -= p * 20;
|
||||
}
|
||||
// Lasciare in tavola un 7 o il settebello vuol dire regalarli.
|
||||
value -= left.filter(a => a.value === 7).length * (counting ? 1.5 : 0) + (left.some(a => isSettebello(a, v.deck)) ? 6 : 0);
|
||||
return value;
|
||||
}
|
||||
export function moves(v) {
|
||||
return v.hand.flatMap((card, index) => {
|
||||
const opts = captures(v.table, card);
|
||||
return opts.length ? opts.map((captured, option) => ({ index, option, captured })) : [{ index, option: null, captured: null }];
|
||||
});
|
||||
}
|
||||
// «Alla bona» gioca a caso; «Seria» e «A fassi male» scelgono la mossa che vale di più.
|
||||
// Taratura (tools/calibrate_scopa.mjs, 300 partite): Andrea vince 92% contro «Alla bona», 64% contro «Seria», 52% contro «A fassi male».
|
||||
export function choose(v, level = 'seria', seed = 1) {
|
||||
const all = moves(v);
|
||||
if (level === 'bona') return all[Math.floor(rng(seed)() * all.length)];
|
||||
const counting = level === 'male';
|
||||
return all.reduce((a, b) => score(v, v.hand[b.index], b.captured, counting) > score(v, v.hand[a.index], a.captured, counting) ? b : a);
|
||||
}
|
||||
export function update(c, dt) {
|
||||
if (!Number.isFinite(dt) || dt < 0 || c.step === 'result') return;
|
||||
c.t += dt;
|
||||
if (c.step === 'take' && c.t >= (c.last.captured.length ? 1.3 : 0.6)) after(c);
|
||||
else if (c.step === 'play' && c.current === 'cpu' && c.t >= 0.75) {
|
||||
c.aiSeed = lcg(c.aiSeed);
|
||||
const m = choose(observation(c, 'cpu'), c.level, c.aiSeed);
|
||||
play(c, m.index, 'cpu', m.option);
|
||||
}
|
||||
}
|
||||
export function status(c, names = { player: 'Andrea', cpu: 'Il giocatore' }) {
|
||||
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Hai vinto' : `Vince ${names.cpu}`}, ${c.score.player} a ${c.score.cpu}!`;
|
||||
if (c.step === 'smazzata') return `Smazzata finita: siamo ${c.score.player} a ${c.score.cpu}. Invio per continuare.`;
|
||||
if (c.step === 'choose') return 'Più prese possibili: scegli quale (frecce o tocca una carta in tavola), Invio prende.';
|
||||
if (c.step === 'take') {
|
||||
const l = c.last;
|
||||
return l.scopa ? `Scopa di ${names[l.who]}!` : l.captured.length ? `${names[l.who]} prende con ${cardName(l.card)}.` : `${names[l.who]} posa ${cardName(l.card)}.`;
|
||||
}
|
||||
return c.current === 'player' ? 'Tocca una carta per giocarla. Frecce o 1–2–3 scelgono, Invio gioca.' : `${names.cpu} pensa…`;
|
||||
}
|
||||
@@ -44,8 +44,8 @@ function scoreboard(ctx, c) {
|
||||
text(ctx, `Smazzata ${c.round} · ai 21`, 85, 62, 5, paper, 'center');
|
||||
}
|
||||
function accuses(ctx, c, names) {
|
||||
// Le accuse si vedono finché non è finita la prima presa.
|
||||
if (c.tricks.length || !c.accuse.length) return;
|
||||
// Le accuse dette restano in vista per tutta la smazzata.
|
||||
if (!c.accuse.length) return;
|
||||
panel(ctx, 456, 34, 158, 12 + c.accuse.length * 11);
|
||||
c.accuse.forEach((a, i) => text(ctx, `${names[a.who]}: ${accuseName(a)} +${a.points}`, 463, 41 + i * 11, 5, a.who % 2 ? theirs : ours));
|
||||
}
|
||||
@@ -87,6 +87,6 @@ export function drawTressette(ctx, c, def) {
|
||||
drawTable(ctx, 'TRESSETTE AI 21');
|
||||
scoreboard(ctx, c); others(ctx, c, names); accuses(ctx, c, names); trick(ctx, c, names); hand(ctx, c);
|
||||
if (c.step === 'smazzata') summary(ctx, c, `Fine della smazzata ${c.round}`);
|
||||
if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Avete vinto!' : 'Hanno vinto loro');
|
||||
if (c.step === 'result') summary(ctx, c, c.result.winner === 'player' ? 'Fuori! Avete vinto' : 'Sono fuori loro');
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
+61
-35
@@ -27,7 +27,8 @@ function shuffle(cards, random) {
|
||||
const byMin = (list, key) => list.reduce((a, b) => key(b) < key(a) ? b : a);
|
||||
|
||||
// Le accuse: napoletana (Asso, 2 e 3 dello stesso seme) 3 punti; buongioco, tre o quattro Assi, 2 o 3, 3 o 4 punti.
|
||||
export function accuse(hand) {
|
||||
// Del tris si dice il seme che manca (`suits`, i quattro semi del mazzo): chi ascolta sa dove sono le altre tre.
|
||||
export function accuse(hand, suits = [...new Set(hand.map(a => a.suit))]) {
|
||||
const out = [], tops = ['Asso', '2', '3'];
|
||||
for (const suit of new Set(hand.map(a => a.suit))) {
|
||||
const cards = hand.filter(a => a.suit === suit && tops.includes(a.rank));
|
||||
@@ -35,11 +36,12 @@ export function accuse(hand) {
|
||||
}
|
||||
for (const rank of tops) {
|
||||
const cards = hand.filter(a => a.rank === rank);
|
||||
if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards });
|
||||
if (cards.length >= 3) out.push({ kind: 'buongioco', rank, points: cards.length, cards, missing: cards.length === 3 ? suits.find(s => !cards.some(a => a.suit === s)) : null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export const accuseName = a => a.kind === 'napoletana' ? `napoletana di ${a.suit}` : `${a.cards.length === 4 ? 'quattro' : 'tre'} ${a.rank === 'Asso' ? 'Assi' : a.rank}`;
|
||||
export const accuseName = a => a.kind === 'napoletana' ? `napoletana di ${a.suit}`
|
||||
: `${a.cards.length === 4 ? 'quattro' : 'tre'} ${a.rank === 'Asso' ? 'Assi' : a.rank}${a.missing ? `, manca ${a.missing}` : ''}`;
|
||||
|
||||
// Si risponde al seme quando si può; chi esce gioca quel che vuole.
|
||||
export function legal(hand, table) {
|
||||
@@ -47,12 +49,13 @@ export function legal(hand, table) {
|
||||
return same.length ? same : hand.map((_, i) => i);
|
||||
}
|
||||
export const best = table => table.reduce((a, p) => p.card.suit === table[0].card.suit && p.card.power > a.card.power ? p : a);
|
||||
// I segni sono onesti: volo con l'ultima carta del seme, striscio se ne restano almeno due, busso se ne resta una.
|
||||
// I segni li fa solo chi esce. Volo: era l'ultima del seme; striscio: ne ho altre; busso (prendi con la migliore e
|
||||
// ritornamelo) si può fare sempre. Volo e striscio sono onesti: il gioco rifiuta quelli falsi.
|
||||
export function signalOk(hand, index, table, signal) {
|
||||
if (!signal) return true;
|
||||
if (table.length || !SIGNALS.includes(signal) || !hand[index]) return false;
|
||||
const rest = hand.filter((a, i) => i !== index && a.suit === hand[index].suit).length;
|
||||
return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 2 : rest >= 1;
|
||||
return signal === 'volo' ? rest === 0 : signal === 'striscio' ? rest >= 1 : true;
|
||||
}
|
||||
// Perché una carta non si può giocare: la frase da mostrare ad Andrea, o '' se va bene.
|
||||
export function reason(c, index, signal = c.signal) {
|
||||
@@ -60,8 +63,9 @@ export function reason(c, index, signal = c.signal) {
|
||||
if (!card) return 'Scegli una carta.';
|
||||
if (!legal(hand, c.table).includes(index)) return `Devi rispondere a ${c.table[0].card.suit}.`;
|
||||
if (!signal || signalOk(hand, index, c.table, signal)) return '';
|
||||
if (!SIGNALS.includes(signal)) return 'Segno non riconosciuto.';
|
||||
if (c.table.length) return 'I segni li fa solo chi esce.';
|
||||
return { volo: `Volo solo con l'ultima carta di ${card.suit}.`, striscio: `Striscio solo se di ${card.suit} te ne restano almeno due.`, busso: `Busso solo se di ${card.suit} te ne resta almeno un'altra.` }[signal];
|
||||
return { volo: `Volo solo con l'ultima carta di ${card.suit}.`, striscio: `Striscio solo se di ${card.suit} te ne resta almeno un'altra.` }[signal];
|
||||
}
|
||||
|
||||
const order = (a, b) => a.suit === b.suit ? b.power - a.power : a.id - b.id;
|
||||
@@ -69,11 +73,12 @@ function deal(c) {
|
||||
c.seed = lcg(c.seed);
|
||||
const stock = shuffle([...DECK_CARDS[c.deck]], rng(c.seed));
|
||||
c.hands = SEATS.map(seat => stock.slice(seat * 10, seat * 10 + 10).sort(order));
|
||||
// Fa le carte chi sta a sinistra di chi ha fatto prima; esce chi sta alla destra del mazziere.
|
||||
// Il mazzo passa a destra a ogni smazzata; esce chi sta alla destra del mazziere.
|
||||
c.dealer = c.round ? next(c.dealer) : c.dealer; c.round++;
|
||||
c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0];
|
||||
c.accuse = SEATS.map(i => (c.mano + i) % 4).flatMap(who => accuse(c.hands[who]).map(a => ({ who, ...a })));
|
||||
for (const a of c.accuse) c.score[team(a.who)] += a.points;
|
||||
c.current = c.mano = next(c.dealer); c.table = []; c.tricks = []; c.thirds = [0, 0]; c.base = [...c.score];
|
||||
// Le accuse si dicono al proprio turno, giocando la prima carta: fino ad allora restano in mano.
|
||||
c.pending = SEATS.map(who => accuse(c.hands[who], DECKS[c.deck].suits).map(a => ({ who, ...a })));
|
||||
c.accuse = [];
|
||||
c.step = 'play'; c.t = 0; c.selected = 0; c.signal = null; c.refused = '';
|
||||
c.events.push({ kind: 'card-shuffle' });
|
||||
}
|
||||
@@ -103,6 +108,10 @@ export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.sign
|
||||
if (who === 0) c.refused = reason(c, index, signal);
|
||||
return false;
|
||||
}
|
||||
if (!c.tricks.length && c.pending[who].length) {
|
||||
c.accuse.push(...c.pending[who]); c.pending[who] = []; recount(c);
|
||||
c.events.push({ kind: 'accusa', who });
|
||||
}
|
||||
const [card] = c.hands[who].splice(index, 1);
|
||||
c.table.push({ who, card, signal: signal || null }); c.t = 0; c.landing = true;
|
||||
// Il segno si sente: busso batte sul tavolo, striscio la fa strusciare, volo la lascia cadere dall'alto.
|
||||
@@ -112,28 +121,35 @@ export function play(c, index = c.selected, who = 0, signal = who === 0 ? c.sign
|
||||
else c.current = next(who);
|
||||
return true;
|
||||
}
|
||||
// Il punteggio si tiene presa per presa: quel che c'era a inizio smazzata, le accuse dette e i punti interi presi.
|
||||
const accused = (c, t) => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0);
|
||||
function recount(c) { c.score = [0, 1].map(t => c.base[t] + accused(c, t) + Math.floor(c.thirds[t] / 3)); }
|
||||
export function collect(c) {
|
||||
if (c.step !== 'trick') return false;
|
||||
const winner = c.last.winner;
|
||||
c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0);
|
||||
const winner = c.last.winner, end = !c.hands[0].length;
|
||||
// L'ultima presa vale un punto; i terzi avanzati non contano.
|
||||
c.thirds[team(winner)] += c.table.reduce((n, p) => n + p.card.thirds, 0) + (end ? 3 : 0);
|
||||
c.tricks.push({ leader: c.table[0].who, winner, cards: c.table }); c.table = [];
|
||||
c.current = winner; c.t = 0; c.events.push({ kind: 'card-slide' });
|
||||
if (c.hands[0].length) { c.step = 'play'; return true; }
|
||||
// L'ultima presa vale un punto; i terzi avanzati non contano.
|
||||
c.thirds[team(winner)] += 3;
|
||||
const points = c.thirds.map(t => Math.floor(t / 3)), accused = [0, 1].map(t => c.accuse.filter(a => team(a.who) === t).reduce((n, a) => n + a.points, 0));
|
||||
c.score = c.score.map((s, t) => s + points[t]);
|
||||
c.summary = { thirds: [...c.thirds], points, accused, lastTeam: team(winner), score: [...c.score] };
|
||||
c.step = 'smazzata';
|
||||
recount(c);
|
||||
// Si va ai 21: chi ci arriva per primo è fuori e ha vinto, anche a metà smazzata (una presa porta punti a una
|
||||
// coppia sola, quindi non ci arrivano mai insieme).
|
||||
const out = [0, 1].find(t => c.score[t] >= TARGET);
|
||||
if (end || out !== undefined) {
|
||||
c.summary = { thirds: [...c.thirds], points: c.thirds.map(t => Math.floor(t / 3)), accused: [0, 1].map(t => accused(c, t)),
|
||||
lastTeam: end ? team(winner) : null, score: [...c.score], out: out ?? null };
|
||||
if (out !== undefined) { c.step = 'result'; c.result = { winner: out === 0 ? 'player' : 'cpu', score: [...c.score] }; }
|
||||
else c.step = 'smazzata';
|
||||
return true;
|
||||
}
|
||||
c.step = 'play';
|
||||
return true;
|
||||
}
|
||||
// Si va ai 21: chi arriva per primo a fine smazzata; pari sopra i 21, si gioca un'altra smazzata.
|
||||
export const finished = c => Math.max(...c.score) >= TARGET && c.score[0] !== c.score[1];
|
||||
export const finished = c => c.step === 'result';
|
||||
// Dopo il riepilogo (resta finché Andrea non va avanti) si fa la smazzata dopo.
|
||||
export function advance(c) {
|
||||
if (c.step !== 'smazzata') return false;
|
||||
if (finished(c)) { c.step = 'result'; c.result = { winner: c.score[0] > c.score[1] ? 'player' : 'cpu', score: [...c.score] }; }
|
||||
else deal(c);
|
||||
return true;
|
||||
deal(c); return true;
|
||||
}
|
||||
|
||||
// La sola porta d'ingresso degli avversari e del socio: la propria mano e quello che si è visto al tavolo.
|
||||
@@ -151,7 +167,11 @@ export function knowledge(v, full) {
|
||||
const out = DECK_CARDS[v.deck].filter(a => !gone.has(a.id));
|
||||
const voids = SEATS.map(() => new Set()), holder = new Map();
|
||||
if (full) {
|
||||
for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => { if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit); });
|
||||
// Chi non risponde al seme l'ha finito; chi vola anche.
|
||||
for (const t of [...v.tricks, { cards: v.table }]) t.cards.forEach((p, i) => {
|
||||
if (i && p.card.suit !== t.cards[0].card.suit) voids[p.who].add(t.cards[0].card.suit);
|
||||
if (!i && p.signal === 'volo') voids[p.who].add(p.card.suit);
|
||||
});
|
||||
for (const a of v.accuse) if (a.who !== v.seat) for (const card of a.cards) if (!gone.has(card.id)) holder.set(card.id, a.who);
|
||||
}
|
||||
const could = (seat, card) => seat !== v.seat && !voids[seat].has(card.suit) && (!holder.has(card.id) || holder.get(card.id) === seat);
|
||||
@@ -159,7 +179,8 @@ export function knowledge(v, full) {
|
||||
const beatable = (card, seats) => out.some(a => a.suit === card.suit && a.power > card.power && seats.some(s => could(s, a)));
|
||||
return { out, voids, could, beatable, full };
|
||||
}
|
||||
const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power); // il carico più grosso, senza buttare i 3
|
||||
// Il carico più grosso, ma il 3 e il 2 si tengono: sono le carte che fanno le prese dopo.
|
||||
const rich = cards => byMin(cards, a => -a.thirds * 20 + a.power + (a.power >= 8 ? 25 : 0));
|
||||
const cheap = cards => byMin(cards, a => a.thirds * 20 + a.power); // la carta che costa meno
|
||||
function lead(v, k) {
|
||||
const hand = v.hand, foes = [next(v.seat), (v.seat + 3) % 4];
|
||||
@@ -174,7 +195,8 @@ function lead(v, k) {
|
||||
if (sure.length) { const card = rich(sure); return { index: hand.indexOf(card), signal: signalFor(hand, card, k) }; }
|
||||
// Altrimenti dal seme più lungo, la più bassa; il segno dice al socio com'è fatto il seme.
|
||||
const count = suit => hand.filter(a => a.suit === suit).length;
|
||||
const longest = byMin(hand, a => -count(a.suit) * 10 - a.thirds).suit;
|
||||
// A parità di lunghezza, il seme con meno punti da esporre (un Asso scoperto lo prendono gli altri).
|
||||
const longest = byMin(hand, a => -count(a.suit) * 10 + hand.filter(b => b.suit === a.suit).reduce((n, b) => n + b.thirds, 0)).suit;
|
||||
const card = cheap(hand.filter(a => a.suit === longest));
|
||||
return { index: hand.indexOf(card), signal: signalFor(hand, card, k) };
|
||||
}
|
||||
@@ -190,6 +212,12 @@ function follow(v, k, moves) {
|
||||
const cards = moves.map(i => v.hand[i]), top = best(v.table), mate = team(top.who) === team(v.seat);
|
||||
const after = [1, 2, 3].map(n => (v.seat + n) % 4).slice(0, 3 - v.table.length).filter(s => team(s) !== team(v.seat));
|
||||
const pick = card => v.hand.indexOf(card);
|
||||
// Il socio ha bussato: vuole che prenda con la migliore (poi gli ritorno il seme).
|
||||
const knock = v.table[0].who === partner(v.seat) && v.table[0].signal === 'busso' && k.full;
|
||||
if (knock && cards[0].suit === top.card.suit) {
|
||||
const over = cards.filter(a => a.power > top.card.power);
|
||||
if (over.length) return pick(over.reduce((a, b) => b.power > a.power ? b : a));
|
||||
}
|
||||
// Il socio prende di sicuro: gli si dà il carico (anche l'Asso di un altro seme, se il nostro è finito).
|
||||
if (mate && !k.beatable(top.card, after)) return pick(rich(cards));
|
||||
if (cards[0].suit === top.card.suit) {
|
||||
@@ -201,7 +229,7 @@ function follow(v, k, moves) {
|
||||
// «Alla bona» butta una carta a caso fra quelle buone; «Seria» gioca a regola ma non conta; «A fassi male» conta
|
||||
// le carte uscite, i semi finiti, le accuse e risponde al busso del socio. Il socio di Andrea gioca sempre «A fassi male».
|
||||
// Taratura (tools/calibrate_tressette.mjs, 300 partite ai 21 per livello, Andrea e il socio che giocano attenti):
|
||||
// Andrea e il socio vincono 86% contro «Alla bona», 59% contro «Seria», 52% contro «A fassi male» (docs/TRESSETTE.md).
|
||||
// Andrea e il socio vincono 87% contro «Alla bona», 61% contro «Seria», 48% contro «A fassi male» (docs/TRESSETTE.md).
|
||||
export function choose(v, level = 'seria', seed = 1) {
|
||||
const moves = legal(v.hand, v.table);
|
||||
if (level === 'bona') return { index: moves[Math.floor(rng(seed)() * moves.length)], signal: null };
|
||||
@@ -214,7 +242,6 @@ export function update(c, dt) {
|
||||
c.t += dt;
|
||||
if (c.landing && c.t >= 0.18) { c.events.push({ kind: 'card-tap' }); c.landing = false; }
|
||||
if (c.step === 'trick' && c.t >= 1.2) collect(c);
|
||||
else if (c.step === 'smazzata' && c.t >= 7) advance(c);
|
||||
else if (c.step === 'play' && c.current !== 0 && c.t >= 0.75) {
|
||||
c.aiSeed = lcg(c.aiSeed);
|
||||
const seat = c.current, { index, signal } = choose(observation(c, seat), seat === 2 ? 'male' : c.level, c.aiSeed);
|
||||
@@ -224,13 +251,12 @@ export function update(c, dt) {
|
||||
export const NAMES = ['Andrea', 'Il giocatore', 'Il socio', 'L\'altro'];
|
||||
export function status(c, names = NAMES) {
|
||||
if (c.step === 'result') return `${c.result.winner === 'player' ? 'Avete vinto voi' : 'Hanno vinto loro'}, ${c.score[0]} a ${c.score[1]}!`;
|
||||
if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per continuare.`;
|
||||
if (c.step === 'smazzata') return `Smazzata finita: noi ${c.summary.points[0]}, loro ${c.summary.points[1]}. Siamo ${c.score[0]} a ${c.score[1]}. Invio per la prossima.`;
|
||||
if (c.step === 'trick') return `Prende ${names[c.last.winner]}.`;
|
||||
if (c.current !== 0) {
|
||||
const p = c.table.at(-1);
|
||||
return p?.signal ? `${names[p.who]}: «${p.signal[0].toUpperCase() + p.signal.slice(1)}!»` : `${names[c.current]} pensa…`;
|
||||
}
|
||||
// Quel che c'è in tavola, a parole: serve anche a chi non vede il disegno.
|
||||
const table = c.table.map(p => `${names[p.who]}: ${cardName(p.card)}${p.signal ? ` («${p.signal}!»)` : ''}`).join('; ');
|
||||
if (c.current !== 0) return `${table ? table + '. ' : ''}Tocca a ${names[c.current]}…`;
|
||||
if (c.refused) return c.refused;
|
||||
if (c.table.length) return `Rispondi a ${c.table[0].card.suit}, se ce l'hai. Frecce scelgono, Invio gioca.`;
|
||||
if (c.table.length) return `${table}. Rispondi a ${c.table[0].card.suit}, se ce l'hai. Frecce scelgono, Invio gioca.`;
|
||||
return `Esci tu${c.signal ? `, col segno «${c.signal}»` : ''}. B, S, V per bussare, strisciare o volare; Invio gioca.`;
|
||||
}
|
||||
|
||||
+72
-5
@@ -14,6 +14,8 @@ import * as briscola from './engine/briscola.js';
|
||||
import { cardAt } from './engine/briscola-render.js';
|
||||
import * as tressette from './engine/tressette.js';
|
||||
import { cardAt as tressetteAt } from './engine/tressette-render.js';
|
||||
import * as scopa from './engine/scopa.js';
|
||||
import { cardAt as scopaAt, tableAt as scopaTableAt } from './engine/scopa-render.js';
|
||||
import { VERSION, versioned } from './version.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
@@ -403,11 +405,18 @@ canvas.addEventListener('pointerdown', e => {
|
||||
if (!running || e.button > 0) return;
|
||||
// Nel primo piano un tocco sfoglia (o salta la battuta): gli oggetti della scena dietro non si toccano.
|
||||
if (game.closeup) {
|
||||
const deck = CARD_AT[story.closeups[game.closeup.id].type];
|
||||
const type = story.closeups[game.closeup.id].type, deck = CARD_AT[type];
|
||||
if (deck) {
|
||||
if (game.skip()) return;
|
||||
// Tressette: fra una smazzata e l'altra un tocco va avanti.
|
||||
if (tressette.advance(game.closeup)) return;
|
||||
// Tressette e scopa: fra una smazzata e l'altra un tocco va avanti.
|
||||
if (MINI[type].advance?.(game.closeup)) return;
|
||||
// Scopa, più prese possibili: si tocca una carta in tavola; se sta in una presa sola, si prende quella.
|
||||
if (type === 'scopa' && game.closeup.step === 'choose') {
|
||||
const c = game.closeup, card = c.table[scopaTableAt(c, scenePoint(e))];
|
||||
const options = c.choice.options.flatMap((o, k) => o.includes(card) ? [k] : []);
|
||||
if (options.length === 1) scopa.take(c, options[0]); else if (options.length) scopa.select(c, options[0]);
|
||||
return;
|
||||
}
|
||||
press = { card: deck(game.closeup, scenePoint(e)), state: game.closeup, at: deck };
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
return;
|
||||
@@ -463,6 +472,7 @@ canvas.addEventListener('pointerleave', () => { mouse = null; });
|
||||
|
||||
// Comandi dei minigiochi separati dai verbi: carte oppure mira, forza e lancio.
|
||||
const MINI = {
|
||||
scopa: { ...scopa, back: 'Torna all’oratorio', label: 'Scopa: tocca una carta per giocarla. Se le prese sono più d’una, tocca una carta in tavola o usa le frecce, Invio prende. Esc esce.' },
|
||||
tressette: { ...tressette, back: 'Torna all’oratorio', label: 'Tressette: tocca una carta per giocarla. Frecce scelgono, Invio gioca; B, S, V fanno busso, striscio e volo. Esc esce.' },
|
||||
briscola: { ...briscola, back: 'Torna all’oratorio', label: 'Briscola: tocca una carta per giocarla. Frecce o 1, 2, 3 scelgono; Invio gioca. Esc esce.' },
|
||||
bocce: { ...bocce, nudge: (c, dir) => bocce.aim(c, c.aim.u, c.aim.w + dir * 0.3), back: 'Torna al pallaio', label: 'Bocce: tocca il campo o il dettaglio per mirare. Regola la forza e premi Lancia.' },
|
||||
@@ -473,7 +483,14 @@ const mini = () => MINI[story.closeups[game.closeup.id].type];
|
||||
$('bocce-power').addEventListener('input', e => { const c = miniState(); if (c) mini().setPower?.(c, Number(e.target.value) / 100); });
|
||||
$('bocce-throw').addEventListener('click', () => { const c = miniState(); if (c && !game.speech) mini().shoot?.(c); });
|
||||
// Le carte da gioco: dove si tocca la carta sul tavolo, gioco per gioco.
|
||||
const CARD_AT = { briscola: cardAt, tressette: tressetteAt };
|
||||
const CARD_AT = { briscola: cardAt, tressette: tressetteAt, scopa: scopaAt };
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const button = $('scopa-card-' + i);
|
||||
button.addEventListener('click', () => game?.closeupPlay(i));
|
||||
button.addEventListener('focus', () => { if (game?.closeup?.id === 'scopa' && game.closeup.step === 'play') scopa.select(game.closeup, i); });
|
||||
}
|
||||
$('scopa-options').addEventListener('click', e => { const k = e.target.dataset?.option; if (k !== undefined && game?.closeup?.id === 'scopa') scopa.take(game.closeup, Number(k)); });
|
||||
$('scopa-next').addEventListener('click', () => { if (game?.closeup?.id === 'scopa') scopa.advance(game.closeup); });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const button = $('tressette-card-' + i);
|
||||
button.addEventListener('click', () => game?.closeupPlay(i));
|
||||
@@ -575,6 +592,27 @@ window.addEventListener('keydown', e => {
|
||||
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.
|
||||
if (running && game.closeup) {
|
||||
if (story.closeups[game.closeup.id].type === 'scopa') {
|
||||
const inControls = e.target.closest?.('#scopa-controls'), c = game.closeup, key = e.key.toLowerCase();
|
||||
if (e.repeat && ['enter', ' '].includes(key)) { e.preventDefault(); return; }
|
||||
if (inControls && ['BUTTON', 'SUMMARY'].includes(e.target.tagName) && ['enter', ' '].includes(key)) return;
|
||||
if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'enter', ' '].includes(key) || /^[1-9]$/.test(key)) {
|
||||
e.preventDefault();
|
||||
if (e.repeat || game.skip()) return;
|
||||
if (/^[1-9]$/.test(key)) scopa.select(c, Number(key) - 1);
|
||||
else if (key.startsWith('arrow')) scopa.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
|
||||
else if (c.step === 'result') game.closeupClose();
|
||||
else if (c.step === 'choose') scopa.take(c);
|
||||
else if (!scopa.advance(c) && key === 'enter') game.closeupPlay();
|
||||
// Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta.
|
||||
if (inControls && scopa.canPlay(c)) $('scopa-card-' + c.selected).focus();
|
||||
return;
|
||||
}
|
||||
// Esc: nella scelta della presa si torna alla mano, altrimenti si lascia il tavolo.
|
||||
if (e.key === 'Escape') { if (!scopa.cancel(c)) game.closeupClose(); }
|
||||
else if (key === 'm') toggleMute();
|
||||
return;
|
||||
}
|
||||
if (story.closeups[game.closeup.id].type === 'tressette') {
|
||||
const inControls = e.target.closest?.('#tressette-controls'), c = game.closeup;
|
||||
if (e.repeat && ['Enter', ' '].includes(e.key)) { e.preventDefault(); return; }
|
||||
@@ -588,6 +626,8 @@ window.addEventListener('keydown', e => {
|
||||
else if (key.startsWith('arrow')) tressette.nudge(c, ['arrowleft', 'arrowup'].includes(key) ? -1 : 1);
|
||||
else if (c.step === 'result') game.closeupClose();
|
||||
else if (!tressette.advance(c) && key === 'enter') game.closeupPlay();
|
||||
// Il fuoco segue la carta scelta: Invio su un pulsante gioca sempre quella che si vede scelta.
|
||||
if (inControls && tressette.canPlay(c) && !signal) $('tressette-card-' + c.selected).focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') game.closeupClose();
|
||||
@@ -700,11 +740,38 @@ function updateUi() {
|
||||
updateChoices();
|
||||
updateSpeech();
|
||||
const mg = miniState() && mini(), def = mg && story.closeups[c.id];
|
||||
const cards = def?.type === 'briscola' || def?.type === 'tressette';
|
||||
const cards = ['briscola', 'tressette', 'scopa'].includes(def?.type);
|
||||
$('bocce-controls').hidden = !mg || cards;
|
||||
$('bocce-controls').inert = !running;
|
||||
$('briscola-controls').hidden = def?.type !== 'briscola';
|
||||
$('briscola-controls').inert = !running;
|
||||
$('scopa-controls').hidden = def?.type !== 'scopa';
|
||||
$('scopa-controls').inert = !running;
|
||||
if (def?.type === 'scopa') {
|
||||
$('panel').hidden = $('choices').hidden = true;
|
||||
const line = scopa.status(c, def.names);
|
||||
if ($('scopa-status').textContent !== line) $('scopa-status').textContent = line;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const button = $('scopa-card-' + i), card = c.hands.player[i];
|
||||
const label = card ? `${i + 1}. ${scopa.cardName(card)}` : `${i + 1}. —`;
|
||||
if (button.textContent !== label) button.textContent = label;
|
||||
button.disabled = !scopa.canPlay(c) || !card || !!game.speech;
|
||||
button.setAttribute('aria-pressed', String(c.selected === i && !!card && c.step === 'play'));
|
||||
}
|
||||
// Le prese fra cui scegliere, una per pulsante.
|
||||
const options = c.step === 'choose' ? c.choice.options.map(o => o.map(scopa.cardName).join(' + ')) : [];
|
||||
const box = $('scopa-options');
|
||||
if (box.dataset.list !== options.join('|')) {
|
||||
box.dataset.list = options.join('|');
|
||||
box.replaceChildren(...options.map((o, k) => {
|
||||
const b = Object.assign(document.createElement('button'), { type: 'button', textContent: `Prendi ${o}` });
|
||||
b.dataset.option = k; return b;
|
||||
}));
|
||||
}
|
||||
box.hidden = !options.length;
|
||||
[...box.children].forEach((b, k) => b.setAttribute('aria-pressed', String(c.choice?.selected === k)));
|
||||
$('scopa-next').hidden = c.step !== 'smazzata';
|
||||
}
|
||||
$('tressette-controls').hidden = def?.type !== 'tressette';
|
||||
$('tressette-controls').inert = !running;
|
||||
if (def?.type === 'tressette') {
|
||||
|
||||
@@ -124,6 +124,14 @@ export const story = {
|
||||
draw: [],
|
||||
quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
|
||||
},
|
||||
// La scopa ai 11 (idea di Andrea): carte, mattoni (o denari), settebello, primiera e scope.
|
||||
scopa: {
|
||||
type: 'scopa', names: { player: 'Andrea', cpu: 'Il giocatore' }, decks: { napoletane: 'carte_napoletane' },
|
||||
win: [{ set: 'scopa_vinta' }, { say: 'Settebello, primiera e pure le scope. Oggi l\'hai vista bene.', who: 'giocatore_carte', pose: 'talk' }],
|
||||
lose: [{ say: 'Undici! Il settebello lo sento arrivare da lontano.', who: 'giocatore_carte', pose: 'laugh' }],
|
||||
draw: [],
|
||||
quit: [{ say: 'Vai, ti tengo il posto. Le carte mica scappano.', who: 'giocatore_carte' }],
|
||||
},
|
||||
// Le bocce al pallaio contro Mariano, il campione (idea di Andrea, 25 ottobre): solo per gloria.
|
||||
bocce: {
|
||||
type: 'bocce',
|
||||
@@ -2154,6 +2162,7 @@ export const story = {
|
||||
start: { options: [
|
||||
{ text: 'Una briscola?', when: '!briscola_giocata', next: 'mazzo', then: [{ say: 'Gnamo. In due, senza soci e senza scuse.', who: 'giocatore_carte', pose: 'shuffle' }] },
|
||||
{ text: 'La rivincita?', when: 'briscola_giocata', next: 'mazzo', then: [{ say: 'Il tavolo è qui. Il coraggio ce lo metti te.', who: 'giocatore_carte', pose: 'laugh' }] },
|
||||
{ text: 'Una scopa?', next: 'mazzo_scopa', then: [{ say: 'Ai undici. E occhio al settebello: quello è sempre mio.', who: 'giocatore_carte', pose: 'shuffle' }] },
|
||||
{ text: 'Un tressette ai 21?', next: 'mazzo_tressette', then: [{ say: 'Ci vuole quattro: chiamo quei due che giocano qui dietro. Uno ti fa da socio.', who: 'giocatore_carte', pose: 'talk' }] },
|
||||
{ text: 'Ora cerco il palio. A dopo.', next: 'end' },
|
||||
] },
|
||||
@@ -2169,6 +2178,18 @@ export const story = {
|
||||
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'briscola', level: 'male' }] },
|
||||
{ text: 'Ci ripenso.', next: 'end' },
|
||||
] },
|
||||
// La scopa: stesse scelte (e stesse battute) della briscola.
|
||||
mazzo_scopa: { options: [
|
||||
{ text: 'Con le toscane.', next: 'livello_scopa', then: [{ unset: 'carte_napoletane' }, { say: 'Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?', who: 'giocatore_carte' }] },
|
||||
{ text: 'Con le napoletane.', next: 'livello_scopa', then: [{ set: 'carte_napoletane' }, { say: 'Le napoletane? Le tengo per i villeggianti. Come la vuoi?', who: 'giocatore_carte', pose: 'shuffle' }] },
|
||||
{ text: 'Ci ripenso.', next: 'end' },
|
||||
] },
|
||||
livello_scopa: { options: [
|
||||
{ text: 'Alla bona.', next: 'end', then: [{ say: 'Alla bona. I carichi li regalo, oggi.', who: 'giocatore_carte' }, { closeup: 'scopa', level: 'bona' }] },
|
||||
{ text: 'Seria.', next: 'end', then: [{ say: 'Allora posa il gomito e guarda le carte.', who: 'giocatore_carte' }, { closeup: 'scopa', level: 'seria' }] },
|
||||
{ text: 'A fassi male.', next: 'end', then: [{ say: 'Io le conto tutte. Anche quelle che butti via per sbaglio.', who: 'giocatore_carte', pose: 'shuffle' }, { closeup: 'scopa', level: 'male' }] },
|
||||
{ text: 'Ci ripenso.', next: 'end' },
|
||||
] },
|
||||
// Il tressette: stesse scelte (e stesse battute) della briscola, poi il tavolo in quattro.
|
||||
mazzo_tressette: { options: [
|
||||
{ text: 'Con le toscane.', next: 'livello_tressette', then: [{ unset: 'carte_napoletane' }, { say: 'Brao. Gobbo, donna e regio, come Dio comanda. Come la vuoi?', who: 'giocatore_carte' }] },
|
||||
|
||||
Reference in New Issue
Block a user