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,56 @@
|
||||
import { CocosStorage } from '../modules/save/CocosStorage';
|
||||
import { SaveManager } from '../modules/save/SaveManager';
|
||||
import { StatsSection } from '../modules/save/StatsSection';
|
||||
|
||||
/**
|
||||
* The application's services, created once by the loader scene.
|
||||
*
|
||||
* Cocos components are built by the engine, so there is nowhere to inject
|
||||
* dependencies through a constructor; a small explicit locator is the honest
|
||||
* alternative to every component reaching for its own singleton. Everything it
|
||||
* holds is engine-independent apart from the storage adapter, so a test can
|
||||
* build one over `MemoryStorage` and drive the whole thing headlessly.
|
||||
*/
|
||||
export class App {
|
||||
private static _instance: App | null = null;
|
||||
|
||||
readonly save: SaveManager;
|
||||
readonly stats: StatsSection;
|
||||
|
||||
private constructor(save: SaveManager, stats: StatsSection) {
|
||||
this.save = save;
|
||||
this.stats = stats;
|
||||
}
|
||||
|
||||
/** Builds the services and loads saved progress. Called once, from `Bootstrap`. */
|
||||
static create(): App {
|
||||
if (App._instance) {
|
||||
return App._instance;
|
||||
}
|
||||
|
||||
const stats = new StatsSection();
|
||||
const save = new SaveManager(new CocosStorage());
|
||||
save.register(stats);
|
||||
save.load();
|
||||
|
||||
App._instance = new App(save, stats);
|
||||
return App._instance;
|
||||
}
|
||||
|
||||
static get instance(): App {
|
||||
if (!App._instance) {
|
||||
throw new Error('App.create() has not run — is the loader scene the start scene?');
|
||||
}
|
||||
return App._instance;
|
||||
}
|
||||
|
||||
static get exists(): boolean {
|
||||
return App._instance !== null;
|
||||
}
|
||||
|
||||
/** Records the result of a match and writes it out immediately. */
|
||||
recordMatch(outcome: 'win' | 'loss' | 'draw'): void {
|
||||
this.stats.recordResult(outcome);
|
||||
this.save.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "982035ad-66a6-428b-bba3-4fe31ed7a095",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { _decorator, Component, director, game, resources, SpriteFrame } from 'cc';
|
||||
import { App } from './App';
|
||||
import { CARDS_DIR } from '../ui/game/CardLibrary';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* The loader scene, per ARCHITECTURE.md: bring the modules up, then hand over
|
||||
* to the lobby. Everything that must exist before any gameplay runs belongs
|
||||
* here, and nowhere else.
|
||||
*/
|
||||
@ccclass('Bootstrap')
|
||||
export class Bootstrap extends Component {
|
||||
@property({ tooltip: 'Scene to open once the modules are ready.' })
|
||||
nextScene = 'lobby';
|
||||
|
||||
start(): void {
|
||||
// A phone that throttles to 30 fps makes the card animations stutter;
|
||||
// asking for 60 up front is cheap and the engine falls back on its own.
|
||||
game.frameRate = 60;
|
||||
|
||||
App.create();
|
||||
|
||||
// Warm the card art here rather than on the first deal: thirty-seven
|
||||
// sprites are the only sizeable load in the game, and a loader scene is
|
||||
// exactly where a player expects to wait for it.
|
||||
resources.loadDir(CARDS_DIR, SpriteFrame, (error) => {
|
||||
if (error) {
|
||||
console.error('[bootstrap] could not preload the card art', error);
|
||||
}
|
||||
this._openLobby();
|
||||
});
|
||||
}
|
||||
|
||||
private _openLobby(): void {
|
||||
director.loadScene(this.nextScene, (error) => {
|
||||
if (error) {
|
||||
console.error(`[bootstrap] could not open '${this.nextScene}'`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c91dd91e-e180-44f4-98d1-b58cd4db0169",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Card } from '../cards/Card';
|
||||
import { Action, Actions } from './Action';
|
||||
import { GameState, Phase, PlayerId } from './GameState';
|
||||
import { beats, currentActor, legalActions, unbeatenSlots } from './DurakRules';
|
||||
|
||||
/**
|
||||
* What tapping a card means for a player right now, or null if that card cannot
|
||||
* be played.
|
||||
*
|
||||
* This lives in the core rather than in the game screen so it can be tested
|
||||
* without the engine: it is the one piece of interface logic that can silently
|
||||
* turn a legal move into a rejected tap, and it is far too easy to get wrong by
|
||||
* eye. The screen just calls it and submits whatever comes back.
|
||||
*
|
||||
* One tap is the whole interaction. When defending, the attack to beat is worked
|
||||
* out here instead of being chosen separately — the serialised rules leave only
|
||||
* one unanswered attack at a time, and where several somehow exist the lowest
|
||||
* slot this card can beat is taken.
|
||||
*/
|
||||
export function intentFor(state: GameState, player: PlayerId, card: Card): Action | null {
|
||||
if (currentActor(state) !== player) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.phase === Phase.Defence) {
|
||||
const slot = unbeatenSlots(state)
|
||||
.find((i) => beats(card, state.table[i].attack, state.trumpSuit));
|
||||
return slot === undefined ? null : Actions.defend(player, card, slot);
|
||||
}
|
||||
|
||||
const wanted = Actions.attack(player, card);
|
||||
const legal = legalActions(state, player)
|
||||
.some((a) => a.type === wanted.type && (a as { card?: Card }).card === card);
|
||||
return legal ? wanted : null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b1aa773a-2d2c-43f5-980a-d6e2ffb829fd",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0b757fc9-4bb2-4007-9b13-b618d0655b6e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "672ddd76-ded7-498e-97b1-df2d7d30f662",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "3ba498f9-9dcb-48dc-bb1d-1de46602c43a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
Reference in New Issue
Block a user