diff --git a/client/src/Animation.ts b/client/src/Animation.ts index 50c4f00..bd49ce4 100644 --- a/client/src/Animation.ts +++ b/client/src/Animation.ts @@ -1,31 +1,31 @@ -import type { Sprite, Ticker, TickerCallback } from "pixi.js"; -import { CardStatus } from "./Hand"; +import type { Container, Sprite, Ticker, TickerCallback } from "pixi.js"; +import { CardStatus, Hand } from "./Hand"; import { getSpriteSheet } from "./spritesheet"; +import { CARD_HEIGHT, CARD_WIDTH } from "./constants"; const spritesheet = await getSpriteSheet(); export abstract class Animation { protected ticker: Ticker; - protected cb: TickerCallback; - #next: Animation | null; + protected boundCallback: TickerCallback; 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; + this.boundCallback = () => {}; } - abstract getCallback(): TickerCallback; + abstract onTick(ticker: Ticker): void; - next(animation: Animation) { - this.#next = animation; + getCallback() { + this.boundCallback = this.onTick.bind(this); + return this.boundCallback; } done() { console.log("removing", this.ticker); - this.ticker.remove(this.cb); + this.ticker.remove(this.boundCallback); } } @@ -38,51 +38,212 @@ export class FlipCard extends Animation { constructor(ticker: Ticker, card: Sprite, status: CardStatus) { super(ticker); - this.cb = () => {}; + this.boundCallback = () => {}; 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; + onTick(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); - 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 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 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; + } - // 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; + if (this.flipped && this.card.width === this.cardWidth) { + console.log("remove", this.flipped, this.card.width, this.cardWidth); + super.done(); + } + } +} + +export class ToHorizontal extends Animation { + #cardDeltas: [number, number][]; // change in x and y of each card + #finalPositions: [number, number][]; // final position of each card (relative to container) + #hand: Hand; + #cards: Sprite[]; + #container: Container; + #containerSizeDelta: [number, number]; // change in size of container + #containerFinalPosition: [number, number]; // final x and y of container + + constructor(ticker: Ticker, hand: Hand) { + super(ticker); + + if (hand.size() < 2) throw new Error("cannot spread fewer than two cards"); + + this.#hand = hand; + this.#cards = this.#hand.getCardSprites(); + this.#container = hand.getContainer(); + + // figure out the current positions of each card + const currentPositions = this.#hand.getCardPositions( + this.#hand.getLayout(), + this.#hand.size(), + ); + + // figure out where each card would be if the layout was horizontal + const finalPositions = this.#hand.getCardPositions( + "horizontal", + this.#hand.size(), + ); + + // calculate the x and y amounts that each card will move over the + // course of the animation + const deltas = []; + for (let i = 0; i < currentPositions.length; i++) { + const [currentX, currentY] = currentPositions[i]; + const [finalX, finalY] = finalPositions[i]; + + const adjustment: [number, number] = [ + finalX - currentX, + finalY - currentY, + ]; + deltas.push(adjustment); + } + + this.#finalPositions = finalPositions; + this.#cardDeltas = deltas; + + // The container will almost certainly change its size over the course + // of the animation. This will cause the container to no longer be + // aligned properly. For each frame where cards are adjusted, it's easy + // to also adjust to position of the container to keep it aligned. + // + // TODO: Currently I am assuming that the container is center aligned, + // but it can actually be aligned in some other ways. Adjust this + // so it can keep left and right-aligned containers in position + // as well. + + const count = hand.size(); + + // figure out the current width and height, and final width and height + // of the container after the animation + const initialWidth = this.#cards[count - 1].x + CARD_WIDTH; + const initialHeight = this.#cards[count - 1].y + CARD_HEIGHT; + const finalWidth = finalPositions[count - 1][0] + CARD_WIDTH; + const finalHeight = finalPositions[count - 1][1] + CARD_HEIGHT; + + // the containers change in width and height over the course of the + // animation + this.#containerSizeDelta = [ + finalWidth - initialWidth, + finalHeight - initialHeight, + ]; + + // the final x and y of the container after the animation is completed + this.#containerFinalPosition = [ + this.#container.x + (this.#container.width - finalWidth) / 2, + this.#container.y, // currently this does not change... + ]; + } + + onTick(ticker: Ticker) { + let isXAnimating = false; // true if the x value of the card updated + let isYAnimating = false; // true of the y value of the card updated + + // rate is the percent of the total distance travelled that each + // card will complete per frame at the target framerate. + // + // For more information about the number this rate will be + // multiplied against, see: + // https://pixijs.download/release/docs/ticker.Ticker.html#deltatime + const rate = 0.04; + + for (let i = 0; i < this.#cardDeltas.length; i++) { + const card = this.#cards[i]; + + // x, y coordinates where the card will end up + const [finalX, finalY] = this.#finalPositions[i]; + + // x, y distances travelled by the end of the animation + const [deltaX, deltaY] = this.#cardDeltas[i]; + + // These are the x, y distances remaining between where the + // card is in the animation, and where it will end up. They are + // going to be used to determine if the current adjustment + // should be the final adjustment. + const remainingX = this.#cards[i].x - this.#finalPositions[i][0]; + const remainingY = this.#cards[i].y - this.#finalPositions[i][1]; + + // These are the numbers of pixels that should be travelled this + // tick. They are calculated by taking the total travel + // distance (deltaX), taking a percentage of that journey that + // should be taken in a given target frame (rate), and then + // multiplying that by ticker.deltaTime to get the fraction of + // the target framerate that has actually elapsed (ideally, 1.0). + // + // For more information about ticker.deltaTime, see: + // https://pixijs.download/release/docs/ticker.Ticker.html#deltatime + const offsetX = deltaX * rate * ticker.deltaTime; + const offsetY = deltaY * rate * ticker.deltaTime; + + // If the remaining x distance is less than, or equal to the offset, + // then this is the last adjustment and, instead of offsetting, the + // card should just be set to its desired final position... + if (Math.abs(remainingX) <= Math.abs(offsetX)) { + card.x = finalX; + } + + // ...otherwise, apply the offset and set isXAnimating to true since + // there is yet more animating along x to do! + else { + card.x += offsetX; + isXAnimating = true; + } + + // and for y + if (Math.abs(remainingY) <= Math.abs(offsetY)) { + card.y = finalY; + } else { + card.y += offsetY; + isYAnimating = true; + } + } + + // When neither x nor y still need to be animated, the animation is .complete + if (!isXAnimating && !isYAnimating) { + this.#hand.setCardLayout("horizontal"); // the hand layout has changed + + // The container should now be at its final tageted coordinates. + const [containerX, containerY] = this.#containerFinalPosition; + this.#container.x = containerX; + this.#container.y = containerY; + + // Clear the callback our of the ticker. + super.done(); + } + + // If x is still animating, then the container may need to be adjusted to + // keep it aligned as its width changes. + // + // TODO: Currently this only handles horizontal, centered alignment. Some + // additional logic, and maybe even some checks of y, will be needed + // for fancier layouts of the future. + if (isXAnimating) { + const offset = this.#containerSizeDelta[0] * rate * ticker.deltaTime; + this.#container.x -= offset / 2; + } } } diff --git a/client/src/Hand.ts b/client/src/Hand.ts index 72883f3..a3c798d 100644 --- a/client/src/Hand.ts +++ b/client/src/Hand.ts @@ -4,11 +4,11 @@ import { Sprite, Spritesheet, Ticker, - type TickerCallback, + TickerCallback, } from "pixi.js"; import { Card } from "./Card"; import { CARD_HOVER_DIST, CARD_WIDTH } from "./constants"; -import { Animation, FlipCard } from "./Animation"; +import { Animation, FlipCard, ToHorizontal } from "./Animation"; type Pivot = "left" | "right" | "center"; type Layout = "horizontal" | "ascending" | "stacked"; @@ -30,6 +30,7 @@ export class Hand { #maxWidth: number; #pivot: Pivot; #ticker: Ticker | null; + #gap: number; constructor(maxWidth: number, cards: Card[] = []) { if (maxWidth < CARD_WIDTH) { @@ -41,6 +42,7 @@ export class Hand { this.#hover = false; this.#maxWidth = maxWidth; this.#pivot = "left"; + this.#gap = 0; this.#layout = "horizontal"; this.#statuses = cards.map((card) => ({ face: card, @@ -89,6 +91,10 @@ export class Hand { }); } + setGap(gap: number) { + this.#gap = gap; + } + flip(cardIdx: number) { if (cardIdx >= this.#cardSprites.length) throw new Error(`card index out of range: ${cardIdx}`); @@ -99,10 +105,20 @@ export class Hand { const card = this.#cardSprites[cardIdx]; const status = this.#statuses[cardIdx]; const ticker = this.#ticker; - this.#animations.push(new FlipCard(ticker, card, status)); } + size(): number { + return this.#cardSprites.length; + } + + spread() { + if (!this.#ticker) + throw new Error("can't animate hand before passing it a ticker"); + + this.#animations.push(new ToHorizontal(this.#ticker, this)); + } + #setCardHover(sprite: Sprite) { if (this.#hover) { const { y } = sprite; @@ -121,6 +137,14 @@ export class Hand { } } + getCardSprites() { + return this.#cardSprites; + } + + getLayout() { + return this.#layout; + } + hover(hasHover: boolean) { if (this.#hover === hasHover) return; @@ -157,20 +181,23 @@ export class Hand { this.#pivot = pivot; } - fanCards() { - const count = this.#cardSprites.length; + getCardPositions(layout: Layout, cardCount: number) { + const positions: [number, number][] = []; const max = this.#maxWidth; let offset = CARD_WIDTH / 3; - if (this.#layout === "horizontal") { - offset = count * CARD_WIDTH > max ? max / count : CARD_WIDTH; + if (layout === "horizontal") { + offset = + cardCount * (CARD_WIDTH + this.#gap) > max + ? max / cardCount + : CARD_WIDTH + this.#gap; } + let x = 0; let y = 0; - for (const card of this.#cardSprites) { - card.x = x; - card.y = y; - switch (this.#layout) { + for (let i = 0; i < cardCount; i++) { + positions.push([x, y]); + switch (layout) { case "ascending": x += offset; y -= offset; @@ -183,6 +210,22 @@ export class Hand { break; } } + + return positions; + } + + fanCards() { + const positions = this.getCardPositions( + this.#layout, + this.#cardSprites.length, + ); + + for (let i = 0; i < positions.length; i++) { + const [x, y] = positions[i]; + const card = this.#cardSprites[i]; + card.x = x; + card.y = y; + } } getContainer(): Container { diff --git a/client/src/main.ts b/client/src/main.ts index 179a41e..0745892 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,17 +1,14 @@ -import { - Application, - Bounds, - FillGradient, - Graphics, - Text, - TextStyle, - TextStyleOptions, -} from "pixi.js"; +import { Application, Graphics, Text, TextStyleOptions } from "pixi.js"; import { Hand } from "./Hand"; import { Card } from "./Card"; import { CARD_HEIGHT, CARD_HOVER_DIST, CARD_WIDTH } from "./constants"; -const playerCards: Card[] = ["nineOfSpades", "sixOfClubs"]; +const playerCards: Card[] = [ + "nineOfSpades", + "sixOfClubs", + "kingOfClubs", + "nineOfDiamonds", +]; const dealerCards: Card[] = ["unknown", "aceOfHearts"]; @@ -44,31 +41,6 @@ const dealerCards: Card[] = ["unknown", "aceOfHearts"]; 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", @@ -81,11 +53,6 @@ const dealerCards: Card[] = ["unknown", "aceOfHearts"]; 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", @@ -99,32 +66,19 @@ const dealerCards: Card[] = ["unknown", "aceOfHearts"]; 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 positionElements = () => { + markTable(app, tableMarkings); + positionPayoutInfo(app, blackjackPayout); + positionDealerInfo(app, dealerRules); + positionInsurancePayout(app, insurancePayout); + positionPlayer(app, playerHand); + positionDealer(app, dealerHand); }; - const positionDealer = () => { - dealerHand.setPivot("center"); - dealerHand.setPosition(app.screen.width / 2, 0 + CARD_HOVER_DIST); - dealerHand.setCardLayout("stacked"); - }; - - markTable(); - positionPayoutInfo(); - positionDealerInfo(); - positionInsurancePayout(); - positionPlayer(); - positionDealer(); + positionElements(); app.stage.addChild(tableMarkings); app.stage.addChild(blackjackPayout); @@ -144,30 +98,90 @@ const dealerCards: Card[] = ["unknown", "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(); + positionElements(); }); - let ms = 0; - let elapsed = 0; - app.ticker.add((time) => { - elapsed += time.elapsedMS; - if (elapsed >= ms) { - ms += 300; - if (playerCards.length) { - playerHand.add(playerCards.shift()!); - } + for (const card of playerCards) { + playerHand.add(card, false); + } - if (dealerCards.length) { - dealerHand.add(dealerCards.shift()!); - } - } - }); + let firstCard = true; + for (const card of dealerCards) { + dealerHand.add(card, firstCard); + firstCard = false; + } + + dealerHand.setGap(CARD_WIDTH * 0.1); + dealerHand.animationTicker(app.ticker); + dealerHand.spread(); + + playerHand.setGap(CARD_WIDTH * 0.1); + playerHand.animationTicker(app.ticker); + playerHand.spread(); + + // let ms = 0; + // let elapsed = 0; + // app.ticker.add((time) => { + // elapsed += time.elapsedMS; + // if (elapsed >= ms) { + // ms += 300; + // if (playerCards.length) { + // playerHand.add(playerCards.shift()!); + // } + + // if (dealerCards.length) { + // dealerHand.add(dealerCards.shift()!); + // } + // } + // }); } catch (err) { console.error(err); } })(); + +function positionPlayer(app: Application, h: Hand) { + h.setPivot("center"); + h.setPosition(app.screen.width / 2, CARD_HEIGHT * 3); + h.setCardLayout("ascending"); +} + +function positionDealer(app: Application, h: Hand) { + h.setPivot("center"); + h.setPosition(app.screen.width / 2, 0 + CARD_HOVER_DIST); + h.setCardLayout("stacked"); +} + +function positionDealerInfo(app: Application, t: Text) { + t.pivot.set(t.width / 2, 0); + t.position.set(app.screen.width / 2, CARD_HEIGHT * 1.85); +} + +function positionPayoutInfo(app: Application, t: Text) { + t.pivot.set(t.width / 2, 0); + t.position.set(app.screen.width / 2, CARD_HEIGHT * 1.5); +} + +function positionInsurancePayout(app: Application, t: Text) { + t.pivot.set(t.width / 2, 0); + t.position.set(app.screen.width / 2, CARD_HEIGHT * 2.2); +} + +function markTable(app: Application, g: Graphics) { + g.clear(); + + // dark green dealer area + g.rect(0, 0, app.screen.width, CARD_HEIGHT * 1.8); + g.fill("#055010"); + + // white insurance info box + g.rect( + CARD_WIDTH / 2, + CARD_HEIGHT * 2.15, + app.screen.width - CARD_WIDTH, + CARD_HEIGHT / 2.5, + ); + g.stroke({ + color: "#ffffff", + width: 8, + }); +}