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 */ } } }