feat: enhance craps UI and improve ghost AI skill scaling

- Replace Righteous font with JqkasWild for craps title, puck, and number labels
- Refactor zone label rendering into dedicated methods for cleaner code
- Add payout information display on craps number boxes (6→SIX, 9→NINE)
- Redesign Field zone with styled number circles (accent for 2/12)
- Update Don't Pass/Don't Come zones with bar text and red accent borders
- Add common words trie for Ghost AI to target on skill ≤3
- Make lower-skill Ghost AI play more human-like by preferring common word continuations
- Fallback to off-dictionary play when no common word path available
This commit is contained in:
Brian Fertig 2026-06-19 21:54:04 -06:00
parent ea135b35ed
commit e3fb356520
5 changed files with 165 additions and 28 deletions

Binary file not shown.

View File

@ -126,7 +126,7 @@ export default class CrapsGame extends Phaser.Scene {
buildTitle() {
this.add.text(CX, 52, 'Craps', {
fontFamily: 'Righteous', fontSize: '50px', color: COLORS.textHex,
fontFamily: 'JqkasWild', fontSize: '50px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.statusText = this.add.text(CX, 96, '', {
@ -146,22 +146,29 @@ export default class CrapsGame extends Phaser.Scene {
const startX = CX - totalW / 2;
const boxY = 150;
BOX_NUMS.forEach((n, i) => {
add(`box${n}`, BET.PLACE, n, String(n), startX + i * (boxW + gap), boxY, boxW, boxH);
const lbl = n === 6 ? 'SIX' : n === 9 ? 'NINE' : String(n);
const payout = (n === 4 || n === 10) ? 'PAYS 9 TO 5'
: (n === 5 || n === 9) ? 'PAYS 7 TO 5' : 'PAYS 7 TO 6';
add(`box${n}`, BET.PLACE, n, lbl, startX + i * (boxW + gap), boxY, boxW, boxH);
this.zones[`box${n}`].payout = payout;
});
this._boxY = boxY; this._boxH = boxH;
// Don't Come (left of the boxes)
add('dontcome', BET.DONT_COME, null, "DON'T\nCOME", TABLE.x + 24, boxY, startX - TABLE.x - 44, boxH * 2 + 14);
this.zones.dontcome.barText = 'BAR 12';
// Come
add('come', BET.COME, null, 'COME', startX, 268, totalW, 88);
// Field
add('field', BET.FIELD, null, 'FIELD 2 3 4 9 10 11 12', TABLE.x + 60, 372, TABLE.w - 120, 86,
'2 pays double · 12 pays triple');
add('field', BET.FIELD, null, 'FIELD', TABLE.x + 60, 372, TABLE.w - 120, 86,
'2 pays double · 12 pays triple');
this.zones.field.fieldNums = [2, 3, 4, 9, 10, 11, 12];
// Don't Pass bar
add('dontpass', BET.DONT_PASS, null, "DON'T PASS BAR", TABLE.x + 140, 476, TABLE.w - 280, 60);
add('dontpass', BET.DONT_PASS, null, "DON'T PASS", TABLE.x + 140, 476, TABLE.w - 280, 60);
this.zones.dontpass.barText = 'BAR 12';
// Pass line
add('pass', BET.PASS, null, 'PASS LINE', TABLE.x + 100, 552, TABLE.w - 200, 76);
@ -187,18 +194,98 @@ export default class CrapsGame extends Phaser.Scene {
zone.on('pointerout', () => this.hoverZone(key, false));
zone.on('pointerup', () => this.onZoneClick(key));
const big = z.number != null;
this.add.text(z.center.x, z.center.y, z.label, {
fontFamily: big ? 'Righteous' : '"Julius Sans One"',
fontSize: big ? '40px' : '22px',
color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(D.zoneLabel);
if (z.sub) {
this.add.text(z.center.x, z.rect.y + z.rect.h - 16, z.sub, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
this._renderZoneLabel(key, z);
}
}
_renderZoneLabel(key, z) {
if (z.number != null) return this._renderNumberBox(z);
if (key === 'field') return this._renderFieldZone(z);
if (key === 'dontpass') return this._renderDontZone(z, 21, 9);
if (key === 'dontcome') return this._renderDontComeZone(z);
if (key === 'pass') return this._renderLineZone(z, 'PASS LINE', 34);
if (key === 'come') return this._renderLineZone(z, 'COME', 32);
}
_renderNumberBox(z) {
this.add.text(z.center.x, z.center.y - 16, z.label, {
fontFamily: 'JqkasWild', fontSize: '38px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.zoneLabel);
this.add.text(z.center.x, z.center.y + 24, z.payout, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.zoneLabel);
}
_renderLineZone(z, text, fontSize) {
this.add.text(z.center.x, z.center.y, text, {
fontFamily: '"Julius Sans One"', fontSize: `${fontSize}px`, color: COLORS.textHex,
letterSpacing: 14,
}).setOrigin(0.5).setDepth(D.zoneLabel);
}
_renderDontZone(z, fontSize, ls) {
this.add.text(z.center.x, z.center.y - 11, "DON'T PASS", {
fontFamily: '"Julius Sans One"', fontSize: `${fontSize}px`, color: COLORS.textHex,
letterSpacing: ls,
}).setOrigin(0.5).setDepth(D.zoneLabel);
this.add.text(z.center.x, z.center.y + 17, z.barText, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.zoneLabel);
}
_renderDontComeZone(z) {
this.add.text(z.center.x, z.center.y - 44, "DON'T", {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.zoneLabel);
this.add.text(z.center.x, z.center.y, 'COME', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
letterSpacing: 8,
}).setOrigin(0.5).setDepth(D.zoneLabel);
this.add.text(z.center.x, z.center.y + 44, z.barText, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.zoneLabel);
}
_renderFieldZone(z) {
// "FIELD" label left-anchored
this.add.text(z.rect.x + 55, z.center.y, 'FIELD', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
letterSpacing: 6,
}).setOrigin(0.5).setDepth(D.zoneLabel);
// Vertical separator
const sep = this.add.graphics().setDepth(D.zoneLabel);
sep.lineStyle(1, COLORS.accent, 0.35);
sep.lineBetween(z.rect.x + 100, z.rect.y + 8, z.rect.x + 100, z.rect.y + z.rect.h - 8);
// 7 number tokens, evenly spaced from x=430 to x=1630, step=200
const fieldNums = z.fieldNums;
const startX = 430, step = 200;
fieldNums.forEach((n, i) => {
const nx = startX + i * step;
const ny = z.center.y;
if (n === 2 || n === 12) {
const cg = this.add.graphics().setDepth(D.zoneLabel);
cg.fillStyle(COLORS.accent, 0.85);
cg.fillCircle(nx, ny, 22);
cg.lineStyle(2, COLORS.gold, 1);
cg.strokeCircle(nx, ny, 22);
this.add.text(nx, ny, String(n), {
fontFamily: 'JqkasWild', fontSize: n === 12 ? '22px' : '22px',
color: COLORS.textDarkHex,
}).setOrigin(0.5).setDepth(D.zoneLabel);
} else {
const fs = (n === 10 || n === 11) ? '24px' : '28px';
this.add.text(nx, ny, String(n), {
fontFamily: 'JqkasWild', fontSize: fs, color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.zoneLabel);
}
}
});
// Sub-text at bottom of zone
this.add.text(z.center.x, z.rect.y + z.rect.h - 10, z.sub, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.zoneLabel);
}
drawZones(hoverKey = null) {
@ -208,10 +295,19 @@ export default class CrapsGame extends Phaser.Scene {
for (const [key, z] of Object.entries(this.zones)) {
const { x, y, w, h } = z.rect;
const isLegal = !this.gs || legal.has(z.type);
const isDont = key === 'dontpass' || key === 'dontcome';
g.fillStyle(0x0c2e19, isLegal ? 0.5 : 0.18);
g.fillRoundedRect(x, y, w, h, 10);
g.lineStyle(key === hoverKey && isLegal ? 4 : 2, key === hoverKey && isLegal ? COLORS.gold : 0x3c8a52, isLegal ? 0.9 : 0.4);
const strokeColor = (key === hoverKey && isLegal) ? COLORS.gold
: isDont ? 0x8b2020 : 0x3c8a52;
const strokeAlpha = isLegal ? 0.9 : 0.4;
const strokeW = (key === hoverKey && isLegal) ? 4 : 2;
g.lineStyle(strokeW, strokeColor, strokeAlpha);
g.strokeRoundedRect(x, y, w, h, 10);
if (z.number != null) {
g.fillStyle(COLORS.accent, isLegal ? 0.55 : 0.2);
g.fillRect(x + 10, y, w - 20, 4);
}
}
}
@ -231,7 +327,7 @@ export default class CrapsGame extends Phaser.Scene {
buildTooltip() {
const bg = this.add.graphics();
const title = this.add.text(0, 0, '', {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex,
fontFamily: 'JqkasWild', fontSize: '30px', color: COLORS.goldHex,
});
const desc = this.add.text(0, 0, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
@ -293,7 +389,7 @@ export default class CrapsGame extends Phaser.Scene {
this.puck.add(g);
this.puckGfx = g;
this.puckText = this.add.text(0, 0, 'OFF', {
fontFamily: 'Righteous', fontSize: '18px', color: '#1a1208',
fontFamily: 'JqkasWild', fontSize: '18px', color: '#1a1208',
}).setOrigin(0.5);
this.puck.add(this.puckText);
this.drawPuck(false);
@ -579,7 +675,7 @@ export default class CrapsGame extends Phaser.Scene {
// Place a chip in the left/right side of a zone, skirting the center text.
chipSidePos(z, zoneKey, i, n, pi) {
// Approximate widths of each zone's center text label.
const avoidW = { pass: 250, dontpass: 310, field: 550, come: 170, dontcome: 60 }[zoneKey] ?? 240;
const avoidW = { pass: 250, dontpass: 310, field: 550, come: 200, dontcome: 60 }[zoneKey] ?? 240;
const margin = 28;
const jitter = this.seatJitter[pi % this.seatJitter.length];
const leftBound = z.rect.x + margin;

View File

@ -19,6 +19,13 @@
font-style: normal;
}
@font-face {
font-family: 'JqkasWild';
src: url('/assets/fonts/JqkasWild.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
html, body {
margin: 0;
padding: 0;

View File

@ -12,6 +12,7 @@
const MIN_LEN = 4;
let TRIE = null;
let COMMON_TRIE = null;
export function initGhostDictionary(words) {
TRIE = { children: Object.create(null), terminal: false, loss: false };
@ -26,6 +27,19 @@ export function initGhostDictionary(words) {
computeLoss(TRIE);
}
export function initGhostCommonWords(words) {
COMMON_TRIE = { children: Object.create(null), terminal: false, loss: false };
for (const w of words) {
if (w.length < MIN_LEN) continue;
let node = COMMON_TRIE;
for (const ch of w) {
node = node.children[ch] || (node.children[ch] = { children: Object.create(null), terminal: false, loss: false });
}
node.terminal = true;
}
computeLoss(COMMON_TRIE);
}
export function dictionaryReady() {
return TRIE !== null;
}
@ -44,8 +58,8 @@ function computeLoss(node) {
node.loss = !win;
}
function nodeFor(fragment) {
let node = TRIE;
function nodeFor(fragment, trie = TRIE) {
let node = trie;
for (const ch of fragment) {
node = node.children[ch];
if (!node) return null;
@ -86,19 +100,36 @@ export function chooseLetter(fragment, skill) {
const node = nodeFor(F);
if (!node) return { letter: null, isWord: false, isPrefix: false };
const legal = Object.keys(node.children); // every child is a valid prefix
const legal = Object.keys(node.children); // every child is a valid ENABLE prefix
if (legal.length === 0) return { letter: null, isWord: false, isPrefix: false };
// Skill <= 3: restrict candidates to letters that continue toward a common word.
// If no common-word continuation exists (human steered off the common path or
// none was available), the AI concedes by going off-dictionary.
// isWord / safe filtering always uses the authoritative ENABLE trie so the AI
// never accidentally complete a real word it thinks is "safe".
let candidates = legal;
if (skill <= 3 && COMMON_TRIE) {
const commonNode = nodeFor(F, COMMON_TRIE);
const commonSet = commonNode ? new Set(Object.keys(commonNode.children)) : new Set();
const filtered = legal.filter(L => commonSet.has(L));
if (filtered.length > 0) {
candidates = filtered;
} else {
return { letter: randomFrom(legal), isWord: false, isPrefix: false };
}
}
const profile = SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3];
const safe = legal.filter(L => !node.children[L].terminal); // don't complete a word
const safe = candidates.filter(L => !node.children[L].terminal); // don't complete a word
let letter;
if (safe.length === 0) {
// Forced: every legal letter completes a word -> the AI self-loses this turn.
letter = randomFrom(legal);
// Forced: every candidate letter completes a word -> the AI self-loses this turn.
letter = randomFrom(candidates);
} else if (Math.random() < profile.blunder) {
// Careless: pick any legal letter, which may accidentally complete a word.
letter = randomFrom(legal);
// Careless: pick any candidate letter, which may accidentally complete a word.
letter = randomFrom(candidates);
} else {
// Careful: never complete a word; use foresight to hand off a losing position.
const winning = safe.filter(L => node.children[L].loss);

View File

@ -3,7 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { initScrabbleDictionary, isValidWord, chooseMove } from './scrabbleEngine.js';
import { initGhostDictionary, judge as ghostJudge, chooseLetter as ghostChooseLetter, suggestWords as ghostSuggestWords } from './ghostEngine.js';
import { initGhostDictionary, initGhostCommonWords, judge as ghostJudge, chooseLetter as ghostChooseLetter, suggestWords as ghostSuggestWords } from './ghostEngine.js';
import {
initWordLadderDictionary,
initCommonWordSets as ladderInitCommon,
@ -184,6 +184,9 @@ function loadWordLists() {
const common4 = commonWords.filter(w => /^[A-Z]{4}$/.test(w));
ladderInitCommon(common3, common4);
console.log(`[words] loaded Word Ladder common words (${common3.length} 3-letter, ${common4.length} 4-letter)`);
const ghostCommon = commonWords.filter(w => w.length >= 4 && /^[A-Z]+$/.test(w));
initGhostCommonWords(ghostCommon);
console.log(`[words] loaded ${ghostCommon.length} Ghost common words for skill ≤3 AI targeting`);
} catch (err) {
console.warn('[words] common.txt not found; Kiitos AI will use full ENABLE list');
}