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:
parent
ea135b35ed
commit
e3fb356520
Binary file not shown.
|
|
@ -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,
|
||||
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,
|
||||
}).setOrigin(0.5).setDepth(D.zoneLabel);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue