M5-M7: сцены, лобби, экран игры и цикл матча
Обе сцены собраны через Code Mode, не руками. - loader: поднимает модули, прогревает арт карт, уходит в lobby - lobby: обе UI-ветки в одной сцене (по ARCHITECTURE.md), матч — это смена видимого корня, а не загрузка сцены - GameScreen рисует текущее состояние и доводит узлы твинами, а не проигрывает поток событий: узел карты живёт вместе со своей картой, поэтому "шестёрка ушла из руки на стол" не требует отдельного случая - один тап = ход целиком; логика вынесена в core/rules/intent.ts и покрыта тестами, в том числе проверкой, что она никогда не предложит ход, который правила отвергнут - арт карт переехал в assets/resources: .pac — это инструкция упаковки на сборке, а не рантайм-ассет, и без ссылок на кадры в сборку не попадало ничего. Теперь все 37 спрайтов в одной текстуре 2048x2048 - клики кнопок подписываются в коде, а не через clickEvents сцены - строки интерфейса собраны в одном файле Сборка web-mobile проходит чисто, все ссылки в сцене проверены. Вживую не запускалось: браузер в этом окружении не поднимает главный цикл движка. Подробности — в docs/TECHNICAL.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { _decorator, Component, resources, SpriteFrame } from 'cc';
|
||||
import { Card } from '../../core/cards/Card';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/** Folder under `assets/resources` holding the 36 faces and the back. */
|
||||
export const CARDS_DIR = 'cards';
|
||||
const BACK_NAME = 'card_back';
|
||||
|
||||
/**
|
||||
* Turns a `Card` into the sprite frame that draws it.
|
||||
*
|
||||
* The frames are loaded as a folder out of the `resources` bundle rather than
|
||||
* being referenced one by one in the scene: thirty-seven inspector slots would
|
||||
* be miserable to maintain, and an auto-atlas (`.pac`) is only a build-time
|
||||
* packing instruction — it is not something that can be loaded and asked for a
|
||||
* frame by name at runtime. The `.pac` beside the images still does its job,
|
||||
* packing all thirty-seven into one texture so the table costs one draw call.
|
||||
*
|
||||
* The only contract between the rules and the art is `Card.assetName`.
|
||||
*/
|
||||
@ccclass('CardLibrary')
|
||||
export class CardLibrary extends Component {
|
||||
@property({ tooltip: 'Folder under assets/resources holding the card sprites.' })
|
||||
directory = CARDS_DIR;
|
||||
|
||||
private readonly _frames = new Map<string, SpriteFrame>();
|
||||
private readonly _missingReported = new Set<string>();
|
||||
private _ready = false;
|
||||
private _waiting: (() => void)[] = [];
|
||||
|
||||
onLoad(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
get ready(): boolean {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the folder. Bootstrap preloads the same folder before the lobby
|
||||
* opens, so in practice this resolves from cache within a frame.
|
||||
*/
|
||||
load(): void {
|
||||
if (this._ready) {
|
||||
return;
|
||||
}
|
||||
resources.loadDir(this.directory, SpriteFrame, (error, frames) => {
|
||||
if (error) {
|
||||
console.error(`[cards] could not load '${this.directory}'`, error);
|
||||
return;
|
||||
}
|
||||
for (const frame of frames) {
|
||||
this._frames.set(frame.name, frame);
|
||||
}
|
||||
this._ready = true;
|
||||
const waiting = this._waiting;
|
||||
this._waiting = [];
|
||||
for (const resolve of waiting) resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/** Runs `done` once the frames are available — immediately if they already are. */
|
||||
whenReady(done: () => void): void {
|
||||
if (this._ready) {
|
||||
done();
|
||||
} else {
|
||||
this._waiting.push(done);
|
||||
}
|
||||
}
|
||||
|
||||
frameFor(card: Card): SpriteFrame | null {
|
||||
return this._frame(card.assetName);
|
||||
}
|
||||
|
||||
get backFrame(): SpriteFrame | null {
|
||||
return this._frame(BACK_NAME);
|
||||
}
|
||||
|
||||
private _frame(name: string): SpriteFrame | null {
|
||||
const frame = this._frames.get(name) ?? null;
|
||||
if (!frame && this._ready && !this._missingReported.has(name)) {
|
||||
this._missingReported.add(name);
|
||||
console.error(`[cards] '${name}' is missing — run 'npm run build:art'`);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4e2c42cf-866c-4ba0-aa4f-78c256d28a0f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { _decorator, Component, Sprite, Tween, tween, UIOpacity, UITransform, v3, Vec3 } from 'cc';
|
||||
import { Card } from '../../core/cards/Card';
|
||||
import { Layout, Timing } from '../common/Theme';
|
||||
import { CardLibrary } from './CardLibrary';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* One card on screen.
|
||||
*
|
||||
* A card node belongs to a `Card` for as long as that card is visible, and
|
||||
* follows it from hand to table to discard. Movement is therefore just a tween
|
||||
* to a new position, and the natural-looking animation falls out of the layout
|
||||
* rather than having to be choreographed per event.
|
||||
*/
|
||||
@ccclass('CardView')
|
||||
export class CardView extends Component {
|
||||
@property(Sprite)
|
||||
sprite: Sprite | null = null;
|
||||
|
||||
private _library: CardLibrary | null = null;
|
||||
private _card: Card | null = null;
|
||||
private _faceUp = false;
|
||||
|
||||
get card(): Card | null {
|
||||
return this._card;
|
||||
}
|
||||
|
||||
get faceUp(): boolean {
|
||||
return this._faceUp;
|
||||
}
|
||||
|
||||
/** Called once, when the view is taken from the pool. */
|
||||
bind(library: CardLibrary, card: Card | null, faceUp: boolean): void {
|
||||
this._library = library;
|
||||
this._card = card;
|
||||
this._faceUp = faceUp;
|
||||
this._applyFrame();
|
||||
|
||||
const transform = this.getComponent(UITransform);
|
||||
transform?.setContentSize(Layout.cardWidth, Layout.cardHeight);
|
||||
}
|
||||
|
||||
/** Flips the card, turning it edge-on halfway through so the swap is hidden. */
|
||||
flipTo(faceUp: boolean, animated = true): void {
|
||||
if (this._faceUp === faceUp) {
|
||||
return;
|
||||
}
|
||||
this._faceUp = faceUp;
|
||||
|
||||
if (!animated) {
|
||||
this._applyFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = this.node.scale.clone();
|
||||
tween(this.node)
|
||||
.to(Timing.flip / 2, { scale: v3(0.02, scale.y, scale.z) })
|
||||
.call(() => this._applyFrame())
|
||||
.to(Timing.flip / 2, { scale })
|
||||
.start();
|
||||
}
|
||||
|
||||
/** Moves, rotates and scales the card into place. */
|
||||
moveTo(position: Vec3, scale: number, angle: number, duration: number, delay = 0): void {
|
||||
const target = { position, scale: v3(scale, scale, 1), angle };
|
||||
if (duration <= 0) {
|
||||
this.node.setPosition(position);
|
||||
this.node.setScale(target.scale);
|
||||
this.node.angle = angle;
|
||||
return;
|
||||
}
|
||||
tween(this.node)
|
||||
.delay(delay)
|
||||
.to(duration, target, { easing: 'quadOut' })
|
||||
.start();
|
||||
}
|
||||
|
||||
/** Fades out, then hands the node back through `done`. */
|
||||
fadeOut(duration: number, done: () => void): void {
|
||||
const opacity = this.node.getComponent(UIOpacity) ?? this.node.addComponent(UIOpacity);
|
||||
tween(opacity)
|
||||
.to(duration, { opacity: 0 })
|
||||
.call(() => {
|
||||
opacity.opacity = 255;
|
||||
done();
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
/** Stops any running tween. Used when a match is torn down mid-animation. */
|
||||
cancelAnimations(): void {
|
||||
Tween.stopAllByTarget(this.node);
|
||||
const opacity = this.node.getComponent(UIOpacity);
|
||||
if (opacity) {
|
||||
Tween.stopAllByTarget(opacity);
|
||||
opacity.opacity = 255;
|
||||
}
|
||||
}
|
||||
|
||||
private _applyFrame(): void {
|
||||
if (!this.sprite || !this._library) {
|
||||
return;
|
||||
}
|
||||
this.sprite.spriteFrame = this._faceUp && this._card
|
||||
? this._library.frameFor(this._card)
|
||||
: this._library.backFrame;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "da0167b3-fa30-48b1-95e3-91ad10cda5d8",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
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<Card, CardView>();
|
||||
/** 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<Card>();
|
||||
|
||||
// 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<Card>, 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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "a8194580-ebb7-475e-b575-6d2cad42fc1f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user