ecs-observable/examples/blackjack/components.ts

204 lines
6.5 KiB
TypeScript

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("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;
}
}