refactor(examples): reorganized code
Move code to avoid big main.ts and changed the bt to match the pattern
This commit is contained in:
parent
5d125167cc
commit
a97488e8d6
|
|
@ -1,4 +1,26 @@
|
||||||
import { defineComponent } from "../../src/component";
|
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. */
|
/** Player takes another card. */
|
||||||
export const Hit = defineComponent("hit", {});
|
export const Hit = defineComponent("hit", {});
|
||||||
|
|
@ -14,3 +36,112 @@ export const BetMore = defineComponent("betMore", {});
|
||||||
|
|
||||||
/** Decrease the bet. */
|
/** Decrease the bet. */
|
||||||
export const BetLess = defineComponent("betLess", {});
|
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.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { defineComponent } from "../../src/component";
|
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 ────────────────────────────
|
||||||
|
|
||||||
// ── Card ─────────────────────────────────────────────
|
|
||||||
/** Each card is its own entity. `order` tracks position within its collection. */
|
/** Each card is its own entity. `order` tracks position within its collection. */
|
||||||
export const Card = defineComponent("card", {
|
export const Card = defineComponent("card", {
|
||||||
rank: "A",
|
rank: "A",
|
||||||
|
|
@ -36,3 +40,164 @@ export const GamePhase = defineComponent("gamePhase", {
|
||||||
phase: "betting" as Phase,
|
phase: "betting" as Phase,
|
||||||
message: "",
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
//
|
//
|
||||||
// Architecture:
|
// Architecture:
|
||||||
// Behaviour Tree (buildTree) — controls game flow:
|
// Behaviour Tree (buildTree) — controls game flow:
|
||||||
// root (repeat)
|
// parallel
|
||||||
// └── seq (sequential)
|
// ├── dealerPlay (leaf) — generator loop, auto-plays dealer hand
|
||||||
// ├── handleInput (leaf) — reads queued commands, mutates state
|
// └── repeat
|
||||||
// ├── dealerPlay (leaf) — auto-plays dealer hand on dealerTurn
|
// └── seq (sequential)
|
||||||
// └── render (leaf) — draws via blessed
|
// ├── handleInput (leaf) — reads queued commands
|
||||||
|
// └── render (leaf) — draws via blessed
|
||||||
//
|
//
|
||||||
// CommandQueue — processes input:
|
// CommandQueue — processes input:
|
||||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||||
|
|
@ -22,34 +23,28 @@
|
||||||
// npx tsx examples/blackjack/main.ts
|
// npx tsx examples/blackjack/main.ts
|
||||||
|
|
||||||
import { World } from "../../src/index";
|
import { World } from "../../src/index";
|
||||||
import { query } from "../../src/query";
|
|
||||||
import { buildTree } from "../../src/bt/index";
|
import { buildTree } from "../../src/bt/index";
|
||||||
import { CommandQueue } from "../../src/commands/index";
|
import { CommandQueue } from "../../src/commands/index";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
|
||||||
InDeck,
|
|
||||||
InPlayerHand,
|
|
||||||
InDealerHand,
|
|
||||||
HoleHidden,
|
|
||||||
Score,
|
Score,
|
||||||
Bet,
|
Bet,
|
||||||
GamePhase,
|
GamePhase,
|
||||||
|
InDealerHand,
|
||||||
|
createCardHelpers,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
|
||||||
import { Hit, Stand, NewRound, BetMore, BetLess } from "./commands";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SUITS,
|
registerCommands,
|
||||||
RANKS,
|
resolveRound,
|
||||||
handValue,
|
Hit,
|
||||||
isBust,
|
Stand,
|
||||||
isBlackjack,
|
NewRound,
|
||||||
dealerShouldHit,
|
BetMore,
|
||||||
determineOutcome,
|
BetLess,
|
||||||
payout,
|
} from "./commands";
|
||||||
type CardData,
|
|
||||||
} from "./game";
|
import { dealerShouldHit } from "./game";
|
||||||
|
|
||||||
import { createUI, render } from "./render";
|
import { createUI, render } from "./render";
|
||||||
import { startInput, type Key } from "./input";
|
import { startInput, type Key } from "./input";
|
||||||
|
|
@ -57,305 +52,60 @@ import { startInput, type Key } from "./input";
|
||||||
// ── Setup ────────────────────────────────────────────
|
// ── Setup ────────────────────────────────────────────
|
||||||
const world = new World();
|
const world = new World();
|
||||||
|
|
||||||
// Singleton game state
|
|
||||||
world.addSingleton(Score);
|
world.addSingleton(Score);
|
||||||
world.addSingleton(Bet);
|
world.addSingleton(Bet);
|
||||||
world.addSingleton(GamePhase);
|
world.addSingleton(GamePhase);
|
||||||
|
|
||||||
// Create blessed UI
|
|
||||||
const ui = createUI();
|
const ui = createUI();
|
||||||
|
const cards = createCardHelpers(world);
|
||||||
// ── Card helpers (tag-based) ─────────────────────────
|
|
||||||
|
|
||||||
/** Create all 52 card entities with the InDeck tag. */
|
|
||||||
function 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. */
|
|
||||||
function shuffleDeck(): void {
|
|
||||||
const entities = [...world.query(query(Card, InDeck))];
|
|
||||||
// Fisher-Yates shuffle
|
|
||||||
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]];
|
|
||||||
}
|
|
||||||
// Reassign order to match shuffled position
|
|
||||||
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. */
|
|
||||||
function drawCard(): number | null {
|
|
||||||
const cards = [...world.query(query(Card, InDeck))];
|
|
||||||
if (cards.length === 0) return null;
|
|
||||||
// Find the one with the highest order
|
|
||||||
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. */
|
|
||||||
function dealTo(cardEntity: number, 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. */
|
|
||||||
function 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). */
|
|
||||||
function clearHand(tag: typeof InPlayerHand): void {
|
|
||||||
for (const e of world.query(query(Card, tag))) {
|
|
||||||
world.destroy(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Count cards remaining in the deck. */
|
|
||||||
function deckCount(): number {
|
|
||||||
return [...world.query(query(Card, InDeck))].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Game flow ────────────────────────────────────────
|
|
||||||
|
|
||||||
function dealInitial(): void {
|
|
||||||
const c1 = drawCard();
|
|
||||||
const c2 = drawCard();
|
|
||||||
const c3 = drawCard();
|
|
||||||
const c4 = drawCard();
|
|
||||||
if (c1) dealTo(c1, InPlayerHand);
|
|
||||||
if (c2) dealTo(c2, InDealerHand);
|
|
||||||
if (c3) dealTo(c3, InPlayerHand);
|
|
||||||
if (c4) dealTo(c4, InDealerHand);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRound(): void {
|
|
||||||
const bet = world.getSingleton(Bet);
|
|
||||||
const score = world.getSingleton(Score);
|
|
||||||
|
|
||||||
// Deduct bet
|
|
||||||
score.chips -= bet.amount;
|
|
||||||
|
|
||||||
// Re-shuffle if deck is low
|
|
||||||
if (deckCount() < 15) {
|
|
||||||
// Move all remaining InDeck cards back, then shuffle
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
shuffleDeck();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear old hands
|
|
||||||
clearHand(InPlayerHand);
|
|
||||||
clearHand(InDealerHand);
|
|
||||||
world.removeSingleton(HoleHidden);
|
|
||||||
|
|
||||||
dealInitial();
|
|
||||||
|
|
||||||
// Check for natural blackjack
|
|
||||||
const playerCards = getHand(InPlayerHand);
|
|
||||||
const dealerCards = 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("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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRound(): void {
|
|
||||||
const playerCards = getHand(InPlayerHand);
|
|
||||||
const dealerCards = 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.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command handlers ─────────────────────────────────
|
|
||||||
const commands = new CommandQueue(world);
|
const commands = new CommandQueue(world);
|
||||||
|
|
||||||
commands.handle(Hit, () => {
|
registerCommands(world, commands, cards);
|
||||||
const phase = world.getSingleton(GamePhase);
|
|
||||||
if (phase.phase !== "playerTurn") return;
|
|
||||||
|
|
||||||
const cardEntity = drawCard();
|
|
||||||
if (cardEntity) {
|
|
||||||
dealTo(cardEntity, InPlayerHand);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBust(getHand(InPlayerHand))) {
|
|
||||||
world.removeSingleton(HoleHidden);
|
|
||||||
resolveRound();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Behaviour Tree ───────────────────────────────────
|
// ── Behaviour Tree ───────────────────────────────────
|
||||||
const runner = buildTree(world, {
|
const runner = buildTree(world, {
|
||||||
kind: "repeat",
|
kind: "parallel",
|
||||||
child: {
|
children: [
|
||||||
kind: "sequential",
|
{
|
||||||
children: [
|
kind: "leaf",
|
||||||
{
|
*run() {
|
||||||
kind: "leaf",
|
while (true) {
|
||||||
run: () => {
|
const dt: number = yield;
|
||||||
commands.execute();
|
const phase = world.getSingleton(GamePhase);
|
||||||
},
|
if (phase.phase !== "dealerTurn") continue;
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "leaf",
|
|
||||||
*run() {
|
|
||||||
while (true) {
|
|
||||||
const dt: number = yield;
|
|
||||||
const phase = world.getSingleton(GamePhase);
|
|
||||||
if (phase.phase !== "dealerTurn") continue;
|
|
||||||
|
|
||||||
if (dealerShouldHit(getHand(InDealerHand))) {
|
if (dealerShouldHit(cards.getHand(InDealerHand))) {
|
||||||
const cardEntity = drawCard();
|
const cardEntity = cards.drawCard();
|
||||||
if (cardEntity) {
|
if (cardEntity) {
|
||||||
dealTo(cardEntity, InDealerHand);
|
cards.dealTo(cardEntity, InDealerHand);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resolveRound();
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
resolveRound(world, cards);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
kind: "leaf",
|
{
|
||||||
run: () => {
|
kind: "repeat",
|
||||||
render(world, ui);
|
child: {
|
||||||
},
|
kind: "sequential",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
kind: "leaf",
|
||||||
|
run: () => {
|
||||||
|
commands.execute();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "leaf",
|
||||||
|
run: () => {
|
||||||
|
render(world, ui);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Input → Command mapping ──────────────────────────
|
// ── Input → Command mapping ──────────────────────────
|
||||||
|
|
@ -381,8 +131,8 @@ world.setSingleton(GamePhase, {
|
||||||
message: "Welcome to Blackjack! Press N to start.",
|
message: "Welcome to Blackjack! Press N to start.",
|
||||||
});
|
});
|
||||||
|
|
||||||
buildDeck();
|
cards.buildDeck();
|
||||||
shuffleDeck();
|
cards.shuffleDeck();
|
||||||
runner.schedule((runner as any).root);
|
runner.schedule((runner as any).root);
|
||||||
|
|
||||||
const TICK_MS = 16;
|
const TICK_MS = 16;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,24 @@
|
||||||
import { defineComponent } from "../../src/component";
|
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. */
|
/** Move the active piece left. */
|
||||||
export const MoveLeft = defineComponent("moveLeft", {});
|
export const MoveLeft = defineComponent("moveLeft", {});
|
||||||
|
|
@ -20,3 +40,85 @@ export const TogglePause = defineComponent("togglePause", {});
|
||||||
|
|
||||||
/** Restart after game over. */
|
/** Restart after game over. */
|
||||||
export const Restart = defineComponent("restart", {});
|
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 { 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. */
|
/** The playfield grid (20 rows × 10 cols). 0 = empty, non-zero = color index. */
|
||||||
export const Board = defineComponent("board", {
|
export const Board = defineComponent("board", {
|
||||||
grid: Array.from({ length: 20 }, () => new Uint8Array(10)) as Uint8Array[],
|
grid: Array.from({ length: 20 }, () => new Uint8Array(10)) as Uint8Array[],
|
||||||
|
|
@ -29,3 +39,45 @@ export const TickTimer = defineComponent("tickTimer", {
|
||||||
accumulator: 0,
|
accumulator: 0,
|
||||||
interval: 800, // ms between gravity ticks
|
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:
|
// Architecture:
|
||||||
// Behaviour Tree (buildTree) — controls game flow:
|
// Behaviour Tree (buildTree) — controls game flow:
|
||||||
// root (repeat)
|
// parallel
|
||||||
// └── seq (sequential)
|
// ├── gravityTick (leaf) — generator loop, auto-drop piece on timer
|
||||||
// ├── handleInput (leaf) — reads queued commands, mutates state
|
// └── repeat
|
||||||
// ├── gravityTick (leaf) — auto-drop piece on timer
|
// └── seq (sequential)
|
||||||
// └── render (leaf) — draws via blessed
|
// ├── handleInput (leaf) — reads queued commands
|
||||||
|
// └── render (leaf) — draws via blessed
|
||||||
//
|
//
|
||||||
// CommandQueue — processes input:
|
// CommandQueue — processes input:
|
||||||
// Keyboard → spawn command entities → CommandQueue.execute()
|
// Keyboard → spawn command entities → CommandQueue.execute()
|
||||||
|
|
@ -22,9 +23,18 @@ import { World } from "../../src/index";
|
||||||
import { buildTree } from "../../src/bt/index";
|
import { buildTree } from "../../src/bt/index";
|
||||||
import { CommandQueue } from "../../src/commands/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 {
|
import {
|
||||||
|
registerCommands,
|
||||||
MoveLeft,
|
MoveLeft,
|
||||||
MoveRight,
|
MoveRight,
|
||||||
Rotate,
|
Rotate,
|
||||||
|
|
@ -34,15 +44,7 @@ import {
|
||||||
Restart,
|
Restart,
|
||||||
} from "./commands";
|
} from "./commands";
|
||||||
|
|
||||||
import {
|
import { collides } from "./game";
|
||||||
randomPiece,
|
|
||||||
collides,
|
|
||||||
lockPiece,
|
|
||||||
clearLines,
|
|
||||||
scoreForLines,
|
|
||||||
tryRotate,
|
|
||||||
BOARD_W,
|
|
||||||
} from "./game";
|
|
||||||
|
|
||||||
import { createUI, render } from "./render";
|
import { createUI, render } from "./render";
|
||||||
import { startInput, type Key } from "./input";
|
import { startInput, type Key } from "./input";
|
||||||
|
|
@ -50,208 +52,67 @@ import { startInput, type Key } from "./input";
|
||||||
// ── Setup ────────────────────────────────────────────
|
// ── Setup ────────────────────────────────────────────
|
||||||
const world = new World();
|
const world = new World();
|
||||||
|
|
||||||
// Singleton game state — one entity holds all of these
|
|
||||||
world.addSingleton(Board);
|
world.addSingleton(Board);
|
||||||
world.addSingleton(Score);
|
world.addSingleton(Score);
|
||||||
world.addSingleton(TickTimer);
|
world.addSingleton(TickTimer);
|
||||||
|
|
||||||
// Create blessed UI
|
|
||||||
const ui = createUI();
|
const ui = createUI();
|
||||||
|
const pieces = createPieceHelpers(world);
|
||||||
// Spawn the first piece
|
|
||||||
spawnPiece();
|
|
||||||
|
|
||||||
// ── Command handlers ─────────────────────────────────
|
|
||||||
const commands = new CommandQueue(world);
|
const commands = new CommandQueue(world);
|
||||||
|
|
||||||
function spawnPiece(): void {
|
registerCommands(world, commands, pieces);
|
||||||
const p = randomPiece();
|
pieces.spawnPiece();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Behaviour Tree ───────────────────────────────────
|
// ── Behaviour Tree ───────────────────────────────────
|
||||||
const runner = buildTree(world, {
|
const runner = buildTree(world, {
|
||||||
kind: "repeat",
|
kind: "parallel",
|
||||||
child: {
|
children: [
|
||||||
kind: "sequential",
|
{
|
||||||
children: [
|
kind: "leaf",
|
||||||
{
|
*run() {
|
||||||
kind: "leaf",
|
while (true) {
|
||||||
run: () => {
|
const dt: number = yield;
|
||||||
commands.execute();
|
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
||||||
},
|
continue;
|
||||||
},
|
}
|
||||||
{
|
const timer = world.getSingleton(TickTimer);
|
||||||
kind: "leaf",
|
timer.accumulator += dt;
|
||||||
*run() {
|
if (timer.accumulator >= timer.interval) {
|
||||||
while (true) {
|
timer.accumulator -= timer.interval;
|
||||||
const dt: number = yield;
|
if (world.hasSingleton(Piece)) {
|
||||||
if (world.hasSingleton(GameOver) || world.hasSingleton(Paused)) {
|
const piece = world.getSingleton(Piece);
|
||||||
continue;
|
const board = world.getSingleton(Board);
|
||||||
}
|
if (!collides(board.grid, piece.shape, piece.x, piece.y + 1)) {
|
||||||
const timer = world.getSingleton(TickTimer);
|
piece.y++;
|
||||||
timer.accumulator += dt;
|
} else {
|
||||||
if (timer.accumulator >= timer.interval) {
|
pieces.lockAndSpawn();
|
||||||
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: "leaf",
|
{
|
||||||
run: () => {
|
kind: "repeat",
|
||||||
render(world, ui);
|
child: {
|
||||||
},
|
kind: "sequential",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
kind: "leaf",
|
||||||
|
run: () => {
|
||||||
|
commands.execute();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "leaf",
|
||||||
|
run: () => {
|
||||||
|
render(world, ui);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Input → Command mapping ──────────────────────────
|
// ── Input → Command mapping ──────────────────────────
|
||||||
|
|
@ -281,7 +142,6 @@ const interval = setInterval(() => {
|
||||||
runner.tick(TICK_MS);
|
runner.tick(TICK_MS);
|
||||||
}, TICK_MS);
|
}, TICK_MS);
|
||||||
|
|
||||||
// Cleanup on exit
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
ui.screen.destroy();
|
ui.screen.destroy();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue