Compare commits

...

5 Commits

9 changed files with 403 additions and 48 deletions

View File

@ -935,13 +935,31 @@
"x": 0, "x": 0,
"y": 0 "y": 0
} }
},
"unknown": {
"frame": {
"w": 144,
"h": 224,
"x": 1872,
"y": 0
},
"sourceSize": {
"w": 144,
"h": 224
},
"spriteSourceSize": {
"w": 144,
"h": 224,
"x": 0,
"y": 0
}
} }
}, },
"meta": { "meta": {
"image": "cards.png", "image": "cards.png",
"format": "RGBA8888", "format": "RGBA8888",
"size": { "size": {
"w": 1872, "w": 2016,
"h": 896 "h": 896
}, },
"scale": 1 "scale": 1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 229 KiB

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

@ -50,4 +50,5 @@ export type Card =
| "jackOfDiamonds" | "jackOfDiamonds"
| "queenOfDiamonds" | "queenOfDiamonds"
| "kingOfDiamonds" | "kingOfDiamonds"
| "aceOfDiamonds"; | "aceOfDiamonds"
| "unknown";

View File

@ -1,23 +1,53 @@
import { Assets, Container, Size, PointData, 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_HEIGHT, CARD_WIDTH } from "./constants"; import { CARD_HOVER_DIST, CARD_WIDTH } from "./constants";
import { Animation, FlipCard } from "./Animation";
const spritesheet = await Assets.load("/public/assets/cards.json"); type Pivot = "left" | "right" | "center";
type Layout = "horizontal" | "ascending" | "stacked";
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;
#parent: Size & PointData; #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.#parent = { x: 0, y: 0, width: 0, height: 0 }; 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) { if (cards.length > 0) {
const sprites: Sprite[] = []; const sprites: Sprite[] = [];
@ -25,47 +55,133 @@ 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 -= 50; this.#statuses.push({
}; face: card,
sprite.onmouseleave = () => { isAnimating: false,
sprite.y += 50; isFaceDown: faceDown,
}; });
this.#cards.push(sprite);
this.#container.addChild(sprite); this.#container.addChild(sprite);
this.#setCardHover(sprite);
this.fanCards(); this.fanCards();
this.repositionContainer(); this.setPivot(this.#pivot);
} }
parentArea(x: number, y: number, width: number, height: number) { animationTicker(ticker: Ticker) {
this.#parent = { x, y, width, height }; this.#ticker = ticker;
this.repositionContainer();
ticker.add((ticker) => {
if (this.#animations.length) {
const animation = this.#animations.shift()!;
ticker.add(animation.getCallback());
}
});
} }
repositionContainer() { flip(cardIdx: number) {
this.#container.pivot.set(this.#container.width / 2, CARD_HEIGHT); if (cardIdx >= this.#cardSprites.length)
this.#container.position.set(this.#parent.width / 2, this.#parent.height); 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":
this.#container.pivot.set(0, 0);
break;
case "right":
this.#container.pivot.set(this.#container.width, 0);
break;
case "center":
this.#container.pivot.set(this.#container.width / 2, 0);
break;
default:
throw new Error(`unknown pivot position: ${pivot}`);
}
this.#pivot = pivot;
} }
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,2 +1,3 @@
export const CARD_WIDTH = 144; export const CARD_WIDTH = 144;
export const CARD_HEIGHT = 224; export const CARD_HEIGHT = 224;
export const CARD_HOVER_DIST = 50;

View File

@ -1,15 +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, CARD_WIDTH } from "./constants";
const cards: Card[] = [ const playerCards: Card[] = ["nineOfSpades", "sixOfClubs"];
"threeOfClubs",
"twoOfDiamonds", const dealerCards: Card[] = ["unknown", "aceOfHearts"];
"aceOfClubs",
"eightOfDiamonds",
"nineOfSpades",
"sixOfClubs",
];
(async () => { (async () => {
try { try {
@ -24,11 +28,110 @@ const cards: Card[] = [
// 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 hand = new Hand(350, []); const payoutStyle: TextStyleOptions = {
fontFamily: "Fira Sans",
fontWeight: "bold",
fontSize: CARD_HEIGHT / 5,
fill: "#ff9900",
padding: 10,
letterSpacing: 10,
};
hand.parentArea(0, 0, app.screen.width, app.screen.height); const blackjackPayout = new Text({
text: "BLACKJACK PAYS 3 TO 2",
style: payoutStyle,
});
app.stage.addChild(hand.getContainer()); 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, 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());
// When the user resizes the window, resize the app. There is a plugin // When the user resizes the window, resize the app. There is a plugin
// for doing this automatically, but it doesn't reposition stage's // for doing this automatically, but it doesn't reposition stage's
@ -41,16 +144,27 @@ const cards: Card[] = [
// 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);
hand.parentArea(0, 0, app.screen.width, app.screen.height); markTable();
positionPayoutInfo();
positionDealerInfo();
positionInsurancePayout();
positionPlayer();
positionDealer();
}); });
let ms = 0; let ms = 0;
let elapsed = 0; let elapsed = 0;
app.ticker.add((time) => { app.ticker.add((time) => {
elapsed += time.elapsedMS; elapsed += time.elapsedMS;
if (cards.length && elapsed >= ms) { if (elapsed >= ms) {
ms += 300; ms += 300;
hand.add(cards.pop()!); if (playerCards.length) {
playerHand.add(playerCards.shift()!);
}
if (dealerCards.length) {
dealerHand.add(dealerCards.shift()!);
}
} }
}); });
} catch (err) { } catch (err) {

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

View File

@ -26,7 +26,7 @@ function getCardSpritesheet() {
meta: { meta: {
image: "/public/assets/cards.png", image: "/public/assets/cards.png",
format: "RGBA8888", format: "RGBA8888",
size: { w: 1872, h: 896 }, size: { w: 2016, h: 896 },
scale: 1, scale: 1,
}, },
}; };
@ -48,10 +48,10 @@ function getCardSpritesheet() {
"ace", "ace",
]; ];
const size = { w: 144, h: 224 };
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
for (let j = 0; j < 13; j++) { for (let j = 0; j < 13; j++) {
const cardName = `${values[j]}Of${suits[i]}`; const cardName = `${values[j]}Of${suits[i]}`;
const size = { w: 144, h: 224 };
const position = { x: j * size.w, y: i * size.h }; const position = { x: j * size.w, y: i * size.h };
spriteSheet.frames[cardName] = { spriteSheet.frames[cardName] = {
@ -62,5 +62,11 @@ function getCardSpritesheet() {
} }
} }
spriteSheet.frames["unknown"] = {
frame: { ...size, x: size.w * 13, y: 0 },
sourceSize: size,
spriteSourceSize: { ...size, x: 0, y: 0 },
};
return spriteSheet; return spriteSheet;
} }