import { _decorator, Button, Component, EventTouch, Label, Node, Prefab, instantiate, tween, UIOpacity, v3, Vec3, } from 'cc'; import { Card } from '../../core/cards/Card'; import { BasicBot } from '../../core/bot/BasicBot'; import { Match } from '../../core/match/Match'; import { Action, ActionType, Actions, IllegalActionError } from '../../core/rules/Action'; import { Phase, PlayerId } from '../../core/rules/GameState'; import { legalActions } from '../../core/rules/DurakRules'; import { intentFor } from '../../core/rules/intent'; import { Layout, Palette, Timing } from '../common/Theme'; import { Strings } from '../common/Strings'; import { CardLibrary } from './CardLibrary'; import { CardView } from './CardView'; const { ccclass, property } = _decorator; export type MatchOutcome = 'win' | 'loss' | 'draw'; /** Seat ids. The human is always seated first; the rules core does not care. */ const HUMAN: PlayerId = 'player'; const OPPONENT: PlayerId = 'opponent'; /** * The match screen. * * It renders whatever the current `GameState` says, rather than replaying the * event stream: after every action the whole table is laid out again and the * card nodes tween to their new places. Because a card node stays bound to its * card for as long as it is visible, "the six of clubs moved from my hand to the * table" needs no special case — it is the same node arriving at a new position. * * The screen never reads anything the player should not see. The opponent's hand * is drawn from its *size*, and the only cards ever turned face up are the ones * the rules have made public. */ @ccclass('GameScreen') export class GameScreen extends Component { @property(Prefab) cardPrefab: Prefab | null = null; @property(CardLibrary) library: CardLibrary | null = null; @property({ type: Node, tooltip: 'Parent for every card node.' }) cardLayer: Node | null = null; @property(Node) deckAnchor: Node | null = null; @property(Label) deckCountLabel: Label | null = null; @property(Label) opponentLabel: Label | null = null; @property(Label) messageLabel: Label | null = null; @property(Button) takeButton: Button | null = null; @property(Button) doneButton: Button | null = null; @property({ type: Node, tooltip: 'Shown when the match is over.' }) resultOverlay: Node | null = null; @property(Label) resultTitle: Label | null = null; @property(Label) resultBody: Label | null = null; @property(Button) quitButton: Button | null = null; @property({ type: Button, tooltip: 'Dismisses the result overlay.' }) continueButton: Button | null = null; /** Set by `MainFlow`. */ onFinished: ((outcome: MatchOutcome) => void) | null = null; onQuitRequested: (() => void) | null = null; private _match: Match | null = null; private _bot = new BasicBot(OPPONENT); /** Card nodes in use, keyed by the card they show. */ private _views = new Map(); /** Face-down nodes standing in for the opponent's hand and the deck. */ private _anonymous: CardView[] = []; private _pool: CardView[] = []; private _busy = false; private _selected: Card | null = null; private _pendingOutcome: MatchOutcome | null = null; // ----------------------------------------------------------------------- // Lifecycle // ----------------------------------------------------------------------- onLoad(): void { // Subscribed in code rather than through the scene's clickEvents list, so // the wiring is visible in the source and a renamed handler is a compile // error instead of a button that silently stops working. this.takeButton?.node.on(Button.EventType.CLICK, this.take, this); this.doneButton?.node.on(Button.EventType.CLICK, this.done, this); this.quitButton?.node.on(Button.EventType.CLICK, this.quit, this); this.continueButton?.node.on(Button.EventType.CLICK, this.dismissResult, this); } onDestroy(): void { this.takeButton?.node.off(Button.EventType.CLICK, this.take, this); this.doneButton?.node.off(Button.EventType.CLICK, this.done, this); this.quitButton?.node.off(Button.EventType.CLICK, this.quit, this); this.continueButton?.node.off(Button.EventType.CLICK, this.dismissResult, this); } startNewMatch(): void { this.teardown(); setActive(this.resultOverlay, false); // Bootstrap preloads the card art, so this is all but instant; going // through whenReady means a cold start cannot deal onto blank cards. const library = this.library; if (!library) { console.error('[game] no card library assigned'); return; } library.whenReady(() => { if (!this.isValid || !this.node.activeInHierarchy) { return; } this._match = new Match({ players: [HUMAN, OPPONENT], seed: (Math.random() * 0x7fffffff) | 0, }); this._busy = false; this._selected = null; this.render(Timing.deal); this.scheduleOnce(() => this._advance(), Timing.deal + 0.2); }); } /** Releases every card node and forgets the match. Safe to call twice. */ teardown(): void { this.unscheduleAllCallbacks(); for (const view of [...this._views.values(), ...this._anonymous]) { view.cancelAnimations(); this._recycle(view); } this._views.clear(); this._anonymous.length = 0; this._match?.clearListeners(); this._match = null; this._selected = null; this._busy = false; } onDisable(): void { this.unscheduleAllCallbacks(); } /** Wired to the Leave button. */ quit(): void { this.onQuitRequested?.(); } // ----------------------------------------------------------------------- // Input // ----------------------------------------------------------------------- /** Wired to the Take button. */ take(): void { this._submit(Actions.take(HUMAN)); } /** Wired to the Done button. */ done(): void { this._submit(Actions.pass(HUMAN)); } private _onCardTapped(card: Card): void { const match = this._match; if (!match || this._busy || match.currentActor !== HUMAN) { return; } const action = this._match ? intentFor(this._match.state, HUMAN, card) : null; if (!action) { this._flashMessage(Strings.cannotPlayThat); this._nudge(card); return; } this._submit(action); } private _submit(action: Action): void { const match = this._match; if (!match || this._busy) { return; } try { match.perform(action); } catch (error) { if (error instanceof IllegalActionError) { this._flashMessage(Strings.cannotPlayThat); return; } throw error; } this._selected = null; this._busy = true; this.render(Timing.play); this.scheduleOnce(() => this._advance(), Timing.play + 0.05); } // ----------------------------------------------------------------------- // Turn loop // ----------------------------------------------------------------------- /** Hands the move on: finish, wait for the player, or let the bot think. */ private _advance(): void { const match = this._match; if (!match) { return; } if (match.finished) { this._busy = true; this.scheduleOnce(() => this._showResult(), Timing.turnBreak); return; } if (match.currentActor === HUMAN) { this._busy = false; this.render(Timing.play); return; } this._busy = true; this.render(Timing.play); this.scheduleOnce(() => { const current = this._match; if (!current || current.currentActor !== OPPONENT) { return; } current.perform(this._bot.decide(current.viewFor(OPPONENT))); this.render(Timing.play); this.scheduleOnce(() => this._advance(), Timing.play + 0.05); }, Timing.botThinking); } private _showResult(): void { const match = this._match; if (!match) return; const outcome: MatchOutcome = match.loser === null ? 'draw' : (match.loser === HUMAN ? 'loss' : 'win'); setText(this.resultTitle, { win: Strings.wonTitle, loss: Strings.lostTitle, draw: Strings.drawTitle, }[outcome]); setText(this.resultBody, { win: Strings.wonBody, loss: Strings.lostBody, draw: Strings.drawBody, }[outcome]); if (this.resultOverlay) { this.resultOverlay.active = true; const opacity = this.resultOverlay.getComponent(UIOpacity) ?? this.resultOverlay.addComponent(UIOpacity); opacity.opacity = 0; tween(opacity).to(0.25, { opacity: 255 }).start(); } this._pendingOutcome = outcome; } /** Wired to the result overlay's button. */ dismissResult(): void { const outcome = this._pendingOutcome ?? 'draw'; this._pendingOutcome = null; this.onFinished?.(outcome); } // ----------------------------------------------------------------------- // Rendering // ----------------------------------------------------------------------- /** Lays the whole table out from the current state, tweening over `duration`. */ render(duration: number): void { const match = this._match; if (!match || !this.library || !this.cardLayer) { return; } const state = match.state; const view = match.viewFor(HUMAN); const live = new Set(); // Trump card, lying under the deck. if (state.deck.length > 0) { live.add(state.trumpCard); this._place(state.trumpCard, true, v3(Layout.region.trump.x, Layout.region.trump.y, 0), 0.62, 90, duration); } // The player's hand: a straight row, tightened as it grows. const hand = [...view.hand].sort(byTrumpThenRank(state.trumpSuit)); const step = Math.min( Layout.handStep, hand.length > 1 ? (Layout.handMaxWidth - Layout.cardWidth) / (hand.length - 1) : Layout.handStep, ); const handLeft = -step * (hand.length - 1) / 2; hand.forEach((card, i) => { live.add(card); const lifted = card === this._selected ? Layout.handLift : 0; this._place(card, true, v3(handLeft + i * step, Layout.region.playerHandY + lifted, 0), 1, 0, duration); }); // The table: attacking cards with whatever beat them, three to a row. state.table.forEach((slot, i) => { const column = i % 3; const row = Math.floor(i / 3); const x = (column - 1) * Layout.region.tableColumnStep; const y = Layout.region.tableTopY - row * Layout.region.tableRowStep; live.add(slot.attack); this._place(slot.attack, true, v3(x, y, 0), Layout.tableCardScale, 0, duration); if (slot.defence) { live.add(slot.defence); this._place(slot.defence, true, v3(x + Layout.beatOffset.x, y + Layout.beatOffset.y, 0), Layout.tableCardScale, 8, duration); } }); this._retireCardsNotIn(live, duration); this._renderAnonymous(state.deck.length, view.opponents[0]?.handSize ?? 0, duration); this._renderChrome(view.opponents[0]?.handSize ?? 0); } /** Face-down nodes: a short stack for the deck, and the opponent's hand. */ private _renderAnonymous(deckSize: number, opponentHand: number, duration: number): void { // Three backs are enough to read as a pile; drawing twenty-four is waste. const deckBacks = Math.min(deckSize, 3); const needed = deckBacks + opponentHand; while (this._anonymous.length < needed) { this._anonymous.push(this._take(null, false)); } while (this._anonymous.length > needed) { this._recycle(this._anonymous.pop() as CardView); } for (let i = 0; i < deckBacks; i++) { this._anonymous[i].moveTo( v3(Layout.region.deck.x + i * 3, Layout.region.deck.y + i * 3, 0), 0.62, 0, duration, ); } const step = Math.min(70, opponentHand > 1 ? 760 / (opponentHand - 1) : 70); const left = -step * (opponentHand - 1) / 2; for (let i = 0; i < opponentHand; i++) { this._anonymous[deckBacks + i].moveTo( v3(left + i * step, Layout.region.opponentHandY, 0), Layout.opponentCardScale, 0, duration, ); } } private _renderChrome(opponentHand: number): void { const match = this._match; if (!match) return; const state = match.state; setText(this.deckCountLabel, `${Strings.deckLeft} ${state.deck.length}`); setText(this.opponentLabel, `${Strings.opponentHand}: ${opponentHand}`); setActive(this.deckAnchor, state.deck.length > 0); const mine = match.currentActor === HUMAN; const options = mine ? legalActions(state, HUMAN) : []; setButton(this.takeButton, mine && options.some((a) => a.type === ActionType.Take)); setButton(this.doneButton, mine && options.some((a) => a.type === ActionType.Pass)); if (match.finished) { setText(this.messageLabel, ''); } else if (!mine) { setText(this.messageLabel, Strings.opponentThinking); } else if (state.phase === Phase.Defence) { setText(this.messageLabel, Strings.yourDefence); } else if (state.phase === Phase.Reinforce) { setText(this.messageLabel, Strings.addMore); } else { setText(this.messageLabel, Strings.yourMove); } } // ----------------------------------------------------------------------- // Card node pool // ----------------------------------------------------------------------- private _place(card: Card, faceUp: boolean, position: Vec3, scale: number, angle: number, duration: number): void { let view = this._views.get(card); if (!view) { view = this._take(card, faceUp); // A card appearing for the first time slides out of the deck. view.moveTo(v3(Layout.region.deck.x, Layout.region.deck.y, 0), 0.62, 0, 0); this._views.set(card, view); } view.flipTo(faceUp); view.moveTo(position, scale, angle, duration); view.node.setSiblingIndex(this.cardLayer?.children.length ?? 0); } /** Cards that have left the visible table — they went to the discard or a hand. */ private _retireCardsNotIn(live: Set, duration: number): void { for (const [card, view] of [...this._views]) { if (live.has(card)) continue; this._views.delete(card); const goingToOpponent = !this._match?.state.discard.includes(card); const target = goingToOpponent ? v3(0, Layout.region.opponentHandY, 0) : v3(Layout.region.discard.x, Layout.region.discard.y, 0); view.moveTo(target, 0.4, 0, duration); view.flipTo(false); view.fadeOut(duration, () => this._recycle(view)); } } private _take(card: Card | null, faceUp: boolean): CardView { let view = this._pool.pop(); if (!view) { const node = instantiate(this.cardPrefab as Prefab); view = node.getComponent(CardView) as CardView; node.on(Node.EventType.TOUCH_END, (event: EventTouch) => { event.propagationStopped = true; const tapped = view?.card; if (tapped) this._onCardTapped(tapped); }); } view.node.parent = this.cardLayer; view.node.active = true; view.bind(this.library as CardLibrary, card, faceUp); return view; } private _recycle(view: CardView): void { view.cancelAnimations(); view.node.active = false; view.node.parent = null; if (!this._pool.includes(view)) { this._pool.push(view); } } // ----------------------------------------------------------------------- // Feedback // ----------------------------------------------------------------------- private _flashMessage(text: string): void { if (!this.messageLabel) return; this.messageLabel.string = text; this.messageLabel.color = Palette.bad; this.scheduleOnce(() => { if (this.messageLabel) this.messageLabel.color = Palette.cream; this._renderChrome(this._match?.viewFor(HUMAN).opponents[0]?.handSize ?? 0); }, 1.1); } /** A small shake, so a rejected tap is felt rather than only read. */ private _nudge(card: Card): void { const view = this._views.get(card); if (!view) return; const home = view.node.position.clone(); tween(view.node) .to(0.05, { position: home.clone().add3f(-10, 0, 0) }) .to(0.05, { position: home.clone().add3f(10, 0, 0) }) .to(0.05, { position: home }) .start(); } } /** Trumps to the right, ascending — the order a player would hold them in. */ function byTrumpThenRank(trumpSuit: number) { return (a: Card, b: Card): number => { const aTrump = a.suit === trumpSuit ? 1 : 0; const bTrump = b.suit === trumpSuit ? 1 : 0; if (aTrump !== bTrump) return aTrump - bTrump; if (a.suit !== b.suit) return a.suit - b.suit; return a.rank - b.rank; }; } function sameAction(wanted: Action) { return (other: Action): boolean => other.type === wanted.type && (other as { card?: Card }).card === (wanted as { card?: Card }).card; } function setText(label: Label | null, text: string): void { if (label) label.string = text; } function setActive(node: Node | null, active: boolean): void { if (node) node.active = active; } function setButton(button: Button | null, enabled: boolean): void { if (!button) return; button.node.active = enabled; button.interactable = enabled; }