817 lines
34 KiB
JavaScript
817 lines
34 KiB
JavaScript
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 */ }
|
||
}
|
||
}
|