layout blackjack table

This commit is contained in:
2025-11-03 18:15:33 -08:00
parent 0ce2e6b328
commit c344c60db0
4 changed files with 325 additions and 36 deletions

88
client/src/Animation.ts Normal file
View File

@ -0,0 +1,88 @@
import type { Sprite, Ticker, TickerCallback } from "pixi.js";
import { CardStatus } from "./Hand";
import { getSpriteSheet } from "./spritesheet";
const spritesheet = await getSpriteSheet();
export abstract class Animation {
protected ticker: Ticker;
protected cb: TickerCallback<any>;
#next: Animation | null;
constructor(ticker: Ticker) {
console.log("this got called", ticker);
if (ticker === undefined) throw new Error("got nothing");
this.ticker = ticker;
this.cb = () => {};
this.#next = null;
}
abstract getCallback(): TickerCallback<any>;
next(animation: Animation) {
this.#next = animation;
}
done() {
console.log("removing", this.ticker);
this.ticker.remove(this.cb);
}
}
export class FlipCard extends Animation {
card: Sprite;
cardWidth: number;
status: CardStatus;
flipped: boolean;
constructor(ticker: Ticker, card: Sprite, status: CardStatus) {
super(ticker);
this.cb = () => {};
this.card = card;
this.status = status;
this.flipped = false;
this.cardWidth = card.width;
}
getCallback() {
const callback = (ticker: Ticker) => {
const to = this.status.isFaceDown ? this.status.face : "unknown";
this.status.isFaceDown = !this.status.isFaceDown;
console.log(this);
this.card.pivot.set(this.cardWidth / 2, 0);
// the card has just fipped
if (!this.flipped && this.card.width <= 10) {
this.flipped = true;
this.card.texture = spritesheet.textures[to];
}
// the other side of the card is how showing
if (this.flipped) {
const change = 20 * ticker.deltaTime;
if (this.card.width + change >= this.cardWidth)
this.card.width = this.cardWidth;
else this.card.width += change;
}
// the original side is still showing
else {
const change = 20 * ticker.deltaTime;
if (this.card.width <= change) this.card.width = 0;
else this.card.width -= change;
}
if (this.flipped && this.card.width === this.cardWidth) {
console.log("remove", this.flipped, this.card.width, this.cardWidth);
super.done();
}
};
this.cb = callback.bind(this);
return this.cb;
}
}

View File

@ -1,25 +1,53 @@
import { Assets, Container, Sprite } from "pixi.js"; import {
Assets,
Container,
Sprite,
Spritesheet,
Ticker,
type TickerCallback,
} from "pixi.js";
import { Card } from "./Card"; import { Card } from "./Card";
import { CARD_HOVER_DIST, CARD_WIDTH } from "./constants"; import { CARD_HOVER_DIST, CARD_WIDTH } from "./constants";
import { Animation, FlipCard } from "./Animation";
type Pivot = "left" | "right" | "center"; type Pivot = "left" | "right" | "center";
type Layout = "horizontal" | "ascending" | "stacked";
const spritesheet = await Assets.load("/public/assets/cards.json"); const spritesheet = await Assets.load<Spritesheet>("/public/assets/cards.json");
export type CardStatus = {
face: Card;
isAnimating: boolean;
isFaceDown: boolean;
};
export class Hand { export class Hand {
#cards: Sprite[]; #animations: Animation[];
#cardSprites: Sprite[];
#statuses: CardStatus[];
#container: Container; #container: Container;
#hover: boolean;
#layout: Layout;
#maxWidth: number; #maxWidth: number;
#pivot: Pivot; #pivot: Pivot;
#ticker: Ticker | null;
constructor(maxWidth: number, cards: Card[] = []) { constructor(maxWidth: number, cards: Card[] = []) {
if (maxWidth < CARD_WIDTH) { if (maxWidth < CARD_WIDTH) {
throw new Error("hand cannot be narrower than a single card"); throw new Error("hand cannot be narrower than a single card");
} }
this.#maxWidth = maxWidth; this.#animations = [];
this.#container = new Container(); this.#container = new Container();
this.#hover = false;
this.#maxWidth = maxWidth;
this.#pivot = "left"; this.#pivot = "left";
this.#layout = "horizontal";
this.#statuses = cards.map((card) => ({
face: card,
isAnimating: false,
isFaceDown: false,
}));
this.#ticker = null;
if (cards.length > 0) { if (cards.length > 0) {
const sprites: Sprite[] = []; const sprites: Sprite[] = [];
@ -27,33 +55,90 @@ export class Hand {
this.add(card); this.add(card);
} }
this.#cards = sprites; this.#cardSprites = sprites;
this.#container.addChild(...sprites); this.#container.addChild(...sprites);
this.fanCards(); this.fanCards();
} else { } else {
this.#cards = []; this.#cardSprites = [];
} }
} }
add(card: Card) { add(card: Card, faceDown = false) {
const sprite = new Sprite(spritesheet.textures[card]); const sprite = new Sprite(spritesheet.textures[card]);
sprite.eventMode = "dynamic";
sprite.onmouseenter = () => { this.#cardSprites.push(sprite);
sprite.y -= CARD_HOVER_DIST; this.#statuses.push({
}; face: card,
sprite.onmouseleave = () => { isAnimating: false,
sprite.y += CARD_HOVER_DIST; isFaceDown: faceDown,
}; });
this.#cards.push(sprite);
this.#container.addChild(sprite); this.#container.addChild(sprite);
this.#setCardHover(sprite);
this.fanCards(); this.fanCards();
this.setPivot(this.#pivot); this.setPivot(this.#pivot);
} }
animationTicker(ticker: Ticker) {
this.#ticker = ticker;
ticker.add((ticker) => {
if (this.#animations.length) {
const animation = this.#animations.shift()!;
ticker.add(animation.getCallback());
}
});
}
flip(cardIdx: number) {
if (cardIdx >= this.#cardSprites.length)
throw new Error(`card index out of range: ${cardIdx}`);
if (!this.#ticker)
throw new Error("can't animate hand before passing it a ticker");
const card = this.#cardSprites[cardIdx];
const status = this.#statuses[cardIdx];
const ticker = this.#ticker;
this.#animations.push(new FlipCard(ticker, card, status));
}
#setCardHover(sprite: Sprite) {
if (this.#hover) {
const { y } = sprite;
sprite.eventMode = "dynamic";
sprite.onmouseenter = () => {
sprite.y -= CARD_HOVER_DIST;
};
sprite.onmouseleave = () => {
sprite.y = y;
};
} else {
sprite.eventMode = "static";
}
}
hover(hasHover: boolean) {
if (this.#hover === hasHover) return;
this.#hover = hasHover;
for (const sprite of this.#cardSprites) {
this.#setCardHover(sprite);
}
}
setPosition(x: number, y: number) { setPosition(x: number, y: number) {
this.#container.position.set(x, y); this.#container.position.set(x, y);
} }
setCardLayout(layout: Layout) {
this.#layout = layout;
}
setPivot(pivot: "left" | "right" | "center") { setPivot(pivot: "left" | "right" | "center") {
switch (pivot) { switch (pivot) {
case "left": case "left":
@ -73,13 +158,30 @@ export class Hand {
} }
fanCards() { fanCards() {
const count = this.#cards.length; const count = this.#cardSprites.length;
const max = this.#maxWidth; const max = this.#maxWidth;
const offset = count * CARD_WIDTH > max ? max / count : CARD_WIDTH; let offset = CARD_WIDTH / 3;
if (this.#layout === "horizontal") {
offset = count * CARD_WIDTH > max ? max / count : CARD_WIDTH;
}
let x = 0; let x = 0;
for (const card of this.#cards) { let y = 0;
for (const card of this.#cardSprites) {
card.x = x; card.x = x;
x += offset; card.y = y;
switch (this.#layout) {
case "ascending":
x += offset;
y -= offset;
break;
case "horizontal":
x += offset;
break;
case "stacked":
x += CARD_WIDTH / 20;
break;
}
} }
} }

View File

@ -1,18 +1,19 @@
import { Application } from "pixi.js"; import {
Application,
Bounds,
FillGradient,
Graphics,
Text,
TextStyle,
TextStyleOptions,
} from "pixi.js";
import { Hand } from "./Hand"; import { Hand } from "./Hand";
import { Card } from "./Card"; import { Card } from "./Card";
import { CARD_HEIGHT, CARD_HOVER_DIST } from "./constants"; import { CARD_HEIGHT, CARD_HOVER_DIST, CARD_WIDTH } from "./constants";
const playerCards: Card[] = [ const playerCards: Card[] = ["nineOfSpades", "sixOfClubs"];
"threeOfClubs",
"twoOfDiamonds",
"aceOfClubs",
"eightOfDiamonds",
"nineOfSpades",
"sixOfClubs",
];
const dealerCards: Card[] = ["aceOfHearts"]; const dealerCards: Card[] = ["unknown", "aceOfHearts"];
(async () => { (async () => {
try { try {
@ -27,25 +28,108 @@ const dealerCards: Card[] = ["aceOfHearts"];
// Append the application canvas to the document body // Append the application canvas to the document body
document.getElementById("pixi-container")!.appendChild(app.canvas); document.getElementById("pixi-container")!.appendChild(app.canvas);
const payoutStyle: TextStyleOptions = {
fontFamily: "Fira Sans",
fontWeight: "bold",
fontSize: CARD_HEIGHT / 5,
fill: "#ff9900",
padding: 10,
letterSpacing: 10,
};
const blackjackPayout = new Text({
text: "BLACKJACK PAYS 3 TO 2",
style: payoutStyle,
});
const tableMarkings = new Graphics();
const markTable = () => {
tableMarkings.clear();
// dark green dealer area
tableMarkings.rect(0, 0, app.screen.width, CARD_HEIGHT * 1.8);
tableMarkings.fill("#055010");
// white insurance info box
tableMarkings.rect(
CARD_WIDTH / 2,
CARD_HEIGHT * 2.15,
app.screen.width - CARD_WIDTH,
CARD_HEIGHT / 2.5,
);
tableMarkings.stroke({
color: "#ffffff",
width: 8,
});
};
const positionPayoutInfo = () => {
blackjackPayout.pivot.set(blackjackPayout.width / 2, 0);
blackjackPayout.position.set(app.screen.width / 2, CARD_HEIGHT * 1.5);
};
const dealerRulesStyle: TextStyleOptions = {
fontFamily: "Fira Sans",
fontStyle: "italic",
fontSize: CARD_HEIGHT / 7,
fill: "#ffffff",
};
const dealerRules = new Text({
text: "Dealer must stand on 17 and must draw to 16",
style: dealerRulesStyle,
});
const positionDealerInfo = () => {
dealerRules.pivot.set(dealerRules.width / 2, 0);
dealerRules.position.set(app.screen.width / 2, CARD_HEIGHT * 1.85);
};
const insuranceStyle: TextStyleOptions = {
fontFamily: "Fira Sans",
fontWeight: "bold",
fontSize: CARD_HEIGHT / 5,
fill: "#ffffff",
letterSpacing: 10,
};
const insurancePayout = new Text({
text: "INSURANCE PAYS 2 TO 1",
style: insuranceStyle,
});
const positionInsurancePayout = () => {
insurancePayout.pivot.set(insurancePayout.width / 2, 0);
insurancePayout.position.set(app.screen.width / 2, CARD_HEIGHT * 2.2);
};
const playerHand = new Hand(350, []); const playerHand = new Hand(350, []);
const dealerHand = new Hand(350, []); const dealerHand = new Hand(350, []);
const positionPlayer = () => { const positionPlayer = () => {
playerHand.setPivot("center"); playerHand.setPivot("center");
playerHand.setPosition( playerHand.setPosition(app.screen.width / 2, CARD_HEIGHT * 3);
app.screen.width / 2, playerHand.setCardLayout("ascending");
app.screen.height - CARD_HEIGHT + CARD_HOVER_DIST,
);
}; };
const positionDealer = () => { const positionDealer = () => {
dealerHand.setPivot("center"); dealerHand.setPivot("center");
dealerHand.setPosition(app.screen.width / 2, 0 + CARD_HOVER_DIST); dealerHand.setPosition(app.screen.width / 2, 0 + CARD_HOVER_DIST);
dealerHand.setCardLayout("stacked");
}; };
markTable();
positionPayoutInfo();
positionDealerInfo();
positionInsurancePayout();
positionPlayer(); positionPlayer();
positionDealer(); positionDealer();
app.stage.addChild(tableMarkings);
app.stage.addChild(blackjackPayout);
app.stage.addChild(dealerRules);
app.stage.addChild(insurancePayout);
app.stage.addChild(playerHand.getContainer()); app.stage.addChild(playerHand.getContainer());
app.stage.addChild(dealerHand.getContainer()); app.stage.addChild(dealerHand.getContainer());
@ -60,6 +144,10 @@ const dealerCards: Card[] = ["aceOfHearts"];
// https://pixijs.com/8.x/guides/concepts/scene-graph#local-vs-global-coordinates // https://pixijs.com/8.x/guides/concepts/scene-graph#local-vs-global-coordinates
window.addEventListener?.("resize", () => { window.addEventListener?.("resize", () => {
app.renderer.resize(window.innerWidth, window.innerHeight); app.renderer.resize(window.innerWidth, window.innerHeight);
markTable();
positionPayoutInfo();
positionDealerInfo();
positionInsurancePayout();
positionPlayer(); positionPlayer();
positionDealer(); positionDealer();
}); });
@ -71,11 +159,11 @@ const dealerCards: Card[] = ["aceOfHearts"];
if (elapsed >= ms) { if (elapsed >= ms) {
ms += 300; ms += 300;
if (playerCards.length) { if (playerCards.length) {
playerHand.add(playerCards.pop()!); playerHand.add(playerCards.shift()!);
} }
if (dealerCards.length) { if (dealerCards.length) {
dealerHand.add(dealerCards.pop()!); dealerHand.add(dealerCards.shift()!);
} }
} }
}); });

11
client/src/spritesheet.ts Normal file
View File

@ -0,0 +1,11 @@
import { Assets, Spritesheet } from "pixi.js";
let spriteSheet: Spritesheet | null = null;
export async function getSpriteSheet(): Promise<Spritesheet> {
if (spriteSheet === null) {
spriteSheet = await Assets.load<Spritesheet>("public/assets/cards.json");
}
return spriteSheet;
}