From c344c60db037261066bcde376e69960a8c83bcc4 Mon Sep 17 00:00:00 2001 From: Nolan Hellyer Date: Mon, 3 Nov 2025 18:15:33 -0800 Subject: [PATCH] layout blackjack table --- client/src/Animation.ts | 88 ++++++++++++++++++++++++ client/src/Hand.ts | 140 ++++++++++++++++++++++++++++++++------ client/src/main.ts | 122 ++++++++++++++++++++++++++++----- client/src/spritesheet.ts | 11 +++ 4 files changed, 325 insertions(+), 36 deletions(-) create mode 100644 client/src/Animation.ts create mode 100644 client/src/spritesheet.ts diff --git a/client/src/Animation.ts b/client/src/Animation.ts new file mode 100644 index 0000000..50c4f00 --- /dev/null +++ b/client/src/Animation.ts @@ -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; + #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; + + 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; + } +} diff --git a/client/src/Hand.ts b/client/src/Hand.ts index a313368..72883f3 100644 --- a/client/src/Hand.ts +++ b/client/src/Hand.ts @@ -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_HOVER_DIST, CARD_WIDTH } from "./constants"; +import { Animation, FlipCard } from "./Animation"; type Pivot = "left" | "right" | "center"; +type Layout = "horizontal" | "ascending" | "stacked"; -const spritesheet = await Assets.load("/public/assets/cards.json"); +const spritesheet = await Assets.load("/public/assets/cards.json"); +export type CardStatus = { + face: Card; + isAnimating: boolean; + isFaceDown: boolean; +}; export class Hand { - #cards: Sprite[]; + #animations: Animation[]; + #cardSprites: Sprite[]; + #statuses: CardStatus[]; #container: Container; + #hover: boolean; + #layout: Layout; #maxWidth: number; #pivot: Pivot; + #ticker: Ticker | null; constructor(maxWidth: number, cards: Card[] = []) { if (maxWidth < CARD_WIDTH) { throw new Error("hand cannot be narrower than a single card"); } - this.#maxWidth = maxWidth; + this.#animations = []; this.#container = new Container(); + this.#hover = false; + this.#maxWidth = maxWidth; this.#pivot = "left"; + this.#layout = "horizontal"; + this.#statuses = cards.map((card) => ({ + face: card, + isAnimating: false, + isFaceDown: false, + })); + this.#ticker = null; if (cards.length > 0) { const sprites: Sprite[] = []; @@ -27,33 +55,90 @@ export class Hand { this.add(card); } - this.#cards = sprites; + this.#cardSprites = sprites; this.#container.addChild(...sprites); this.fanCards(); } else { - this.#cards = []; + this.#cardSprites = []; } } - add(card: Card) { + add(card: Card, faceDown = false) { const sprite = new Sprite(spritesheet.textures[card]); - sprite.eventMode = "dynamic"; - sprite.onmouseenter = () => { - sprite.y -= CARD_HOVER_DIST; - }; - sprite.onmouseleave = () => { - sprite.y += CARD_HOVER_DIST; - }; - this.#cards.push(sprite); + + this.#cardSprites.push(sprite); + this.#statuses.push({ + face: card, + isAnimating: false, + isFaceDown: faceDown, + }); this.#container.addChild(sprite); + this.#setCardHover(sprite); this.fanCards(); 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) { this.#container.position.set(x, y); } + setCardLayout(layout: Layout) { + this.#layout = layout; + } + setPivot(pivot: "left" | "right" | "center") { switch (pivot) { case "left": @@ -73,13 +158,30 @@ export class Hand { } fanCards() { - const count = this.#cards.length; + const count = this.#cardSprites.length; 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; - for (const card of this.#cards) { + let y = 0; + for (const card of this.#cardSprites) { 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; + } } } diff --git a/client/src/main.ts b/client/src/main.ts index e511a93..179a41e 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -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 { Card } from "./Card"; -import { CARD_HEIGHT, CARD_HOVER_DIST } from "./constants"; +import { CARD_HEIGHT, CARD_HOVER_DIST, CARD_WIDTH } from "./constants"; -const playerCards: Card[] = [ - "threeOfClubs", - "twoOfDiamonds", - "aceOfClubs", - "eightOfDiamonds", - "nineOfSpades", - "sixOfClubs", -]; +const playerCards: Card[] = ["nineOfSpades", "sixOfClubs"]; -const dealerCards: Card[] = ["aceOfHearts"]; +const dealerCards: Card[] = ["unknown", "aceOfHearts"]; (async () => { try { @@ -27,25 +28,108 @@ const dealerCards: Card[] = ["aceOfHearts"]; // Append the application canvas to the document body 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 dealerHand = new Hand(350, []); const positionPlayer = () => { playerHand.setPivot("center"); - playerHand.setPosition( - app.screen.width / 2, - app.screen.height - CARD_HEIGHT + CARD_HOVER_DIST, - ); + playerHand.setPosition(app.screen.width / 2, CARD_HEIGHT * 3); + playerHand.setCardLayout("ascending"); }; const positionDealer = () => { dealerHand.setPivot("center"); dealerHand.setPosition(app.screen.width / 2, 0 + CARD_HOVER_DIST); + dealerHand.setCardLayout("stacked"); }; + markTable(); + positionPayoutInfo(); + positionDealerInfo(); + positionInsurancePayout(); positionPlayer(); 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(dealerHand.getContainer()); @@ -60,6 +144,10 @@ const dealerCards: Card[] = ["aceOfHearts"]; // https://pixijs.com/8.x/guides/concepts/scene-graph#local-vs-global-coordinates window.addEventListener?.("resize", () => { app.renderer.resize(window.innerWidth, window.innerHeight); + markTable(); + positionPayoutInfo(); + positionDealerInfo(); + positionInsurancePayout(); positionPlayer(); positionDealer(); }); @@ -71,11 +159,11 @@ const dealerCards: Card[] = ["aceOfHearts"]; if (elapsed >= ms) { ms += 300; if (playerCards.length) { - playerHand.add(playerCards.pop()!); + playerHand.add(playerCards.shift()!); } if (dealerCards.length) { - dealerHand.add(dealerCards.pop()!); + dealerHand.add(dealerCards.shift()!); } } }); diff --git a/client/src/spritesheet.ts b/client/src/spritesheet.ts new file mode 100644 index 0000000..4616a85 --- /dev/null +++ b/client/src/spritesheet.ts @@ -0,0 +1,11 @@ +import { Assets, Spritesheet } from "pixi.js"; + +let spriteSheet: Spritesheet | null = null; + +export async function getSpriteSheet(): Promise { + if (spriteSheet === null) { + spriteSheet = await Assets.load("public/assets/cards.json"); + } + + return spriteSheet; +}