Обе сцены собраны через 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>
520 lines
18 KiB
TypeScript
520 lines
18 KiB
TypeScript
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;
|
|
}
|