layout blackjack table
This commit is contained in:
		
							
								
								
									
										88
									
								
								client/src/Animation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								client/src/Animation.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -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<Spritesheet>("/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;
 | 
			
		||||
			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;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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()!);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								client/src/spritesheet.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								client/src/spritesheet.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user