fertig-classic-games/public/src/games/kiitos/KiitosGame.js

817 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { api } from '../../services/api.js';
import { Button } from '../../ui/Button.js';
import { TextInput } from '../../ui/TextInput.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { KiitosLogic } from './KiitosLogic.js';
import { THEME, TOTAL_ROUNDS } from './KiitosData.js';
import { chooseAIMove, thinkDelay } from './KiitosAI.js';
// ── Layout ───────────────────────────────────────────────────────────────────
const D = { bg: 0, table: 1, center: 10, centerTxt: 12, panel: 16, hand: 20, ui: 26, bubble: 30, overlay: 40, overlayUI: 42 };
const CENTER_Y = 430;
const CARD_W = 70, CARD_H = 96, CARD_GAP = 10;
const HAND_Y = 902;
const HAND_W = 86, HAND_H = 120, HAND_GAP = 14;
// Positions at which inserting one letter turns sequence `a` into `b`
// (|b| === |a| + 1). Used to map an AI hint's prefix back onto a drop slot.
function insertionsFromTo(a, b) {
const res = [];
for (let i = 0; i < b.length; i++) {
if (b.slice(0, i) + b.slice(i + 1) === a) res.push({ index: i, letter: b[i] });
}
return res;
}
export default class KiitosGame extends Phaser.Scene {
constructor() { super('KiitosGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
const opps = (data.opponents ?? []).slice(0, 3);
this.seats = [
{ id: 'player', name: 'You', isHuman: true, skill: 5, spriteIndex: -1 },
...opps.map((o) => ({
id: o.id, name: o.name ?? 'Rival', isHuman: false,
skill: o.skill ?? 3, spriteIndex: o.spriteIndex ?? 0,
})),
];
this.busy = false;
}
create() {
this.drawTable();
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
this.add.text(46, 38, 'Kiitos', {
fontFamily: 'YummyCupcakes', fontSize: '60px', color: THEME.accentHex,
}).setDepth(D.ui);
this.add.text(48, 104, 'say "thank you" when a rival finishes your word', {
fontFamily: 'Righteous', fontSize: '20px', color: '#f3e3c2',
}).setDepth(D.ui).setAlpha(0.7);
new Button(this, GAME_WIDTH - 96, GAME_HEIGHT - 40, 'Leave',
() => this.scene.start('GameMenu'), { variant: 'ghost', width: 150, height: 46, fontSize: 20 })
.setDepth(D.ui);
this.logic = new KiitosLogic(this.seats);
this.roundObjs = [];
this.handCards = [];
this.slotObjs = [];
this.registerDragHandlers();
this.events.once('shutdown', () => this.cleanup());
this.showRoundIntro();
}
cleanup() {
this.input?.removeAllListeners();
this.clearHumanControls();
}
// ── Table backdrop ──────────────────────────────────────────────────────────
drawTable() {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, THEME.tableTop).setDepth(D.bg);
const g = this.add.graphics().setDepth(D.table);
// subtle grain stripes
g.lineStyle(40, THEME.tableGrain, 0.12);
for (let x = -100; x < GAME_WIDTH + 100; x += 120) g.lineBetween(x, 0, x + 220, GAME_HEIGHT);
// teal runner down the middle
g.fillStyle(THEME.cloth, 1);
g.fillRoundedRect(GAME_WIDTH / 2 - 560, 150, 1120, 760, 28);
g.lineStyle(6, THEME.clothEdge, 1);
g.strokeRoundedRect(GAME_WIDTH / 2 - 560, 150, 1120, 760, 28);
g.fillStyle(THEME.clothEdge, 0.5);
g.fillRoundedRect(GAME_WIDTH / 2 - 540, 168, 1080, 6, 3);
}
// ── Round intro ─────────────────────────────────────────────────────────────
showRoundIntro() {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const cfg = this.logic.config;
const first = this.logic.round === 1;
const objs = [];
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setDepth(D.overlay);
objs.push(dim);
const panelH = 560;
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(THEME.creamFill, 1);
panel.fillRoundedRect(cx - 440, cy - panelH / 2, 880, panelH, 24);
panel.lineStyle(4, THEME.kraftEdge, 1);
panel.strokeRoundedRect(cx - 440, cy - panelH / 2, 880, panelH, 24);
panel.postFX.addShadow(0, 8, 0.02, 1.2, 0x000000, 14, 0.6);
objs.push(panel);
const top = cy - panelH / 2;
objs.push(this.add.text(cx, top + 80, 'Kiitos', {
fontFamily: 'YummyCupcakes', fontSize: '92px', color: THEME.accentHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
objs.push(this.add.text(cx, top + 160, `Round ${this.logic.round} of ${TOTAL_ROUNDS}`, {
fontFamily: 'YummyCupcakes', fontSize: '44px', color: THEME.inkHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
objs.push(this.add.text(cx, top + 230, cfg.rules, {
fontFamily: 'Righteous', fontSize: '26px', color: '#6a4a22',
align: 'center', wordWrap: { width: 720 },
}).setOrigin(0.5).setDepth(D.overlayUI));
if (!first) {
const standings = this.logic.standings();
standings.forEach((p, i) => {
objs.push(this.add.text(cx, top + 310 + i * 40, `${p.name}: ${p.total}`, {
fontFamily: 'YummyCupcakes', fontSize: '32px',
color: p.isHuman ? THEME.accentHex : THEME.inkHex,
}).setOrigin(0.5).setDepth(D.overlayUI));
});
}
const begin = new Button(this, cx, top + panelH - 76, first ? 'Start Playing' : 'Begin Round',
() => { objs.forEach((o) => o.destroy()); this.beginRound(); },
{ width: 320, height: 70, fontSize: 30, bg: THEME.accent });
begin.setDepth(D.overlayUI);
objs.push(begin);
}
beginRound() {
playSound(this, SFX.CARD_SHUFFLE);
this.buildBoard();
this.renderState();
this.time.delayedCall(400, () => this.nextTurn());
}
// ── Persistent board for a round ────────────────────────────────────────────
buildBoard() {
this.destroyBoard();
this.roundObjs = [];
// round/rules banner
const cfg = this.logic.config;
this.bannerTxt = this.add.text(GAME_WIDTH / 2, 196, '', {
fontFamily: 'Righteous', fontSize: '24px', color: '#fbf3df',
}).setOrigin(0.5).setDepth(D.ui);
this.bannerTxt.setText(`Round ${this.logic.round} · ${cfg.rules}`);
this.roundObjs.push(this.bannerTxt);
// owner / target line
this.ownerTxt = this.add.text(GAME_WIDTH / 2, CENTER_Y - 96, '', {
fontFamily: 'YummyCupcakes', fontSize: '34px', color: '#fbf3df',
}).setOrigin(0.5).setDepth(D.centerTxt);
this.roundObjs.push(this.ownerTxt);
this.centerLayer = this.add.container(0, 0).setDepth(D.center);
this.roundObjs.push(this.centerLayer);
// turn prompt (under the centre)
this.promptTxt = this.add.text(GAME_WIDTH / 2, CENTER_Y + 110, '', {
fontFamily: 'Righteous', fontSize: '28px', color: '#fff', align: 'center',
wordWrap: { width: 1000 },
}).setOrigin(0.5).setDepth(D.centerTxt);
this.roundObjs.push(this.promptTxt);
// seat panels (AIs across the top arc, human bottom-left summary)
this.seatPanels = {};
const ais = this.seats.filter((s) => !s.isHuman);
ais.forEach((s, i) => {
const x = 360 + i * 420;
this.seatPanels[s.id] = this.buildSeatPanel(s, x, 250);
});
this.seatPanels['player'] = this.buildSeatPanel(this.seats[0], 200, 760);
// Hand cards (top-level so drag coordinates map straight to world space) and
// drop slots are tracked in their own arrays, rebuilt each render.
this.clearHandCards();
this.clearSlots();
}
buildSeatPanel(seat, x, y) {
const W = 300, H = 132;
const g = this.add.graphics().setDepth(D.panel);
g.fillStyle(THEME.creamFill, 0.94);
g.fillRoundedRect(x - W / 2, y - H / 2, W, H, 16);
g.lineStyle(3, THEME.kraftEdge, 1);
g.strokeRoundedRect(x - W / 2, y - H / 2, W, H, 16);
this.roundObjs.push(g);
const glow = this.add.graphics().setDepth(D.panel - 1);
this.roundObjs.push(glow);
let avatar = null;
if (seat.spriteIndex >= 0 && this.textures.exists('opponents')) {
avatar = this.add.sprite(x - W / 2 + 54, y - 18, 'opponents', seat.spriteIndex).setDepth(D.panel + 1);
const sc = 84 / avatar.height; avatar.setScale(sc);
const m = this.make.graphics(); m.fillCircle(x - W / 2 + 54, y - 18, 40);
avatar.setMask(m.createGeometryMask());
this.roundObjs.push(avatar);
const ring = this.add.graphics().setDepth(D.panel + 1);
ring.lineStyle(3, THEME.accent, 1); ring.strokeCircle(x - W / 2 + 54, y - 18, 40);
this.roundObjs.push(ring);
} else {
const c = this.add.graphics().setDepth(D.panel + 1);
c.fillStyle(THEME.accent, 1); c.fillCircle(x - W / 2 + 54, y - 18, 40);
this.roundObjs.push(c);
this.roundObjs.push(this.add.text(x - W / 2 + 54, y - 18, '☕', { fontSize: '40px' })
.setOrigin(0.5).setDepth(D.panel + 2));
}
this.roundObjs.push(this.add.text(x - W / 2 + 100, y - 44, seat.name, {
fontFamily: 'YummyCupcakes', fontSize: '26px', color: THEME.inkHex,
}).setOrigin(0, 0.5).setDepth(D.panel + 1));
const hand = this.add.text(x - W / 2 + 100, y - 12, '', {
fontFamily: 'Righteous', fontSize: '18px', color: '#6a4a22',
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
const pos = this.add.text(x - W / 2 + 100, y + 18, '', {
fontFamily: 'Righteous', fontSize: '18px', color: THEME.positiveHex,
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
const neg = this.add.text(x - W / 2 + 100, y + 44, '', {
fontFamily: 'Righteous', fontSize: '18px', color: THEME.negativeHex,
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
this.roundObjs.push(hand, pos, neg);
return { x, y, W, H, glow, hand, pos, neg };
}
destroyBoard() {
(this.roundObjs ?? []).forEach((o) => o.destroy());
this.roundObjs = [];
this.clearHandCards();
this.clearSlots();
this.clearHumanControls();
}
// ── Rendering current state ─────────────────────────────────────────────────
renderState() {
const c = this.logic.center;
const cfg = this.logic.config;
// owner / target headline
if (c.targetWord) {
const owner = this.logic.players[c.ownerIdx];
this.ownerTxt.setText(`${owner.name === 'You' ? 'You are' : owner.name + ' is'} spelling "${c.targetWord}"`);
} else {
const cur = this.logic.current();
this.ownerTxt.setText(`${cur.name === 'You' ? 'You' : cur.name} to start a word`);
}
// centre cards. In "deviate view" (the human is choosing where to drop) we show
// only the physical cards on the table; otherwise we show the announced target
// with placed letters solid and the rest as ghosts.
this.centerLayer.removeAll(true);
const word = this._deviateView ? c.built : (c.targetWord ?? c.built);
const len = Math.max(word.length, 1);
const totW = len * CARD_W + (len - 1) * CARD_GAP;
const startX = GAME_WIDTH / 2 - totW / 2 + CARD_W / 2;
this._rowStartX = startX;
this._rowStep = CARD_W + CARD_GAP;
let x = startX;
for (let i = 0; i < len; i++) {
const placed = i < c.built.length;
const letter = word[i] ?? '';
const card = this.makeCard(x, CENTER_Y, CARD_W, CARD_H, letter, {
ghost: !placed, next: !this._deviateView && !placed && i === c.built.length,
});
this.centerLayer.add(card);
x += CARD_W + CARD_GAP;
}
// seat panels
for (const seat of this.seats) {
const p = this.logic.players.find((q) => q.id === seat.id);
const panel = this.seatPanels[seat.id];
panel.hand.setText(`hand: ${p.hand.length}`);
panel.pos.setText(`banked: ${p.positive.length}`);
panel.neg.setText(`penalty: ${p.negative.length}`);
panel.glow.clear();
if (this.logic.turnIdx === this.seats.indexOf(seat) && !this.logic.roundOver) {
panel.glow.fillStyle(THEME.accent, 0.35);
panel.glow.fillRoundedRect(panel.x - panel.W / 2 - 8, panel.y - panel.H / 2 - 8, panel.W + 16, panel.H + 16, 20);
}
}
this.renderHand();
void cfg;
}
renderHand() {
this.clearHandCards();
const human = this.logic.players[0];
const hand = human.hand;
const n = hand.length;
const totW = n * HAND_W + (n - 1) * HAND_GAP;
let x = GAME_WIDTH / 2 - totW / 2 + HAND_W / 2;
// Which cards can be dragged this turn: only the forced letter when forced,
// otherwise every card. (Disabled entirely once a card is in flight/staged.)
const myTurn = this._humanActive && this.logic.turnIdx === 0 && !this._tentative;
const forced = this._humanForced;
let forcedTagged = false;
for (let i = 0; i < n; i++) {
const letter = hand[i];
const isForcedCard = forced && letter === forced && !forcedTagged;
const draggable = myTurn && (forced ? isForcedCard : true);
const card = this.makeCard(x, HAND_Y, HAND_W, HAND_H, letter,
{ hand: true, highlight: isForcedCard });
card.setData('letter', letter);
card.setData('homeX', x);
card.setData('homeY', HAND_Y);
card.setDepth(D.hand);
if (draggable) {
if (isForcedCard) forcedTagged = true;
card.setSize(HAND_W, HAND_H);
// A Container's displayOrigin is width*0.5, so the auto Rectangle(0,0,w,h)
// hit area lands centred. A manual (-w/2,-h/2,w,h) rect double-shifts it
// up-left — don't (see ScrabbleGame.renderRack).
card.setInteractive({ useHandCursor: true });
card.input.cursor = 'grab';
this.input.setDraggable(card);
card.setData('draggable', true);
}
this.handCards.push(card);
x += HAND_W + HAND_GAP;
}
}
clearHandCards() {
(this.handCards ?? []).forEach((c) => c.destroy());
this.handCards = [];
}
// Build a kraft letter card as a container (bg graphics at index 0 + text).
makeCard(x, y, w, h, letter, opts = {}) {
const cont = this.add.container(x, y);
const g = this.add.graphics();
const s = { w2: w / 2, h2: h / 2 };
if (opts.ghost) {
g.fillStyle(0x000000, 0.18);
g.fillRoundedRect(-s.w2, -s.h2, w, h, 10);
g.lineStyle(2, THEME.kraftEdge, 0.7);
g.strokeRoundedRect(-s.w2, -s.h2, w, h, 10);
} else {
g.fillStyle(THEME.kraftEdge, 1);
g.fillRoundedRect(-s.w2 + 2, -s.h2 + 4, w, h, 10);
g.fillStyle(opts.highlight ? THEME.kraftTop : THEME.kraft, 1);
g.fillRoundedRect(-s.w2, -s.h2, w, h, 10);
g.fillStyle(THEME.kraftTop, 0.6);
g.fillRoundedRect(-s.w2 + 6, -s.h2 + 6, w - 12, h * 0.34, 8);
g.lineStyle(opts.highlight ? 4 : 2, opts.highlight ? THEME.accent : THEME.kraftEdge, 1);
g.strokeRoundedRect(-s.w2, -s.h2, w, h, 10);
}
if (opts.next) {
g.lineStyle(3, THEME.accent, 0.9);
g.strokeRoundedRect(-s.w2, -s.h2, w, h, 10);
}
cont.add(g);
const txt = this.add.text(0, 0, letter, {
fontFamily: 'Righteous', fontSize: `${Math.round(h * 0.46)}px`,
color: opts.ghost ? '#8a6a3a' : THEME.inkHex,
}).setOrigin(0.5);
if (opts.ghost) txt.setAlpha(0.5);
cont.add(txt);
return cont;
}
// ── Turn loop ───────────────────────────────────────────────────────────────
nextTurn() {
if (this.logic.roundOver) { this.endRound(); return; }
this._humanActive = false;
this._humanForced = null;
this._deviateView = false;
this._tentative = null;
this.renderState();
const cur = this.logic.current();
if (cur.isHuman) this.humanTurn();
else this.time.delayedCall(thinkDelay(cur.skill), () => this.aiTurn());
}
async aiTurn() {
if (this.logic.roundOver) { this.endRound(); return; }
const cfg = this.logic.config;
const cur = this.logic.current();
this.promptTxt.setText(`${cur.name} is thinking…`);
const move = await chooseAIMove({
hand: cur.hand, built: this.logic.center.built, targetWord: this.logic.center.targetWord,
minLen: cfg.minLen, skill: cur.skill,
allowPrepend: cfg.allowPrepend, superKiitos: cfg.superKiitos,
});
this.applyMove(move, this.logic.turnIdx);
}
applyMove(move, playerIdx) {
let evt;
if (move.type === 'play') {
evt = this.logic.applyForced(playerIdx);
playSound(this, SFX.CARD_PLACE);
} else if (move.type === 'deviate') {
evt = this.logic.applyDeviate(playerIdx, move.letter, move.prefix ?? move.newBuilt, move.word);
playSound(this, SFX.CARD_PLACE);
} else {
evt = this.logic.applyPass(playerIdx);
if (evt.resolved) {
const owner = this.logic.players[evt.resolved.ownerIdx];
this.flashCenter(`${owner.name === 'You' ? 'Your' : owner.name + "'s"} word fizzled — ${evt.resolved.cards} cards to penalty`, THEME.negativeHex);
playSound(this, SFX.CARD_SHUFFLE);
}
}
this.renderState();
if (evt?.completed) {
const owner = this.logic.players[evt.ownerIdx];
this.kiitosBubble(owner, evt.word);
}
if (evt?.roundOver) { this.time.delayedCall(700, () => this.endRound()); return; }
this.time.delayedCall(evt?.completed ? 1100 : 360, () => this.nextTurn());
}
// ── Human turn (drag & drop) ──────────────────────────────────────────────────
humanTurn() {
this.clearHumanControls();
this._humanActive = true;
this._tentative = null;
const forced = this.logic.forcedLetter(0);
this._humanForced = forced;
this._deviateView = !forced; // deviation shows the bare table + drop slots
this.renderState(); // draws the centre + (re)builds draggable hand
this.buildSlots();
this.buildTurnButtons();
const built = this.logic.center.built;
if (forced) {
const owner = this.logic.players[this.logic.center.ownerIdx];
const tag = owner.id === 'player' ? 'your own word' : `${owner.name}'s word`;
this.promptTxt.setText(`You must play "${forced}" — drag it onto the slot to finish ${tag} ("${this.logic.center.targetWord}").`);
} else {
this.promptTxt.setText(built
? 'Drag a letter onto a slot to build your word, then name it — or pass.'
: 'Drag a letter onto the table to start a word, then name it — or pass.');
}
}
buildTurnButtons() {
this.feedbackTxt = this.add.text(GAME_WIDTH / 2, CENTER_Y + 260, '', {
fontFamily: 'Righteous', fontSize: '24px', color: THEME.negativeHex,
}).setOrigin(0.5).setDepth(D.ui);
if (this._humanForced) return; // forced play: you must drag the card, no buttons
this.humanBtns = [
new Button(this, GAME_WIDTH / 2 - 130, CENTER_Y + 200, 'Pass', () => this.humanPass(),
{ variant: 'ghost', width: 180, height: 54, fontSize: 22 }).setDepth(D.ui),
new Button(this, GAME_WIDTH / 2 + 130, CENTER_Y + 200, 'Hint', () => this.humanHint(),
{ variant: 'ghost', width: 180, height: 54, fontSize: 22 }).setDepth(D.ui),
];
}
// ── Drag plumbing (registered once) ──────────────────────────────────────────
registerDragHandlers() {
this.input.on('dragstart', (p, obj) => {
if (!obj.active || !obj.getData?.('draggable')) return;
obj.setData('dragging', true);
obj.setDepth(D.bubble);
if (obj.input) obj.input.cursor = 'grabbing';
this.highlightSlots(true);
});
this.input.on('drag', (p, obj, dx, dy) => {
if (!obj.active || !obj.getData?.('dragging')) return;
obj.x = dx; obj.y = dy;
});
this.input.on('drop', (p, obj, zone) => {
if (!obj.active || !obj.getData?.('dragging')) return;
obj.setData('dropped', true);
this.onCardDropped(obj, zone);
});
this.input.on('dragend', (p, obj) => {
if (!obj.active || !obj.getData?.('dragging')) return;
obj.setData('dragging', false);
if (obj.input) obj.input.cursor = 'grab';
this.highlightSlots(false);
if (!obj.getData('dropped')) this.snapBack(obj);
obj.setData('dropped', false);
});
}
onCardDropped(card, zone) {
const letter = card.getData('letter');
const insertIndex = zone.getData('insertIndex');
const built = this.logic.center.built;
if (this._humanForced) {
if (letter !== this._humanForced || insertIndex !== built.length) { this.snapBack(card); return; }
const slotX = zone.getData('mx'); // capture before clearSlots() destroys the zone
this._humanActive = false;
this.lockDrag();
this.clearSlots();
this.tweenCardToSlot(card, slotX);
this.time.delayedCall(170, () => this.applyMove({ type: 'play', letter, forced: true }, 0));
return;
}
const newBuilt = built.slice(0, insertIndex) + letter + built.slice(insertIndex);
this.stageTentative(card, zone, letter, insertIndex, newBuilt);
}
// Freeze the rest of the hand while one card is in play / being named.
lockDrag() {
for (const c of (this.handCards ?? [])) {
if (c.input) this.input.setDraggable(c, false);
c.setData('draggable', false);
}
}
tweenCardToSlot(card, x) {
if (x == null) x = card.x; // defensive: never feed the tween an undefined target
card.setDepth(D.centerTxt);
this.tweens.add({
targets: card, x, y: CENTER_Y, scaleX: CARD_W / HAND_W, scaleY: CARD_H / HAND_H,
duration: 170, ease: 'Quad.easeOut',
});
playSound(this, SFX.CARD_PLACE);
}
snapBack(card) {
card.setDepth(D.hand);
this.tweens.add({
targets: card, x: card.getData('homeX'), y: card.getData('homeY'),
scaleX: 1, scaleY: 1, duration: 180, ease: 'Quad.easeOut',
});
}
stageTentative(card, zone, letter, insertIndex, newBuilt) {
const slotX = zone.getData('mx'); // capture before clearSlots() destroys the zone
this._tentative = { card, letter, insertIndex, newBuilt };
this._humanActive = false;
this.lockDrag();
this.clearSlots();
this.tweenCardToSlot(card, slotX);
this.promptNameWord(newBuilt);
}
promptNameWord(newBuilt) {
(this.humanBtns ?? []).forEach((b) => b.destroy());
this.promptTxt.setText(`Name your word — it must start with "${newBuilt}".`);
const cy = CENTER_Y + 200;
this.humanInput = new TextInput(this, GAME_WIDTH / 2 - 130, cy, {
width: 360, height: 56, value: newBuilt.toLowerCase(),
placeholder: `word starting "${newBuilt}"…`, maxLength: 12, autocomplete: 'off',
});
this.humanInput.focus();
this.humanInput.on('keydown', (e) => { if (e.key === 'Enter') this.confirmTentativeWord(); });
this.feedbackTxt?.destroy();
this.feedbackTxt = this.add.text(GAME_WIDTH / 2, cy + 60, '', {
fontFamily: 'Righteous', fontSize: '24px', color: THEME.negativeHex,
}).setOrigin(0.5).setDepth(D.ui);
this.humanBtns = [
new Button(this, GAME_WIDTH / 2 + 130, cy, 'Announce', () => this.confirmTentativeWord(),
{ width: 200, height: 56, fontSize: 24, bg: THEME.accent }).setDepth(D.ui),
new Button(this, GAME_WIDTH / 2 - 360, cy, 'Cancel', () => this.cancelTentative(),
{ variant: 'ghost', width: 150, height: 56, fontSize: 22 }).setDepth(D.ui),
];
}
async confirmTentativeWord() {
if (!this._tentative || !this.humanInput) return;
const cfg = this.logic.config;
const { letter, newBuilt } = this._tentative;
const W = String(this.humanInput.value).toUpperCase().replace(/[^A-Z]/g, '');
if (W.length < cfg.minLen) { this.setFeedback(`Words need ${cfg.minLen}+ letters.`); return; }
if (!W.startsWith(newBuilt)) { this.setFeedback(`Your word must start with "${newBuilt}".`); return; }
let ok = false;
try {
const r = await api.post('/words/kiitos/validate', { word: W, minLen: cfg.minLen, prefix: newBuilt });
ok = !!r.valid;
} catch { this.setFeedback('Could not reach the dictionary.'); return; }
if (!ok) { this.setFeedback(`"${W}" isn't in the word list.`); return; }
this._tentative = null;
this.clearHumanControls();
this.applyMove({ type: 'deviate', letter, prefix: newBuilt, word: W }, 0);
}
cancelTentative() {
if (!this._tentative) return;
this._tentative = null;
this.humanTurn(); // rebuilds the hand at home, slots, and buttons
}
async humanHint() {
if (this._tentative || this._humanForced) return;
const cfg = this.logic.config;
const human = this.logic.players[0];
this.setFeedback('thinking…', '#6a4a22');
const move = await chooseAIMove({
hand: human.hand, built: this.logic.center.built, targetWord: this.logic.center.targetWord,
minLen: cfg.minLen, skill: 5, allowPrepend: cfg.allowPrepend, superKiitos: cfg.superKiitos,
});
if (move.type !== 'deviate') { this.setFeedback('No word found — you may need to pass.', '#6a4a22'); return; }
const built = this.logic.center.built;
const spots = insertionsFromTo(built, move.prefix);
const card = this.handCards.find((c) => c.getData('letter') === move.letter && c.getData('draggable'));
if (!spots.length || !card) { this.setFeedback(`Try "${move.word}".`, THEME.positiveHex); return; }
const insertIndex = spots[0].index;
const x = this.slotXFor(insertIndex);
const fakeZone = { getData: (k) => (k === 'insertIndex' ? insertIndex : k === 'mx' ? x : undefined) };
this.stageTentative(card, fakeZone, move.letter, insertIndex, move.prefix);
if (this.humanInput) this.humanInput.value = move.word.toLowerCase();
this.setFeedback(`Suggested: ${move.word}`, THEME.positiveHex);
}
humanPass() {
if (this._tentative) return;
this._humanActive = false;
this.clearHumanControls();
this.applyMove({ type: 'pass' }, 0);
}
// ── Drop slots ────────────────────────────────────────────────────────────────
slotXFor(insertIndex) {
const built = this.logic.center.built;
if (this._humanForced) return this._rowStartX + built.length * this._rowStep;
if (!built.length) return GAME_WIDTH / 2;
return this._rowStartX + (insertIndex - 0.5) * this._rowStep;
}
buildSlots() {
this.clearSlots();
if (!this._humanActive || this.logic.turnIdx !== 0) return;
const built = this.logic.center.built;
const cfg = this.logic.config;
const indices = [];
if (this._humanForced) indices.push(built.length);
else if (!built.length) indices.push(0);
else {
indices.push(built.length); // append at the end
if (cfg.allowPrepend) indices.push(0); // or the front
if (cfg.superKiitos) for (let k = 1; k < built.length; k++) indices.push(k); // anywhere
}
for (const k of [...new Set(indices)].sort((a, b) => a - b)) {
const x = this.slotXFor(k);
const zw = CARD_W + 22, zh = CARD_H + 110;
const zone = this.add.zone(x, CENTER_Y, zw, zh).setRectangleDropZone(zw, zh).setDepth(D.center);
zone.setData('insertIndex', k);
zone.setData('mx', x);
const marker = this.add.graphics().setDepth(D.centerTxt);
this.drawSlotMarker(marker, x, false);
this.slotObjs.push(zone, marker);
this.slotMarkers.push({ g: marker, x });
}
}
drawSlotMarker(g, x, hot) {
g.clear();
const w = CARD_W + 8, h = CARD_H + 8;
g.lineStyle(hot ? 5 : 3, hot ? THEME.accent : THEME.kraftEdge, hot ? 1 : 0.65);
g.strokeRoundedRect(x - w / 2, CENTER_Y - h / 2, w, h, 12);
g.fillStyle(hot ? THEME.accent : THEME.kraftEdge, hot ? 1 : 0.7);
const ty = CENTER_Y - h / 2 - 26;
g.fillTriangle(x - 12, ty, x + 12, ty, x, ty + 15);
}
highlightSlots(on) {
for (const m of (this.slotMarkers ?? [])) this.drawSlotMarker(m.g, m.x, on);
}
clearSlots() {
(this.slotObjs ?? []).forEach((o) => o.destroy());
this.slotObjs = [];
this.slotMarkers = [];
}
setFeedback(msg, colorHex = THEME.negativeHex) {
if (!this.feedbackTxt) return;
this.feedbackTxt.setText(msg).setColor(colorHex).setAlpha(1).setScale(1.1);
this.tweens.add({ targets: this.feedbackTxt, scale: 1, duration: 140, ease: 'Back.easeOut' });
}
clearHumanControls() {
this.humanInput?.destroy(); this.humanInput = null;
this.feedbackTxt?.destroy(); this.feedbackTxt = null;
(this.humanBtns ?? []).forEach((b) => b.destroy());
this.humanBtns = null;
this.clearSlots();
}
// ── Effects ─────────────────────────────────────────────────────────────────
kiitosBubble(owner, word) {
playSound(this, SFX.VICTORY_SHORT);
const cx = GAME_WIDTH / 2, cy = CENTER_Y;
const cont = this.add.container(cx, cy).setDepth(D.bubble);
const g = this.add.graphics();
g.fillStyle(0xffffff, 0.97); g.fillRoundedRect(-220, -70, 440, 140, 22);
g.lineStyle(4, THEME.accent, 1); g.strokeRoundedRect(-220, -70, 440, 140, 22);
cont.add(g);
cont.add(this.add.text(0, -26, 'Kiitos!', {
fontFamily: 'YummyCupcakes', fontSize: '52px', color: THEME.accentHex,
}).setOrigin(0.5));
cont.add(this.add.text(0, 28, `${owner.name === 'You' ? 'You' : owner.name} banked "${word}"`, {
fontFamily: 'Righteous', fontSize: '24px', color: THEME.inkHex,
}).setOrigin(0.5));
cont.setScale(0.2);
this.tweens.add({ targets: cont, scale: 1, duration: 280, ease: 'Back.easeOut' });
this.tweens.add({ targets: cont, alpha: 0, delay: 900, duration: 300, onComplete: () => cont.destroy() });
}
flashCenter(msg, colorHex) {
const t = this.add.text(GAME_WIDTH / 2, CENTER_Y + 56, msg, {
fontFamily: 'Righteous', fontSize: '26px', color: colorHex,
}).setOrigin(0.5).setDepth(D.bubble);
this.tweens.add({ targets: t, y: t.y - 30, alpha: 0, delay: 700, duration: 700, onComplete: () => t.destroy() });
}
// ── Round / match end ───────────────────────────────────────────────────────
endRound() {
this.clearHumanControls();
this.logic.scoreRound();
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const objs = [];
objs.push(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setDepth(D.overlay));
objs.push(this.add.text(cx, 150, `Round ${this.logic.round} — Scores`, {
fontFamily: 'YummyCupcakes', fontSize: '70px', color: '#fbf3df',
}).setOrigin(0.5).setDepth(D.overlayUI));
const rows = this.logic.standings();
rows.forEach((p, i) => {
const y = 300 + i * 88;
objs.push(this.add.text(cx - 360, y, p.name, {
fontFamily: 'YummyCupcakes', fontSize: '40px',
color: p.isHuman ? THEME.accentHex : '#fbf3df',
}).setOrigin(0, 0.5).setDepth(D.overlayUI));
objs.push(this.add.text(cx + 40, y, `+${p.positive.length} ${p.negative.length}`, {
fontFamily: 'Righteous', fontSize: '30px', color: '#cbb892',
}).setOrigin(0, 0.5).setDepth(D.overlayUI));
objs.push(this.add.text(cx + 360, y, `${p.total}`, {
fontFamily: 'Righteous', fontSize: '40px', color: THEME.accentHex,
}).setOrigin(1, 0.5).setDepth(D.overlayUI));
});
const last = this.logic.isMatchOver();
const btn = new Button(this, cx, GAME_HEIGHT - 130, last ? 'Final Results' : 'Next Round',
() => {
objs.forEach((o) => o.destroy());
if (last) { this.showFinal(); }
else { this.logic.advanceRound(); this.showRoundIntro(); }
}, { width: 300, height: 66, fontSize: 28, bg: THEME.accent });
btn.setDepth(D.overlayUI);
objs.push(btn);
}
showFinal() {
this.destroyBoard();
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const standings = this.logic.standings();
const top = standings[0].total;
const winners = standings.filter((p) => p.total === top);
const human = this.logic.players[0];
const playerWon = human.total === top;
const result = playerWon ? (winners.length > 1 ? 'draw' : 'win') : 'loss';
this.recordResult(result);
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.72).setDepth(D.overlay);
const panel = this.add.graphics().setDepth(D.overlayUI);
panel.fillStyle(THEME.creamFill, 1); panel.fillRoundedRect(cx - 420, cy - 330, 840, 660, 24);
panel.lineStyle(4, THEME.kraftEdge, 1); panel.strokeRoundedRect(cx - 420, cy - 330, 840, 660, 24);
const title = playerWon ? (winners.length > 1 ? 'A Friendly Tie!' : 'Kiitos — You Win!')
: `${standings[0].name} Wins!`;
this.add.text(cx, cy - 246, title, {
fontFamily: 'YummyCupcakes', fontSize: '72px', color: THEME.accentHex,
}).setOrigin(0.5).setDepth(D.overlayUI + 1);
standings.forEach((p, i) => {
this.add.text(cx - 300, cy - 130 + i * 60, `${i + 1}. ${p.name}`, {
fontFamily: 'YummyCupcakes', fontSize: '40px',
color: p.isHuman ? THEME.accentHex : THEME.inkHex,
}).setOrigin(0, 0.5).setDepth(D.overlayUI + 1);
this.add.text(cx + 300, cy - 130 + i * 60, `${p.total}`, {
fontFamily: 'Righteous', fontSize: '40px', color: THEME.inkHex,
}).setOrigin(1, 0.5).setDepth(D.overlayUI + 1);
});
new Button(this, cx - 160, cy + 250, 'Play Again',
() => this.scene.restart(this._initData), { width: 280, height: 62, fontSize: 26, bg: THEME.accent })
.setDepth(D.overlayUI + 1);
new Button(this, cx + 160, cy + 250, 'Leave',
() => this.scene.start('GameMenu'), { variant: 'ghost', width: 280, height: 62, fontSize: 26 })
.setDepth(D.overlayUI + 1);
}
async recordResult(result) {
try {
await api.post('/history/single-player', {
slug: 'kiitos',
score: this.logic.players[0].total,
opponentScores: this.logic.players.filter((p) => !p.isHuman).map((p) => p.total),
result,
});
} catch { /* best effort */ }
}
}