Compare commits
3 Commits
ef9abf03c6
...
a97488e8d6
| Author | SHA1 | Date |
|---|---|---|
|
|
a97488e8d6 | |
|
|
5d125167cc | |
|
|
2469cdc7cb |
|
|
@ -0,0 +1,147 @@
|
|||
import { defineComponent } from "../../src/component";
|
||||
import type { World, Entity } from "../../src/index";
|
||||
import { query } from "../../src/query";
|
||||
import type { CommandQueue } from "../../src/commands/index";
|
||||
import {
|
||||
Card,
|
||||
InDeck,
|
||||
InPlayerHand,
|
||||
InDealerHand,
|
||||
HoleHidden,
|
||||
Score,
|
||||
Bet,
|
||||
GamePhase,
|
||||
} from "./components";
|
||||
import {
|
||||
handValue,
|
||||
isBust,
|
||||
isBlackjack,
|
||||
determineOutcome,
|
||||
payout,
|
||||
} from "./game";
|
||||
|
||||
// ── Command definitions ──────────────────────────────
|
||||
|
||||
/** Player takes another card. */
|
||||
export const Hit = defineComponent("hit", {});
|
||||
|
||||
/** Player stands (ends their turn). */
|
||||
export const Stand = defineComponent("stand", {});
|
||||
|
||||
/** Start a new round. */
|
||||
export const NewRound = defineComponent("newRound", {});
|
||||
|
||||
/** Increase the bet. */
|
||||
export const BetMore = defineComponent("betMore", {});
|
||||
|
||||
/** Decrease the bet. */
|
||||
export const BetLess = defineComponent("betLess", {});
|
||||
|
||||
// ── Command handlers ─────────────────────────────────
|
||||
|
||||
export function registerCommands(
|
||||
world: World,
|
||||
commands: CommandQueue,
|
||||
helpers: ReturnType<typeof import("./components").createCardHelpers>,
|
||||
): void {
|
||||
commands.handle(Hit, () => {
|
||||
const phase = world.getSingleton(GamePhase);
|
||||
if (phase.phase !== "playerTurn") return;
|
||||
|
||||
const cardEntity = helpers.drawCard();
|
||||
if (cardEntity) {
|
||||
helpers.dealTo(cardEntity, InPlayerHand);
|
||||
}
|
||||
|
||||
if (isBust(helpers.getHand(InPlayerHand))) {
|
||||
world.removeSingleton(HoleHidden);
|
||||
resolveRound(world, helpers);
|
||||
}
|
||||
});
|
||||
|
||||
commands.handle(Stand, () => {
|
||||
const phase = world.getSingleton(GamePhase);
|
||||
if (phase.phase !== "playerTurn") return;
|
||||
|
||||
world.removeSingleton(HoleHidden);
|
||||
world.setSingleton(GamePhase, {
|
||||
phase: "dealerTurn",
|
||||
message: "Dealer's turn...",
|
||||
});
|
||||
});
|
||||
|
||||
commands.handle(NewRound, () => {
|
||||
const phase = world.getSingleton(GamePhase);
|
||||
if (phase.phase !== "roundOver" && phase.phase !== "betting") return;
|
||||
|
||||
const score = world.getSingleton(Score);
|
||||
if (score.chips <= 0) {
|
||||
world.setSingleton(GamePhase, {
|
||||
phase: "roundOver",
|
||||
message: "You're out of chips! Restart the program to play again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
helpers.startRound();
|
||||
});
|
||||
|
||||
commands.handle(BetMore, () => {
|
||||
const phase = world.getSingleton(GamePhase);
|
||||
if (phase.phase !== "betting" && phase.phase !== "roundOver") return;
|
||||
|
||||
const bet = world.getSingleton(Bet);
|
||||
const score = world.getSingleton(Score);
|
||||
bet.amount = Math.min(bet.amount + 10, score.chips);
|
||||
});
|
||||
|
||||
commands.handle(BetLess, () => {
|
||||
const phase = world.getSingleton(GamePhase);
|
||||
if (phase.phase !== "betting" && phase.phase !== "roundOver") return;
|
||||
|
||||
const bet = world.getSingleton(Bet);
|
||||
bet.amount = Math.max(bet.amount - 10, 10);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────
|
||||
|
||||
export function resolveRound(
|
||||
world: World,
|
||||
helpers: ReturnType<typeof import("./components").createCardHelpers>,
|
||||
): void {
|
||||
const playerCards = helpers.getHand(InPlayerHand);
|
||||
const dealerCards = helpers.getHand(InDealerHand);
|
||||
const score = world.getSingleton(Score);
|
||||
const bet = world.getSingleton(Bet);
|
||||
|
||||
const outcome = determineOutcome(playerCards, dealerCards);
|
||||
const winnings = payout(outcome, bet.amount);
|
||||
|
||||
score.chips += bet.amount + winnings;
|
||||
|
||||
switch (outcome) {
|
||||
case "blackjack":
|
||||
case "win":
|
||||
score.wins++;
|
||||
break;
|
||||
case "lose":
|
||||
score.losses++;
|
||||
break;
|
||||
case "push":
|
||||
score.pushes++;
|
||||
break;
|
||||
}
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
win: "You win!",
|
||||
lose: "Dealer wins.",
|
||||
push: "Push — tie!",
|
||||
blackjack: "Blackjack! You win 3:2!",
|
||||
};
|
||||
|
||||
world.setSingleton(GamePhase, {
|
||||
phase: "roundOver",
|
||||
message: `${messages[outcome]} Press N for new round.`,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { defineComponent } from "../../src/component";
|
||||
import type { World, Entity } from "../../src/index";
|
||||
import { query } from "../../src/query";
|
||||
import { SUITS, RANKS, isBlackjack, type CardData } from "./game";
|
||||
|
||||
// ── Component definitions ────────────────────────────
|
||||
|
||||
/** Each card is its own entity. `order` tracks position within its collection. */
|
||||
export const Card = defineComponent("card", {
|
||||
rank: "A",
|
||||
suit: "♠",
|
||||
order: 0,
|
||||
});
|
||||
|
||||
// ── Tag components — mark which collection a card belongs to ─
|
||||
export const InDeck = defineComponent("inDeck", {});
|
||||
export const InPlayerHand = defineComponent("inPlayerHand", {});
|
||||
export const InDealerHand = defineComponent("inDealerHand", {});
|
||||
|
||||
// ── Dealer state ─────────────────────────────────────
|
||||
/** When present (as singleton), the dealer's hole card is hidden. */
|
||||
export const HoleHidden = defineComponent("holeHidden", {});
|
||||
|
||||
// ── Score / state ────────────────────────────────────
|
||||
export const Score = defineComponent("score", {
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
pushes: 0,
|
||||
chips: 100,
|
||||
});
|
||||
|
||||
export const Bet = defineComponent("bet", {
|
||||
amount: 10,
|
||||
});
|
||||
|
||||
/** Current phase of the game. */
|
||||
export type Phase = "betting" | "playerTurn" | "dealerTurn" | "roundOver";
|
||||
|
||||
export const GamePhase = defineComponent("gamePhase", {
|
||||
phase: "betting" as Phase,
|
||||
message: "",
|
||||
});
|
||||
|
||||
// ── Card helpers ─────────────────────────────────────
|
||||
|
||||
export function createCardHelpers(world: World) {
|
||||
return {
|
||||
/** Create all 52 card entities with the InDeck tag. */
|
||||
buildDeck(): void {
|
||||
let order = 0;
|
||||
for (const suit of SUITS) {
|
||||
for (const rank of RANKS) {
|
||||
const e = world.spawn();
|
||||
world.add(e, Card, { rank, suit, order });
|
||||
world.add(e, InDeck);
|
||||
order++;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Shuffle the deck: collect all InDeck entities, shuffle, reassign order. */
|
||||
shuffleDeck(): void {
|
||||
const entities = [...world.query(query(Card, InDeck))];
|
||||
for (let i = entities.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[entities[i], entities[j]] = [entities[j], entities[i]];
|
||||
}
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
world.get(entities[i], Card).order = i;
|
||||
}
|
||||
},
|
||||
|
||||
/** Draw the top card from the deck (highest order). Returns entity or null. */
|
||||
drawCard(): Entity | null {
|
||||
const cards = [...world.query(query(Card, InDeck))];
|
||||
if (cards.length === 0) return null;
|
||||
let top = cards[0];
|
||||
let topOrder = world.get(top, Card).order;
|
||||
for (let i = 1; i < cards.length; i++) {
|
||||
const o = world.get(cards[i], Card).order;
|
||||
if (o > topOrder) {
|
||||
top = cards[i];
|
||||
topOrder = o;
|
||||
}
|
||||
}
|
||||
world.remove(top, InDeck);
|
||||
return top;
|
||||
},
|
||||
|
||||
/** Move a card entity to a hand tag, assigning the next order. */
|
||||
dealTo(cardEntity: Entity, tag: typeof InPlayerHand): void {
|
||||
const existing = [...world.query(query(Card, tag))];
|
||||
const nextOrder =
|
||||
existing.length === 0
|
||||
? 0
|
||||
: Math.max(...existing.map((e) => world.get(e, Card).order)) + 1;
|
||||
world.add(cardEntity, tag);
|
||||
world.get(cardEntity, Card).order = nextOrder;
|
||||
},
|
||||
|
||||
/** Collect CardData from a hand tag, sorted by order. */
|
||||
getHand(tag: typeof InPlayerHand): CardData[] {
|
||||
return [...world.query(query(Card, tag))]
|
||||
.map((e) => {
|
||||
const c = world.get(e, Card);
|
||||
return { rank: c.rank, suit: c.suit, order: c.order };
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(({ rank, suit }) => ({ rank, suit }));
|
||||
},
|
||||
|
||||
/** Remove all cards from a hand tag (destroy the card entities). */
|
||||
clearHand(tag: typeof InPlayerHand): void {
|
||||
for (const e of world.query(query(Card, tag))) {
|
||||
world.destroy(e);
|
||||
}
|
||||
},
|
||||
|
||||
/** Count cards remaining in the deck. */
|
||||
deckCount(): number {
|
||||
return [...world.query(query(Card, InDeck))].length;
|
||||
},
|
||||
|
||||
/** Deal initial 4 cards and start a round. */
|
||||
startRound(): void {
|
||||
const bet = world.getSingleton(Bet);
|
||||
const score = world.getSingleton(Score);
|
||||
|
||||
score.chips -= bet.amount;
|
||||
|
||||
if (this.deckCount() < 15) {
|
||||
const remaining = [...world.query(query(Card, InDeck))];
|
||||
for (const e of remaining) {
|
||||
world.remove(e, InDeck);
|
||||
}
|
||||
for (const e of remaining) {
|
||||
world.add(e, InDeck);
|
||||
}
|
||||
this.shuffleDeck();
|
||||
}
|
||||
|
||||
this.clearHand(InPlayerHand);
|
||||
this.clearHand(InDealerHand);
|
||||
world.removeSingleton(HoleHidden);
|
||||
|
||||
const c1 = this.drawCard();
|
||||
const c2 = this.drawCard();
|
||||
const c3 = this.drawCard();
|
||||
const c4 = this.drawCard();
|
||||
if (c1) this.dealTo(c1, InPlayerHand);
|
||||
if (c2) this.dealTo(c2, InDealerHand);
|
||||
if (c3) this.dealTo(c3, InPlayerHand);
|
||||
if (c4) this.dealTo(c4, InDealerHand);
|
||||
|
||||
const playerCards = this.getHand(InPlayerHand);
|
||||
const dealerCards = this.getHand(InDealerHand);
|
||||
|
||||
if (isBlackjack(playerCards)) {
|
||||
world.removeSingleton(HoleHidden);
|
||||
|
||||
if (isBlackjack(dealerCards)) {
|
||||
score.chips += bet.amount;
|
||||
score.pushes++;
|
||||
world.setSingleton(GamePhase, {
|
||||
phase: "roundOver",
|
||||
message: "Both have Blackjack — Push! Press N for new round.",
|
||||
});
|
||||
} else {
|
||||
const winnings = payout(0, "blackjack", bet.amount);
|
||||
score.chips += bet.amount + winnings;
|
||||
score.wins++;
|
||||
world.setSingleton(GamePhase, {
|
||||
phase: "roundOver",
|
||||
message: "Blackjack! You win 3:2! Press N for new round.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
world.addSingleton(HoleHidden);
|
||||
world.setSingleton(GamePhase, {
|
||||
phase: "playerTurn",
|
||||
message: "Your turn — H to hit, S to stand.",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────
|
||||
|
||||
function payout(outcome: string, bet: number): number {
|
||||
switch (outcome) {
|
||||
case "blackjack":
|
||||
return Math.floor(bet * 1.5);
|
||||
case "win":
|
||||
return bet;
|
||||
case "push":
|
||||
return 0;
|
||||
case "lose":
|
||||
return -bet;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// ── Blackjack game logic (pure functions, no ECS dependency) ──
|
||||
|
||||
export const SUITS = ["♠", "♥", "♦", "♣"] as const;
|
||||
export const RANKS = [
|
||||
"A",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"J",
|
||||
"Q",
|
||||
"K",
|
||||
] as const;
|
||||
|
||||
export interface CardData {
|
||||
rank: string;
|
||||
suit: string;
|
||||
}
|
||||
|
||||
// ── Hand evaluation ──────────────────────────────────
|
||||
/** Numeric value of a single card rank. */
|
||||
export function rankValue(rank: string): number {
|
||||
if (rank === "A") return 11;
|
||||
if (rank === "K" || rank === "Q" || rank === "J") return 10;
|
||||
return parseInt(rank, 10);
|
||||
}
|
||||
|
||||
/** Best total for a hand (aces count as 1 or 11). */
|
||||
export function handValue(cards: CardData[]): number {
|
||||
let total = 0;
|
||||
let aces = 0;
|
||||
for (const c of cards) {
|
||||
total += rankValue(c.rank);
|
||||
if (c.rank === "A") aces++;
|
||||
}
|
||||
while (total > 21 && aces > 0) {
|
||||
total -= 10;
|
||||
aces--;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function isBust(cards: CardData[]): boolean {
|
||||
return handValue(cards) > 21;
|
||||
}
|
||||
|
||||
export function isBlackjack(cards: CardData[]): boolean {
|
||||
return cards.length === 2 && handValue(cards) === 21;
|
||||
}
|
||||
|
||||
export function isSoft(cards: CardData[]): boolean {
|
||||
let total = 0;
|
||||
let aces = 0;
|
||||
for (const c of cards) {
|
||||
total += rankValue(c.rank);
|
||||
if (c.rank === "A") aces++;
|
||||
}
|
||||
return aces > 0 && total <= 21;
|
||||
}
|
||||
|
||||
// ── Dealer logic ─────────────────────────────────────
|
||||
/** Dealer must hit on soft 17 in this variant. */
|
||||
export function dealerShouldHit(cards: CardData[]): boolean {
|
||||
const val = handValue(cards);
|
||||
if (val < 17) return true;
|
||||
if (val === 17 && isSoft(cards)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Outcome ──────────────────────────────────────────
|
||||
export type Outcome = "win" | "lose" | "push" | "blackjack";
|
||||
|
||||
export function determineOutcome(
|
||||
playerCards: CardData[],
|
||||
dealerCards: CardData[],
|
||||
): Outcome {
|
||||
if (isBust(playerCards)) return "lose";
|
||||
if (isBust(dealerCards)) return "win";
|
||||
if (isBlackjack(playerCards) && !isBlackjack(dealerCards)) return "blackjack";
|
||||
const pv = handValue(playerCards);
|
||||
const dv = handValue(dealerCards);
|
||||
if (pv > dv) return "win";
|
||||
if (pv < dv) return "lose";
|
||||
return "push";
|
||||
}
|
||||
|
||||
/** Payout multiplier. Blackjack pays 3:2, win pays 1:1. */
|
||||
export function payout(outcome: Outcome, bet: number): number {
|
||||
switch (outcome) {
|
||||
case "blackjack":
|
||||
return Math.floor(bet * 1.5);
|
||||
case "win":
|
||||
return bet;
|
||||
case "push":
|
||||
return 0;
|
||||
case "lose":
|
||||
return -bet;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// ── Keyboard input via blessed ────────────────────────
|
||||
import type blessed from "blessed";
|
||||
|
||||
export type Key =
|
||||
| "h"
|
||||
| "s"
|
||||
| "n"
|
||||
| "up"
|
||||
| "down"
|
||||
| "q";
|
||||
|
||||
/** Wire blessed screen key events to a callback. */
|
||||
export function startInput(
|
||||
screen: blessed.Widgets.Screen,
|
||||
onKey: (key: Key) => void,
|
||||
): void {
|
||||
screen.key(
|
||||
["h", "s", "n", "up", "down", "q", "C-c"],
|
||||
(_ch, key) => {
|
||||
if (key.name === "q" || key.name === "C-c") {
|
||||
screen.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
onKey(key.name as Key);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
// ── Blackjack: BT-driven game loop with command-based input ──
|
||||
//
|
||||
// Architecture:
|
||||
// Behaviour Tree (buildTree) — controls game flow:
|
||||
// parallel
|
||||
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand
|
||||
// └── repeat
|
||||
// └── seq (sequential)
|
||||
// ├── handleInput (leaf) — reads queued commands
|
||||
// └── render (leaf) — draws via blessed
|
||||
//
|
||||
// CommandQueue — processes input:
|
||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||
// → handlers mutate game state
|
||||
//
|
||||
// Cards as entities with tag components:
|
||||
// Each card is an entity with a Card component ({ rank, suit, order }).
|
||||
// Tag components (InDeck, InPlayerHand, InDealerHand) mark which
|
||||
// collection the card belongs to. Queries find cards by tag.
|
||||
// Order is tracked via the `order` field on Card.
|
||||
//
|
||||
// Usage:
|
||||
// npx tsx examples/blackjack/main.ts
|
||||
|
||||
import { World } from "../../src/index";
|
||||
import { buildTree } from "../../src/bt/index";
|
||||
import { CommandQueue } from "../../src/commands/index";
|
||||
|
||||
import {
|
||||
Score,
|
||||
Bet,
|
||||
GamePhase,
|
||||
InDealerHand,
|
||||
createCardHelpers,
|
||||
} from "./components";
|
||||
|
||||
import {
|
||||
registerCommands,
|
||||
resolveRound,
|
||||
Hit,
|
||||
Stand,
|
||||
NewRound,
|
||||
BetMore,
|
||||
BetLess,
|
||||
} from "./commands";
|
||||
|
||||
import { dealerShouldHit } from "./game";
|
||||
|
||||
import { createUI, render } from "./render";
|
||||
import { startInput, type Key } from "./input";
|
||||
|
||||
// ── Setup ────────────────────────────────────────────
|
||||
const world = new World();
|
||||
|
||||
world.addSingleton(Score);
|
||||
world.addSingleton(Bet);
|
||||
world.addSingleton(GamePhase);
|
||||
|
||||
const ui = createUI();
|
||||
const cards = createCardHelpers(world);
|
||||
const commands = new CommandQueue(world);
|
||||
|
||||
registerCommands(world, commands, cards);
|
||||
|
||||
// ── Behaviour Tree ───────────────────────────────────
|
||||
const runner = buildTree(world, {
|
||||
kind: "parallel",
|
||||
children: [
|
||||
{
|
||||
kind: "leaf",
|
||||
*run() {
|
||||
while (true) {
|
||||
const dt: number = yield;
|
||||
const phase = world.getSingleton(GamePhase);
|
||||
if (phase.phase !== "dealerTurn") continue;
|
||||
|
||||
if (dealerShouldHit(cards.getHand(InDealerHand))) {
|
||||
const cardEntity = cards.drawCard();
|
||||
if (cardEntity) {
|
||||
cards.dealTo(cardEntity, InDealerHand);
|
||||
}
|
||||
} else {
|
||||
resolveRound(world, cards);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "repeat",
|
||||
child: {
|
||||
kind: "sequential",
|
||||
children: [
|
||||
{
|
||||
kind: "leaf",
|
||||
run: () => {
|
||||
commands.execute();
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "leaf",
|
||||
run: () => {
|
||||
render(world, ui);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// ── Input → Command mapping ──────────────────────────
|
||||
const keyToCommand: Partial<Record<Key, typeof Hit>> = {
|
||||
h: Hit,
|
||||
s: Stand,
|
||||
n: NewRound,
|
||||
up: BetMore,
|
||||
down: BetLess,
|
||||
};
|
||||
|
||||
startInput(ui.screen, (key) => {
|
||||
const cmd = keyToCommand[key];
|
||||
if (cmd) {
|
||||
const cmdEntity = world.spawn();
|
||||
world.add(cmdEntity, cmd);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Game loop ────────────────────────────────────────
|
||||
world.setSingleton(GamePhase, {
|
||||
phase: "betting",
|
||||
message: "Welcome to Blackjack! Press N to start.",
|
||||
});
|
||||
|
||||
cards.buildDeck();
|
||||
cards.shuffleDeck();
|
||||
runner.schedule((runner as any).root);
|
||||
|
||||
const TICK_MS = 16;
|
||||
const interval = setInterval(() => {
|
||||
runner.tick(TICK_MS);
|
||||
}, TICK_MS);
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(interval);
|
||||
ui.screen.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
// ── Terminal rendering via blessed ────────────────────
|
||||
import blessed from "blessed";
|
||||
import type { World } from "../../src/index";
|
||||
import { query } from "../../src/query";
|
||||
import {
|
||||
Card,
|
||||
InPlayerHand,
|
||||
InDealerHand,
|
||||
HoleHidden,
|
||||
Score,
|
||||
Bet,
|
||||
GamePhase,
|
||||
} from "./components";
|
||||
import { handValue, isBust, isBlackjack } from "./game";
|
||||
|
||||
const SUIT_SYMBOLS: Record<string, string> = {
|
||||
"♠": "\x1b[37m♠\x1b[0m",
|
||||
"♥": "\x1b[31m♥\x1b[0m",
|
||||
"♦": "\x1b[31m♦\x1b[0m",
|
||||
"♣": "\x1b[37m♣\x1b[0m",
|
||||
};
|
||||
|
||||
export function createUI(): {
|
||||
screen: blessed.Widgets.Screen;
|
||||
dealerBox: blessed.Widgets.BoxElement;
|
||||
playerBox: blessed.Widgets.BoxElement;
|
||||
infoText: blessed.Widgets.TextElement;
|
||||
controlsText: blessed.Widgets.TextElement;
|
||||
} {
|
||||
const screen = blessed.screen({
|
||||
smartCSR: true,
|
||||
title: "Blackjack",
|
||||
});
|
||||
|
||||
const dealerBox = blessed.box({
|
||||
parent: screen,
|
||||
top: 2,
|
||||
left: "center",
|
||||
width: 50,
|
||||
height: 8,
|
||||
border: { type: "line" },
|
||||
label: " Dealer ",
|
||||
style: { border: { fg: "white" } },
|
||||
});
|
||||
|
||||
const playerBox = blessed.box({
|
||||
parent: screen,
|
||||
top: 12,
|
||||
left: "center",
|
||||
width: 50,
|
||||
height: 8,
|
||||
border: { type: "line" },
|
||||
label: " Player ",
|
||||
style: { border: { fg: "white" } },
|
||||
});
|
||||
|
||||
const infoText = blessed.text({
|
||||
parent: screen,
|
||||
top: 22,
|
||||
left: "center",
|
||||
width: 50,
|
||||
height: 5,
|
||||
style: { fg: "white" },
|
||||
});
|
||||
|
||||
const controlsText = blessed.text({
|
||||
parent: screen,
|
||||
bottom: 0,
|
||||
left: "center",
|
||||
width: 60,
|
||||
height: 1,
|
||||
style: { fg: "gray" },
|
||||
});
|
||||
|
||||
return { screen, dealerBox, playerBox, infoText, controlsText };
|
||||
}
|
||||
|
||||
function formatCard(rank: string, suit: string): string {
|
||||
return `${rank}${SUIT_SYMBOLS[suit] ?? suit}`;
|
||||
}
|
||||
|
||||
/** Collect cards from a hand tag, sorted by order. */
|
||||
function collectHand(
|
||||
world: World,
|
||||
tag: typeof InPlayerHand,
|
||||
): { rank: string; suit: string }[] {
|
||||
return [...world.query(query(Card, tag))]
|
||||
.map((e) => {
|
||||
const c = world.get(e, Card);
|
||||
return { rank: c.rank, suit: c.suit, order: c.order };
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(({ rank, suit }) => ({ rank, suit }));
|
||||
}
|
||||
|
||||
function formatHand(
|
||||
cards: { rank: string; suit: string }[],
|
||||
hideHole: boolean,
|
||||
): string {
|
||||
if (cards.length === 0) return " (empty)";
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (hideHole && cards.length >= 2) {
|
||||
lines.push(` ${formatCard(cards[0].rank, cards[0].suit)} ██`);
|
||||
lines.push(` Value: ${handValue([cards[0]])}`);
|
||||
} else {
|
||||
const cardStr = cards.map((c) => formatCard(c.rank, c.suit)).join(" ");
|
||||
lines.push(` ${cardStr}`);
|
||||
const val = handValue(cards);
|
||||
const extra = isBlackjack(cards)
|
||||
? " — BLACKJACK!"
|
||||
: isBust(cards)
|
||||
? " — BUST!"
|
||||
: "";
|
||||
lines.push(` Value: ${val}${extra}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Render the full game state into the blessed UI. */
|
||||
export function render(world: World, ui: ReturnType<typeof createUI>): void {
|
||||
const score = world.tryGetSingleton(Score);
|
||||
const bet = world.tryGetSingleton(Bet);
|
||||
const phase = world.tryGetSingleton(GamePhase);
|
||||
const holeHidden = world.hasSingleton(HoleHidden);
|
||||
|
||||
// Dealer
|
||||
const dealerCards = collectHand(world, InDealerHand);
|
||||
ui.dealerBox.setContent(formatHand(dealerCards, holeHidden));
|
||||
|
||||
// Player
|
||||
const playerCards = collectHand(world, InPlayerHand);
|
||||
ui.playerBox.setContent(formatHand(playerCards, false));
|
||||
|
||||
// Info
|
||||
let info = "";
|
||||
if (score) {
|
||||
info += `Chips: ${score.chips} Wins: ${score.wins} Losses: ${score.losses} Pushes: ${score.pushes}\n`;
|
||||
}
|
||||
if (bet) {
|
||||
info += `Bet: ${bet.amount}\n`;
|
||||
}
|
||||
if (phase) {
|
||||
info += `\n${phase.message}`;
|
||||
}
|
||||
ui.infoText.setContent(info);
|
||||
|
||||
// Dynamic controls based on phase
|
||||
if (phase) {
|
||||
switch (phase.phase) {
|
||||
case "betting":
|
||||
ui.controlsText.setContent(
|
||||
"N : new round ↑↓ : adjust bet Q : quit",
|
||||
);
|
||||
break;
|
||||
case "playerTurn":
|
||||
ui.controlsText.setContent("H : hit S : stand Q : quit");
|
||||
break;
|
||||
case "dealerTurn":
|
||||
ui.controlsText.setContent("Dealer is playing...");
|
||||
break;
|
||||
case "roundOver":
|
||||
ui.controlsText.setContent(
|
||||
"N : new round ↑↓ : adjust bet Q : quit",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ui.screen.render();
|
||||
}
|
||||
|
|
@ -1,4 +1,24 @@
|
|||
import { defineComponent } from "../../src/component";
|
||||
import type { World } from "../../src/index";
|
||||
import type { CommandQueue } from "../../src/commands/index";
|
||||
import {
|
||||
Board,
|
||||
Piece,
|
||||
Score,
|
||||
GameOver,
|
||||
Paused,
|
||||
TickTimer,
|
||||
createPieceHelpers,
|
||||
} from "./components";
|
||||
import {
|
||||
collides,
|
||||
lockPiece,
|
||||
clearLines,
|
||||
scoreForLines,
|
||||
tryRotate,
|
||||
} from "./game";
|
||||
|
||||
// ── Command definitions ──────────────────────────────
|
||||
|
||||
/** Move the active piece left. */
|
||||
export const MoveLeft = defineComponent("moveLeft", {});
|
||||
|
|
@ -20,3 +40,85 @@ export const TogglePause = defineComponent("togglePause", {});
|
|||
|
||||
/** Restart after game over. */
|
||||
export const Restart = defineComponent("restart", {});
|
||||
|
||||
// ── Command handlers ─────────────────────────────────
|
||||
|
||||
export function registerCommands(
|
||||
world: World,
|
||||
commands: CommandQueue,
|
||||
pieces: ReturnType<typeof createPieceHelpers>,
|
||||
): void {
|
||||
const hasActivePiece = () => world.hasSingleton(Piece);
|
||||
const isBlocked = () =>
|
||||
world.hasSingleton(GameOver) || world.hasSingleton(Paused);
|
||||
|
||||
commands.handle(MoveLeft, () => {
|
||||
if (!hasActivePiece() || isBlocked()) return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
|
||||
piece.x--;
|
||||
}
|
||||
});
|
||||
|
||||
commands.handle(MoveRight, () => {
|
||||
if (!hasActivePiece() || isBlocked()) return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
|
||||
piece.x++;
|
||||
}
|
||||
});
|
||||
|
||||
commands.handle(Rotate, () => {
|
||||
if (!hasActivePiece() || isBlocked()) return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
|
||||
if (result) {
|
||||
piece.shape = result.shape;
|
||||
piece.x = result.x;
|
||||
}
|
||||
});
|
||||
|
||||
commands.handle(SoftDrop, () => {
|
||||
if (!hasActivePiece() || isBlocked()) return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
}
|
||||
});
|
||||
|
||||
commands.handle(HardDrop, () => {
|
||||
if (!hasActivePiece() || isBlocked()) return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
}
|
||||
pieces.lockAndSpawn();
|
||||
});
|
||||
|
||||
commands.handle(TogglePause, () => {
|
||||
if (world.hasSingleton(GameOver)) return;
|
||||
if (world.hasSingleton(Paused)) {
|
||||
world.removeSingleton(Paused);
|
||||
} else {
|
||||
world.addSingleton(Paused);
|
||||
}
|
||||
});
|
||||
|
||||
commands.handle(Restart, () => {
|
||||
if (!world.hasSingleton(GameOver)) return;
|
||||
const board = world.getSingleton(Board);
|
||||
for (let r = 0; r < board.grid.length; r++) {
|
||||
board.grid[r].fill(0);
|
||||
}
|
||||
world.setSingleton(Score, { points: 0, lines: 0, level: 1 });
|
||||
world.setSingleton(TickTimer, { accumulator: 0, interval: 800 });
|
||||
world.removeSingleton(GameOver);
|
||||
if (world.hasSingleton(Piece)) world.removeSingleton(Piece);
|
||||
pieces.spawnPiece();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { defineComponent } from "../../src/component";
|
||||
import type { World } from "../../src/index";
|
||||
import {
|
||||
randomPiece,
|
||||
collides,
|
||||
lockPiece,
|
||||
clearLines,
|
||||
scoreForLines,
|
||||
BOARD_W,
|
||||
} from "./game";
|
||||
|
||||
// ── Component definitions ────────────────────────────
|
||||
|
||||
// ── Board ────────────────────────────────────────────
|
||||
/** The playfield grid (20 rows × 10 cols). 0 = empty, non-zero = color index. */
|
||||
export const Board = defineComponent("board", {
|
||||
grid: Array.from({ length: 20 }, () => new Uint8Array(10)) as Uint8Array[],
|
||||
|
|
@ -29,3 +39,45 @@ export const TickTimer = defineComponent("tickTimer", {
|
|||
accumulator: 0,
|
||||
interval: 800, // ms between gravity ticks
|
||||
});
|
||||
|
||||
// ── Piece helpers ────────────────────────────────────
|
||||
|
||||
export function createPieceHelpers(world: World) {
|
||||
return {
|
||||
spawnPiece(): void {
|
||||
const p = randomPiece();
|
||||
world.addSingleton(Piece, {
|
||||
shape: p.shape,
|
||||
color: p.color,
|
||||
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
|
||||
y: 0,
|
||||
});
|
||||
},
|
||||
|
||||
lockAndSpawn(): void {
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
|
||||
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
|
||||
world.removeSingleton(Piece);
|
||||
|
||||
const cleared = clearLines(board.grid);
|
||||
if (cleared > 0) {
|
||||
const score = world.getSingleton(Score);
|
||||
score.lines += cleared;
|
||||
score.points += scoreForLines(cleared, score.level);
|
||||
score.level = Math.floor(score.lines / 10) + 1;
|
||||
const timer = world.getSingleton(TickTimer);
|
||||
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
|
||||
}
|
||||
|
||||
this.spawnPiece();
|
||||
|
||||
const newPiece = world.getSingleton(Piece);
|
||||
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
|
||||
world.removeSingleton(Piece);
|
||||
world.addSingleton(GameOver);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
//
|
||||
// Architecture:
|
||||
// Behaviour Tree (buildTree) — controls game flow:
|
||||
// root (repeat)
|
||||
// └── seq (sequential)
|
||||
// ├── handleInput (leaf) — reads queued commands, mutates state
|
||||
// ├── gravityTick (leaf) — auto-drop piece on timer
|
||||
// └── render (leaf) — draws via blessed
|
||||
// parallel
|
||||
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
|
||||
// └── repeat
|
||||
// └── seq (sequential)
|
||||
// ├── handleInput (leaf) — reads queued commands
|
||||
// └── render (leaf) — draws via blessed
|
||||
//
|
||||
// CommandQueue — processes input:
|
||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||
|
|
@ -22,9 +23,18 @@ import { World } from "../../src/index";
|
|||
import { buildTree } from "../../src/bt/index";
|
||||
import { CommandQueue } from "../../src/commands/index";
|
||||
|
||||
import { Board, Piece, Score, GameOver, Paused, TickTimer } from "./components";
|
||||
import {
|
||||
Board,
|
||||
Piece,
|
||||
Score,
|
||||
GameOver,
|
||||
Paused,
|
||||
TickTimer,
|
||||
createPieceHelpers,
|
||||
} from "./components";
|
||||
|
||||
import {
|
||||
registerCommands,
|
||||
MoveLeft,
|
||||
MoveRight,
|
||||
Rotate,
|
||||
|
|
@ -34,15 +44,7 @@ import {
|
|||
Restart,
|
||||
} from "./commands";
|
||||
|
||||
import {
|
||||
randomPiece,
|
||||
collides,
|
||||
lockPiece,
|
||||
clearLines,
|
||||
scoreForLines,
|
||||
tryRotate,
|
||||
BOARD_W,
|
||||
} from "./game";
|
||||
import { collides } from "./game";
|
||||
|
||||
import { createUI, render } from "./render";
|
||||
import { startInput, type Key } from "./input";
|
||||
|
|
@ -50,208 +52,67 @@ import { startInput, type Key } from "./input";
|
|||
// ── Setup ────────────────────────────────────────────
|
||||
const world = new World();
|
||||
|
||||
// Singleton game state — one entity holds all of these
|
||||
world.addSingleton(Board);
|
||||
world.addSingleton(Score);
|
||||
world.addSingleton(TickTimer);
|
||||
|
||||
// Create blessed UI
|
||||
const ui = createUI();
|
||||
|
||||
// Spawn the first piece
|
||||
spawnPiece();
|
||||
|
||||
// ── Command handlers ─────────────────────────────────
|
||||
const pieces = createPieceHelpers(world);
|
||||
const commands = new CommandQueue(world);
|
||||
|
||||
function spawnPiece(): void {
|
||||
const p = randomPiece();
|
||||
world.addSingleton(Piece, {
|
||||
shape: p.shape,
|
||||
color: p.color,
|
||||
x: Math.floor((BOARD_W - p.shape[0].length) / 2),
|
||||
y: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function hasActivePiece(): boolean {
|
||||
return world.hasSingleton(Piece);
|
||||
}
|
||||
|
||||
// Move left
|
||||
commands.handle(MoveLeft, () => {
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x - 1, piece.y)) {
|
||||
piece.x--;
|
||||
}
|
||||
});
|
||||
|
||||
// Move right
|
||||
commands.handle(MoveRight, () => {
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x + 1, piece.y)) {
|
||||
piece.x++;
|
||||
}
|
||||
});
|
||||
|
||||
// Rotate
|
||||
commands.handle(Rotate, () => {
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
const result = tryRotate(board.grid, piece.shape, piece.x, piece.y);
|
||||
if (result) {
|
||||
piece.shape = result.shape;
|
||||
piece.x = result.x;
|
||||
}
|
||||
});
|
||||
|
||||
// Soft drop
|
||||
commands.handle(SoftDrop, () => {
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
}
|
||||
});
|
||||
|
||||
// Hard drop
|
||||
commands.handle(HardDrop, () => {
|
||||
if (
|
||||
!hasActivePiece() ||
|
||||
world.hasSingleton(GameOver) ||
|
||||
world.hasSingleton(Paused)
|
||||
)
|
||||
return;
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
while (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
}
|
||||
lockAndSpawn();
|
||||
});
|
||||
|
||||
// Toggle pause
|
||||
commands.handle(TogglePause, () => {
|
||||
if (world.hasSingleton(GameOver)) return;
|
||||
if (world.hasSingleton(Paused)) {
|
||||
world.removeSingleton(Paused);
|
||||
} else {
|
||||
world.addSingleton(Paused);
|
||||
}
|
||||
});
|
||||
|
||||
// Restart
|
||||
commands.handle(Restart, () => {
|
||||
if (!world.hasSingleton(GameOver)) return;
|
||||
const board = world.getSingleton(Board);
|
||||
for (let r = 0; r < board.grid.length; r++) {
|
||||
board.grid[r].fill(0);
|
||||
}
|
||||
world.setSingleton(Score, { points: 0, lines: 0, level: 1 });
|
||||
world.setSingleton(TickTimer, { accumulator: 0, interval: 800 });
|
||||
world.removeSingleton(GameOver);
|
||||
if (world.hasSingleton(Piece)) world.removeSingleton(Piece);
|
||||
spawnPiece();
|
||||
});
|
||||
|
||||
// ── Lock piece & spawn next ──────────────────────────
|
||||
function lockAndSpawn(): void {
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
|
||||
lockPiece(board.grid, piece.shape, piece.color, piece.x, piece.y);
|
||||
world.removeSingleton(Piece);
|
||||
|
||||
const cleared = clearLines(board.grid);
|
||||
if (cleared > 0) {
|
||||
const score = world.getSingleton(Score);
|
||||
score.lines += cleared;
|
||||
score.points += scoreForLines(cleared, score.level);
|
||||
score.level = Math.floor(score.lines / 10) + 1;
|
||||
const timer = world.getSingleton(TickTimer);
|
||||
timer.interval = Math.max(100, 800 - (score.level - 1) * 70);
|
||||
}
|
||||
|
||||
spawnPiece();
|
||||
|
||||
const newPiece = world.getSingleton(Piece);
|
||||
if (collides(board.grid, newPiece.shape, newPiece.x, newPiece.y)) {
|
||||
world.removeSingleton(Piece);
|
||||
world.addSingleton(GameOver);
|
||||
}
|
||||
}
|
||||
registerCommands(world, commands, pieces);
|
||||
pieces.spawnPiece();
|
||||
|
||||
// ── Behaviour Tree ───────────────────────────────────
|
||||
const runner = buildTree(world, {
|
||||
kind: "repeat",
|
||||
child: {
|
||||
kind: "sequential",
|
||||
children: [
|
||||
{
|
||||
kind: "leaf",
|
||||
run: () => {
|
||||
commands.execute();
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "leaf",
|
||||
*run() {
|
||||
while (true) {
|
||||
const dt: number = yield;
|
||||
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||
continue;
|
||||
}
|
||||
const timer = world.getSingleton(TickTimer);
|
||||
timer.accumulator += dt;
|
||||
if (timer.accumulator >= timer.interval) {
|
||||
timer.accumulator -= timer.interval;
|
||||
if (hasActivePiece()) {
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
} else {
|
||||
lockAndSpawn();
|
||||
}
|
||||
kind: "parallel",
|
||||
children: [
|
||||
{
|
||||
kind: "leaf",
|
||||
*run() {
|
||||
while (true) {
|
||||
const dt: number = yield;
|
||||
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||
continue;
|
||||
}
|
||||
const timer = world.getSingleton(TickTimer);
|
||||
timer.accumulator += dt;
|
||||
if (timer.accumulator >= timer.interval) {
|
||||
timer.accumulator -= timer.interval;
|
||||
if (world.hasSingleton(Piece)) {
|
||||
const piece = world.getSingleton(Piece);
|
||||
const board = world.getSingleton(Board);
|
||||
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
} else {
|
||||
pieces.lockAndSpawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: "leaf",
|
||||
run: () => {
|
||||
render(world, ui);
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "repeat",
|
||||
child: {
|
||||
kind: "sequential",
|
||||
children: [
|
||||
{
|
||||
kind: "leaf",
|
||||
run: () => {
|
||||
commands.execute();
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "leaf",
|
||||
run: () => {
|
||||
render(world, ui);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// ── Input → Command mapping ──────────────────────────
|
||||
|
|
@ -281,7 +142,6 @@ const interval = setInterval(() => {
|
|||
runner.tick(TICK_MS);
|
||||
}, TICK_MS);
|
||||
|
||||
// Cleanup on exit
|
||||
process.on("SIGINT", () => {
|
||||
clearInterval(interval);
|
||||
ui.screen.destroy();
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ function clearSubtree(world: World, entity: Entity): void {
|
|||
}
|
||||
}
|
||||
|
||||
function childrenOf(world: World, parent: Entity): Entity[] {
|
||||
return world.getRelatedTo(parent, ChildOf);
|
||||
function* childrenOf(world: World, parent: Entity): IterableIterator<Entity> {
|
||||
yield* world.getRelatedTo(parent, ChildOf);
|
||||
}
|
||||
|
||||
function parentOf(world: World, child: Entity): Entity | null {
|
||||
|
|
@ -230,10 +230,9 @@ export class TaskRunner {
|
|||
}
|
||||
|
||||
private _executeRandom(entity: Entity): void {
|
||||
const children = childrenOf(this._world, entity);
|
||||
|
||||
// Check if any child already reached a terminal
|
||||
for (const child of children) {
|
||||
// Single pass: check terminals and collect eligible children
|
||||
const eligible: Entity[] = [];
|
||||
for (const child of childrenOf(this._world, entity)) {
|
||||
if (isTerminal(this._world, child)) {
|
||||
const status = terminalStatus(this._world, child)!;
|
||||
this._finish(
|
||||
|
|
@ -246,13 +245,14 @@ export class TaskRunner {
|
|||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this._world.has(child, Running) &&
|
||||
!this._world.has(child, Scheduled)
|
||||
) {
|
||||
eligible.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
// Pick a random child that isn't already running
|
||||
const eligible = children.filter(
|
||||
(c) => !this._world.has(c, Running) && !this._world.has(c, Scheduled),
|
||||
);
|
||||
|
||||
if (eligible.length > 0) {
|
||||
const pick = eligible[Math.floor(Math.random() * eligible.length)];
|
||||
this._world.add(pick, Scheduled);
|
||||
|
|
@ -261,7 +261,7 @@ export class TaskRunner {
|
|||
}
|
||||
|
||||
private _executeRepeat(entity: Entity): void {
|
||||
const children = childrenOf(this._world, entity);
|
||||
const children = [...childrenOf(this._world, entity)];
|
||||
|
||||
// Repeat expects exactly one child
|
||||
if (children.length === 0) return;
|
||||
|
|
|
|||
15
src/world.ts
15
src/world.ts
|
|
@ -388,17 +388,22 @@ export class World {
|
|||
/**
|
||||
* Get all source entities that point to `target` via this relationship.
|
||||
*/
|
||||
getRelatedTo(target: Entity, rel: RelationshipDef): Entity[] {
|
||||
*getRelatedTo(
|
||||
target: Entity,
|
||||
rel: RelationshipDef,
|
||||
): IterableIterator<Entity> {
|
||||
const ti = entityIndex(target);
|
||||
if (!this._isAlive(ti, target)) return [];
|
||||
if (!this._isAlive(ti, target)) return;
|
||||
|
||||
const rev = this._relReverse.get(rel._key);
|
||||
if (!rev) return [];
|
||||
if (!rev) return;
|
||||
|
||||
const sources = rev.get(ti);
|
||||
if (!sources) return [];
|
||||
if (!sources) return;
|
||||
|
||||
return [...sources].map((si) => makeEntity(si, this._generations[si]));
|
||||
for (const si of sources) {
|
||||
yield makeEntity(si, this._generations[si]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ describe("Relationships", () => {
|
|||
world.relate(a, ChildOf, parent);
|
||||
world.relate(b, ChildOf, parent);
|
||||
|
||||
const children = world.getRelatedTo(parent, ChildOf);
|
||||
const children = [...world.getRelatedTo(parent, ChildOf)];
|
||||
expect(children).toHaveLength(2);
|
||||
expect(children).toContain(a);
|
||||
expect(children).toContain(b);
|
||||
|
|
@ -63,7 +63,7 @@ describe("Relationships", () => {
|
|||
|
||||
it("getRelatedTo returns empty when no edges", () => {
|
||||
const e = world.spawn();
|
||||
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
|
||||
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
|
||||
});
|
||||
|
||||
it("unrelate removes the relationship", () => {
|
||||
|
|
@ -74,7 +74,7 @@ describe("Relationships", () => {
|
|||
world.unrelate(child, ChildOf);
|
||||
|
||||
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
||||
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
|
||||
});
|
||||
|
||||
it("unrelate is idempotent", () => {
|
||||
|
|
@ -89,13 +89,13 @@ describe("Relationships", () => {
|
|||
|
||||
world.relate(a, ChildOf, b);
|
||||
expect(world.getRelated(a, ChildOf)).toBe(b);
|
||||
expect(world.getRelatedTo(b, ChildOf)).toContain(a);
|
||||
expect([...world.getRelatedTo(b, ChildOf)]).toContain(a);
|
||||
|
||||
world.relate(a, ChildOf, c);
|
||||
expect(world.getRelated(a, ChildOf)).toBe(c);
|
||||
// a should no longer point to b
|
||||
expect(world.getRelatedTo(b, ChildOf)).toEqual([]);
|
||||
expect(world.getRelatedTo(c, ChildOf)).toContain(a);
|
||||
expect([...world.getRelatedTo(b, ChildOf)]).toEqual([]);
|
||||
expect([...world.getRelatedTo(c, ChildOf)]).toContain(a);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -243,7 +243,7 @@ describe("Destroy cleanup", () => {
|
|||
|
||||
world.destroy(child);
|
||||
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
||||
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes edges when target is destroyed", () => {
|
||||
|
|
@ -252,7 +252,7 @@ describe("Destroy cleanup", () => {
|
|||
world.relate(child, ChildOf, parent);
|
||||
|
||||
world.destroy(parent);
|
||||
expect(world.getRelatedTo(parent, ChildOf)).toEqual([]);
|
||||
expect([...world.getRelatedTo(parent, ChildOf)]).toEqual([]);
|
||||
expect(world.getRelated(child, ChildOf)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -376,6 +376,6 @@ describe("Dead entity safety", () => {
|
|||
it("getRelatedTo returns empty for dead entity", () => {
|
||||
const e = world.spawn();
|
||||
world.destroy(e);
|
||||
expect(world.getRelatedTo(e, ChildOf)).toEqual([]);
|
||||
expect([...world.getRelatedTo(e, ChildOf)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue