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:
ftc
2026-09-13 11:30:28 +03:00
co-authored by Claude Opus 5
parent 2056673946
commit 4f3a26dc45
111 changed files with 8168 additions and 37 deletions
+71
View File
@@ -0,0 +1,71 @@
import { _decorator, Component, Node } from 'cc';
import { App } from '../app/App';
import { LobbyScreen } from './lobby/LobbyScreen';
import { GameScreen, MatchOutcome } from './game/GameScreen';
const { ccclass, property } = _decorator;
/**
* Owns the two screens the lobby scene contains and moves between them.
*
* ARCHITECTURE.md puts the main game UI in the lobby scene, so a match is a
* change of visible screen rather than a scene load: no reload cost between
* games, and no way for a half-finished match to leak into a new one as long
* as `GameScreen.teardown` is honest about clearing up.
*/
@ccclass('MainFlow')
export class MainFlow extends Component {
@property(LobbyScreen)
lobby: LobbyScreen | null = null;
@property(GameScreen)
game: GameScreen | null = null;
@property(Node)
lobbyRoot: Node | null = null;
@property(Node)
gameRoot: Node | null = null;
start(): void {
if (this.lobby) {
this.lobby.onStartRequested = () => this.startMatch();
}
if (this.game) {
this.game.onFinished = (outcome) => this.finishMatch(outcome);
this.game.onQuitRequested = () => this.abandonMatch();
}
this.showLobby();
}
private showLobby(): void {
setActive(this.gameRoot, false);
setActive(this.lobbyRoot, true);
this.lobby?.refresh();
}
private startMatch(): void {
setActive(this.lobbyRoot, false);
setActive(this.gameRoot, true);
this.game?.startNewMatch();
}
private finishMatch(outcome: MatchOutcome): void {
if (App.exists) {
App.instance.recordMatch(outcome);
}
this.game?.teardown();
this.showLobby();
}
/** Leaving early still ends the match — and it counts as a loss. */
private abandonMatch(): void {
this.finishMatch('loss');
}
}
function setActive(node: Node | null, active: boolean): void {
if (node) {
node.active = active;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0b757fc9-4bb2-4007-9b13-b618d0655b6e",
"files": [],
"subMetas": {},
"userData": {}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Every piece of text the player reads, in one place.
*
* Localisation is out of scope for iteration 1 (see docs/PLAN.md), but keeping
* the strings together means adding a language later is a change to this file
* and nothing else — rather than a hunt through scenes and components.
*/
export const Strings = {
appTitle: 'Fool',
appSubtitle: 'a game of durak',
newGame: 'New Game',
quit: 'Leave',
playAgain: 'Play Again',
backToLobby: 'Back',
statsTitle: 'Your record',
statsPlayed: 'Played',
statsWon: 'Won',
statsLost: 'Lost',
statsStreak: 'Streak',
take: 'Take',
done: 'Done',
yourMove: 'Your move',
yourDefence: 'Beat it, or take',
addMore: 'Add cards, or say done',
opponentThinking: 'Opponent is thinking',
opponentTook: 'Opponent takes the cards',
youTook: 'You take the cards',
trumpIs: 'Trump',
deckLeft: 'Deck',
opponentHand: 'Opponent',
wonTitle: 'You win',
lostTitle: 'You are the fool',
drawTitle: 'A draw',
wonBody: 'Your opponent was left holding the cards.',
lostBody: 'You were left holding the cards.',
drawBody: 'You both ran out at the same time.',
cannotPlayThat: 'You cannot play that card',
} as const;
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "672ddd76-ded7-498e-97b1-df2d7d30f662",
"files": [],
"subMetas": {},
"userData": {}
}
+81
View File
@@ -0,0 +1,81 @@
import { Color } from 'cc';
/**
* Shared visual constants. Anything a layout or a colour is tuned by lives here
* rather than being typed into each scene, so a change lands everywhere at once
* and the scene files stay free of magic numbers.
*
* Lengths are in design units — the screen is always exactly 1080 wide, see
* docs/DECISIONS.md.
*/
export const Layout = {
designWidth: 1080,
designHeight: 1920,
/** Card sprite size, matching what scripts/build-art.mjs produces. */
cardWidth: 220,
cardHeight: 320,
/** Cards on the table are drawn smaller so six pairs fit comfortably. */
tableCardScale: 0.62,
/** The opponent's hand is smaller again — it is only ever card backs. */
opponentCardScale: 0.5,
/** Horizontal step between neighbouring cards in the player's hand. */
handStep: 128,
/** Never let a hand grow wider than this; the step shrinks instead. */
handMaxWidth: 940,
/** How far the tapped card lifts out of the hand. */
handLift: 48,
/** Offset of a beating card from the card it beats. */
beatOffset: { x: 26, y: -34 },
/** Comfortable minimum for anything tappable, per docs/UI.md. */
minTapSize: 120,
screenMargin: 40,
/**
* Where things sit on the table, in canvas coordinates (origin at the
* centre, y up). Vertical positions stay well inside the 1080×1920 design
* area so that a 4:3 screen — which crops the top and bottom — still shows
* every one of them. See docs/DECISIONS.md.
*/
region: {
opponentHandY: 600,
opponentLabelY: 770,
deck: { x: -390, y: 150 },
trump: { x: -300, y: 120 },
discard: { x: 430, y: 150 },
/** Centre of the first table row; the second row sits a row-height below. */
tableTopY: -20,
tableRowStep: 235,
tableColumnStep: 180,
messageY: 330,
actionBarY: -430,
playerHandY: -700,
},
} as const;
export const Palette = {
gold: new Color(244, 194, 69, 255),
cream: new Color(247, 240, 222, 255),
dim: new Color(174, 196, 182, 255),
good: new Color(126, 217, 140, 255),
bad: new Color(232, 116, 104, 255),
shade: new Color(0, 0, 0, 170),
} as const;
/** How long the standard card movements take, in seconds. */
export const Timing = {
deal: 0.28,
dealStagger: 0.06,
play: 0.22,
collect: 0.3,
flip: 0.16,
/** Pause before the bot acts, so its moves can be followed. */
botThinking: 0.55,
/** Pause after a turn resolves, before the next one starts. */
turnBreak: 0.4,
} as const;
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "3ba498f9-9dcb-48dc-bb1d-1de46602c43a",
"files": [],
"subMetas": {},
"userData": {}
}
+88
View File
@@ -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": {}
}
+109
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "da0167b3-fa30-48b1-95e3-91ad10cda5d8",
"files": [],
"subMetas": {},
"userData": {}
}
+519
View File
@@ -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": {}
}
+82
View File
@@ -0,0 +1,82 @@
import { _decorator, Button, Component, Label, Node } from 'cc';
import { App } from '../../app/App';
const { ccclass, property } = _decorator;
/**
* The lobby, per LOBBY.md: one button that starts a game, and — since the save
* module is already keeping them — the player's record.
*
* It owns no game logic. Starting a match is reported to `MainFlow` through
* `onStartRequested`, which keeps the lobby ignorant of what a match even is.
*/
@ccclass('LobbyScreen')
export class LobbyScreen extends Component {
@property(Label)
playedLabel: Label | null = null;
@property(Label)
wonLabel: Label | null = null;
@property(Label)
lostLabel: Label | null = null;
@property(Label)
streakLabel: Label | null = null;
@property(Node)
statsPanel: Node | null = null;
@property(Button)
newGameButton: Button | null = null;
/** Set by `MainFlow`. */
onStartRequested: (() => void) | null = null;
onLoad(): void {
// Clicks are subscribed here rather than through the scene's clickEvents
// list: the wiring stays visible in the source, and a renamed handler
// becomes a compile error instead of a button that silently does nothing.
this.newGameButton?.node.on(Button.EventType.CLICK, this.startGame, this);
}
onDestroy(): void {
this.newGameButton?.node.off(Button.EventType.CLICK, this.startGame, this);
}
onEnable(): void {
this.refresh();
}
/** Re-reads the saved statistics. Called whenever the lobby comes back into view. */
refresh(): void {
if (!App.exists) {
return;
}
const stats = App.instance.stats;
setText(this.playedLabel, `${stats.played}`);
setText(this.wonLabel, `${stats.won}`);
setText(this.lostLabel, `${stats.lost}`);
setText(this.streakLabel, stats.bestStreak > 0
? `${stats.streak} (best ${stats.bestStreak})`
: `${stats.streak}`);
// Nothing to show before the first game — an empty table of zeroes is
// worse than no table at all.
if (this.statsPanel) {
this.statsPanel.active = stats.played > 0;
}
}
/** Wired to the New Game button in the scene. */
startGame(): void {
this.onStartRequested?.();
}
}
function setText(label: Label | null, text: string): void {
if (label) {
label.string = text;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "41c969a5-d2d3-4e51-9b23-db1e3907fb53",
"files": [],
"subMetas": {},
"userData": {}
}