Files
ftcandClaude Opus 5 7fbe4c324d Игра запускается и играется: три рантайм-бага и адаптивная вёрстка
Проверено вживую в Chrome: лобби -> матч -> бот ходит -> результат ->
статистика в лобби. Полный матч отыгран, оба исхода видел.

Баги, которые собирались и типизировались, но падали только в рантайме:

1. Canvas не менял размер под экран. Своего cc.Widget у него не было,
   а alignCanvasWithScreen правит только камеру. Canvas оставался
   1280x720, и все растянутые по нему дети — тоже.
2. [...map.values()] превращается сборкой в [].concat(map.values()),
   а concat не разворачивает итератор, а кладёт его одним элементом.
   Отсюда "view.cancelAnimations is not a function" на первой же уборке
   карт. Теперь Array.from.
3. Рубашка козыря рисовалась поверх колоды, а не под ней.

Вёрстка переделана под требование UI.md (4:3 .. 21:9):
- fitWidth даёт постоянную ширину, но не высоту: на 4:3 видно только
  ±720 юнитов. Кнопка "Leave" на y=840 и низ руки на -860 были за
  экраном именно там, где это важнее всего
- рука и панель действий считаются от нижней границы экрана,
  "Leave" прибит виджетом к левому верхнему углу
- стол — один ряд до шести карт вместо двух рядов по три: экономит
  ~215 юнитов высоты, которых на 4:3 нет. Ряд центрируется по тем
  картам, что реально на столе
- затемнение под окном результата рисуется непрозрачным спрайтом:
  тонировать и без того полупрозрачный спрайт почти ничего не даёт

Инструменты:
- npm run serve — отдаёт сборку и роут /_aspects: игра в трёх
  фиксированных iframe-ах 4:3 / 16:9 / 21:9. Движок подгоняет себя
  под окно, поэтому iframe — единственный простой способ увидеть
  вёрстку в форме, которой окно не имеет
- npm run check:spreads — ловит спред неитерируемого в массив
- npm run check — чистота core, спреды, сцены, типы, тесты

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:41:47 +03:00

561 lines
20 KiB
TypeScript

import {
_decorator, Button, Component, EventTouch, Label, Node, Prefab,
instantiate, tween, UIOpacity, UITransform, 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 { bottomAnchors, Layout, Palette, tableSlotX, 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;
@property({ type: Node, tooltip: 'Holds Take and Done; follows the bottom edge.' })
actionBar: Node | 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();
// Array.from, not [...map.values()]. The build transpiles array spread
// to [].concat(...), which appends a Map iterator as a single element
// instead of expanding it — so the loop would run once over the iterator
// itself. See docs/DECISIONS.md.
const live = Array.from(this._views.values()).concat(this._anonymous);
for (const view of live) {
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
// -----------------------------------------------------------------------
/** Half the canvas height, which is how far the layout may reach from centre. */
private _halfHeight(): number {
const transform = this.node.getComponent(UITransform);
const height = transform && transform.height > 0 ? transform.height : Layout.designHeight;
return height / 2;
}
/** 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>();
const anchors = bottomAnchors(this._halfHeight());
this._layOutHud(anchors.actionBarY);
// The trump card lies face up under the draw pile, turned sideways so the
// suit shows. _place lifts whatever it touches to the top of the layer,
// so it is pushed back down afterwards.
if (state.deck.length > 0) {
live.add(state.trumpCard);
this._place(state.trumpCard, true,
v3(Layout.trump.x, Layout.trump.y, 0), Layout.tableCardScale, 90, duration);
this._views.get(state.trumpCard)?.node.setSiblingIndex(0);
}
// The player's hand: a straight row, tightened as it grows, and sitting
// against the bottom edge wherever that happens to be.
const hand = view.hand.slice().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, anchors.handY + lifted, 0), 1, 0, duration);
});
// The table: one centred row of attacking cards, each with whatever beat it.
state.table.forEach((slot, i) => {
const x = tableSlotX(i, state.table.length);
const y = Layout.tableY;
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);
}
/**
* Puts the scene's fixed furniture where this screen shape wants it. Doing it
* here rather than in the scene keeps one source of truth for the layout, and
* the action bar has to move with the hand anyway.
*/
private _layOutHud(actionBarY: number): void {
moveNodeY(this.actionBar, actionBarY);
moveNodeY(this.opponentLabel?.node ?? null, Layout.opponentLabelY);
moveNodeY(this.messageLabel?.node ?? null, Layout.messageY);
if (this.deckAnchor) {
this.deckAnchor.setPosition(Layout.deck.x, Layout.deckLabelY, 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.deck.x + i * 3, Layout.deck.y + i * 3, 0),
Layout.tableCardScale, 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.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.deck.x, Layout.deck.y, 0), Layout.tableCardScale, 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 {
// Array.from, not [...map] — see the note in teardown.
for (const [card, view] of Array.from(this._views.entries())) {
if (live.has(card)) continue;
this._views.delete(card);
const goingToOpponent = !this._match?.state.discard.includes(card);
const target = goingToOpponent
? v3(0, Layout.opponentHandY, 0)
: v3(Layout.discard.x, Layout.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 moveNodeY(node: Node | null, y: number): void {
if (node && node.position.y !== y) {
node.setPosition(node.position.x, y, 0);
}
}
function setButton(button: Button | null, enabled: boolean): void {
if (!button) return;
button.node.active = enabled;
button.interactable = enabled;
}