Compare commits

..
16 Commits
Author SHA1 Message Date
cthangover c5b38ed956 Update card_battle implementation, assets and documentation 2026-10-31 15:28:48 +03:00
cthangover f38a8c6824 Add mod documentation in seven languages 2026-10-29 09:25:47 +01:00
iisaev 7d90e4ff1a gitignore file 2026-10-28 15:20:01 +03:00
cthangover ed6870c5c9 Localization in es, zh, ja, ko, vi 2026-10-23 20:54:31 +02:00
cthangover 19e2eef7c0 Mod cover 2026-10-22 17:54:07 +02:00
cthangover 968b8d6fe1 Line endings: LF everywhere
.gitattributes forces LF (CRLF only for .bat/.cmd), core.autocrlf is off;
files that differed only by CRLF are normalized.
2026-10-21 13:29:30 +02:00
cthangover 65e632a66f Commit pending working-tree changes (local edits, line endings) 2026-10-21 12:31:47 +02:00
cthangover 589ee46395 Mod naming rule and engine UI in engine mods
Mods are renamed by role (shell2d_base, menu_base, ui_base, live2d_base,
editor_tools, cthangover_story, cthangover_murakami, cthangover_party) and
every reference follows. Engine UI prefabs (dialog, answers, message, game
menu, inventory, map, skills) and their panel entries moved out of the story
mod into the subsystem mods; the card_novel and vn_starter_novel collections
are removed.
2026-10-18 19:00:06 +02:00
cthangover 6950443d93 Drop the unneeded visual_novel_core dependency
The card core needs only the battle platform and the interface; depending on the content mod pulled the whole novel into every game that uses it.
2026-10-17 14:30:58 +00:00
cthangover a63ab5e71e Own effects/select.png (moved from visual_novel_core)
The card selection effect is used only by the card layer; the resource atlas flagged it as taken from visual_novel_core without a dependency.
2026-10-17 14:27:43 +00:00
cthangover 5a9bda2e55 card_novel collection: entry scene, battle core, starter party, ru texts
- entry.scene = start_scene and settings.battle.core = card_battle, so the
  game starts where it should and turn_battle_base picks the card core.
- The core block also pulls starter_party (the starter party initializer
  was split out of visual_novel_core).
- Russian name/description for every block of the collection.
2026-10-17 14:27:43 +00:00
iisaevandClaude Opus 5.5 214ddb32fc upd: sync local changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:18:12 +00:00
cthangover b243a318f4 new battle gui 2026-09-28 10:48:05 +03:00
cthangover f65663fd91 ui stule update 2026-09-26 08:05:12 +03:00
cthangover f83f968ae1 upd 2026-09-24 11:17:03 +03:00
cthangover 004d8f1490 move ui to mods 2026-09-24 10:42:42 +03:00
70 changed files with 3074 additions and 969 deletions

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
* text=auto eol=lf
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
*.bat text eol=crlf
*.cmd text eol=crlf
# Binaries: never converted, never diffed as text.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.svg text
*.ogg binary
*.wav binary
*.mp3 binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary
*.dll binary
*.so binary
*.dylib binary
*.pdb binary
*.exe binary
*.zip binary
*.pck binary
*.moc3 binary
*.res binary
*.scn binary
+1
View File
@@ -12,6 +12,7 @@ obj/
gen/ gen/
obj/ obj/
bin/ bin/
art_sources/
*.tres~ *.tres~
*.tscn~ *.tscn~
+9
View File
@@ -0,0 +1,9 @@
{
"options": [
{
"key": "battle.core",
"value": "card_battle",
"name": "collection_settings/cards"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+47
View File
@@ -0,0 +1,47 @@
# Card Battle — battles with cards dragged onto targets
Card Battle is a battle system in which characters are big cards and their actions are smaller cards: pick a character, then drag one of its action cards onto a target. It is the battle system of Cthangover, and RPG Plus can use it instead of FF Battle. The battle itself — its start, backdrop, victory and defeat screens, experience and loot — comes from Turn Battle Base.
## The battle screen
The RPG Plus warrior against a wolf, after clicking the hero and hovering over Power strike:
![The card battle screen: the Card battle panel (Round 1, Enemies alive 1) at the top left, the End turn button and speed buttons at the top right, the wolf's card on the right (HP 31/31, DEF 2, ATK 7, AP 1), the hero's card at the bottom left (HP 42/42, DEF 4, ATK 12, AP 1), the battle log in the middle and the Actions panel with three cards — Basic attack, Defence, Power strike — and the hint "A full swing: one and a half times the attack and a bit more from strength."](assets/card-hover.jpg)
- **Character cards** show health (HP), the shield (DEF, absorbs damage first), attack (ATK) and action points (AP).
- **Actions panel** — the action cards of the selected character, with their target (enemy or ally), cost in AP and values. A card the character cannot pay for is dimmed.
- **The hint line** under the cards shows what the hovered card will do: the damage for a plain attack, the shield for a defence, the description for a skill written as a formula.
- **End turn** ends the party's turn early; with 0 AP left the turn ends by itself.
## A turn
1. **Click a party card** to see its actions.
2. **Drag an action card onto a target** — an enemy card for an attack, an ally card for a defence. The target shows whether the action can be applied there.
3. When the party is out of action points, the enemies act, and a new round starts.
![The battle log and actions panel after one round: Shield of Wolf absorbed 2; Power strike: Hero → Wolf · −19 HP; The party is out of action points; Enemy turn; Defence: Wolf · +4 DEF; Player turn · round 2.](assets/card-turn.jpg)
The hero's Power strike did 21: the wolf's shield of 2 took the first part and its health fell by 19, from 31 to 12. The wolf then raised its shield to 4.
## Actions
Card Battle carries out the same basic kinds of actions as FF Battle:
- `Attack=1.5` — damage of the attack times the number, absorbed first by the target's shield;
- `Defence=4` — a shield on an ally;
- `Turn=2` — stun for that many turns.
Skills written as formulas (`Formula=` values, such as Classic RPG Rules' Power strike) work here too through Battle Formulas.
## Choosing Card Battle
In a game's collection set `"battle.core": "card_battle"`, or switch from a scenario before the battle:
```scenario
action battle.set_core core=card_battle
```
## If something goes wrong
1. **Dragging a card does nothing.** No character is selected, the character has too few action points, or the target is of the wrong side (attacks go onto enemies, defences onto allies).
2. **The battle opens in FF Battle.** The game's `battle.core` setting names `ff_battle`.
+47
View File
@@ -0,0 +1,47 @@
# Card Battle — combates en los que se arrastran cartas sobre los objetivos
Card Battle es un sistema de combate en el que los personajes son cartas grandes y sus acciones, cartas más pequeñas: elige un personaje y arrastra una de sus cartas de acción sobre un objetivo. Es el sistema de combate de Cthangover, y RPG Plus puede usarlo en lugar de FF Battle. El combate en sí — el inicio, el fondo, las pantallas de victoria y derrota, la experiencia y el botín — lo pone Turn Battle Base.
## La pantalla de combate
El guerrero de RPG Plus contra un lobo, tras hacer clic en el héroe y pasar el cursor por Power strike:
![La pantalla de combate con cartas: el panel Card battle (Round 1, Enemies alive 1) arriba a la izquierda, el botón End turn y los botones de velocidad arriba a la derecha, la carta del lobo a la derecha (HP 31/31, DEF 2, ATK 7, AP 1), la carta del héroe abajo a la izquierda (HP 42/42, DEF 4, ATK 12, AP 1), el registro de combate en medio y el panel Actions con tres cartas — Basic attack, Defence, Power strike — y la pista «Un golpe completo: ataque y medio, y un poco más por fuerza».](assets/card-hover.jpg)
- **Las cartas de personaje** muestran la salud (HP), el escudo (DEF, absorbe el daño primero), el ataque (ATK) y los puntos de acción (AP).
- **El panel Actions** — las cartas de acción del personaje seleccionado, con su objetivo (enemigo o aliado), su coste en AP y sus valores. Las cartas que el personaje no puede pagar aparecen atenuadas.
- **La línea de pista** bajo las cartas dice qué hará la carta bajo el cursor: el daño de un ataque normal, el escudo de una defensa, la descripción de una habilidad escrita como fórmula.
- **End turn** termina antes el turno del grupo; con 0 AP restantes el turno acaba solo.
## Un turno
1. **Haz clic en la carta de un miembro del grupo** para ver sus acciones.
2. **Arrastra una carta de acción sobre un objetivo** — la carta de un enemigo para un ataque, la de un aliado para una defensa. El objetivo indica si la acción se le puede aplicar.
3. Cuando el grupo se queda sin puntos de acción, actúan los enemigos y empieza una ronda nueva.
![El registro de combate y el panel de acciones tras una ronda: Shield of Wolf absorbed 2; Power strike: Hero → Wolf · −19 HP; The party is out of action points; Enemy turn; Defence: Wolf · +4 DEF; Player turn · round 2.](assets/card-turn.jpg)
El Power strike del héroe sacó 21: los 2 primeros los absorbió el escudo del lobo y su salud cayó 19, de 31 a 12. Después el lobo subió su escudo a 4.
## Las acciones
Card Battle ejecuta los mismos tipos básicos de acciones que FF Battle:
- `Attack=1.5` — el daño del ataque multiplicado por el número; lo absorbe primero el escudo del objetivo;
- `Defence=4` — un escudo sobre un aliado;
- `Turn=2` — aturdimiento durante ese número de turnos.
Las habilidades escritas como fórmulas (valores `Formula=`, como el Power strike de Classic RPG Rules) también funcionan aquí, mediante Battle Formulas.
## Cómo elegir Card Battle
En la colección del juego pon `"battle.core": "card_battle"`, o cambia de sistema desde un escenario antes del combate:
```scenario
action battle.set_core core=card_battle
```
## Si algo sale mal
1. **Arrastrar la carta no hace nada.** No hay personaje seleccionado, al personaje le faltan puntos de acción o el objetivo es del bando equivocado (los ataques van a los enemigos, las defensas a los aliados).
2. **El combate se abre en FF Battle.** El ajuste `battle.core` del juego nombra `ff_battle`.
+47
View File
@@ -0,0 +1,47 @@
# Card Battle —— カードをターゲットにドラッグする戦闘
Card Battle は、キャラクターが大きなカード、そのアクションが小さなカードとして表されるバトルシステムです。キャラクターを選び、そのアクションカードの一枚をターゲットへドラッグします。Cthangover のバトルシステムであり、RPG Plus は FF Battle の代わりにこれを使えます。戦闘そのもの——開始、背景、勝利・敗北の画面、経験値と戦利品——は Turn Battle Base が担います。
## 戦闘画面
RPG Plus の戦士対狼。英雄をクリックし、Power strike にカーソルを重ねたところです:
![カードバトルの画面:左上に Card battle パネル(Round 1、Enemies alive 1)、右上に End turn ボタンと速度ボタン、右側に狼のカード(HP 31/31、DEF 2、ATK 7、AP 1)、左下に英雄のカード(HP 42/42、DEF 4、ATK 12、AP 1)、中央に戦闘ログ、そして Actions パネルには Basic attack・Defence・Power strike の3枚のカードとヒント「渾身の一撃:攻撃の1.5倍に力の値を少し上乗せ」。](assets/card-hover.jpg)
- **キャラクターカード**には体力(HP)、シールド(DEF。ダメージを最初に引き受ける)、攻撃(ATK)、行動ポイント(AP)が表示されます。
- **Actions パネル**——選択中キャラクターのアクションカードです。対象(敵か味方)、AP 消費、数値が示され、支払えないカードは薄く表示されます。
- カードの下の**ヒント行**には、カーソルを合わせたカードの効果が出ます。通常攻撃ならダメージ、防御ならシールド、数式で書かれたスキルなら説明です。
- **End turn** はパーティのターンを途中で終わらせます。AP が 0 になればターンは自然に終わります。
## ターンの流れ
1. **パーティのカードをクリック**して、そのアクションを表示します。
2. **アクションカードをターゲットへドラッグ**します。攻撃なら敵のカード、防御なら味方のカードです。その対象に効くかどうかはカード側で示されます。
3. パーティの行動ポイントを使い切ると、今度は敵が行動し、新しいラウンドが始まります。
![1ラウンド後の戦闘ログとアクションパネル:Shield of Wolf absorbed 2/Power strike: Hero → Wolf · −19 HP/The party is out of action points/Enemy turn/Defence: Wolf · +4 DEF/Player turn · round 2。](assets/card-turn.jpg)
英雄の Power strike は 21 のダメージ。最初の 2 は狼のシールドが受け止め、体力は 31 から 12 へと 19 減りました。その後、狼はシールドを 4 まで上げます。
## アクション
Card Battle は、FF Battle と同じ基本種類のアクションを実行します:
- `Attack=1.5`——攻撃ダメージに数値を掛けたもの。まず対象のシールドが受け止めます;
- `Defence=4`——味方にシールドを付与;
- `Turn=2`——そのターン数だけスタン。
数式で書かれたスキル(`Formula=` 値。Classic RPG Rules の Power strike など)も、Battle Formulas を通してここで機能します。
## Card Battle の選び方
ゲームのコレクション設定で `"battle.core": "card_battle"` を指定するか、戦闘前にシナリオから切り替えてください:
```scenario
action battle.set_core core=card_battle
```
## うまくいかないときは
1. **カードをドラッグしても何も起きない。** キャラクターが選ばれていない、行動ポイントが足りない、または対象の陣営が違います(攻撃は敵へ、防御は味方へ)。
2. **戦闘が FF Battle で開く。** ゲームの `battle.core` 設定が `ff_battle` になっています。
+47
View File
@@ -0,0 +1,47 @@
# Card Battle — 카드를 대상에 끌어다 놓는 전투
Card Battle은 캐릭터가 큰 카드로, 그 행동은 더 작은 카드로 표현되는 전투 시스템입니다. 캐릭터를 고르고 행동 카드 하나를 대상에 끌어다 놓으세요. Cthangover의 전투 시스템이며, RPG Plus도 FF Battle 대신 이 시스템을 쓸 수 있습니다. 전투 자체 — 시작, 배경, 승리·패배 화면, 경험치와 전리품 — 은 Turn Battle Base가 맡습니다.
## 전투 화면
RPG Plus의 전사가 늑대와 맞서는 장면입니다. 영웅을 클릭하고 Power strike에 마우스를 올려 둔 상태죠:
![카드 전투 화면: 왼쪽 위에 Card battle 패널(Round 1, Enemies alive 1), 오른쪽 위에 End turn 버튼과 속도 버튼, 오른쪽에 늑대의 카드(HP 31/31, DEF 2, ATK 7, AP 1), 왼쪽 아래에 영웅의 카드(HP 42/42, DEF 4, ATK 12, AP 1), 가운데에 전투 로그, 그리고 Actions 패널에는 Basic attack·Defence·Power strike 세 장의 카드와 힌트 "전력으로 휘두르는 일격: 공격의 1.5배에 힘에서 조금 더".](assets/card-hover.jpg)
- **캐릭터 카드**에는 체력(HP), 방어막(DEF, 피해를 먼저 흡수), 공격력(ATK), 행동력(AP)이 표시됩니다.
- **Actions 패널** — 선택한 캐릭터의 행동 카드 모음입니다. 대상(적 또는 아군), AP 소모, 수치가 표시되고, 캐릭터가 지불할 수 없는 카드는 흐리게 보입니다.
- 카드 아래의 **힌트 줄**에는 마우스를 올린 카드가 무엇을 하는지 나옵니다. 일반 공격은 피해, 방어는 방어막, 수식으로 쓰인 스킬은 설명이 표시됩니다.
- **End turn**은 파티 턴을 일찍 끝냅니다. AP가 0으로 떨어지면 턴은 저절로 끝납니다.
## 한 턴의 진행
1. **파티원의 카드를 클릭**하면 그 캐릭터의 행동이 보입니다.
2. **행동 카드를 대상에 끌어다 놓습니다** — 공격은 적 카드에, 방어는 아군 카드에 놓습니다. 대상 쪽에 그 행동을 적용할 수 있는지 표시됩니다.
3. 파티의 행동력이 바닥나면 적들이 행동하고, 새 라운드가 시작됩니다.
![한 라운드 후의 전투 로그와 행동 패널: Shield of Wolf absorbed 2; Power strike: Hero → Wolf · −19 HP; The party is out of action points; Enemy turn; Defence: Wolf · +4 DEF; Player turn · round 2.](assets/card-turn.jpg)
영웅의 Power strike은 21의 피해를 냈습니다. 처음 2는 늑대의 방어막이 흡수했고, 체력은 31에서 12로 19 줄었습니다. 그러고는 늑대가 방어막을 4까지 올립니다.
## 행동
Card Battle은 FF Battle과 같은 기본 행동 유형을 처리합니다:
- `Attack=1.5` — 공격 피해에 그 수를 곱한 값입니다. 대상의 방어막이 먼저 흡수합니다;
- `Defence=4` — 아군에게 방어막을 겁니다;
- `Turn=2` — 그만큼의 턴 동안 기절.
수식으로 쓰인 스킬(`Formula=` 값. Classic RPG Rules의 Power strike 등)도 Battle Formulas를 통해 여기서 작동합니다.
## Card Battle 선택하기
게임 컬렉션 설정에 `"battle.core": "card_battle"`를 넣거나, 전투 전에 시나리오로 바꿔 주세요:
```scenario
action battle.set_core core=card_battle
```
## 문제가 생겼을 때
1. **카드를 끌어다 놓아도 아무 일도 없음.** 캐릭터가 선택되지 않았거나, 행동력이 부족하거나, 대상 진영이 틀립니다(공격은 적에게, 방어는 아군에게).
2. **전투가 FF Battle로 열림.** 게임의 `battle.core` 설정이 `ff_battle`을 가리킵니다.
+47
View File
@@ -0,0 +1,47 @@
# Card Battle — бои, где карты перетаскивают на цели
Card Battle — боевая система, в которой персонажи — крупные карты, а их действия — карты поменьше: выберите персонажа и перетащите одну из его карт действий на цель. Это боевая система Cthangover, а RPG Plus может играть в неё вместо FF Battle. Сам бой — начало, фон, экраны победы и поражения, опыт и добыча — берётся из Turn Battle Base.
## Экран боя
Воин из RPG Plus против волка: герой нажат, курсор наведён на Power strike:
![Экран карточного боя: слева вверху панель Card battle (Round 1, Enemies alive 1), справа вверху кнопка End turn и кнопки скорости, справа карта волка (HP 31/31, DEF 2, ATK 7, AP 1), слева внизу карта героя (HP 42/42, DEF 4, ATK 12, AP 1), посередине журнал боя, а также панель Actions с тремя картами — Basic attack, Defence, Power strike — и подсказкой «Полный замах: полторы атаки и ещё немного от силы».](assets/card-hover.jpg)
- **Карты персонажей** показывают здоровье (HP), щит (DEF, поглощает урон первым), атаку (ATK) и очки действий (AP).
- **Панель Actions** — карты действий выбранного персонажа: цель (враг или союзник), стоимость в AP и значения. Карту, которую персонаж не может оплатить, видно приглушённой.
- **Строка подсказки** под картами объясняет, что сделает карта под курсором: урон для обычной атаки, щит для защиты, описание — для навыка, записанного формулой.
- **End turn** завершает ход отряда досрочно; когда AP кончаются (0), ход завершается сам.
## Ход
1. **Нажмите карту члена отряда**, чтобы увидеть его действия.
2. **Перетащите карту действия на цель** — карту врага для атаки, карту союзника для защиты. Цель подсвечивает, применимо ли к ней действие.
3. Когда у отряда кончаются очки действий, ходят враги, и начинается новый раунд.
![Журнал боя и панель действий после одного раунда: Shield of Wolf absorbed 2; Power strike: Hero → Wolf · −19 HP; The party is out of action points; Enemy turn; Defence: Wolf · +4 DEF; Player turn · round 2.](assets/card-turn.jpg)
Power strike героя снял 21: первые 2 поглотил щит волка, а здоровье упало на 19 — с 31 до 12. После этого волк поднял щит до 4.
## Действия
В Card Battle работают те же базовые виды действий, что и в FF Battle:
- `Attack=1.5` — урон атаки, умноженный на число; первым его поглощает щит цели;
- `Defence=4` — щит на союзнике;
- `Turn=2` — оглушение на столько ходов.
Навыки, записанные формулами (значения `Formula=`, например Power strike из Classic RPG Rules), тоже работают здесь — через Battle Formulas.
## Как включить Card Battle
В коллекции игры задайте `"battle.core": "card_battle"`, либо переключите систему сценарием до боя:
```scenario
action battle.set_core core=card_battle
```
## Если что-то пошло не так
1. **Перетаскивание карты ничего не делает.** Не выбран персонаж, у него не хватает очков действий либо цель с другой стороны (атаки — на врагов, защиты — на союзников).
2. **Бой открывается в FF Battle.** В настройке игры `battle.core` прописан `ff_battle`.
+47
View File
@@ -0,0 +1,47 @@
# Card Battle — trận chiến dùng thẻ kéo thả vào mục tiêu
Card Battle là hệ thống chiến đấu trong đó nhân vật là những thẻ cỡ lớn còn kỹ năng của họ là những thẻ nhỏ hơn: chọn một nhân vật, rồi kéo một thẻ hành động của nhân vật đó vào mục tiêu. Đây là hệ thống chiến đấu của Cthangover, và RPG Plus cũng có thể dùng nó thay cho FF Battle. Còn bản thân trận chiến — khởi đầu, nền, màn hình thắng thua, kinh nghiệm và chiến lợi phẩm — do Turn Battle Base đảm nhiệm.
## Màn hình chiến đấu
Chiến binh RPG Plus đấu với sói, sau khi nhấp vào anh hùng và rê chuột lên Power strike:
![Màn hình chiến đấu thẻ bài: bảng Card battle (Round 1, Enemies alive 1) ở trên trái, nút End turn và các nút tốc độ ở trên phải, thẻ của con sói bên phải (HP 31/31, DEF 2, ATK 7, AP 1), thẻ của anh hùng dưới trái (HP 42/42, DEF 4, ATK 12, AP 1), nhật ký chiến đấu ở giữa và bảng Actions với ba thẻ — Basic attack, Defence, Power strike — kèm gợi ý "Vung trọn đòn: một lần rưỡi sát thương và thêm một chút từ sức mạnh".](assets/card-hover.jpg)
- **Thẻ nhân vật** hiển thị máu (HP), lớp khiên (DEF, hấp thụ sát thương trước), sát thương (ATK) và điểm hành động (AP).
- **Bảng Actions** — các thẻ hành động của nhân vật đang chọn, kèm mục tiêu (địch hoặc đồng đội), giá AP và các giá trị. Thẻ nào nhân vật không trả nổi sẽ bị mờ đi.
- **Dòng gợi ý** dưới các thẻ cho biết thẻ đang rê chuột sẽ làm gì: sát thương của đòn đánh thường, lớp khiên của phép phòng thủ, mô tả của kỹ năng viết bằng công thức.
- **End turn** kết thúc sớm lượt của đội; khi hết AP (còn 0), lượt tự kết thúc.
## Diễn biến một lượt
1. **Nhấp vào thẻ của một thành viên đội** để xem các hành động của họ.
2. **Kéo thẻ hành động vào mục tiêu** — thẻ địch để tấn công, thẻ đồng đội để phòng thủ. Mục tiêu sẽ cho biết hành động đó có áp dụng được lên nó hay không.
3. Khi đội hết điểm hành động, quân địch sẽ hành động và một vòng mới bắt đầu.
![Nhật ký chiến đấu và bảng hành động sau một vòng: Shield of Wolf absorbed 2; Power strike: Hero → Wolf · −19 HP; The party is out of action points; Enemy turn; Defence: Wolf · +4 DEF; Player turn · round 2.](assets/card-turn.jpg)
Power strike của anh hùng gây 21 sát thương: phần đầu là 2 điểm lớp khiên của sói hấp thụ, máu tụt 19, từ 31 xuống 12. Sau đó con sói nâng lớp khiên lên 4.
## Các hành động
Card Battle xử lý những loại hành động cơ bản giống hệt FF Battle:
- `Attack=1.5` — sát thương của đòn đánh nhân với con số; lớp khiên của mục tiêu hấp thụ trước;
- `Defence=4` — đặt lớp khiên lên đồng đội;
- `Turn=2` — khiến mục tiêu choáng đúng số lượt đó.
Kỹ năng viết bằng công thức (giá trị `Formula=`, chẳng hạn Power strike của Classic RPG Rules) cũng hoạt động ở đây thông qua Battle Formulas.
## Chọn Card Battle
Trong bộ sưu tập của game, đặt `"battle.core": "card_battle"`, hoặc đổi hệ thống từ kịch bản trước trận đấu:
```scenario
action battle.set_core core=card_battle
```
## Khi có sự cố
1. **Kéo thẻ mà chẳng có gì xảy ra.** Chưa chọn nhân vật, nhân vật không đủ điểm hành động, hoặc mục tiêu sai phe (đòn đánh dành cho địch, phép phòng thủ dành cho đồng đội).
2. **Trận chiến mở bằng FF Battle.** Thiết lập `battle.core` của game đang chỉ `ff_battle`.
+47
View File
@@ -0,0 +1,47 @@
# Card Battle —— 把卡牌拖到目标上进行战斗
Card Battle 是一套战斗系统:角色是一张张大的卡牌,他们的动作则是更小的卡牌——先选中角色,再把一张动作卡拖到目标上。它是 Cthangover 的战斗系统,RPG Plus 也可以用它替代 FF Battle。而战斗本身——发起、背景、胜利与失败画面、经验和掉落——都来自 Turn Battle Base。
## 战斗画面
RPG Plus 的战士对阵狼:点击英雄后,光标悬停在 Power strike 上:
![卡牌战斗画面:左上角是 Card battle 面板(Round 1、Enemies alive 1),右上角是 End turn 按钮和倍速按钮,右侧是狼的卡牌(HP 31/31、DEF 2、ATK 7、AP 1),左下角是英雄的卡牌(HP 42/42、DEF 4、ATK 12、AP 1),中间是战斗日志,下方 Actions 面板里有三张卡——Basic attack、Defence、Power strike——以及提示「全力一击:1.5 倍攻击,再加一点力量加成」。](assets/card-hover.jpg)
- **角色卡**显示生命(HP)、护盾(DEF,优先吸收伤害)、攻击(ATK)和行动点(AP)。
- **Actions 面板**——所选角色的动作卡,标明目标(敌人或队友)、AP 消耗和数值。角色付不起的卡会变暗。
- 卡片下方的**提示行**说明鼠标所指卡片的效果:普通攻击显示伤害,防御显示护盾,公式技能则显示描述。
- **End turn** 可提前结束队伍回合;AP 用到 0 时回合会自动结束。
## 一个回合的流程
1. **点击队伍成员的卡牌**,查看其可用动作。
2. **把动作卡拖到目标上**——攻击拖到敌人卡上,防御拖到队友卡上。目标会显示该动作是否适用于它。
3. 队伍的行动点用完后,敌人开始行动,新一轮开始。
![一个回合后的战斗日志和动作面板:Shield of Wolf absorbed 2;Power strike: Hero → Wolf · −19 HP;The party is out of action points;Enemy turn;Defence: Wolf · +4 DEF;Player turn · round 2。](assets/card-turn.jpg)
英雄的 Power strike 打出 21 点:狼的 2 点护盾先被消耗,生命随之下降 19,从 31 降到 12。随后狼把护盾提升到 4。
## 动作类型
Card Battle 执行与 FF Battle 相同的几种基础动作:
- `Attack=1.5`——攻击伤害乘以该数值;优先被目标的护盾吸收;
- `Defence=4`——给队友加护盾;
- `Turn=2`——眩晕相应的回合数。
以公式书写的技能(`Formula=` 值,比如 Classic RPG Rules 的 Power strike)经由 Battle Formulas 在这里同样有效。
## 选择 Card Battle
在游戏的合集设置中写入 `"battle.core": "card_battle"`,或者在战斗前用剧本切换:
```scenario
action battle.set_core core=card_battle
```
## 出问题怎么办
1. **拖动卡牌没有反应。** 可能没有选中角色、角色的行动点不足,或者目标阵营不对(攻击拖到敌人身上,防御拖到队友身上)。
2. **战斗以 FF Battle 打开。** 游戏的 `battle.core` 设置指定的是 `ff_battle`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

+1
View File
@@ -0,0 +1 @@
collection_settings/cards = Card battles
+1
View File
@@ -0,0 +1 @@
collection_settings/cards = Combate con cartas
+1
View File
@@ -0,0 +1 @@
collection_settings/cards = カードバトル
+1
View File
@@ -0,0 +1 @@
collection_settings/cards = 카드 전투
+1
View File
@@ -0,0 +1 @@
collection_settings/cards = Карточные бои
+1
View File
@@ -0,0 +1 @@
collection_settings/cards = Chiến đấu thẻ bài
+1
View File
@@ -0,0 +1 @@
collection_settings/cards = 卡牌战斗
+2
View File
@@ -0,0 +1,2 @@
mod_card_battle_name = Card Battle
mod_card_battle_desc = A battle system with cards: pick a character card and drag its action cards onto targets. Built on Turn Battle Base; the battle of Cthangover.
+2
View File
@@ -0,0 +1,2 @@
mod_card_battle_name = Batalla de cartas
mod_card_battle_desc = Un sistema de combate con cartas: elige la carta de un personaje y arrastra sus cartas de acción sobre los objetivos. Construido sobre Turn Battle Base; el combate de Cthangover.
+2
View File
@@ -0,0 +1,2 @@
mod_card_battle_name = カードバトル
mod_card_battle_desc = カードを使うバトルシステム。キャラクターカードを選び、そのアクションカードをターゲットにドラッグします。Turn Battle Base の上に構築された、Cthangover の戦闘です。
+2
View File
@@ -0,0 +1,2 @@
mod_card_battle_name = 카드 배틀
mod_card_battle_desc = 카드를 사용하는 전투 시스템입니다. 캐릭터 카드를 고르고 행동 카드를 대상에 끌어다 놓으세요. Turn Battle Base 기반이며, Cthangover의 전투입니다.
+2
View File
@@ -0,0 +1,2 @@
mod_card_battle_name = Карточные битвы
mod_card_battle_desc = Боевая система на картах: выберите карту персонажа и перетащите его карты действий на цели. Построена на Turn Battle Base; бой игры Cthangover.
+36 -3
View File
@@ -1,4 +1,37 @@
mod_card_battle_name = CardBattle
mod_card_battle_desc = Card battle mechanics
battle/end_turn = End turn battle/end_turn=End turn
battle/hud_title=Card battle
battle/round=Round
battle/enemies_alive=Enemies alive
battle/turn_player=Player turn
battle/turn_enemy=Enemy turn
battle/actions_title=Actions
battle/no_character=no character selected
battle/empty_actions=Select a character to see their actions
battle/desc_default=Hover a card to see the numbers. Drag it onto a target to execute.
battle/action_hint=LMB — select a character · drag an action card onto a target
battle/action_autoend=0 AP — the turn ends by itself
battle/preview_damage=Damage {0} (ATK {1} × {2}) · cost {3} AP
battle/preview_defence=Target shield +{0} · cost {1} AP
battle/preview_stun=Stun for {0} turns · cost {1} AP
battle/turns_badge={0} turns
battle/stun_status=Stunned {0}
battle/log_title=Battle log
battle/log_round=round {0}
battle/log_turn_player=— Player turn
battle/log_turn_enemy=— Enemy turn
battle/log_enemy_action={0}: {1} → {2}
battle/log_damage={0}: {1} → {2} · −{3} HP
battle/log_shield_absorb=Shield of {0} absorbed {1}
battle/log_shield_gain=Defence: {0} · +{1} DEF
battle/log_absorbed={0}: {1} → {2} · damage absorbed by shield
battle/log_stun=Stun: {0} · {1} turns
battle/log_stun_skip={0} is stunned — skips the turn
battle/log_dead={0} is defeated
battle/log_out_of_points=The party is out of action points
battle/log_invalid_target=Invalid target for "{0}"
battle/log_not_enough_points=Not enough AP for "{0}"
+37
View File
@@ -0,0 +1,37 @@
battle/end_turn = Terminar turno
battle/hud_title = Combate de cartas
battle/round = Ronda
battle/enemies_alive = Enemigos vivos
battle/turn_player = Turno del jugador
battle/turn_enemy = Turno del enemigo
battle/actions_title = Acciones
battle/no_character = ningún personaje seleccionado
battle/empty_actions = Selecciona un personaje para ver sus acciones
battle/desc_default = Pasa el ratón por una carta para ver los números. Arrástrala sobre un objetivo para ejecutarla.
battle/action_hint = Clic izquierdo: seleccionar personaje · arrastra una carta de acción sobre un objetivo
battle/action_autoend = 0 PA: el turno termina solo
battle/preview_damage = Daño {0} (ATQ {1} × {2}) · coste {3} PA
battle/preview_defence = Escudo del objetivo +{0} · coste {1} PA
battle/preview_stun = Aturde {0} turnos · coste {1} PA
battle/turns_badge = {0} turnos
battle/stun_status = Aturdido {0}
battle/log_title = Registro de combate
battle/log_round = ronda {0}
battle/log_turn_player = — Turno del jugador
battle/log_turn_enemy = — Turno del enemigo
battle/log_enemy_action = {0}: {1} → {2}
battle/log_damage = {0}: {1} → {2} · −{3} PV
battle/log_shield_absorb = El escudo de {0} absorbió {1}
battle/log_shield_gain = Defensa: {0} · +{1} DEF
battle/log_absorbed = {0}: {1} → {2} · el escudo absorbió el daño
battle/log_stun = Aturdimiento: {0} · {1} turnos
battle/log_stun_skip = {0} está aturdido: pierde el turno
battle/log_dead = {0} ha sido derrotado
battle/log_out_of_points = Al grupo no le quedan puntos de acción
battle/log_invalid_target = Objetivo no válido para "{0}"
battle/log_not_enough_points = No hay PA suficientes para "{0}"
+37
View File
@@ -0,0 +1,37 @@
battle/end_turn = ターン終了
battle/hud_title = カードバトル
battle/round = ラウンド
battle/enemies_alive = 残りの敵
battle/turn_player = プレイヤーのターン
battle/turn_enemy = 敵のターン
battle/actions_title = アクション
battle/no_character = キャラクター未選択
battle/empty_actions = キャラクターを選ぶとアクションが表示されます
battle/desc_default = カードにカーソルを合わせると数値が見えます。対象にドラッグすると実行します。
battle/action_hint = 左クリック——キャラクターを選択 · アクションカードを対象へドラッグ
battle/action_autoend = AP 0——ターンは自動で終わります
battle/preview_damage = ダメージ {0}(攻撃 {1} × {2})· コスト {3} AP
battle/preview_defence = 対象のシールド +{0} · コスト {1} AP
battle/preview_stun = {0} ターンのスタン · コスト {1} AP
battle/turns_badge = {0} ターン
battle/stun_status = スタン {0}
battle/log_title = バトルログ
battle/log_round = ラウンド {0}
battle/log_turn_player = — プレイヤーのターン
battle/log_turn_enemy = — 敵のターン
battle/log_enemy_action = {0}:{1} → {2}
battle/log_damage = {0}:{1} → {2} · −{3} HP
battle/log_shield_absorb = {0} のシールドが {1} を吸収
battle/log_shield_gain = 防御:{0} · +{1} DEF
battle/log_absorbed = {0}:{1} → {2} · ダメージはシールドが吸収
battle/log_stun = スタン:{0} · {1} ターン
battle/log_stun_skip = {0} はスタン中——ターンを飛ばす
battle/log_dead = {0} を倒した
battle/log_out_of_points = パーティの行動ポイントが尽きた
battle/log_invalid_target = 「{0}」の対象が正しくありません
battle/log_not_enough_points = 「{0}」に必要な AP が足りません
+37
View File
@@ -0,0 +1,37 @@
battle/end_turn = 턴 종료
battle/hud_title = 카드 전투
battle/round = 라운드
battle/enemies_alive = 남은 적
battle/turn_player = 플레이어 턴
battle/turn_enemy = 적 턴
battle/actions_title = 액션
battle/no_character = 선택된 캐릭터 없음
battle/empty_actions = 캐릭터를 선택하면 액션이 보입니다
battle/desc_default = 카드에 마우스를 올리면 수치가 보입니다. 대상으로 끌어다 놓으면 실행됩니다.
battle/action_hint = 좌클릭 — 캐릭터 선택 · 액션 카드를 대상으로 드래그
battle/action_autoend = AP 0 — 턴이 자동으로 끝납니다
battle/preview_damage = 피해 {0} (공격 {1} × {2}) · 비용 {3} AP
battle/preview_defence = 대상 보호막 +{0} · 비용 {1} AP
battle/preview_stun = {0}턴 기절 · 비용 {1} AP
battle/turns_badge = {0}턴
battle/stun_status = 기절 {0}
battle/log_title = 전투 로그
battle/log_round = {0}라운드
battle/log_turn_player = — 플레이어 턴
battle/log_turn_enemy = — 적 턴
battle/log_enemy_action = {0}: {1} → {2}
battle/log_damage = {0}: {1} → {2} · −{3} HP
battle/log_shield_absorb = {0}의 보호막이 {1}을(를) 흡수
battle/log_shield_gain = 방어: {0} · +{1} DEF
battle/log_absorbed = {0}: {1} → {2} · 보호막이 피해를 흡수
battle/log_stun = 기절: {0} · {1}턴
battle/log_stun_skip = {0}은(는) 기절 — 턴을 건너뜀
battle/log_dead = {0} 쓰러짐
battle/log_out_of_points = 파티의 행동 포인트가 바닥났다
battle/log_invalid_target = "{0}"의 대상이 잘못되었습니다
battle/log_not_enough_points = "{0}"에 필요한 AP가 부족합니다
+36 -3
View File
@@ -1,4 +1,37 @@
mod_card_battle_name=Карточная битва
mod_card_battle_desc=Механики карточных сражений
battle/end_turn=Конец хода battle/end_turn=Конец хода
battle/hud_title=Карточная битва
battle/round=Раунд
battle/enemies_alive=Живых врагов
battle/turn_player=Ход игрока
battle/turn_enemy=Ход врага
battle/actions_title=Действия
battle/no_character=персонаж не выбран
battle/empty_actions=Выберите персонажа, чтобы увидеть его действия
battle/desc_default=Наведите на карточку — покажу расчёт. Перетащите её на цель, чтобы выполнить.
battle/action_hint=ЛКМ — выбрать персонажа · карточка действия перетаскивается на цель
battle/action_autoend=0 ОД — ход завершится сам
battle/preview_damage=Урон {0} (АТК {1} × {2}) · расход {3} ОД
battle/preview_defence=Щит цели +{0} · расход {1} ОД
battle/preview_stun=Ошеломление на {0} хода · расход {1} ОД
battle/turns_badge={0} хода
battle/stun_status=Ошеломлён {0}
battle/log_title=Журнал боя
battle/log_round=раунд {0}
battle/log_turn_player=— Ход игрока
battle/log_turn_enemy=— Ход врага
battle/log_enemy_action={0}: {1} → {2}
battle/log_damage={0}: {1} → {2} · −{3} HP
battle/log_shield_absorb=Щит {0} поглотил {1}
battle/log_shield_gain=Защита: {0} · +{1} ЗАЩ
battle/log_absorbed={0}: {1} → {2} · урон поглощён щитом
battle/log_stun=Ошеломление: {0} · {1} хода
battle/log_stun_skip={0} ошеломлён — пропускает ход
battle/log_dead={0} повержен
battle/log_out_of_points=У отряда закончились ОД
battle/log_invalid_target=Недопустимая цель для «{0}»
battle/log_not_enough_points=Недостаточно ОД для «{0}»
+37
View File
@@ -0,0 +1,37 @@
battle/end_turn = Kết thúc lượt
battle/hud_title = Chiến đấu thẻ
battle/round = Vòng
battle/enemies_alive = Kẻ địch còn sống
battle/turn_player = Lượt người chơi
battle/turn_enemy = Lượt kẻ địch
battle/actions_title = Hành động
battle/no_character = chưa chọn nhân vật
battle/empty_actions = Chọn một nhân vật để xem các hành động
battle/desc_default = Rê chuột lên thẻ để xem chỉ số. Kéo thẻ lên mục tiêu để thực hiện.
battle/action_hint = Chuột trái — chọn nhân vật · kéo thẻ hành động lên mục tiêu
battle/action_autoend = 0 AP — lượt tự kết thúc
battle/preview_damage = Sát thương {0} (ATK {1} × {2}) · tốn {3} AP
battle/preview_defence = Khiên mục tiêu +{0} · tốn {1} AP
battle/preview_stun = Choáng {0} lượt · tốn {1} AP
battle/turns_badge = {0} lượt
battle/stun_status = Bị choáng {0}
battle/log_title = Nhật ký trận đấu
battle/log_round = vòng {0}
battle/log_turn_player = — Lượt người chơi
battle/log_turn_enemy = — Lượt kẻ địch
battle/log_enemy_action = {0}: {1} → {2}
battle/log_damage = {0}: {1} → {2} · −{3} HP
battle/log_shield_absorb = Khiên của {0} hấp thụ {1}
battle/log_shield_gain = Phòng thủ: {0} · +{1} DEF
battle/log_absorbed = {0}: {1} → {2} · khiên đã hấp thụ sát thương
battle/log_stun = Choáng: {0} · {1} lượt
battle/log_stun_skip = {0} bị choáng — mất lượt
battle/log_dead = {0} đã bị hạ
battle/log_out_of_points = Tổ đội đã hết điểm hành động
battle/log_invalid_target = Mục tiêu không hợp lệ cho "{0}"
battle/log_not_enough_points = Không đủ AP cho "{0}"
+37
View File
@@ -0,0 +1,37 @@
battle/end_turn = 结束回合
battle/hud_title = 卡牌战斗
battle/round = 回合
battle/enemies_alive = 存活敌人
battle/turn_player = 玩家回合
battle/turn_enemy = 敌方回合
battle/actions_title = 行动
battle/no_character = 未选择角色
battle/empty_actions = 选择一个角色以查看其行动
battle/desc_default = 把鼠标悬停在卡牌上查看数值。把它拖到目标上执行。
battle/action_hint = 左键——选择角色 · 把行动卡拖到目标上
battle/action_autoend = 0 AP——回合自动结束
battle/preview_damage = 伤害 {0}(攻击 {1} × {2})· 消耗 {3} AP
battle/preview_defence = 目标护盾 +{0} · 消耗 {1} AP
battle/preview_stun = 眩晕 {0} 回合 · 消耗 {1} AP
battle/turns_badge = {0} 回合
battle/stun_status = 眩晕 {0}
battle/log_title = 战斗日志
battle/log_round = 第 {0} 回合
battle/log_turn_player = — 玩家回合
battle/log_turn_enemy = — 敌方回合
battle/log_enemy_action = {0}:{1} → {2}
battle/log_damage = {0}:{1} → {2} · −{3} HP
battle/log_shield_absorb = {0} 的护盾吸收了 {1}
battle/log_shield_gain = 防御:{0} · +{1} 防御
battle/log_absorbed = {0}:{1} → {2} · 伤害被护盾吸收
battle/log_stun = 眩晕:{0} · {1} 回合
battle/log_stun_skip = {0} 处于眩晕——跳过回合
battle/log_dead = {0} 被击败
battle/log_out_of_points = 队伍的行动点用完了
battle/log_invalid_target = "{0}" 的目标无效
battle/log_not_enough_points = "{0}" 的 AP 不足
+2
View File
@@ -0,0 +1,2 @@
mod_card_battle_name = Chiến đấu thẻ bài
mod_card_battle_desc = Hệ thống chiến đấu bằng thẻ bài: chọn thẻ nhân vật rồi kéo các thẻ hành động của họ vào mục tiêu. Xây trên Turn Battle Base; trận chiến của Cthangover.
+2
View File
@@ -0,0 +1,2 @@
mod_card_battle_name = 卡牌战斗
mod_card_battle_desc = 以卡牌进行的战斗系统:选中角色卡,再把它的动作卡拖到目标上。基于 Turn Battle Base 构建;Cthangover 的战斗。
+4 -3
View File
@@ -1,11 +1,12 @@
{ {
"id": "card_battle", "id": "card_battle",
"name": "CardBattle", "name": "Card Battle",
"version": "1.0.0", "version": "1.0.0",
"author": "ct", "author": "ct",
"description": "Card battle mechanics", "description": "A battle system with cards: pick a character card and drag its action cards onto targets. Built on Turn Battle Base; the battle of Cthangover.",
"category": "gameplay",
"sources": [ "sources": [
"src/**/*.cs" "src/**/*.cs"
], ],
"depends": [ "core>=1", "interface>=1" ] "depends": [ "ui_base>=1", "turn_battle_base>=1" ]
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+85
View File
@@ -0,0 +1,85 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/ActionCardNode.cs" id="1_script"]
[node name="ActionCardNode" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(0, 86)
layout_mode = 3
size_flags_horizontal = 3
[node name="Panel" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Panel"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 0
[node name="Accent" type="ColorRect" parent="Panel/Row"]
custom_minimum_size = Vector2(4, 0)
layout_mode = 2
mouse_filter = 2
[node name="IconWrap" type="MarginContainer" parent="Panel/Row"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 8
[node name="Icon" type="TextureRect" parent="Panel/Row/IconWrap"]
custom_minimum_size = Vector2(52, 52)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
expand_mode = 1
stretch_mode = 6
[node name="Body" type="MarginContainer" parent="Panel/Row"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
theme_override_constants/margin_left = 4
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 8
[node name="Column" type="VBoxContainer" parent="Panel/Row/Body"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="NameRow" type="HBoxContainer" parent="Panel/Row/Body/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 8
[node name="NameLabel" type="Label" parent="Panel/Row/Body/Column/NameRow"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
theme_override_font_sizes/font_size = 14
clip_text = true
text_overrun_behavior = 3
[node name="IdLabel" type="Label" parent="Panel/Row/Body/Column/NameRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 9
[node name="Badges" type="HBoxContainer" parent="Panel/Row/Body/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 5
[node name="Props" type="HBoxContainer" parent="Panel/Row/Body/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 5
-46
View File
@@ -1,46 +0,0 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/ActionCardNode.cs" id="ActionCardNodeScript"]
[node name="ActionCardNode" type="Control" script=ExtResource("ActionCardNodeScript")]
custom_minimum_size = Vector2(200, 280)
layout_mode = 3
[node name="Image" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = 15.0
offset_top = 15.0
offset_right = -15.0
offset_bottom = -40.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Frame" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="NameLabel" type="Label" parent="."]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -40.0
offset_bottom = 0.0
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
+152
View File
@@ -0,0 +1,152 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleActionsPanel.cs" id="1_script"]
[node name="BattleActionsPanel" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Window" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Column" type="VBoxContainer" parent="Window"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 0
[node name="Header" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 14
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 11
[node name="HeaderRow" type="HBoxContainer" parent="Window/Column/Header"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "battle/actions_title"
uppercase = true
[node name="SubjectLabel" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 14
text = "battle/no_character"
[node name="Spacer" type="Control" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="ApCaption" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
text = "ui/character_panel/stat_ap"
uppercase = true
[node name="Pips" type="HBoxContainer" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
theme_override_constants/separation = 4
[node name="ApHint" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="Rule" type="HSeparator" parent="Window/Column"]
layout_mode = 2
[node name="Body" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 14
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 0
[node name="BodyColumn" type="VBoxContainer" parent="Window/Column/Body"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 10
[node name="EmptyLabel" type="Label" parent="Window/Column/Body/BodyColumn"]
layout_mode = 2
size_flags_vertical = 3
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 14
text = "battle/empty_actions"
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 3
[node name="Grid" type="GridContainer" parent="Window/Column/Body/BodyColumn"]
layout_mode = 2
mouse_filter = 2
columns = 2
theme_override_constants/h_separation = 10
theme_override_constants/v_separation = 10
[node name="DescWrap" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 12
[node name="DescWell" type="PanelContainer" parent="Window/Column/DescWrap"]
custom_minimum_size = Vector2(0, 58)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="DescLabel" type="Label" parent="Window/Column/DescWrap/DescWell"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
autowrap_mode = 3
[node name="FooterRule" type="HSeparator" parent="Window/Column"]
layout_mode = 2
[node name="Footer" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 9
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 11
[node name="FooterRow" type="HBoxContainer" parent="Window/Column/Footer"]
layout_mode = 2
mouse_filter = 2
[node name="HintLabel" type="Label" parent="Window/Column/Footer/FooterRow"]
layout_mode = 2
size_flags_vertical = 4
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 12
text = "battle/action_hint"
[node name="AutoEndLabel" type="Label" parent="Window/Column/Footer/FooterRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "battle/action_autoend"
+21
View File
@@ -0,0 +1,21 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleBanner.cs" id="1_script"]
[node name="BattleBanner" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Plate" type="PanelContainer" parent="."]
modulate = Color(1, 1, 1, 0)
layout_mode = 1
anchors_preset = 5
anchor_left = 0.5
anchor_right = 0.5
grow_horizontal = 2
mouse_filter = 2
[node name="Label" type="Label" parent="Plate"]
layout_mode = 2
theme_override_font_sizes/font_size = 30
horizontal_alignment = 1
+1 -1
View File
@@ -1,4 +1,4 @@
[gd_scene format=3] [gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleCardPanel.cs" id="1_script"] [ext_resource type="Script" path="src/UI/BattleCardPanel.cs" id="1_script"]
+76
View File
@@ -0,0 +1,76 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleHud.cs" id="1_script"]
[node name="BattleHud" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(320, 88)
layout_mode = 3
mouse_filter = 2
[node name="Panel" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Column" type="VBoxContainer" parent="Panel"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 9
[node name="TitleRow" type="HBoxContainer" parent="Panel/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="Panel/Column/TitleRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "battle/hud_title"
uppercase = true
[node name="PhaseBadge" type="PanelContainer" parent="Panel/Column/TitleRow"]
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 4
mouse_filter = 2
[node name="PhaseLabel" type="Label" parent="Panel/Column/TitleRow/PhaseBadge"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "battle/turn_player"
uppercase = true
[node name="MetaRow" type="HBoxContainer" parent="Panel/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 12
[node name="RoundCaption" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "battle/round"
[node name="RoundValue" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "1"
[node name="EnemiesCaption" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "battle/enemies_alive"
[node name="EnemiesValue" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "0"
+65
View File
@@ -0,0 +1,65 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/BattleLogPanel.cs" id="1_script"]
[node name="BattleLogPanel" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Window" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Column" type="VBoxContainer" parent="Window"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 0
[node name="Header" type="MarginContainer" parent="Window/Column"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 13
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 10
[node name="HeaderRow" type="HBoxContainer" parent="Window/Column/Header"]
layout_mode = 2
mouse_filter = 2
[node name="TitleLabel" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "battle/log_title"
uppercase = true
[node name="Spacer" type="Control" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="RoundLabel" type="Label" parent="Window/Column/Header/HeaderRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
[node name="Rule" type="HSeparator" parent="Window/Column"]
layout_mode = 2
[node name="Scroll" type="ScrollContainer" parent="Window/Column"]
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
mouse_filter = 2
[node name="Rows" type="VBoxContainer" parent="Window/Column/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
theme_override_constants/separation = 0
+83
View File
@@ -0,0 +1,83 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/CardBattleScreen.cs" id="1_screen"]
[ext_resource type="Script" path="src/UI/CardController.cs" id="2_controller"]
[ext_resource type="PackedScene" path="scenes/battle_hud.tscn" id="3_hud"]
[ext_resource type="PackedScene" path="scenes/battle_card_panel.tscn" id="4_panel"]
[ext_resource type="PackedScene" path="scenes/battle_actions_panel.tscn" id="5_actions"]
[ext_resource type="PackedScene" path="scenes/battle_log_panel.tscn" id="6_log"]
[ext_resource type="PackedScene" path="scenes/battle_banner.tscn" id="7_banner"]
[ext_resource type="PackedScene" path="scenes/end_turn_button.tscn" id="8_endturn"]
[node name="CardBattleScreen" type="Control" script=ExtResource("1_screen")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Hud" parent="." instance=ExtResource("3_hud")]
layout_mode = 1
offset_left = 20.0
offset_top = 26.0
offset_right = 340.0
offset_bottom = 114.0
[node name="EnemyPanel" parent="." instance=ExtResource("4_panel")]
layout_mode = 1
anchor_left = 0.02
anchor_top = 0.09375
anchor_right = 0.98
anchor_bottom = 0.53975
grow_horizontal = 2
grow_vertical = 2
[node name="PlayerPanel" parent="." instance=ExtResource("4_panel")]
layout_mode = 1
anchor_left = 0.01354
anchor_top = 0.5498
anchor_right = 0.56354
anchor_bottom = 0.96
grow_horizontal = 2
grow_vertical = 2
[node name="ActionsPanel" parent="." instance=ExtResource("5_actions")]
layout_mode = 1
anchor_left = 0.5297
anchor_top = 0.5498
anchor_right = 0.9865
anchor_bottom = 0.9521
grow_horizontal = 2
grow_vertical = 2
[node name="LogPanel" parent="." instance=ExtResource("6_log")]
layout_mode = 1
anchor_left = 0.2708
anchor_top = 0.5566
anchor_right = 0.5156
anchor_bottom = 0.9629
grow_horizontal = 2
grow_vertical = 2
[node name="EndTurnButton" parent="." instance=ExtResource("8_endturn")]
layout_mode = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -376.0
offset_top = 20.0
offset_right = -216.0
offset_bottom = 70.0
[node name="Banner" parent="." instance=ExtResource("7_banner")]
layout_mode = 1
anchor_top = 0.315
anchor_right = 1.0
anchor_bottom = 0.42
grow_horizontal = 2
[node name="CardController" type="Node" parent="." script=ExtResource("2_controller")]
[node name="DragLayer" type="CanvasLayer" parent="."]
layer = 100
+244
View File
@@ -0,0 +1,244 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/CharacterCardNode.cs" id="1_script"]
[node name="CharacterCardNode" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(230, 420)
layout_mode = 3
[node name="Art" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="TeamFrame" type="Panel" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Info" type="PanelContainer" parent="."]
layout_mode = 1
anchors_preset = 12
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -92.0
grow_horizontal = 2
grow_vertical = 0
mouse_filter = 2
[node name="Column" type="VBoxContainer" parent="Info"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 5
[node name="NameLabel" type="Label" parent="Info/Column"]
layout_mode = 2
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
clip_text = true
[node name="Stats" type="HBoxContainer" parent="Info/Column"]
layout_mode = 2
mouse_filter = 2
alignment = 1
theme_override_constants/separation = 4
[node name="HpChip" type="PanelContainer" parent="Info/Column/Stats"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Info/Column/Stats/HpChip"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Key" type="Label" parent="Info/Column/Stats/HpChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_hp"
uppercase = true
[node name="Value" type="Label" parent="Info/Column/Stats/HpChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="DefChip" type="PanelContainer" parent="Info/Column/Stats"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Info/Column/Stats/DefChip"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Key" type="Label" parent="Info/Column/Stats/DefChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_def"
uppercase = true
[node name="Value" type="Label" parent="Info/Column/Stats/DefChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="AtkChip" type="PanelContainer" parent="Info/Column/Stats"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Info/Column/Stats/AtkChip"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Key" type="Label" parent="Info/Column/Stats/AtkChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_atk"
uppercase = true
[node name="Value" type="Label" parent="Info/Column/Stats/AtkChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="ApChip" type="PanelContainer" parent="Info/Column/Stats"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Info/Column/Stats/ApChip"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Key" type="Label" parent="Info/Column/Stats/ApChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_ap"
uppercase = true
[node name="Value" type="Label" parent="Info/Column/Stats/ApChip/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
[node name="HpBarStack" type="Control" parent="Info/Column"]
custom_minimum_size = Vector2(0, 7)
layout_mode = 2
mouse_filter = 2
[node name="ShieldBar" type="ProgressBar" parent="Info/Column/HpBarStack"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
show_percentage = false
[node name="HpBar" type="ProgressBar" parent="Info/Column/HpBarStack"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
show_percentage = false
[node name="Status" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -160.0
offset_top = 10.0
offset_right = -10.0
offset_bottom = 40.0
grow_horizontal = 0
mouse_filter = 2
theme_override_constants/separation = 6
[node name="StunBadge" type="PanelContainer" parent="Status"]
visible = false
layout_mode = 2
size_flags_horizontal = 8
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Status/StunBadge"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 5
[node name="StunIcon" type="TextureRect" parent="Status/StunBadge/Row"]
custom_minimum_size = Vector2(14, 14)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
expand_mode = 1
stretch_mode = 5
[node name="StunLabel" type="Label" parent="Status/StunBadge/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
uppercase = true
[node name="Zap" type="TextureRect" parent="."]
modulate = Color(1, 1, 1, 0)
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 14.0
offset_top = 20.0
offset_right = -14.0
offset_bottom = -70.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 5
[node name="Fx" type="Panel" parent="."]
modulate = Color(1, 1, 1, 0)
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Selection" type="TextureRect" parent="."]
modulate = Color(1, 1, 1, 0)
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 0
-135
View File
@@ -1,135 +0,0 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/CharacterCardNode.cs" id="1_script"]
[node name="CharacterCardNode" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(230, 420)
layout_mode = 3
[node name="TeamOutline" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = -4.0
offset_top = -4.0
offset_right = 4.0
offset_bottom = 4.0
mouse_filter = 2
[node name="Image" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
offset_left = 20.0
offset_top = 20.0
offset_right = -20.0
offset_bottom = -60.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Frame" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
stretch_mode = 4
[node name="Selection" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="InfoPanel" type="Control" parent="."]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -60.0
offset_bottom = 0.0
[node name="NameLabel" type="Label" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
grow_horizontal = 2
offset_bottom = 18.0
horizontal_alignment = 1
theme_override_font_sizes/font_size = 16
[node name="HealthBar" type="Control" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 12
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
offset_top = -18.0
offset_bottom = 0.0
[node name="HealthBg" type="ColorRect" parent="InfoPanel/HealthBar"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.2, 0.2, 0.2, 1)
[node name="HealthRect" type="ColorRect" parent="InfoPanel/HealthBar"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0.8, 0, 1)
[node name="Labels" type="HBoxContainer" parent="InfoPanel"]
layout_mode = 1
anchors_preset = 13
anchor_left = 0.0
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
grow_horizontal = 2
alignment = 1
[node name="LabelS" type="Label" parent="InfoPanel/Labels"]
text = "S"
horizontal_alignment = 1
[node name="DefenceLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
[node name="LabelA" type="Label" parent="InfoPanel/Labels"]
text = "A"
horizontal_alignment = 1
[node name="AttackLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
[node name="LabelP" type="Label" parent="InfoPanel/Labels"]
text = "P"
horizontal_alignment = 1
[node name="PointsLabel" type="Label" parent="InfoPanel/Labels"]
text = "0"
+2 -1
View File
@@ -1,4 +1,4 @@
[gd_scene format=3] [gd_scene format=3]
[ext_resource type="Script" path="src/UI/EndTurnButton.cs" id="1_script"] [ext_resource type="Script" path="src/UI/EndTurnButton.cs" id="1_script"]
@@ -14,3 +14,4 @@ anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
theme_override_font_sizes/font_size = 16 theme_override_font_sizes/font_size = 16
text = "battle/end_turn"
+14
View File
@@ -15,16 +15,27 @@ namespace Cthangover.CardBattle.Actions
/// </summary> /// </summary>
public abstract class AbstractBattleAction : IBattleAction public abstract class AbstractBattleAction : IBattleAction
{ {
/// <summary>Seconds timestamp of the current phase start (used for phase progress); -1 before the first phase.</summary>
protected double Timestamp { get; set; } = -1; protected double Timestamp { get; set; } = -1;
/// <summary>Playback speed multiplier applied to all phase durations.</summary>
protected float Speed { get; set; } protected float Speed { get; set; }
/// <summary>The action definition whose stats drive the animation.</summary>
protected ActionCharacter Action { get; set; } protected ActionCharacter Action { get; set; }
/// <summary>Acting card node.</summary>
protected CharacterCardNode Source { get; set; } protected CharacterCardNode Source { get; set; }
/// <summary>Receiving card node.</summary>
protected CharacterCardNode Target { get; set; } protected CharacterCardNode Target { get; set; }
/// <summary>Visual control of the source card (null-safe abort when freed mid-animation).</summary>
protected Control SourceControl { get; set; } protected Control SourceControl { get; set; }
/// <summary>Source position captured in <see cref="DoStart"/> for restoration in <see cref="DoEnd"/>.</summary>
protected Vector2 SourcePos { get; set; } protected Vector2 SourcePos { get; set; }
/// <summary>Source rotation captured in <see cref="DoStart"/>.</summary>
protected float SourceRot { get; set; } protected float SourceRot { get; set; }
/// <summary>Visual control of the target card.</summary>
protected Control TargetControl { get; set; } protected Control TargetControl { get; set; }
/// <summary>Target position captured in <see cref="DoStart"/>.</summary>
protected Vector2 TargetPos { get; set; } protected Vector2 TargetPos { get; set; }
/// <summary>Target rotation captured in <see cref="DoStart"/>.</summary>
protected float TargetRot { get; set; } protected float TargetRot { get; set; }
/// <summary> /// <summary>
@@ -41,8 +52,11 @@ namespace Cthangover.CardBattle.Actions
return DoInternalAction(); return DoInternalAction();
} }
/// <summary>Frame tick of the animation; implementors advance phases and return true when finished.</summary>
protected abstract bool DoInternalAction(); protected abstract bool DoInternalAction();
/// <summary>Phase setup called once before the first <see cref="DoInternalAction"/> tick.</summary>
protected abstract void DoInternalStart(); protected abstract void DoInternalStart();
/// <summary>Cleanup called once after completion — restore captured positions/rotations here.</summary>
protected abstract void DoInternalEnd(); protected abstract void DoInternalEnd();
protected AbstractBattleAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1) protected AbstractBattleAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
+55 -9
View File
@@ -1,6 +1,8 @@
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
using Godot; using Godot;
@@ -67,9 +69,10 @@ namespace Cthangover.CardBattle.Actions
movementPattern = (MovementPattern)GD.RandRange(0, 5); movementPattern = (MovementPattern)GD.RandRange(0, 5);
} }
/// <summary>Resets the phase clock and starts the attack sequence from the Wait phase.</summary>
protected override void DoInternalStart() protected override void DoInternalStart()
{ {
Timestamp = Time.GetTicksUsec() / 1_000_000.0; Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
damageApplied = false; damageApplied = false;
stunEffectSpawned = false; stunEffectSpawned = false;
phase = ActionPhase.Wait; phase = ActionPhase.Wait;
@@ -78,7 +81,7 @@ namespace Cthangover.CardBattle.Actions
private float GetPhaseProgress() private float GetPhaseProgress()
{ {
float elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale; float elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
float duration = GetCurrentPhaseDuration(); float duration = GetCurrentPhaseDuration();
return Mathf.Clamp(elapsed / duration, 0f, 1f); return Mathf.Clamp(elapsed / duration, 0f, 1f);
} }
@@ -100,9 +103,10 @@ namespace Cthangover.CardBattle.Actions
private void ResetTimestamp() private void ResetTimestamp()
{ {
Timestamp = Time.GetTicksUsec() / 1_000_000.0; Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
} }
/// <summary>Advances the multi-phase attack: dash in, impact, damage/stun application, return and recover.</summary>
protected override bool DoInternalAction() protected override bool DoInternalAction()
{ {
float progress = GetPhaseProgress(); float progress = GetPhaseProgress();
@@ -166,12 +170,7 @@ namespace Cthangover.CardBattle.Actions
if (result.Result) if (result.Result)
{ {
if (result.Target.Damage > 0) LogAttackResult(result);
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
var defenceLost = -result.Target.Defence;
if (defenceLost > 0)
ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
} }
else else
{ {
@@ -261,6 +260,7 @@ namespace Cthangover.CardBattle.Actions
return false; return false;
} }
/// <summary>Restores the target's captured position, scale and rotation after the attack animation.</summary>
protected override void DoInternalEnd() protected override void DoInternalEnd()
{ {
if (SourceControl != null) if (SourceControl != null)
@@ -276,6 +276,52 @@ namespace Cthangover.CardBattle.Actions
} }
} }
/// <summary>
/// Mirrors the resolved action onto the UI: floating damage / absorbed
/// shield numbers, a colour pulse on the target card and journal rows, so
/// an AI attack reads the same as the player's own attack. Stun actions
/// (a "Turn" above 1) flash the electric zap instead of damage numbers.
/// </summary>
private void LogAttackResult(ChangedAttributes result)
{
var targetName = TranslationServer.Translate(Target.Card.Name).ToString();
var isStun = Action != null && Action.GetInt("Turn", 0) > 1;
var defenceLost = -result.Target.Defence;
if (defenceLost > 0)
{
ShowDamageBehaviour.SpawnDefence(defenceLost, TargetControl, TargetControl.GlobalPosition);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_absorb"), targetName, defenceLost),
UI.BattleLogKind.Shield);
}
if (isStun)
{
Target.PlayZap();
BattleLogHub.Log(
string.Format(Translate("battle/log_stun"), targetName, Action.GetInt("Turn", 0)),
UI.BattleLogKind.Status);
return;
}
if (result.Target.Damage > 0)
{
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, TargetControl, TargetControl.GlobalPosition);
Target.Flash(UiTokens.DangerText);
BattleLogHub.Log(
string.Format(Translate("battle/log_damage"),
Translate(Action.Name),
TranslationServer.Translate(Source.Card.Name).ToString(),
targetName,
result.Target.Damage),
UI.BattleLogKind.Damage);
}
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
private void InitializeMovementPattern() private void InitializeMovementPattern()
{ {
Vector2 direction = (TargetPos - SourcePos).Normalized(); Vector2 direction = (TargetPos - SourcePos).Normalized();
+3 -2
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Godot; using Godot;
@@ -34,8 +35,8 @@ namespace Cthangover.CardBattle.Actions
return new ChangedAttributes { Result = false }; return new ChangedAttributes { Result = false };
var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value); var damageDelta = Mathf.RoundToInt(actionCard.GetFloat("Attack", 1f) * user.Attributes.Attack.Value);
target.StatusEffectQueue.OnTakeDamage(user, ref damageDelta); StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
user.StatusEffectQueue.OnDealDamage(target, ref damageDelta); StatusEffectService.Of(user).OnDealDamage(target, ref damageDelta);
var defenceDelta = 0; var defenceDelta = 0;
var ignoreArmor = user.HasPassiveEffect("IgnoreArmor"); var ignoreArmor = user.HasPassiveEffect("IgnoreArmor");
+19 -3
View File
@@ -1,6 +1,8 @@
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.TurnBattle;
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
using Godot; using Godot;
@@ -40,9 +42,10 @@ namespace Cthangover.CardBattle.Actions
public PhysicsDefenceAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1) public PhysicsDefenceAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action, float speed = 1)
: base(source, target, action, speed) { } : base(source, target, action, speed) { }
/// <summary>Resets the phase clock and captures the target's original scale and colour.</summary>
protected override void DoInternalStart() protected override void DoInternalStart()
{ {
Timestamp = Time.GetTicksUsec() / 1_000_000.0; Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
effectApplied = false; effectApplied = false;
phase = ActionPhase.Prepare; phase = ActionPhase.Prepare;
targetOriginalScale = TargetControl.Scale; targetOriginalScale = TargetControl.Scale;
@@ -51,7 +54,7 @@ namespace Cthangover.CardBattle.Actions
private float GetPhaseProgress() private float GetPhaseProgress()
{ {
float elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale; float elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
float duration = GetCurrentPhaseDuration(); float duration = GetCurrentPhaseDuration();
return Mathf.Clamp(elapsed / duration, 0f, 1f); return Mathf.Clamp(elapsed / duration, 0f, 1f);
} }
@@ -71,9 +74,10 @@ namespace Cthangover.CardBattle.Actions
private void ResetTimestamp() private void ResetTimestamp()
{ {
Timestamp = Time.GetTicksUsec() / 1_000_000.0; Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
} }
/// <summary>Advances the guard animation: approach, impact, defence stat application, return and recover.</summary>
protected override bool DoInternalAction() protected override bool DoInternalAction()
{ {
float progress = GetPhaseProgress(); float progress = GetPhaseProgress();
@@ -118,7 +122,15 @@ namespace Cthangover.CardBattle.Actions
Target.UpdateInfo(); Target.UpdateInfo();
if (result.Result && result.Target.Defence > 0) if (result.Result && result.Target.Defence > 0)
{
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, TargetControl, TargetControl.GlobalPosition); ShowDamageBehaviour.SpawnDefence(result.Target.Defence, TargetControl, TargetControl.GlobalPosition);
Target.Flash(UiTokens.Info);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_gain"),
TranslationServer.Translate(Target.Card.Name).ToString(),
result.Target.Defence),
UI.BattleLogKind.Buff);
}
} }
if (progress >= 1f) if (progress >= 1f)
@@ -186,6 +198,7 @@ namespace Cthangover.CardBattle.Actions
return false; return false;
} }
/// <summary>Restores the target's captured visual state after the defence animation.</summary>
protected override void DoInternalEnd() protected override void DoInternalEnd()
{ {
if (SourceControl != null) if (SourceControl != null)
@@ -211,5 +224,8 @@ namespace Cthangover.CardBattle.Actions
{ {
return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f; return t < 0.5f ? 2f * t * t : 1f - Mathf.Pow(-2f * t + 2f, 2f) / 2f;
} }
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
} }
} }
+2 -1
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
namespace Cthangover.CardBattle.Actions namespace Cthangover.CardBattle.Actions
@@ -30,7 +31,7 @@ namespace Cthangover.CardBattle.Actions
if (target.HasPassiveEffect("StunImmune")) if (target.HasPassiveEffect("StunImmune"))
return new ChangedAttributes { Result = true }; return new ChangedAttributes { Result = true };
target.StatusEffectQueue.Add("effect/physics/stun", actionCard.GetInt("Turn", 3)); StatusEffectService.Of(target).Add("effect/physics/stun", actionCard.GetInt("Turn", 3));
return new ChangedAttributes { Result = true }; return new ChangedAttributes { Result = true };
} }
+24
View File
@@ -0,0 +1,24 @@
using System;
using Cthangover.CardBattle.UI;
namespace Cthangover.CardBattle
{
/// <summary>
/// Static sink for battle journal entries. Player action strategies and
/// the battle core run without a reference to the UI screen, so they
/// publish here instead of reaching into the scene tree; the
/// <see cref="BattleLogPanel"/> subscribes while it exists and unsubscribes
/// on tree exit, which keeps the hub empty outside of a battle. This is the
/// same service-locator pattern the battle code already uses for
/// <c>ActionExecutorHub</c> and <c>ShowDamageBehaviour</c>.
/// </summary>
public static class BattleLogHub
{
/// <summary>Fired for every entry; handlers receive the text and its kind.</summary>
public static event Action<string, BattleLogKind> Entry;
/// <summary>Publishes one journal row. No-op when no battle UI is listening.</summary>
public static void Log(string text, BattleLogKind kind = BattleLogKind.Info)
=> Entry?.Invoke(text, kind);
}
}
+132 -124
View File
@@ -4,7 +4,9 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.CardBattle.Player; using Cthangover.CardBattle.Player;
@@ -14,49 +16,58 @@ using Godot;
namespace Cthangover.CardBattle namespace Cthangover.CardBattle
{ {
/// <summary> /// <summary>
/// Core orchestrator for the card-based turn battle system. /// Core orchestrator for the card-based turn battle system. Owns the full
/// Manages the full battle lifecycle: initializing UI panels (<see cref="BattleCardPanel"/> and <see cref="CardController"/>), /// battle lifecycle: it instantiates the UI screen rebuilt from the mockup
/// cycling between player and enemy turns, spawning animated actions via <see cref="AbstractBattleAction"/>, /// (mockups/card_battle, defined in <c>scenes/card_battle_screen.tscn</c>),
/// applying status effects each turn, and detecting win/loss conditions when all cards on one side are dead. /// cycles between player and enemy turns, drives the animated enemy actions
/// Implements <see cref="IBattleCore"/> so it can be registered as a battle type provider in the core battle engine. /// through <see cref="AbstractBattleAction"/>, applies status effects each
/// turn and detects win/loss when one side loses all cards.
/// <para/>
/// The core talks to the screen through its assembled widgets
/// (<see cref="CardBattleScreen.Hud"/>, <see cref="BattleLogPanel"/>,
/// <see cref="BattleBanner"/>, panels) instead of walking the scene tree, and
/// publishes journal rows via <see cref="BattleLogHub"/> so other battle code
/// can log without a UI reference. Implements <see cref="IBattleCore"/> so the
/// battle engine can select this mod as the active battle type.
/// </summary> /// </summary>
public class CardBattleCore : IBattleCore, IBattleAutoPlayable public class CardBattleCore : IBattleCore, IBattleAutoPlayable
{ {
/// <summary> private const int PlayerCardLimit = 4;
/// Unique identifier for this battle core, used by the battle engine to select the card battle mod. private const float BaseCardWidth = 230f;
/// </summary> private const float BaseCardHeight = 420f;
public string Id => "card_battle"; private const float CellOffset = 10f;
/// <summary>
/// Provides <see cref="IActionExecutor"/> instances for the three card action types
/// (physics/attack, physics/defence, physics/stun) used during action resolution.
/// </summary>
public IActionExecutorProvider ActionProvider { get; } = new Actions.CardBattleActionProvider();
private Character[] _playerChars; private Character[] _playerChars;
private Character[] _enemyChars; private Character[] _enemyChars;
private IBattleContext _ctx; private IBattleContext _ctx;
private CardBattleScreen _screen;
private BattleCardPanel _playerPanel; private BattleCardPanel _playerPanel;
private BattleCardPanel _enemyPanel; private BattleCardPanel _enemyPanel;
private CardController _cardController; private CardController _cardController;
private Control _actionPanel;
private Control _toolPanel;
private EndTurnButton _endTurnButton; private EndTurnButton _endTurnButton;
private BattleSide _currentOrder; private BattleSide _currentOrder;
private bool _playerTurnActive; private bool _playerTurnActive;
private int _round;
private const float CELL_OFFSET = 10f; /// <summary>Unique identifier for this battle core, used by the battle engine to select the card battle mod.</summary>
private const int PLAYER_CARD_LIMIT = 4; public string Id => "card_battle";
private const float BASE_CARD_WIDTH = 230f;
private const float BASE_CARD_HEIGHT = 420f;
/// <summary> /// <summary>
/// Initializes the card battle by constructing the player and enemy <see cref="BattleCardPanel"/> instances, /// Provides <see cref="IActionExecutor"/> instances for the three card
/// the <see cref="CardController"/> for drag-and-drop input, and the <see cref="EndTurnButton"/>. /// action types (physics/attack, physics/defence, physics/stun) used
/// Enemy characters are deep-copied so defeats are tracked independently per battle instance. /// during action resolution.
/// The <see cref="CardBattleActionProvider"/> is registered with <see cref="ActionExecutorHub"/>. /// </summary>
public IActionExecutorProvider ActionProvider { get; } = new Actions.CardBattleActionProvider();
/// <summary>
/// Instantiates the battle screen from <c>scenes/card_battle_screen.tscn</c>,
/// attaches it to the battle scene's <c>Panel</c> and binds the panels,
/// end-turn button and input controller. Enemy characters are deep-copied
/// so defeats are tracked independently per battle instance. The
/// <see cref="CardBattleActionProvider"/> is registered with
/// <see cref="ActionExecutorHub"/>.
/// </summary> /// </summary>
public void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx) public void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx)
{ {
@@ -64,71 +75,42 @@ namespace Cthangover.CardBattle
_ctx = ctx; _ctx = ctx;
_enemyChars = new Character[enemyChars.Length]; _enemyChars = new Character[enemyChars.Length];
for (int i = 0; i < enemyChars.Length; i++) for (var i = 0; i < enemyChars.Length; i++)
_enemyChars[i] = enemyChars[i]?.Copy() ?? enemyChars[i]; _enemyChars[i] = enemyChars[i]?.Copy() ?? enemyChars[i];
ActionExecutorHub.Instance.SetActiveProvider(ActionProvider); ActionExecutorHub.Instance.SetActiveProvider(ActionProvider);
var root = ctx.RootNode as Node; var root = ctx.RootNode as Node;
var panel = root.GetNodeOrNull<Control>("Panel") ?? root; var panel = root.GetNodeOrNull<Control>("Panel") ?? root;
_playerPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn"); _screen = (CardBattleScreen)TscnScenes.LoadAndBuild("scenes/card_battle_screen.tscn");
_playerPanel.Name = "PlayerPanel"; _screen.Name = "CardBattleScreen";
_playerPanel.EnsureConstructed(); _screen.EnsureConstructed();
panel.AddChild(_playerPanel); panel.AddChild(_screen);
_enemyPanel = (BattleCardPanel)TscnScenes.LoadAndBuild("scenes/battle_card_panel.tscn"); _playerPanel = _screen.PlayerPanel;
_enemyPanel.Name = "EnemyPanel"; _enemyPanel = _screen.EnemyPanel;
_enemyPanel.AlignType = "Right";
_enemyPanel.EnsureConstructed();
_enemyPanel.MouseFilter = Control.MouseFilterEnum.Ignore;
panel.AddChild(_enemyPanel);
_actionPanel = new Control { Name = "ActionPanel" }; _endTurnButton = _screen.EndTurnButton;
panel.AddChild(_actionPanel);
_toolPanel = root.GetNodeOrNull<Control>("ToolPanel");
_endTurnButton = (EndTurnButton)TscnScenes.LoadAndBuild("scenes/end_turn_button.tscn");
_endTurnButton.Name = "EndTurnButton";
_endTurnButton.EnsureConstructed();
_endTurnButton.SetVisible(false); _endTurnButton.SetVisible(false);
_endTurnButton.OnPressed += OnEndTurnPressed; _endTurnButton.OnPressed += OnEndTurnPressed;
_toolPanel?.AddChild(_endTurnButton);
_cardController = new CardController { Name = "CardController" }; _cardController = _screen.Controller;
panel.AddChild(_cardController); _cardController.Setup(_screen);
_cardController.FindPanels();
} }
/// <summary> /// <summary>
/// Positions the UI panels relative to the viewport, calculates a unified <c>cardScale</c> that /// Calculates the pointer scale fitting both sides on screen, initializes
/// fits both player and enemy cards on screen, initializes all character cards, and starts the first player turn. /// every card and starts the first player turn. Subscribes to
/// Subscribes to <c>OnCardDead</c> to record defeated enemies and to <c>OnActionExecuted</c> to detect /// <c>OnCardDead</c> to record defeated enemies for the victory screen and
/// automatic turn completion when all player cards exhaust their action points. /// to <c>OnActionExecuted</c> to detect automatic turn completion.
/// </summary> /// </summary>
public void Start() public void Start()
{ {
var root = _ctx.RootNode as Node;
var panelSize = root.GetViewport().GetVisibleRect().Size;
var enemyCount = _enemyChars?.Length ?? 1; var enemyCount = _enemyChars?.Length ?? 1;
_enemyPanel.UseRows = enemyCount > 6; _enemyPanel.UseRows = enemyCount > 6;
_playerPanel.Position = new Vector2(0, panelSize.Y * 0.55f);
_playerPanel.Size = new Vector2(panelSize.X * 0.55f, panelSize.Y * 0.35f);
_enemyPanel.Position = new Vector2(panelSize.X * 0.02f, panelSize.Y * 0.022f);
_enemyPanel.Size = new Vector2(panelSize.X * 0.96f, panelSize.Y * 0.446f);
_actionPanel.Position = new Vector2(panelSize.X * 0.53f, panelSize.Y * 0.55f);
_actionPanel.Size = new Vector2(panelSize.X * 0.46f, panelSize.Y * 0.35f);
if (_endTurnButton != null)
_endTurnButton.Position = new Vector2(panelSize.X - 180f, (panelSize.Y * 0.1f - 50f) / 2);
var cardScale = CalculateCardScale(enemyCount); var cardScale = CalculateCardScale(enemyCount);
_cardController.CardScale = cardScale; _cardController.CardScale = cardScale;
@@ -140,6 +122,7 @@ namespace Cthangover.CardBattle
BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared; BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared;
_round = 0;
_currentOrder = BattleSide.Player; _currentOrder = BattleSide.Player;
StartPlayerTurn(); StartPlayerTurn();
} }
@@ -147,31 +130,31 @@ namespace Cthangover.CardBattle
private float CalculateCardScale(int enemyCount) private float CalculateCardScale(int enemyCount)
{ {
var viewport = ((Node)_ctx.RootNode).GetViewport().GetVisibleRect().Size; var viewport = ((Node)_ctx.RootNode).GetViewport().GetVisibleRect().Size;
float enemyPanelWidth = viewport.X * 0.96f; var enemyPanelWidth = viewport.X * 0.96f;
float playerPanelWidth = viewport.X * 0.55f; var playerPanelWidth = viewport.X * 0.55f;
float enemyPanelHeight = viewport.Y * 0.446f; var enemyPanelHeight = viewport.Y * 0.446f;
float maxCardScale = 1f;
float maxCardScale;
if (_enemyPanel.UseRows) if (_enemyPanel.UseRows)
{ {
int maxPerRow = _enemyPanel.GetMaxCardsPerRow(); var maxPerRow = _enemyPanel.GetMaxCardsPerRow();
int rows = _enemyPanel.GetCurrentRowCount(enemyCount); var rows = _enemyPanel.GetCurrentRowCount(enemyCount);
float widthScale = (enemyPanelWidth - (maxPerRow - 1) * CELL_OFFSET) / (maxPerRow * BASE_CARD_WIDTH); var widthScale = (enemyPanelWidth - (maxPerRow - 1) * CellOffset) / (maxPerRow * BaseCardWidth);
float heightScale = (enemyPanelHeight - (rows - 1) * CELL_OFFSET) / (rows * BASE_CARD_HEIGHT); var heightScale = (enemyPanelHeight - (rows - 1) * CellOffset) / (rows * BaseCardHeight);
maxCardScale = Mathf.Max(0.1f, Mathf.Min(widthScale, heightScale)); maxCardScale = Mathf.Max(0.1f, Mathf.Min(widthScale, heightScale));
} }
else else
{ {
int effectiveCount = Mathf.Max(enemyCount, 1); var effectiveCount = Mathf.Max(enemyCount, 1);
maxCardScale = Mathf.Max(0.1f, (enemyPanelWidth - (effectiveCount - 1) * CELL_OFFSET) / (effectiveCount * BASE_CARD_WIDTH)); maxCardScale = Mathf.Max(0.1f,
(enemyPanelWidth - (effectiveCount - 1) * CellOffset) / (effectiveCount * BaseCardWidth));
} }
float playerFitScale = Mathf.Max(0.1f, (playerPanelWidth - (PLAYER_CARD_LIMIT - 1) * CELL_OFFSET) / (PLAYER_CARD_LIMIT * BASE_CARD_WIDTH)); var playerFitScale = Mathf.Max(0.1f,
(playerPanelWidth - (PlayerCardLimit - 1) * CellOffset) / (PlayerCardLimit * BaseCardWidth));
float cardScale = Mathf.Min(maxCardScale, playerFitScale); return Mathf.Min(Mathf.Min(maxCardScale, playerFitScale), 1f);
return Mathf.Min(cardScale, 1f);
} }
private void OnEnemyCardDead(CharacterCardNode card) private void OnEnemyCardDead(CharacterCardNode card)
@@ -185,34 +168,43 @@ namespace Cthangover.CardBattle
ctx.RecordEnemyDefeated(card.Card); ctx.RecordEnemyDefeated(card.Card);
ctx.NotifyCharacterDied(card.Card); ctx.NotifyCharacterDied(card.Card);
} }
_screen.Hud.SetEnemiesAlive(CountAlive(_enemyPanel));
BattleLogHub.Log(string.Format(Translate("battle/log_dead"), Translate(card.Card.Name)), BattleLogKind.Dead);
} }
private void StartPlayerTurn() private void StartPlayerTurn()
{ {
GameLogger.Log("CARD_BATTLE", "Player turn started", LogLevel.Debug); _round += 1;
_currentOrder = BattleSide.Player; _currentOrder = BattleSide.Player;
_playerTurnActive = true; _playerTurnActive = true;
BattleSceneContext.Instance.IsWait = false; BattleSceneContext.Instance.IsWait = false;
_cardController.ClearSelections(); _cardController.ClearSelections();
_cardController.ClearActions(); _cardController.ClearActions();
_screen.ActionsPanel.ClearBody();
_screen.ActionsPanel.ClearSubject();
_endTurnButton?.SetVisible(true); _endTurnButton?.SetVisible(true);
foreach (var card in _playerPanel.CardList) foreach (var card in _playerPanel.CardList)
{ {
card.Card.StatusEffectQueue.OnTurnStart(); StatusEffectService.Of(card.Card).OnTurnStart();
card.Card.Attributes.Point.Value = card.Card.Attributes.Point.BaseValue; card.Card.Attributes.Point.Value = card.Card.Attributes.Point.BaseValue;
card.UpdateInfo(); card.UpdateInfo();
} }
_screen.Hud.SetTurn(true);
_screen.Hud.SetRound(_round);
_screen.Hud.SetEnemiesAlive(CountAlive(_enemyPanel));
_screen.LogPanel.SetRound(_round);
_screen.Banner.Show(Translate("battle/turn_player"));
BattleLogHub.Log(
Translate("battle/log_turn_player") + " · " + string.Format(Translate("battle/log_round"), _round),
BattleLogKind.Turn);
} }
private void OnEndTurnPressed() private void OnEndTurnPressed() => OnPlayerTurnEnd();
{
GameLogger.Log("CARD_BATTLE", "End turn button pressed", LogLevel.Debug);
OnPlayerTurnEnd();
}
private void CheckPlayerTurnEnd() private void CheckPlayerTurnEnd()
{ {
@@ -225,30 +217,24 @@ namespace Cthangover.CardBattle
return; return;
} }
bool allOutOfPoints = true;
foreach (var card in _playerPanel.CardList) foreach (var card in _playerPanel.CardList)
{ {
if (!card.IsDead && card.Card?.Attributes?.Point?.Value > 0) if (!card.IsDead && card.Card?.Attributes?.Point?.Value > 0)
{ return;
allOutOfPoints = false;
break;
}
} }
if (allOutOfPoints) BattleLogHub.Log(Translate("battle/log_out_of_points"), BattleLogKind.Info);
OnPlayerTurnEnd(); OnPlayerTurnEnd();
} }
/// <summary> /// <summary>
/// Ends the player's turn by hiding the end-turn button, setting the battle to <c>IsWait</c>, /// Ends the player's turn: hides the end-turn button, pauses the battle,
/// and yielding two frames for UI to settle before running the enemy AI turn via <c>RunEnemyTurn</c>. /// plays the enemy-turn banner and journal line, waits for two frames plus
/// If all enemies are already dead, the battle ends immediately with the player as winner. /// a short pause so the UI settles, then runs the enemy AI. Ends the battle
/// This method is <c>async void</c> so it can await frames without blocking the game loop. /// immediately when no enemies remain.
/// </summary> /// </summary>
public async void OnPlayerTurnEnd() public async void OnPlayerTurnEnd()
{ {
GameLogger.Log("CARD_BATTLE", "Player turn ended", LogLevel.Debug);
_playerTurnActive = false; _playerTurnActive = false;
_endTurnButton?.SetVisible(false); _endTurnButton?.SetVisible(false);
BattleSceneContext.Instance.IsWait = true; BattleSceneContext.Instance.IsWait = true;
@@ -259,6 +245,10 @@ namespace Cthangover.CardBattle
return; return;
} }
_screen.Hud.SetTurn(false);
_screen.Banner.Show(Translate("battle/turn_enemy"));
BattleLogHub.Log(Translate("battle/log_turn_enemy"), BattleLogKind.Turn);
var root = (Node)_ctx.RootNode; var root = (Node)_ctx.RootNode;
await root.ToSignal(root.GetTree(), "process_frame"); await root.ToSignal(root.GetTree(), "process_frame");
await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout"); await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout");
@@ -268,8 +258,6 @@ namespace Cthangover.CardBattle
private async Task RunEnemyTurn() private async Task RunEnemyTurn()
{ {
GameLogger.Log("CARD_BATTLE", "Enemy turn started", LogLevel.Debug);
_currentOrder = BattleSide.Enemy; _currentOrder = BattleSide.Enemy;
_endTurnButton?.SetVisible(false); _endTurnButton?.SetVisible(false);
@@ -280,11 +268,15 @@ namespace Cthangover.CardBattle
if (card.IsDead || card.Card?.Actions == null || card.Card.Actions.Count == 0) if (card.IsDead || card.Card?.Actions == null || card.Card.Actions.Count == 0)
continue; continue;
card.Card.StatusEffectQueue.OnTurnStart(); StatusEffectService.Of(card.Card).OnTurnStart();
if (card.Card.StatusEffectQueue.HasStun()) if (StatusEffectService.Of(card.Card).HasStun())
{ {
GameLogger.Log("CARD_BATTLE", $"{card.Card.Name} is stunned, skipping", LogLevel.Debug); card.UpdateInfo();
card.PlayZap();
BattleLogHub.Log(
string.Format(Translate("battle/log_stun_skip"), Translate(card.Card.Name)),
BattleLogKind.Status);
continue; continue;
} }
@@ -298,9 +290,17 @@ namespace Cthangover.CardBattle
if (target == null) if (target == null)
continue; continue;
GameLogger.Log("CARD_BATTLE", card.UpdateInfo();
$"Enemy action: {card.Card.Name} -> {action.Name} -> {target.Card.Name}",
LogLevel.Message); if (action.Type == ActionCharacterType.ToEnemy)
{
BattleLogHub.Log(
string.Format(Translate("battle/log_enemy_action"),
Translate(card.Card.Name),
ActionCharacterUi.Translate(action.Name),
Translate(target.Card.Name)),
BattleLogKind.Info);
}
var animatedAction = CreateAnimatedAction(card, target, action); var animatedAction = CreateAnimatedAction(card, target, action);
if (animatedAction != null) if (animatedAction != null)
@@ -315,7 +315,6 @@ namespace Cthangover.CardBattle
if (!HasAliveCards(_playerPanel)) if (!HasAliveCards(_playerPanel))
{ {
GameLogger.Log("CARD_BATTLE", "All players dead — enemy wins", LogLevel.Message);
_ctx.EndBattle(BattleSide.Enemy); _ctx.EndBattle(BattleSide.Enemy);
return; return;
} }
@@ -336,8 +335,7 @@ namespace Cthangover.CardBattle
if (actions == null || actions.Count == 0) if (actions == null || actions.Count == 0)
return null; return null;
var random = new Random(); return actions[GD.RandRange(0, actions.Count - 1)];
return actions[random.Next(actions.Count)];
} }
private CharacterCardNode PickEnemyTarget(CharacterCardNode source, ActionCharacter action) private CharacterCardNode PickEnemyTarget(CharacterCardNode source, ActionCharacter action)
@@ -350,7 +348,7 @@ namespace Cthangover.CardBattle
if (alivePlayers.Count == 0) if (alivePlayers.Count == 0)
return null; return null;
return alivePlayers[new Random().Next(alivePlayers.Count)]; return alivePlayers[GD.RandRange(0, alivePlayers.Count - 1)];
} }
if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf) if (action.Type == ActionCharacterType.ToAlias || action.Type == ActionCharacterType.ToSelf)
@@ -360,7 +358,8 @@ namespace Cthangover.CardBattle
return null; return null;
} }
private Actions.AbstractBattleAction CreateAnimatedAction(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) private Actions.AbstractBattleAction CreateAnimatedAction(
CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{ {
if (action.Type == ActionCharacterType.ToEnemy) if (action.Type == ActionCharacterType.ToEnemy)
return new Actions.PhysicsAttackAction(source, target, action); return new Actions.PhysicsAttackAction(source, target, action);
@@ -372,7 +371,7 @@ namespace Cthangover.CardBattle
return null; return null;
} }
private bool HasAliveCards(BattleCardPanel panel) private static bool HasAliveCards(BattleCardPanel panel)
{ {
foreach (var card in panel.CardList) foreach (var card in panel.CardList)
{ {
@@ -382,14 +381,25 @@ namespace Cthangover.CardBattle
return false; return false;
} }
private static int CountAlive(BattleCardPanel panel)
{
var alive = 0;
foreach (var card in panel.CardList)
{
if (!card.IsDead && card.Card?.Attributes?.Health?.Value > 0)
alive++;
}
return alive;
}
private void OnBattleCleared() private void OnBattleCleared()
{ {
_playerPanel?.ClearAll(); _screen?.Clear();
_enemyPanel?.ClearAll();
_cardController?.ClearActions();
_endTurnButton?.SetVisible(false);
} }
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
/// <summary> /// <summary>
/// <c>true</c> between <see cref="StartPlayerTurn"/> and the start of /// <c>true</c> between <see cref="StartPlayerTurn"/> and the start of
/// <see cref="OnPlayerTurnEnd"/> — matches the visibility window of the /// <see cref="OnPlayerTurnEnd"/> — matches the visibility window of the
@@ -397,9 +407,7 @@ namespace Cthangover.CardBattle
/// </summary> /// </summary>
public bool IsWaitingForPlayerInput => _playerTurnActive; public bool IsWaitingForPlayerInput => _playerTurnActive;
/// <summary> /// <summary>Automation hook: ends the player's turn without selecting cards.</summary>
/// Automation hook: ends the player's turn without selecting cards.
/// </summary>
public void AdvancePlayerTurn() => OnPlayerTurnEnd(); public void AdvancePlayerTurn() => OnPlayerTurnEnd();
} }
} }
+7 -15
View File
@@ -2,23 +2,15 @@
namespace Cthangover.CardBattle namespace Cthangover.CardBattle
{ {
/// <summary> /// <summary>
/// Common interface for all card UI nodes in the card battle system. /// Common contract for every card the input controller can hit-test. Both
/// Exposes the <c>Frame</c> and <c>Image</c> <see cref="TextureRect"/> controls so that /// the character cards on the field and the draggable action cards expose
/// <see cref="CardController"/> can perform hit-testing during drag-and-drop targeting /// their root <see cref="Control"/>, because layout, scale and the drag
/// without knowing the concrete card type (<see cref="CharacterCardNode"/> or <see cref="ActionCardNode"/>). /// ghost all operate on the whole card rect rather than on an inner sprite.
/// </summary> /// </summary>
public interface ICard public interface ICard
{ {
/// <summary> /// <summary>Root control used for hit-testing and for reparenting during a drag.</summary>
/// The frame <see cref="TextureRect"/> used for hit-testing whether a pointer is over this card. Control GetControlNode();
/// </summary>
TextureRect Frame { get; }
/// <summary>
/// The portrait/image <see cref="TextureRect"/> of the card, used for dissolve shader effects on death.
/// </summary>
TextureRect Image { get; }
} }
}
}
+57 -19
View File
@@ -1,29 +1,31 @@
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.CardBattle.Player namespace Cthangover.CardBattle.Player
{ {
/// <summary> /// <summary>
/// Strategy for <c>ToEnemy</c> attack actions. Validates that the target is an enemy /// Strategy for <c>ToEnemy</c> actions. Validates that the target is an enemy,
/// (non-player card), highlights both cards with a red attack outline, and executes /// highlights both cards with a red attack outline, executes the action via
/// damage via <see cref="ActionExecutorHub"/> with floating damage/defence numbers. /// <see cref="ActionExecutorHub"/> and reports the outcome: floating damage /
/// This is the standard strategy for all offensive cards in the player's deck. /// defence-loss numbers on the target, a colour pulse on the card and a
/// journal row through <see cref="BattleLogHub"/>. Stun actions (those with a
/// "Turn" above 1) additionally flash the electric zap over the target
/// instead of printing damage, matching the mockup's stun feedback.
/// </summary> /// </summary>
public class AttackToEnemyStrategy : ICardActionStrategy public class AttackToEnemyStrategy : ICardActionStrategy
{ {
/// <summary> /// <summary>Returns <c>true</c> if the action type is <c>ToEnemy</c> and the target is an enemy card.</summary>
/// Returns <c>true</c> if the action type is <c>ToEnemy</c> and the target is an enemy card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{ {
return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer; return action.Type == ActionCharacterType.ToEnemy && target != null && !target.IsPlayer;
} }
/// <summary> /// <summary>Shows a red attack overlay on both the dragged action card and the target enemy card.</summary>
/// Shows a red attack overlay on both the dragged action card and the target enemy card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{ {
actionCard.Attack(); actionCard.Attack();
@@ -31,8 +33,9 @@ namespace Cthangover.CardBattle.Player
} }
/// <summary> /// <summary>
/// Resolves the attack via <see cref="ActionExecutorHub"/> and spawns floating damage /// Resolves the attack and publishes its result: damage and absorbed
/// and defence-loss numbers above the <paramref name="target"/> card. /// shield as floating numbers plus journal rows, or the stun effect with
/// its zap flash for control actions.
/// </summary> /// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{ {
@@ -43,15 +46,50 @@ namespace Cthangover.CardBattle.Player
source.UpdateInfo(); source.UpdateInfo();
target.UpdateInfo(); target.UpdateInfo();
if (result.Result) if (!result.Result)
{ return;
if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition);
var defenceLost = -result.Target.Defence; var actionName = Translate(action.Name);
if (defenceLost > 0) var sourceName = Translate(source.Card.Name);
ShowDamageBehaviour.SpawnDefence(defenceLost, target, target.GlobalPosition); var targetName = Translate(target.Card.Name);
var isStun = action.GetInt("Turn", 0) > 1;
var defenceLost = -result.Target.Defence;
if (defenceLost > 0)
{
ShowDamageBehaviour.SpawnDefence(defenceLost, target, target.GlobalPosition);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_absorb"), targetName, defenceLost),
BattleLogKind.Shield);
}
if (isStun)
{
target.PlayZap();
BattleLogHub.Log(
string.Format(Translate("battle/log_stun"), targetName, action.GetInt("Turn", 0)),
BattleLogKind.Status);
return;
}
if (result.Target.Damage > 0)
{
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition);
target.Flash(UiTokens.DangerText);
BattleLogHub.Log(
string.Format(Translate("battle/log_damage"),
actionName, sourceName, targetName, result.Target.Damage),
BattleLogKind.Damage);
}
else if (defenceLost == 0)
{
BattleLogHub.Log(
string.Format(Translate("battle/log_absorbed"), actionName, sourceName, targetName),
BattleLogKind.Shield);
} }
} }
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
} }
} }
+26 -21
View File
@@ -1,40 +1,36 @@
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.CardBattle.Player namespace Cthangover.CardBattle.Player
{ {
/// <summary> /// <summary>
/// Strategy for self-targeting actions (<c>ToSelf</c>). Validates that source and target /// Strategy for self-targeting actions (<c>ToSelf</c>). Validates that the
/// are the same card, highlights with a green selection outline, and applies the action /// source and target are the same card, highlights with a green selection
/// to the source card itself via <see cref="ActionExecutorHub"/>. Used for self-buffs like /// outline and applies the effect to the caster. Shield gains print a
/// defence boosts. Damage and defence floating text spawn above the source card. /// floating blue number and a journal row; the card pulses to make the buff
/// readable without reading the log.
/// </summary> /// </summary>
public class SelfStrategy : ICardActionStrategy public class SelfStrategy : ICardActionStrategy
{ {
/// <summary> /// <summary>Returns <c>true</c> only when <paramref name="source"/> and <paramref name="target"/> are the same card.</summary>
/// Returns <c>true</c> only when <paramref name="source"/> and <paramref name="target"/> are the same card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{ {
return source != null && source == target; return source != null && source == target;
} }
/// <summary> /// <summary>Shows a green selection overlay on both the dragged action card and the source card.</summary>
/// Shows a green selection overlay on both the dragged action card and the source card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{ {
actionCard.Select(); actionCard.Select();
target.Select(); target.Select();
} }
/// <summary> /// <summary>Executes the self-targeted action and reports shields and healing.</summary>
/// Executes the self-targeted action and spawns floating defence/damage numbers
/// above the <paramref name="source"/> card. Damage numbers are marked with the
/// <c>isHeal</c> flag for green coloring via <see cref="ShowDamageBehaviour"/>.
/// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{ {
if (source?.Card == null || action == null) if (source?.Card == null || action == null)
@@ -43,14 +39,23 @@ namespace Cthangover.CardBattle.Player
var result = ActionExecutorHub.Instance.Execute(action, source.Card, source.Card); var result = ActionExecutorHub.Instance.Execute(action, source.Card, source.Card);
source.UpdateInfo(); source.UpdateInfo();
if (result.Result) if (!result.Result)
{ return;
if (result.Target.Defence > 0)
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, source, source.GlobalPosition);
if (result.Target.Damage > 0) if (result.Target.Defence > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, source, source.GlobalPosition, true); {
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, source, source.GlobalPosition);
source.Flash(UiTokens.Info);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_gain"), Translate(source.Card.Name), result.Target.Defence),
BattleLogKind.Buff);
} }
if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, source, source.GlobalPosition, true);
} }
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
} }
} }
+26 -20
View File
@@ -1,39 +1,36 @@
using Cthangover.CardBattle.UI; using Cthangover.CardBattle.UI;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions; using Cthangover.Core.Battle.Actions;
using Cthangover.Core.UI.Theming;
using Cthangover.TurnBattle;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Godot;
namespace Cthangover.CardBattle.Player namespace Cthangover.CardBattle.Player
{ {
/// <summary> /// <summary>
/// Strategy for ally-support actions (<c>ToAlias</c>). Validates that the target is a friendly /// Strategy for ally-support actions (<c>ToAlias</c>). Validates that the
/// player card, highlights with green selection overlays, and applies defence/healing effects /// target is a friendly card, highlights with green selection overlays and
/// via <see cref="ActionExecutorHub"/>. Allows one player card to buff another during the player's turn. /// applies the effect via <see cref="ActionExecutorHub"/>. Shield gains are
/// shown as floating blue numbers, a colour pulse on the ally and a journal
/// row; healing, when the executor produces it, prints a green number.
/// </summary> /// </summary>
public class SupportStrategy : ICardActionStrategy public class SupportStrategy : ICardActionStrategy
{ {
/// <summary> /// <summary>Returns <c>true</c> when the action type is <c>ToAlias</c> and the target is a player card.</summary>
/// Returns <c>true</c> when the action type is <c>ToAlias</c> and the target is a player card.
/// </summary>
public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target) public bool Check(ActionCharacter action, CharacterCardNode source, CharacterCardNode target)
{ {
return action.Type == ActionCharacterType.ToAlias && target != null && target.IsPlayer; return action.Type == ActionCharacterType.ToAlias && target != null && target.IsPlayer;
} }
/// <summary> /// <summary>Shows a green selection overlay on both the dragged action card and the target ally card.</summary>
/// Shows a green selection overlay on both the dragged action card and the target ally card.
/// </summary>
public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target) public void HighlightTarget(ActionCardNode actionCard, CharacterCardNode target)
{ {
actionCard.Select(); actionCard.Select();
target.Select(); target.Select();
} }
/// <summary> /// <summary>Resolves the support action and reports shields and healing.</summary>
/// Resolves the support action and spawns floating defence and heal text above
/// the <paramref name="target"/> ally. Damage numbers are shown with the <c>isHeal</c>
/// flag for green coloring.
/// </summary>
public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action) public void Execute(CharacterCardNode source, CharacterCardNode target, ActionCharacter action)
{ {
if (source?.Card == null || target?.Card == null || action == null) if (source?.Card == null || target?.Card == null || action == null)
@@ -43,14 +40,23 @@ namespace Cthangover.CardBattle.Player
source.UpdateInfo(); source.UpdateInfo();
target.UpdateInfo(); target.UpdateInfo();
if (result.Result) if (!result.Result)
{ return;
if (result.Target.Defence > 0)
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, target, target.GlobalPosition);
if (result.Target.Damage > 0) if (result.Target.Defence > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition, true); {
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, target, target.GlobalPosition);
target.Flash(UiTokens.Info);
BattleLogHub.Log(
string.Format(Translate("battle/log_shield_gain"), Translate(target.Card.Name), result.Target.Defence),
BattleLogKind.Buff);
} }
if (result.Target.Damage > 0)
ShowDamageBehaviour.SpawnDamage(result.Target.Damage, target, target.GlobalPosition, true);
} }
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
} }
} }
+173 -84
View File
@@ -1,137 +1,226 @@
using Cthangover.Core.Cards;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.UI; using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot; using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.CardBattle.UI namespace Cthangover.CardBattle.UI
{ {
/// <summary> /// <summary>
/// UI node representing a single action card that the player can drag onto a target. /// Draggable action card from the interface mockup (mockups/card_battle):
/// Implements <see cref="ICard"/> so <see cref="CardController"/> can hit-test drag targets. /// the horizontal kit card with a type-coloured accent strip, action icon,
/// Displayed in the <see cref="CardController"/>'s action panel below the character cards. /// name and ID, badges (targeting type, action-point cost, stun turns) and
/// Supports hover scale-up animation, selection highlighting (<see cref="Select"/>, <see cref="Attack"/>, <see cref="Invalid"/>), /// a property-chip row. The node tree lives in <c>scenes/action_card.tscn</c>;
/// and info updates from the underlying <see cref="ActionCharacter"/> data model. /// this class fetches the nodes, dresses them and fills the dynamic badge /
/// chip rows using the shared <see cref="ActionCharacterUi"/> presentation
/// rules, so the battle UI can never disagree with the character panel about
/// an action's colour, caption or cost.
/// <para/>
/// Implements <see cref="ICard"/> so the input controller can hit-test drag
/// targets; the hover style, disabled state and the preview builder
/// (<see cref="DescribeFrom"/>) are the only UI surface the controller and
/// the actions panel need.
/// </summary> /// </summary>
public partial class ActionCardNode : ModWidget, ICard public partial class ActionCardNode : ModWidget, ICard
{ {
private TextureRect frame; private PanelContainer _panel;
private TextureRect image; private ColorRect _accent;
private Label nameLabel; private TextureRect _icon;
private Label _nameLabel;
private Label _idLabel;
private HBoxContainer _badges;
private HBoxContainer _props;
/// <inheritdoc /> private StyleBoxFlat _idleStyle;
public TextureRect Frame => frame; private StyleBoxFlat _hoverStyle;
/// <inheritdoc /> private bool _disabled;
public TextureRect Image => image;
/// <summary> /// <summary>The action data model backing this card.</summary>
/// The <see cref="ActionCharacter"/> data model backing this card. Contains the action's
/// name, image, type, and stat modifiers used during execution.
/// </summary>
public ActionCharacter Card { get; set; } public ActionCharacter Card { get; set; }
/// <summary> /// <summary>True while the character cannot pay the card's action-point cost.</summary>
/// Returns <c>this</c> — action cards are <see cref="Control"/> nodes themselves, public bool IsDisabled => _disabled;
/// unlike <see cref="CharacterCardNode"/> which wraps a separate control.
/// </summary> /// <summary>Raised when the cursor enters the card; the actions panel uses it to print the preview.</summary>
public event System.Action<ActionCardNode> Hovered;
/// <summary>Raised when the cursor leaves the card; the preview returns to its default text.</summary>
public event System.Action<ActionCardNode> Unhovered;
/// <inheritdoc />
public Control GetControlNode() => this; public Control GetControlNode() => this;
private Tween hoverTween; /// <summary>Builds the action card visuals: artwork, name label and point cost chip from the bound ActionCharacter.</summary>
private float baseY;
private bool isHovered;
private Vector2 _baseScale;
protected override void Construct() protected override void Construct()
{ {
image = GetNode<TextureRect>("Image"); CthangoverTheme.Apply(this);
frame = GetNode<TextureRect>("Frame");
nameLabel = GetNode<Label>("NameLabel");
Size = CustomMinimumSize; _panel = GetNode<PanelContainer>("Panel");
_accent = GetNode<ColorRect>("Panel/Row/Accent");
_icon = GetNode<TextureRect>("Panel/Row/IconWrap/Icon");
_nameLabel = GetNode<Label>("Panel/Row/Body/Column/NameRow/NameLabel");
_idLabel = GetNode<Label>("Panel/Row/Body/Column/NameRow/IdLabel");
_badges = GetNode<HBoxContainer>("Panel/Row/Body/Column/Badges");
_props = GetNode<HBoxContainer>("Panel/Row/Body/Column/Props");
_idleStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface1, UiTokens.LineCard);
_hoverStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.SurfaceHover, UiTokens.LineStrong);
_panel.AddThemeStyleboxOverride("panel", _idleStyle);
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
_idLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
MouseEntered += OnMouseEntered; MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited; MouseExited += OnMouseExited;
} }
/// <summary> /// <summary>
/// Placeholder for selection highlight. Currently unused — action cards use /// Fills the card from the action definition: icon, name, ID, accent
/// <see cref="Attack"/> and <see cref="Invalid"/> for drag-over feedback instead. /// strip, badges and property chips. Called by the input controller when
/// the action deck of a character is opened.
/// </summary> /// </summary>
public void Init(ActionCharacter card)
{
Card = card;
if (_icon != null && card?.Image != null)
_icon.Texture = card.Image;
_accent.Color = ActionCharacterUi.AccentColor(card.Type);
_nameLabel.Text = ActionCharacterUi.Translate(card.Name);
_idLabel.Text = card.ID;
TooltipText = ActionCharacterUi.BuildTooltip(card);
BuildBadges();
BuildProps();
}
/// <summary>Refreshes the texts of an already initialised card.</summary>
public void UpdateInfo()
{
if (Card == null)
return;
_nameLabel.Text = ActionCharacterUi.Translate(Card.Name);
}
/// <summary>
/// Dims the card when the selected character cannot pay its cost; hover
/// feedback is suppressed so the disabled state reads as inert.
/// </summary>
public void SetDisabled(bool disabled)
{
_disabled = disabled;
Modulate = UiTokens.Alpha(UiTokens.White, disabled ? 0.45f : 1f);
_panel.AddThemeStyleboxOverride("panel", _idleStyle);
}
/// <summary>
/// Builds the hint printed in the actions panel when the card is
/// hovered: the expected effect computed from the source character's
/// stats ("Урон 5 (АТК 5 × 1.0) · расход 1 ОД"); an enemy action without an
/// <c>Attack</c> multiplier (a formula skill) shows its own description instead.
/// </summary>
public string DescribeFrom(Character source)
{
if (Card == null || source?.Attributes == null)
return string.Empty;
var cost = ActionCharacterUi.CostOf(Card);
if (Card.Type == ActionCharacterType.ToEnemy && Card.GetInt("Turn", 0) > 1)
return string.Format(Translate("battle/preview_stun"), Card.GetInt("Turn", 0), cost);
if (Card.Type == ActionCharacterType.ToEnemy)
{
if (Card.GetFloat("Attack", 0f) <= 0f)
{
var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(Card.Description));
if (!string.IsNullOrEmpty(description))
return description;
}
var multiplier = Card.GetFloat("Attack", 1f);
var damage = Mathf.RoundToInt(multiplier * source.Attributes.Attack.Value);
return string.Format(
Translate("battle/preview_damage"),
damage,
source.Attributes.Attack.Value,
multiplier.ToString("0.0"),
cost);
}
return string.Format(Translate("battle/preview_defence"), Card.GetInt("Defence"), cost);
}
/// <summary>Placeholder for the source-card highlight; the drag ghost covers it.</summary>
public void Select() public void Select()
{ {
} }
/// <summary> /// <summary>Placeholder: target highlighting is applied by the action strategy.</summary>
/// Placeholder for attack-mode highlight. Currently unused — target highlighting
/// is applied by <see cref="ICardActionStrategy.HighlightTarget"/> instead.
/// </summary>
public void Attack() public void Attack()
{ {
} }
/// <summary> /// <summary>Placeholder: validity is painted on the target card instead.</summary>
/// Placeholder for invalid-target highlight. Currently unused — invalidity is
/// indicated by <see cref="ICardActionStrategy.HighlightTarget"/> calling
/// <see cref="CharacterCardNode.Invalid"/> on the target card instead.
/// </summary>
public void Invalid() public void Invalid()
{ {
} }
/// <summary> /// <summary>Restores the neutral Z-index after a cancelled drag.</summary>
/// Resets the Z-index to 0 when the card is no longer being dragged.
/// </summary>
public void Unselect() public void Unselect()
{ {
ZIndex = 0; ZIndex = 0;
} }
private void BuildBadges()
{
foreach (var child in _badges.GetChildren())
{
_badges.RemoveChild(child);
child.QueueFree();
}
var accent = ActionCharacterUi.AccentColor(Card.Type);
_badges.AddChild(ActionCharacterUi.MakeBadge(
ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(Card.Type)).ToUpperInvariant(), accent));
var cost = ActionCharacterUi.CostText(Card);
if (!string.IsNullOrEmpty(cost))
_badges.AddChild(ActionCharacterUi.MakeBadge(cost, UiTokens.Info));
var turns = Card.GetInt("Turn", 0);
if (turns > 1 && Card.Type == ActionCharacterType.ToEnemy)
_badges.AddChild(ActionCharacterUi.MakeBadge(
string.Format(Translate("battle/turns_badge"), turns), UiTokens.Cooldown));
}
private void BuildProps()
{
foreach (var child in _props.GetChildren())
{
_props.RemoveChild(child);
child.QueueFree();
}
foreach (var pair in ActionCharacterUi.SortedProperties(Card))
_props.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key, pair.Value));
}
private void OnMouseEntered() private void OnMouseEntered()
{ {
if (isHovered) return; if (!_disabled)
isHovered = true; _panel.AddThemeStyleboxOverride("panel", _hoverStyle);
baseY = Position.Y; Hovered?.Invoke(this);
hoverTween?.Kill();
hoverTween = CreateTween();
hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY - 10f, 0.12f);
hoverTween.TweenProperty(this, "scale", _baseScale * 1.15f, 0.12f);
} }
private void OnMouseExited() private void OnMouseExited()
{ {
if (!isHovered) return; _panel.AddThemeStyleboxOverride("panel", _idleStyle);
isHovered = false; Unhovered?.Invoke(this);
hoverTween?.Kill();
hoverTween = CreateTween();
hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY, 0.12f);
hoverTween.TweenProperty(this, "scale", _baseScale, 0.12f);
} }
/// <summary> private static string Translate(string key)
/// Initializes the card with data from <paramref name="card"/>, caches the base scale => TranslationServer.Translate(key).ToString();
/// for hover animation, sets the card image texture, and refreshes displayed text.
/// Called by <see cref="CardController.CreateAction"/>.
/// </summary>
public void Init(ActionCharacter card)
{
Card = card;
_baseScale = Scale;
if (image != null)
image.Texture = card.Image;
UpdateInfo();
}
/// <summary>
/// Refreshes the displayed name label from the translated <see cref="ActionCharacter.Name"/>.
/// </summary>
public void UpdateInfo()
{
if (nameLabel != null && Card != null)
nameLabel.Text = TranslationServer.Translate(Card.Name);
}
} }
} }
+163
View File
@@ -0,0 +1,163 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// "Действия" window from the interface mockup (mockups/card_battle): the
/// lower-right panel that opens when a party card is clicked. Layout lives
/// in <c>scenes/battle_actions_panel.tscn</c>: a header with the character
/// name and action-point pips, a two-column grid that receives the
/// <see cref="ActionCardNode"/> instances, a description well and a footer
/// hint. The class owns only content: it repaints pips, swaps the empty
/// state and prints the hovered action's numbers.
/// </summary>
public partial class BattleActionsPanel : ModWidget
{
private const int PipSize = 11;
private Label _subject;
private HBoxContainer _pips;
private Label _apHint;
private Label _emptyLabel;
private GridContainer _grid;
private PanelContainer _descWell;
private Label _descLabel;
/// <summary>Container that receives action cards; owned by the input controller.</summary>
public Control Body => _grid;
/// <summary>Builds the bottom action bar shown while a card is selected.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_subject = GetNode<Label>("Window/Column/Header/HeaderRow/SubjectLabel");
_pips = GetNode<HBoxContainer>("Window/Column/Header/HeaderRow/Pips");
_apHint = GetNode<Label>("Window/Column/Header/HeaderRow/ApHint");
_emptyLabel = GetNode<Label>("Window/Column/Body/BodyColumn/EmptyLabel");
_grid = GetNode<GridContainer>("Window/Column/Body/BodyColumn/Grid");
_descWell = GetNode<PanelContainer>("Window/Column/DescWrap/DescWell");
_descLabel = GetNode<Label>("Window/Column/DescWrap/DescWell/DescLabel");
var windowStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.84f), UiTokens.Line);
windowStyle.ContentMarginLeft = 0;
windowStyle.ContentMarginRight = 0;
windowStyle.ContentMarginTop = 0;
windowStyle.ContentMarginBottom = 0;
windowStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
windowStyle.ShadowSize = 18;
windowStyle.ShadowOffset = new Vector2(0f, 8f);
GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel", windowStyle);
foreach (var path in new[]
{
"Window/Column/Header/HeaderRow/TitleLabel",
"Window/Column/Header/HeaderRow/ApCaption",
"Window/Column/Body/BodyColumn/EmptyLabel",
"Window/Column/Footer/FooterRow/HintLabel",
"Window/Column/Footer/FooterRow/AutoEndLabel",
})
{
var label = GetNode<Label>(path);
label.Text = Translate(label.Text);
}
GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel").AddThemeColorOverride("font_color", UiTokens.Muted);
GetNode<Label>("Window/Column/Header/HeaderRow/ApCaption").AddThemeColorOverride("font_color", UiTokens.Muted);
_apHint.AddThemeColorOverride("font_color", UiTokens.Faint);
_emptyLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
GetNode<Label>("Window/Column/Footer/FooterRow/HintLabel").AddThemeColorOverride("font_color", UiTokens.Faint);
GetNode<Label>("Window/Column/Footer/FooterRow/AutoEndLabel").AddThemeColorOverride("font_color", UiTokens.Faint);
var descStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Surface2, UiTokens.Line);
descStyle.ContentMarginLeft = 12;
descStyle.ContentMarginRight = 12;
descStyle.ContentMarginTop = 10;
descStyle.ContentMarginBottom = 10;
_descWell.AddThemeStyleboxOverride("panel", descStyle);
ClearSubject();
}
/// <summary>Shows the selected character's name in the header.</summary>
public void SetSubject(string name)
{
_subject.RemoveThemeColorOverride("font_color");
_subject.AddThemeColorOverride("font_color", UiTokens.InkBright);
_subject.Text = name;
}
/// <summary>Restores the "no character selected" header and the empty state.</summary>
public void ClearSubject()
{
_subject.AddThemeColorOverride("font_color", UiTokens.Faint);
_subject.Text = Translate("battle/no_character");
_pips.Visible = false;
_apHint.Text = string.Empty;
SetDescriptionDefault();
}
/// <summary>Repaints the action-point pips for the selected character.</summary>
public void SetPoints(int current, int basePoints)
{
_pips.Visible = true;
foreach (var child in _pips.GetChildren())
{
_pips.RemoveChild(child);
child.QueueFree();
}
for (var i = 0; i < basePoints; i++)
{
var on = i < current;
var pip = new Panel
{
MouseFilter = MouseFilterEnum.Ignore,
CustomMinimumSize = new Vector2(PipSize, PipSize),
};
pip.AddThemeStyleboxOverride("panel", UiKit.Frame(
PipSize / 2,
on ? UiTokens.AccentBright : UiTokens.LineDisabled,
on ? UiTokens.Accent : UiTokens.LineFaint));
_pips.AddChild(pip);
}
_apHint.Text = current + " / " + basePoints;
}
/// <summary>Frees every action card and restores the empty state.</summary>
public void ClearBody()
{
foreach (var child in _grid.GetChildren())
{
_grid.RemoveChild(child);
child.QueueFree();
}
RefreshEmpty();
}
/// <summary>Hides the empty state as soon as the grid holds action cards.</summary>
public void RefreshEmpty()
=> _emptyLabel.Visible = _grid.GetChildCount() == 0;
/// <summary>Prints the hovered action preview ("Урон 5 · расход 1 ОД") in the well.</summary>
public void SetDescription(string text)
{
_descLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_descLabel.Text = text;
}
/// <summary>Restores the well's invitation text.</summary>
public void SetDescriptionDefault()
{
_descLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
_descLabel.Text = Translate("battle/desc_default");
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+68
View File
@@ -0,0 +1,68 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Centre-screen turn banner ("Ход игрока" / "Ход врага") from the interface
/// mockup (mockups/card_battle). The plate lives in
/// <c>scenes/battle_banner.tscn</c>; the class only repaints the caption and
/// runs the fade-in / hold / fade-out tween. Tweens are driven by the
/// process delta, so the battle speed control scales them together with the
/// action animations.
/// </summary>
public partial class BattleBanner : ModWidget
{
private const float FadeInSeconds = 0.25f;
private const float HoldSeconds = 1.0f;
private const float FadeOutSeconds = 0.3f;
private PanelContainer _plate;
private Label _label;
private Tween _tween;
/// <summary>Builds the centre-screen turn/victory banner label.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_plate = GetNode<PanelContainer>("Plate");
_label = GetNode<Label>("Plate/Label");
var style = UiKit.Frame(0, UiTokens.Alpha(UiTokens.Page, 0.62f), new Color(0f, 0f, 0f, 0f));
style.BorderWidthTop = 1;
style.BorderWidthBottom = 1;
style.BorderColor = UiTokens.AccentLine;
style.ContentMarginLeft = 44;
style.ContentMarginRight = 44;
style.ContentMarginTop = 12;
style.ContentMarginBottom = 12;
_plate.AddThemeStyleboxOverride("panel", style);
_label.AddThemeColorOverride("font_color", UiTokens.InkBright);
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
/// <summary>Pops the banner with the given caption, then fades it away.</summary>
public void Show(string text)
{
_label.Text = text;
_tween?.Kill();
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_tween = CreateTween();
_tween.TweenProperty(_plate, "modulate:a", 1f, FadeInSeconds).SetTrans(Tween.TransitionType.Cubic);
_tween.TweenInterval(HoldSeconds);
_tween.TweenProperty(_plate, "modulate:a", 0f, FadeOutSeconds).SetTrans(Tween.TransitionType.Cubic);
}
/// <summary>Hides the banner immediately (battle cleared).</summary>
public void Clear()
{
_tween?.Kill();
_plate.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
}
}
+7 -1
View File
@@ -3,8 +3,11 @@ using System.Linq;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.UI; using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot; using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.View;
namespace Cthangover.CardBattle.UI namespace Cthangover.CardBattle.UI
{ {
/// <summary> /// <summary>
@@ -47,8 +50,11 @@ namespace Cthangover.CardBattle.UI
/// </summary> /// </summary>
public event System.Action<CharacterCardNode> OnCardDead; public event System.Action<CharacterCardNode> OnCardDead;
/// <summary>Builds the card grid container rows for one battle side.</summary>
protected override void Construct() protected override void Construct()
{ {
CthangoverTheme.Apply(this);
content ??= GetNodeOrNull<Control>("Content"); content ??= GetNodeOrNull<Control>("Content");
} }
@@ -81,7 +87,7 @@ namespace Cthangover.CardBattle.UI
/// </summary> /// </summary>
public CharacterCardNode Create(Character card, bool isPlayer, float cardScale) public CharacterCardNode Create(Character card, bool isPlayer, float cardScale)
{ {
var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card_node.tscn"); var behaviour = (CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card.tscn");
behaviour.EnsureConstructed(); behaviour.EnsureConstructed();
if (content != null) if (content != null)
+87
View File
@@ -0,0 +1,87 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Battle header from the interface mockup (mockups/card_battle): a compact
/// window in the upper left with the battle title, a phase badge that flips
/// between player and enemy turns, the round counter and the number of
/// enemies still standing. The node tree lives in
/// <c>scenes/battle_hud.tscn</c>; this class only fetches the nodes and
/// repaints the badge for the current phase.
/// </summary>
public partial class BattleHud : ModWidget
{
private Label _phaseLabel;
private PanelContainer _phaseBadge;
private Label _roundValue;
private Label _enemiesValue;
/// <summary>Builds the battle HUD frame: turn indicator, speed control and log button.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_phaseBadge = GetNode<PanelContainer>("Panel/Column/TitleRow/PhaseBadge");
_phaseLabel = GetNode<Label>("Panel/Column/TitleRow/PhaseBadge/PhaseLabel");
_roundValue = GetNode<Label>("Panel/Column/MetaRow/RoundValue");
_enemiesValue = GetNode<Label>("Panel/Column/MetaRow/EnemiesValue");
var panelStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.86f), UiTokens.Line);
panelStyle.ContentMarginLeft = 18;
panelStyle.ContentMarginRight = 18;
panelStyle.ContentMarginTop = 12;
panelStyle.ContentMarginBottom = 13;
panelStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
panelStyle.ShadowSize = 18;
panelStyle.ShadowOffset = new Vector2(0f, 8f);
GetNode<PanelContainer>("Panel").AddThemeStyleboxOverride("panel", panelStyle);
var title = GetNode<Label>("Panel/Column/TitleRow/TitleLabel");
title.Text = Translate(title.Text);
title.AddThemeColorOverride("font_color", UiTokens.InkBright);
foreach (var caption in new[] { "RoundCaption", "EnemiesCaption" })
{
var label = GetNode<Label>("Panel/Column/MetaRow/" + caption);
label.Text = Translate(label.Text);
label.AddThemeColorOverride("font_color", UiTokens.Muted);
}
_phaseLabel.Text = Translate(_phaseLabel.Text);
_roundValue.AddThemeColorOverride("font_color", UiTokens.AccentBright);
_enemiesValue.AddThemeColorOverride("font_color", UiTokens.AccentBright);
}
/// <summary>
/// Repaints the phase badge: blue for the player phase, red for the
/// enemy phase — the same colour language as the targeting highlights.
/// </summary>
public void SetTurn(bool player)
{
var accent = player ? UiTokens.Info : UiTokens.TypeEnemy;
var style = UiKit.Frame(UiTokens.RadiusXs, UiTokens.BadgeFill(accent), UiTokens.BadgeLine(accent));
style.ContentMarginLeft = 7;
style.ContentMarginRight = 7;
style.ContentMarginTop = 2;
style.ContentMarginBottom = 2;
_phaseBadge.AddThemeStyleboxOverride("panel", style);
_phaseLabel.AddThemeColorOverride("font_color", accent);
_phaseLabel.Text = Translate(player ? "battle/turn_player" : "battle/turn_enemy");
}
/// <summary>Writes the round number into the meta row.</summary>
public void SetRound(int round) => _roundValue.Text = round.ToString();
/// <summary>Writes the number of enemies still alive into the meta row.</summary>
public void SetEnemiesAlive(int count) => _enemiesValue.Text = count.ToString();
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Semantic category of a battle journal row, mirrored from the mockup's
/// colour scheme (mockups/card_battle). The panel maps each kind to a
/// left border accent, a background tint and a text colour, so callers
/// only decide what happened, never how it is painted.
/// </summary>
public enum BattleLogKind
{
/// <summary>Neutral note: enemy intent, hints, odds and ends.</summary>
Info,
/// <summary>Turn or battle boundary (gold, highlighted row).</summary>
Turn,
/// <summary>Health loss (red).</summary>
Damage,
/// <summary>Shield gain or absorption (blue).</summary>
Shield,
/// <summary>Support buff (green).</summary>
Buff,
/// <summary>Status effect such as stun (warm).</summary>
Status,
/// <summary>Character defeat (faint).</summary>
Dead,
}
}
+144
View File
@@ -0,0 +1,144 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Battle journal docked between the player and action panels — the
/// "Журнал боя" window from the interface mockup (mockups/card_battle).
/// The node tree lives in <c>scenes/battle_log_panel.tscn</c>; rows are
/// created at runtime because they are data, not layout, and are capped to
/// <see cref="MaxRows"/> so a long battle cannot grow unbounded.
/// <para/>
/// The panel is the single subscriber of <see cref="BattleLogHub"/>, so any
/// battle code can publish entries without knowing about the UI. The
/// subscription is dropped on <see cref="Destruct"/> to keep the static hub
/// clean between battles.
/// </summary>
public partial class BattleLogPanel : ModWidget
{
private const int MaxRows = 80;
private Label _title;
private Label _roundLabel;
private ScrollContainer _scroll;
private VBoxContainer _rows;
/// <summary>Builds the scrollable battle log with coloured entries per <see cref="BattleLogKind"/>.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_title = GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel");
_roundLabel = GetNode<Label>("Window/Column/Header/HeaderRow/RoundLabel");
_scroll = GetNode<ScrollContainer>("Window/Column/Scroll");
_rows = GetNode<VBoxContainer>("Window/Column/Scroll/Rows");
var windowStyle = UiKit.Frame(UiTokens.RadiusLg, UiTokens.Alpha(UiTokens.Surface0, 0.84f), UiTokens.Line);
windowStyle.ContentMarginLeft = 0;
windowStyle.ContentMarginRight = 0;
windowStyle.ContentMarginTop = 0;
windowStyle.ContentMarginBottom = 0;
windowStyle.ShadowColor = new Color(0f, 0f, 0f, 0.6f);
windowStyle.ShadowSize = 18;
windowStyle.ShadowOffset = new Vector2(0f, 8f);
GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel", windowStyle);
_title.Text = Translate(_title.Text);
_title.AddThemeColorOverride("font_color", UiTokens.Muted);
_roundLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
BattleLogHub.Entry += Append;
}
/// <summary>Unsubscribes from the shared <see cref="BattleLogHub"/> so the freed panel stops receiving entries.</summary>
protected override void Destruct()
{
BattleLogHub.Entry -= Append;
}
/// <summary>Updates the "раунд N" caption in the header.</summary>
public void SetRound(int round)
=> _roundLabel.Text = string.Format(Translate("battle/log_round"), round);
/// <summary>Drops every row; called when the battle is cleared or restarted.</summary>
public void Clear()
{
foreach (var child in _rows.GetChildren())
{
_rows.RemoveChild(child);
child.QueueFree();
}
}
/// <summary>
/// Appends one colour-coded row and trims the oldest entries past the cap.
/// The scroll is pushed to the bottom so the newest event stays visible.
/// </summary>
public void Append(string text, BattleLogKind kind)
{
var style = UiKit.Frame(0, BackgroundOf(kind), new Color(0f, 0f, 0f, 0f));
style.BorderWidthLeft = 2;
style.BorderColor = BorderOf(kind);
style.ContentMarginLeft = 12;
style.ContentMarginRight = 12;
style.ContentMarginTop = 7;
style.ContentMarginBottom = 7;
var row = new PanelContainer { MouseFilter = MouseFilterEnum.Ignore };
row.AddThemeStyleboxOverride("panel", style);
var label = UiKit.Label(text, UiTokens.FontBodySmall, TextOf(kind));
label.AutowrapMode = TextServer.AutowrapMode.WordSmart;
row.AddChild(label);
_rows.AddChild(row);
while (_rows.GetChildCount() > MaxRows)
{
var first = _rows.GetChild(0);
_rows.RemoveChild(first);
first.QueueFree();
}
_scroll.ScrollVertical = (int)_scroll.GetVScrollBar().MaxValue;
}
private static Color BackgroundOf(BattleLogKind kind)
=> kind == BattleLogKind.Turn ? UiTokens.AccentSoft : new Color(0f, 0f, 0f, 0f);
private static Color BorderOf(BattleLogKind kind)
{
switch (kind)
{
case BattleLogKind.Turn: return UiTokens.Accent;
case BattleLogKind.Damage: return UiTokens.Danger;
case BattleLogKind.Shield: return UiTokens.TypeResource;
case BattleLogKind.Buff: return UiTokens.TypePassive;
case BattleLogKind.Status: return UiTokens.Cooldown;
case BattleLogKind.Dead: return UiTokens.LineFaint;
default: return UiTokens.Line;
}
}
private static Color TextOf(BattleLogKind kind)
{
switch (kind)
{
case BattleLogKind.Turn: return UiTokens.AccentBright;
case BattleLogKind.Damage: return UiTokens.DangerText;
case BattleLogKind.Shield: return UiTokens.Info;
case BattleLogKind.Buff: return UiTokens.SuccessText;
case BattleLogKind.Status: return UiTokens.Cooldown;
case BattleLogKind.Dead: return UiTokens.Faint;
default: return UiTokens.Muted;
}
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+101
View File
@@ -0,0 +1,101 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.View;
namespace Cthangover.CardBattle.UI
{
/// <summary>
/// Root of the card battle interface rebuilt from the mockup
/// (mockups/card_battle). The whole layout lives in
/// <c>scenes/card_battle_screen.tscn</c> and is composed of the mockup's
/// parts: the HUD window, the enemy and party card panels, the "Действия"
/// window, the battle journal, the turn banner and the end-turn button.
/// This class is the single assembly point: it constructs every child widget
/// once (mod widgets are not auto-constructed by Godot) and exposes them to
/// the battle core and the input controller, so neither has to walk the
/// scene tree by hand.
/// </summary>
public partial class CardBattleScreen : ModWidget
{
/// <summary>Top-left window with the phase badge, round and enemy count.</summary>
public BattleHud Hud { get; private set; }
/// <summary>Panel that manages the enemy card row(s); aligned right.</summary>
public BattleCardPanel EnemyPanel { get; private set; }
/// <summary>Panel that manages the party card row; aligned left.</summary>
public BattleCardPanel PlayerPanel { get; private set; }
/// <summary>Lower-right "Действия" window with the action card grid.</summary>
public BattleActionsPanel ActionsPanel { get; private set; }
/// <summary>Battle journal window that renders <see cref="BattleLogHub"/> entries.</summary>
public BattleLogPanel LogPanel { get; private set; }
/// <summary>Centre-screen turn banner.</summary>
public BattleBanner Banner { get; private set; }
/// <summary>End-turn button in the top-right corner, next to the battle speed bar.</summary>
public EndTurnButton EndTurnButton { get; private set; }
/// <summary>Global input handler that drives selection and drag-and-drop.</summary>
public CardController Controller { get; private set; }
/// <summary>
/// Canvas layer above the whole battle UI (layer 100). The dragged action
/// card is temporarily reparented here, so it always renders over enemy
/// and party cards regardless of their Z-index inside the panels.
/// </summary>
public CanvasLayer DragLayer { get; private set; }
/// <summary>Assembles the full card-battle screen: panels, HUD, banner, log and drag layer.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
Hud = GetNode<BattleHud>("Hud");
Hud.EnsureConstructed();
EnemyPanel = GetNode<BattleCardPanel>("EnemyPanel");
EnemyPanel.EnsureConstructed();
EnemyPanel.AlignType = "Right";
PlayerPanel = GetNode<BattleCardPanel>("PlayerPanel");
PlayerPanel.EnsureConstructed();
ActionsPanel = GetNode<BattleActionsPanel>("ActionsPanel");
ActionsPanel.EnsureConstructed();
LogPanel = GetNode<BattleLogPanel>("LogPanel");
LogPanel.EnsureConstructed();
Banner = GetNode<BattleBanner>("Banner");
Banner.EnsureConstructed();
EndTurnButton = GetNode<EndTurnButton>("EndTurnButton");
EndTurnButton.EnsureConstructed();
Controller = GetNode<CardController>("CardController");
DragLayer = GetNode<CanvasLayer>("DragLayer");
}
/// <summary>
/// Releases every widget when the battle scene is cleared: cards, action
/// deck, journal rows, banner and the end-turn button. Called by the
/// battle core on <c>OnBattleCleared</c>.
/// </summary>
public void Clear()
{
EnemyPanel.ClearAll();
PlayerPanel.ClearAll();
ActionsPanel.ClearBody();
ActionsPanel.ClearSubject();
LogPanel.Clear();
Banner.Clear();
EndTurnButton.SetVisible(false);
}
}
}
+265 -308
View File
@@ -5,177 +5,160 @@ using Cthangover.Core.Scenes;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.Core.UI; using Cthangover.Core.UI;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.UI.Theming;
using Godot; using Godot;
using Cthangover.Mods.Interface.Base;
namespace Cthangover.CardBattle.UI namespace Cthangover.CardBattle.UI
{ {
/// <summary> /// <summary>
/// Central input handler for the card battle UI. Processes mouse clicks and drags to implement /// Central input handler for the card battle UI rebuilt from the mockup
/// the full player interaction flow: click a character card to show its action cards in the /// (mockups/card_battle). Processes mouse clicks and drags: click a party
/// action panel, drag an action card onto a valid target (enemy for attacks, ally for support, /// card to open its action deck in the "Действия" window, drag an action
/// self for self-buffs), and execute the action on drop. Valid targets are determined by /// card onto a valid target (enemy for attacks, ally for support, self for
/// <see cref="CardActionStrategyFactory.Get"/> dispatching the appropriate <see cref="ICardActionStrategy"/>. /// self-buffs) and execute it on drop. Valid targets are decided by
/// Fires <see cref="OnActionExecuted"/> after each action, which <see cref="CardBattleCore"/> /// <see cref="Player.CardActionStrategyFactory"/>; after every executed
/// uses to detect automatic turn end when all points are exhausted. /// action the controller fires <see cref="OnActionExecuted"/> so the core can
/// end the turn when all action points are spent.
/// <para/>
/// Actions are instantiated from <c>scenes/action_card.tscn</c> and parented
/// into the grid of <see cref="BattleActionsPanel"/>, which owns the empty
/// state, the point pips and the effect preview well; the controller only
/// feeds it. Hit-testing uses the card root rects exposed through
/// <see cref="ICard.GetControlNode"/>.
/// </summary> /// </summary>
public partial class CardController : InputHandlerNode public partial class CardController : InputHandlerNode
{ {
private Control actionPanel; private CardBattleScreen _screen;
private BattleCardPanel[] panels; private BattleCardPanel[] _panels;
private List<CharacterCardNode> characterCards = new(); private readonly List<CharacterCardNode> _characterCards = new();
private List<ActionCardNode> actionCards = new(); private readonly List<ActionCardNode> _actionCards = new();
private CharacterCardNode selectedCharacter; private CharacterCardNode _selectedCharacter;
private CharacterCardNode targetCharacter; private CharacterCardNode _targetCharacter;
private ActionCardNode currentActionCharacter; private ActionCardNode _currentActionCard;
private bool isDragging; private bool _isDragging;
private bool dragValid; private bool _dragValid;
private float dragThreshold = 15f; private const float DragThreshold = 15f;
private Vector2 pressPosition; private Vector2 _pressPosition;
private Node _originalActionCardParent; private Node _originalActionCardParent;
/// <summary> /// <summary>
/// Locates the action panel and both battle card panels (<see cref="BattleCardPanel"/>) in the /// Uniform scale factor applied to all action cards created by this
/// scene tree relative to this node. Called once by <see cref="CardBattleCore.Init"/> /// controller. Set by the battle core from the viewport fitting
/// after the controller is added to the scene. /// calculation.
/// </summary> /// </summary>
public void FindPanels() public float CardScale { get; set; } = 1f;
{
actionPanel = GetNodeOrNull<Control>("../ActionPanel");
panels = new[]
{
GetNodeOrNull<BattleCardPanel>("../PlayerPanel"),
GetNodeOrNull<BattleCardPanel>("../EnemyPanel"),
};
}
protected override void OnInput(InputEvent @event)
{
if (@event is InputEventMouseButton mb1 && mb1.Pressed && mb1.ButtonIndex == MouseButton.Left)
GameLogger.Log("CARD_CTRL", "_Input received mouse press", LogLevel.Debug);
if (Cthangover.Core.Battle.BattleSceneContext.Instance != null &&
(Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait ||
Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed))
{
if (@event is InputEventMouseButton mb2 && !mb2.Pressed)
GameLogger.Log("CARD_CTRL",
$"Input blocked: IsWait={Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait} IsDestroyed={Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed}",
LogLevel.Debug);
return;
}
if (@event is InputEventMouseButton mouseButton)
{
if (mouseButton.ButtonIndex == MouseButton.Left)
{
if (mouseButton.Pressed)
{
pressPosition = mouseButton.GlobalPosition;
isDragging = false;
dragValid = false;
OnBeginDrag(mouseButton.GlobalPosition);
}
else
{
if (isDragging && dragValid)
OnEndDrag(mouseButton.GlobalPosition);
else
OnPointerClick(mouseButton.GlobalPosition);
isDragging = false;
dragValid = false;
}
}
}
if (@event is InputEventMouseMotion mouseMotion)
{
if (mouseMotion.ButtonMask.HasFlag(MouseButtonMask.Left))
{
if (dragValid && !isDragging && (mouseMotion.GlobalPosition - pressPosition).Length() > dragThreshold)
isDragging = true;
if (isDragging && dragValid)
OnDrag(mouseMotion.GlobalPosition);
}
}
}
private float cardScale = 1f;
/// <summary>
/// Uniform scale factor applied to all action cards created by this controller.
/// Set by <see cref="CardBattleCore.Start"/> based on viewport fitting calculations.
/// </summary>
public float CardScale
{
get => cardScale;
set => cardScale = value;
}
/// <summary> /// <summary>
/// Fired after a successful drag-and-drop action execution. Subscribed by /// Fired after a successful drag-and-drop action execution. Subscribed by
/// <see cref="CardBattleCore.CheckPlayerTurnEnd"/> to auto-end the player's turn /// the battle core to auto-end the player's turn when all points are spent.
/// when all action points are spent.
/// </summary> /// </summary>
public event System.Action OnActionExecuted; public event System.Action OnActionExecuted;
/// <summary> /// <summary>
/// Clears previous action cards and creates new <see cref="ActionCardNode"/> instances /// Binds the controller to the assembled battle screen: keeps the panels
/// for every action in <paramref name="character"/>'s deck. Action cards fade in and are /// for hit-testing and redraws and the actions panel for the deck grid and
/// laid out horizontally in the action panel. Called when the player clicks a character card. /// the hover preview. Called once by the battle core right after the
/// screen is added to the battle scene.
/// </summary>
public void Setup(CardBattleScreen screen)
{
_screen = screen;
_panels = new[] { screen.PlayerPanel, screen.EnemyPanel };
}
/// <summary>Routes mouse input into card selection and target picking for the player turn.</summary>
protected override void OnInput(InputEvent @event)
{
if (Cthangover.Core.Battle.BattleSceneContext.Instance != null &&
(Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait ||
Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed))
{
return;
}
if (@event is InputEventMouseButton mouseButton && mouseButton.ButtonIndex == MouseButton.Left)
{
if (mouseButton.Pressed)
{
_pressPosition = mouseButton.GlobalPosition;
_isDragging = false;
_dragValid = false;
OnBeginDrag(mouseButton.GlobalPosition);
}
else
{
if (_isDragging && _dragValid)
OnEndDrag(mouseButton.GlobalPosition);
else
OnPointerClick(mouseButton.GlobalPosition);
_isDragging = false;
_dragValid = false;
}
}
if (@event is InputEventMouseMotion mouseMotion &&
mouseMotion.ButtonMask.HasFlag(MouseButtonMask.Left) &&
_dragValid)
{
if (!_isDragging && (mouseMotion.GlobalPosition - _pressPosition).Length() > DragThreshold)
_isDragging = true;
if (_isDragging)
OnDrag(mouseMotion.GlobalPosition);
}
}
/// <summary>
/// Clears previous action cards and fades in a fresh deck for
/// <paramref name="character"/>: one card per battle-usable action, dimmed
/// when the character cannot pay its cost. Called when a party card is
/// clicked.
/// </summary> /// </summary>
public void ShowActions(CharacterCardNode character) public void ShowActions(CharacterCardNode character)
{ {
if (character?.Card?.Actions == null) if (character?.Card?.Actions == null)
{ {
GameLogger.Log("CARD_CTRL", GameLogger.Log("CARD_CTRL",
$"ShowActions: NULL actions for character {(character?.Card?.Name ?? "null")}", "ShowActions: NULL actions for character " + (character?.Card?.Name ?? "null"),
LogLevel.Error); LogLevel.Error);
return; return;
} }
GameLogger.Log("CARD_CTRL",
$"ShowActions: {character.Card.Name} ({character.Card.Actions.Count} actions)",
LogLevel.Debug);
DiscardActionCharacters(); DiscardActionCharacters();
var originPos = character.GlobalPosition * actionPanel.GetGlobalTransformWithCanvas().AffineInverse(); var points = character.Card.Attributes.Point.Value;
var count = character.Card.Actions.Count; foreach (var action in character.Card.Actions)
for (int i = 0; i < count; i++)
{ {
var action = character.Card.Actions[i];
if (!action.Type.UseInBattle()) if (!action.Type.UseInBattle())
continue; continue;
var cardNode = CreateAction(action, cardScale);
cardNode.Modulate = new Color(1, 1, 1, 0);
cardNode.Position = originPos - cardNode.Size / 2;
actionCards.Add(cardNode);
var targetPos = new Vector2(cardNode.Size.X * i, 0); var cardNode = CreateAction(action, CardScale);
cardNode.SetDisabled(points < ActionCharacterUi.CostOf(action));
cardNode.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
cardNode.Hovered += OnActionCardHovered;
cardNode.Unhovered += OnActionCardUnhovered;
_actionCards.Add(cardNode);
var tween = CreateTween(); var tween = CreateTween();
tween.SetParallel(true);
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(cardNode, "modulate:a", 1f, 0.5f); tween.TweenProperty(cardNode, "modulate:a", 1f, 0.25f);
tween.TweenProperty(cardNode, "position", targetPos, 0.5f);
} }
_screen.ActionsPanel.RefreshEmpty();
} }
/// <summary> /// <summary>Fades the current deck out and frees it.</summary>
/// Animates existing action cards fading out and dropping down, then frees them.
/// Called before showing new action cards for a different character.
/// </summary>
public void DiscardActionCharacters() public void DiscardActionCharacters()
{ {
if (actionCards.Count == 0) if (_actionCards.Count == 0)
return; return;
var oldCards = new List<ActionCardNode>(actionCards); var oldCards = new List<ActionCardNode>(_actionCards);
actionCards.Clear(); _actionCards.Clear();
foreach (var card in oldCards) foreach (var card in oldCards)
{ {
@@ -183,257 +166,232 @@ namespace Cthangover.CardBattle.UI
continue; continue;
card.MouseFilter = Control.MouseFilterEnum.Ignore; card.MouseFilter = Control.MouseFilterEnum.Ignore;
card.Hovered -= OnActionCardHovered;
card.Unhovered -= OnActionCardUnhovered;
var tween = CreateTween(); var tween = CreateTween();
tween.SetParallel(true);
tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Cubic); tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(card, "modulate:a", 0f, 0.15f); tween.TweenProperty(card, "modulate:a", 0f, 0.15f);
tween.TweenProperty(card, "position:y", card.Position.Y + 40f, 0.15f); tween.TweenCallback(Callable.From(card.QueueFree));
tween.Chain().TweenCallback(Callable.From(card.QueueFree));
} }
_screen.ActionsPanel.RefreshEmpty();
} }
/// <summary> /// <summary>
/// Deselects the current action card, source character, and target character, clearing /// Drops the source and target highlights, optionally resets the actions
/// their visual highlights and optionally discarding action cards. When /// window back to its empty state and redraws the panels so the Z-order
/// <paramref name="clearSource"/> is <c>true</c>, the action panel is also cleared. Triggers /// returns to normal. Called after each executed action, on empty clicks
/// a redraw of all battle panels to restore normal Z-ordering. /// and when a drag is cancelled.
/// </summary> /// </summary>
public void ClearSelections(bool clearSource = true) public void ClearSelections(bool clearSource = true)
{ {
if (currentActionCharacter != null) if (_currentActionCard != null)
{ {
currentActionCharacter.Unselect(); _currentActionCard.Unselect();
currentActionCharacter = null; _currentActionCard = null;
}
if (selectedCharacter != null && clearSource)
{
selectedCharacter.Unselect();
selectedCharacter = null;
DiscardActionCharacters();
}
if (targetCharacter != null)
{
targetCharacter.Unselect();
targetCharacter = null;
} }
if (panels != null) if (_selectedCharacter != null && clearSource)
{ {
foreach (var panel in panels) _selectedCharacter.Unselect();
_selectedCharacter = null;
DiscardActionCharacters();
_screen.ActionsPanel.ClearSubject();
}
ClearTargetMarkers();
if (_panels != null)
{
foreach (var panel in _panels)
panel?.Redraw(); panel?.Redraw();
} }
} }
/// <summary> /// <summary>
/// Immediately frees all action card nodes without animation. Called at battle cleanup /// Removes the targeting frame from the current target and forgets it.
/// by <see cref="CardBattleCore.OnBattleCleared"/>. /// Every path that changes or abandons a target must go through here:
/// dragging over the gap between cards first nulls the target, and
/// without the explicit <see cref="CharacterCardNode.Unselect"/> the
/// discarded card would keep its red or grey marker forever.
/// </summary> /// </summary>
private void ClearTargetMarkers()
{
if (_targetCharacter != null && GodotObject.IsInstanceValid(_targetCharacter))
_targetCharacter.Unselect();
_targetCharacter = null;
}
/// <summary>Immediately frees all action card nodes without animation (battle cleanup).</summary>
public void ClearActions() public void ClearActions()
{ {
if (actionCards.Count > 0) foreach (var card in _actionCards)
{ card.QueueFree();
foreach (var card in actionCards) _actionCards.Clear();
card.QueueFree();
actionCards.Clear();
}
} }
/// <summary> /// <summary>
/// Re-positions all action cards in a horizontal row within the action panel. /// Instantiates an <see cref="ActionCardNode"/> inside the actions grid
/// When <paramref name="animate"/> is <c>true</c>, cards tween smoothly to their targets. /// and fills it from the action definition.
/// </summary>
public void Redraw(bool animate = true)
{
var size = actionCards.Count;
for (int i = 0; i < size; i++)
SetActionCharacterPosition(actionCards[i], i, size, animate);
}
private void SetActionCharacterPosition(ActionCardNode card, int index, int count, bool animate)
{
var rect = card.GetControlNode();
var targetPos = new Vector2(rect.Size.X * index, 0);
if (animate)
{
var tween = CreateTween();
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
tween.TweenProperty(rect, "position", targetPos, 0.25f);
}
else
{
rect.Position = targetPos;
}
}
/// <summary>
/// Instantiates an <see cref="ActionCardNode"/>, adds it to the action panel, applies
/// the given <paramref name="cardScale"/>, and initializes it with <paramref name="actionCard"/> data.
/// Called by <see cref="ShowActions"/> for each action in a character's deck.
/// </summary> /// </summary>
public ActionCardNode CreateAction(ActionCharacter actionCard, float cardScale) public ActionCardNode CreateAction(ActionCharacter actionCard, float cardScale)
{ {
var card = (ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card_node.tscn"); var card = (ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card.tscn");
card.EnsureConstructed(); card.EnsureConstructed();
actionPanel.AddChild(card); _screen.ActionsPanel.Body.AddChild(card);
card.Scale = new Vector2(cardScale, cardScale); card.Scale = new Vector2(cardScale, cardScale);
card.Init(actionCard); card.Init(actionCard);
return card; return card;
} }
/// <summary> /// <summary>
/// Resolves the currently selected action against the selected target using the /// Resolves the currently selected action against the selected target
/// appropriate <see cref="ICardActionStrategy"/>. Called by <see cref="OnEndDrag"/> /// through the matching <see cref="Player.ICardActionStrategy"/>.
/// when a valid drop is confirmed. /// Called by <see cref="OnEndDrag"/> when a valid drop is confirmed.
/// </summary> /// </summary>
public void DoActionToTarget() public void DoActionToTarget()
{ {
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get( var strategy = Player.CardActionStrategyFactory.Get(
currentActionCharacter.Card, selectedCharacter, targetCharacter); _currentActionCard.Card, _selectedCharacter, _targetCharacter);
strategy?.Execute(selectedCharacter, targetCharacter, currentActionCharacter.Card); strategy?.Execute(_selectedCharacter, _targetCharacter, _currentActionCard.Card);
} }
private void OnActionCardHovered(ActionCardNode card)
=> _screen.ActionsPanel.SetDescription(card.DescribeFrom(_selectedCharacter?.Card));
private void OnActionCardUnhovered(ActionCardNode card)
=> _screen.ActionsPanel.SetDescriptionDefault();
private void OnPointerClick(Vector2 screenPosition) private void OnPointerClick(Vector2 screenPosition)
{ {
characterCards = GetAllCharacters(); var source = TryFindCardByPosition(screenPosition, CollectCharacters());
var source = TryFindCardByPosition(screenPosition, characterCards);
if (source != null) if (source == null || !source.IsPlayer || source.IsDead)
GameLogger.Log("CARD_CTRL",
$"Click on card: {source.Card.Name} isPlayer={source.IsPlayer} actionsCount={source.Card.Actions?.Count ?? 0}",
LogLevel.Debug);
else
GameLogger.Log("CARD_CTRL", "Click on empty space", LogLevel.Debug);
if (source != selectedCharacter && selectedCharacter != null)
selectedCharacter.Unselect();
if (source != null && source.IsPlayer)
{
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService");
audioService?.PlaySound("battle/card_show_actions", Cthangover.Core.Audio.SoundType.CardEffect);
selectedCharacter = source;
ShowActions(selectedCharacter);
selectedCharacter.Select();
}
else
{ {
ClearSelections(); ClearSelections();
return;
} }
if (source != _selectedCharacter && _selectedCharacter != null)
_selectedCharacter.Unselect();
var audioService = SceneContextNode.Instance?.GetSceneRoot<Node>("AudioService") as IAudioService;
audioService?.PlaySound("battle/card_show_actions", SoundType.CardEffect);
_selectedCharacter = source;
_screen.ActionsPanel.SetSubject(TranslationServer.Translate(source.Card.Name).ToString());
_screen.ActionsPanel.SetPoints(source.Card.Attributes.Point.Value, source.Card.Attributes.Point.BaseValue);
ShowActions(_selectedCharacter);
_selectedCharacter.Select();
} }
private void OnBeginDrag(Vector2 screenPosition) private void OnBeginDrag(Vector2 screenPosition)
{ {
var source = TryFindCardByPosition(screenPosition, actionCards); var source = TryFindCardByPosition(screenPosition, _actionCards);
if (currentActionCharacter != null && source != currentActionCharacter)
currentActionCharacter.Unselect();
if (source != null) if (source == null)
return;
if (source.IsDisabled)
{ {
dragValid = true; BattleLogHub.Log(
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService"); string.Format(Translate("battle/log_not_enough_points"), ActionCharacterUi.Translate(source.Card.Name)),
audioService?.PlaySound("battle/card_pick", 4, Cthangover.Core.Audio.SoundType.CardEffect); BattleLogKind.Status);
return;
}
GameLogger.Log("CARD_CTRL", if (_currentActionCard != null && source != _currentActionCard)
$"Begin drag: {source.Card.Name}", _currentActionCard.Unselect();
LogLevel.Debug);
currentActionCharacter = source; _dragValid = true;
var control = currentActionCharacter.GetControlNode(); var audioService = SceneContextNode.Instance?.GetSceneRoot<Node>("AudioService") as IAudioService;
_originalActionCardParent = control.GetParent(); audioService?.PlaySound("battle/card_pick", 4, SoundType.CardEffect);
var globalPos = control.GlobalPosition;
control.Reparent(GetTree().Root);
control.GlobalPosition = globalPos;
control.ZIndex = 4096;
var pickTween = CreateTween();
pickTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
pickTween.TweenProperty(control, "scale", new Vector2(1.1f, 1.1f), 0.1f);
foreach (var card in actionCards) _currentActionCard = source;
{ var control = _currentActionCard.GetControlNode();
if (card != currentActionCharacter && GodotObject.IsInstanceValid(card)) _originalActionCardParent = control.GetParent();
card.MouseFilter = Control.MouseFilterEnum.Ignore; var globalPos = control.GlobalPosition;
} // Отдельный CanvasLayer поверх всего боя: иначе карта рисуется
// под ветками панелей врагов и союзников.
control.Reparent(_screen.DragLayer);
control.GlobalPosition = globalPos;
var pickTween = CreateTween();
pickTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
pickTween.TweenProperty(control, "scale", new Vector2(1.1f, 1.1f), 0.1f);
foreach (var card in _actionCards)
{
if (card != _currentActionCard && GodotObject.IsInstanceValid(card))
card.MouseFilter = Control.MouseFilterEnum.Ignore;
} }
} }
private void OnDrag(Vector2 screenPosition) private void OnDrag(Vector2 screenPosition)
{ {
if (currentActionCharacter == null) if (_currentActionCard == null)
return; return;
var cardControl = currentActionCharacter.GetControlNode(); var cardControl = _currentActionCard.GetControlNode();
cardControl.GlobalPosition = screenPosition - cardControl.Size / 2; cardControl.GlobalPosition = screenPosition - cardControl.Size / 2;
var target = TryFindCardByRect(cardControl, characterCards); var target = TryFindCardByRect(cardControl, _characterCards);
if (target == _targetCharacter)
return;
if (target != targetCharacter && targetCharacter != null) ClearTargetMarkers();
targetCharacter.Unselect();
if (target != null) if (target == null)
{ {
targetCharacter = target; _currentActionCard.Unselect();
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get( return;
currentActionCharacter.Card, selectedCharacter, target); }
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, target); _targetCharacter = target;
var strategy = Player.CardActionStrategyFactory.Get(
_currentActionCard.Card, _selectedCharacter, target);
GameLogger.Log("CARD_CTRL", if (strategy != null && strategy.Check(_currentActionCard.Card, _selectedCharacter, target))
$"Drag over: {target.Card.Name} valid={valid}", {
LogLevel.Debug); strategy.HighlightTarget(_currentActionCard, _targetCharacter);
if (valid)
{
strategy.HighlightTarget(currentActionCharacter, targetCharacter);
}
else
{
targetCharacter.Invalid();
currentActionCharacter.Invalid();
}
} }
else else
{ {
targetCharacter = null; _targetCharacter.Invalid();
currentActionCharacter.Unselect(); _currentActionCard.Invalid();
} }
} }
private void OnEndDrag(Vector2 screenPosition) private void OnEndDrag(Vector2 screenPosition)
{ {
foreach (var card in actionCards) foreach (var card in _actionCards)
{ {
if (card != null && GodotObject.IsInstanceValid(card)) if (card != null && GodotObject.IsInstanceValid(card))
card.MouseFilter = Control.MouseFilterEnum.Stop; card.MouseFilter = Control.MouseFilterEnum.Stop;
} }
if (selectedCharacter != null && currentActionCharacter != null && targetCharacter != null) if (_selectedCharacter != null && _currentActionCard != null && _targetCharacter != null)
{ {
var strategy = Cthangover.CardBattle.Player.CardActionStrategyFactory.Get( var strategy = Player.CardActionStrategyFactory.Get(
currentActionCharacter.Card, selectedCharacter, targetCharacter); _currentActionCard.Card, _selectedCharacter, _targetCharacter);
var valid = strategy != null && strategy.Check(currentActionCharacter.Card, selectedCharacter, targetCharacter); if (strategy != null && strategy.Check(_currentActionCard.Card, _selectedCharacter, _targetCharacter))
GameLogger.Log("CARD_CTRL",
$"End drag: {currentActionCharacter.Card.Name} from {selectedCharacter.Card.Name} to {targetCharacter.Card.Name} valid={valid}",
LogLevel.Debug);
if (valid)
{ {
if (_originalActionCardParent != null) if (_originalActionCardParent != null)
{ {
currentActionCharacter.GetControlNode().Reparent(_originalActionCardParent); _currentActionCard.GetControlNode().Reparent(_originalActionCardParent);
_originalActionCardParent = null; _originalActionCardParent = null;
} }
var audioService = SceneContextNode.Instance?.GetSceneRoot<AudioService>("AudioService"); var audioService = SceneContextNode.Instance?.GetSceneRoot<Node>("AudioService") as IAudioService;
audioService?.PlaySound("battle/card_drop", 4, Cthangover.Core.Audio.SoundType.CardEffect); audioService?.PlaySound("battle/card_drop", 4, SoundType.CardEffect);
DoActionToTarget(); DoActionToTarget();
if (panels != null) if (_panels != null)
{ {
foreach (var panel in panels) foreach (var panel in _panels)
panel?.Redraw(); panel?.Redraw();
} }
@@ -443,9 +401,9 @@ namespace Cthangover.CardBattle.UI
} }
} }
if (currentActionCharacter != null) if (_currentActionCard != null)
{ {
var control = currentActionCharacter.GetControlNode(); var control = _currentActionCard.GetControlNode();
if (_originalActionCardParent != null) if (_originalActionCardParent != null)
{ {
control.Reparent(_originalActionCardParent); control.Reparent(_originalActionCardParent);
@@ -454,57 +412,56 @@ namespace Cthangover.CardBattle.UI
var resetTween = CreateTween(); var resetTween = CreateTween();
resetTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); resetTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
resetTween.TweenProperty(control, "scale", Vector2.One, 0.15f); resetTween.TweenProperty(control, "scale", Vector2.One, 0.15f);
control.ZIndex = 0;
BattleLogHub.Log(
string.Format(Translate("battle/log_invalid_target"), ActionCharacterUi.Translate(_currentActionCard.Card.Name)),
BattleLogKind.Status);
} }
Redraw();
ClearSelections(false); ClearSelections(false);
} }
private T TryFindCardByPosition<T>(Vector2 screenPosition, IList<T> cards, ICard exclude = null) where T : class, ICard private T TryFindCardByPosition<T>(Vector2 screenPosition, IList<T> cards) where T : class, ICard
{ {
if (cards == null)
return null;
foreach (var card in cards) foreach (var card in cards)
{ {
if (card == exclude || card == null) if (card == null || !GodotObject.IsInstanceValid(card.GetControlNode()))
continue; continue;
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPosition)) var rect = card.GetControlNode();
if (rect.GetGlobalRect().HasPoint(screenPosition))
return card; return card;
} }
return null; return null;
} }
private T TryFindCardByRect<T>(Control draggingRect, IList<T> cards, ICard exclude = null) where T : class, ICard private T TryFindCardByRect<T>(Control draggingRect, IList<T> cards) where T : class, ICard
{ {
Vector2 screenPoint = draggingRect.GlobalPosition + draggingRect.Size / 2; var screenPoint = draggingRect.GlobalPosition + draggingRect.Size / 2;
foreach (var card in cards) foreach (var card in cards)
{ {
if (card == exclude || card == null) if (card == null || !GodotObject.IsInstanceValid(card.GetControlNode()))
continue; continue;
if (card.Frame != null && card.Frame.GetGlobalRect().HasPoint(screenPoint)) var rect = card.GetControlNode();
if (rect.GetGlobalRect().HasPoint(screenPoint))
return card; return card;
} }
return null; return null;
} }
private List<CharacterCardNode> GetAllCharacters() private List<CharacterCardNode> CollectCharacters()
{ {
var list = new List<CharacterCardNode>(); _characterCards.Clear();
var root = GetParent(); foreach (var panel in _panels)
if (root != null) {
CollectNodes<CharacterCardNode>(root, list); if (panel == null)
return list; continue;
foreach (var card in panel.CardList)
_characterCards.Add(card);
}
return _characterCards;
} }
private static void CollectNodes<T>(Node parent, List<T> results) where T : Node private static string Translate(string key)
{ => TranslationServer.Translate(key).ToString();
foreach (Node child in parent.GetChildren())
{
if (child is T t)
results.Add(t);
CollectNodes(child, results);
}
}
} }
} }
+286 -168
View File
@@ -1,155 +1,213 @@
using Cthangover.Core.Cards; using System.Linq;
using Cthangover.Core.Mods; using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Mods.Resolvers; using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Characters; using Cthangover.Core.Characters;
using Cthangover.Core.UI; using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot; using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.CardBattle.UI namespace Cthangover.CardBattle.UI
{ {
/// <summary> /// <summary>
/// UI node representing a single character (player or enemy) on the battle field. /// Party or enemy card on the battle field, rebuilt from the interface
/// Displays the character portrait, name, health bar, and stat labels (defence S, attack A, action points P). /// mockup (mockups/card_battle): full-bleed portrait, team-coloured frame,
/// Implements <see cref="ICard"/> for hit-testing during drag-and-drop targeting. /// bottom info block with the name, four stat chips (HP/ЗАЩ/АТК/ОД) and a
/// Supports team-colored outlines (blue for player, yellow for enemy), hover scale-up animation, /// layered health bar (<see cref="UiKit.StyleShieldedHealthBar"/>:
/// and multiple selection highlight modes (<see cref="Select"/>, <see cref="Attack"/>, <see cref="Invalid"/>) /// track → health fill → blue defence segment drawn past the health edge
/// driven by the <see cref="ICardActionStrategy"/> flow. Manages Z-index layering for selected, /// while <see cref="CharacterAttributes.Defence"/> absorbs damage), plus
/// hovered, and normal states. /// stun badge, zap flash and selection frame in the upper right.
/// The node tree lives in <c>scenes/character_card.tscn</c>; this class
/// fetches the nodes once, dresses them with <see cref="UiTokens"/> styles and
/// exposes the battle-facing API: <see cref="UpdateInfo"/> is the single
/// refresh entry point for mutable stats, <see cref="Select"/> /
/// <see cref="Attack"/> / <see cref="Invalid"/> paint target feedback for the
/// drag-and-drop flow, <see cref="PlayZap"/> and <see cref="Flash"/> give
/// impact feedback, <see cref="AllImages"/> feeds the dissolve shader of
/// <see cref="CardDeathAnimation"/>.
/// <para/>
/// Implements <see cref="ICard"/> for hit-testing during drag-and-drop
/// targeting and keeps the hover lift / Z-index layering rules of the
/// original widget so overlapping cards stay readable.
/// </summary> /// </summary>
public partial class CharacterCardNode : ModWidget, ICard public partial class CharacterCardNode : ModWidget, ICard
{ {
private ColorRect teamOutline; private const float HoverLift = 10f;
private TextureRect frame; private const float HoverScale = 1.12f;
private TextureRect image;
private TextureRect selection;
private ColorRect healthRect; private TextureRect _art;
private Label nameLabel; private Panel _teamFrame;
private Label defenceLabel; private Label _nameLabel;
private Label attackLabel; private ProgressBar _shieldBar;
private Label pointsLabel; private ProgressBar _hpBar;
private Label _hpValue;
private Label _defValue;
private Label _atkValue;
private Label _apValue;
private VBoxContainer _status;
private PanelContainer _stunBadge;
private TextureRect _stunIcon;
private Label _stunLabel;
private TextureRect _zap;
private Panel _fx;
private TextureRect _selection;
private Color selectionState; private Color _selectionState = Colors.Transparent;
private float defaultWidth; private float _baseY;
private float baseY; private bool _isHovered;
private bool isHovered; private bool _selected;
private Tween hoverTween; private int _baseZIndex;
private Tween selectionTween; private Vector2 _baseScale = Vector2.One;
private static Texture2D cachedSelectTex; private Tween _hoverTween;
private static bool selectTexLoaded; private Tween _selectionTween;
private static Texture2D _cachedSelectTex;
private static bool _texturesLoaded;
/// <summary> /// <summary>
/// Distinguishes player-owned cards from enemy cards. Affects team outline color in /// Distinguishes player-owned cards from enemy cards. Must be assigned by
/// <see cref="SetTeamColors"/> and determines whether the card is clickable in /// <see cref="BattleCardPanel"/> before <see cref="Init"/>, because the
/// <see cref="CardController.OnPointerClick"/>. /// team frame colour is applied during initialisation.
/// </summary> /// </summary>
public bool IsPlayer { get; set; } public bool IsPlayer { get; set; }
/// <summary> /// <summary>
/// Marks the card as dead. Set by <see cref="BattleCardPanel.Dead"/>, checked by /// Marks the card as dead. Set by <see cref="BattleCardPanel.Dead"/>,
/// <see cref="CardBattleCore"/> when counting alive cards and by /// checked by the battle core when counting alive cards and when the
/// <see cref="CardBattleCore.RunEnemyTurn"/> when iterating for enemy actions. /// enemy AI picks targets.
/// </summary> /// </summary>
public bool IsDead { get; set; } public bool IsDead { get; set; }
private int _baseZIndex; /// <summary>The character data model backing this card: stats, action deck, status effects.</summary>
private bool _selected;
private Vector2 _baseScale;
/// <inheritdoc />
public TextureRect Frame => frame;
/// <inheritdoc />
public TextureRect Image => image;
/// <summary>
/// All <see cref="TextureRect"/> nodes on this card (frame, image, selection).
/// Consumed by <see cref="CardDeathAnimation"/> to apply the dissolve shader to every visual element.
/// </summary>
public TextureRect[] AllImages => _allImages ??= new[] { frame, image, selection };
private TextureRect[] _allImages;
/// <summary>
/// The <see cref="Character"/> data model backing this card. Contains attributes
/// (health, attack, defence, points) and the action deck.
/// </summary>
public Character Card { get; set; } public Character Card { get; set; }
/// <summary> /// <summary>
/// Returns <c>this</c> — character cards are self-contained <see cref="Control"/> nodes. /// Textures that receive the dissolve shader during the death animation:
/// the portrait and the selection frame. The team frame and the info
/// block are simple styleboxes and fade with the whole card node.
/// </summary> /// </summary>
public TextureRect[] AllImages => _allImages ??= new[] { _art, _selection };
private TextureRect[] _allImages;
/// <inheritdoc />
public Control GetControlNode() => this; public Control GetControlNode() => this;
/// <summary>Builds the character card: portrait, art frame, selection overlay and stat labels.</summary>
protected override void Construct() protected override void Construct()
{ {
teamOutline = GetNode<ColorRect>("TeamOutline"); CthangoverTheme.Apply(this);
image = GetNode<TextureRect>("Image");
frame = GetNode<TextureRect>("Frame"); _art = GetNode<TextureRect>("Art");
selection = GetNode<TextureRect>("Selection"); _teamFrame = GetNode<Panel>("TeamFrame");
nameLabel = GetNode<Label>("InfoPanel/NameLabel"); _nameLabel = GetNode<Label>("Info/Column/NameLabel");
healthRect = GetNode<ColorRect>("InfoPanel/HealthBar/HealthRect"); _shieldBar = GetNode<ProgressBar>("Info/Column/HpBarStack/ShieldBar");
defenceLabel = GetNode<Label>("InfoPanel/Labels/DefenceLabel"); _hpBar = GetNode<ProgressBar>("Info/Column/HpBarStack/HpBar");
attackLabel = GetNode<Label>("InfoPanel/Labels/AttackLabel"); _hpValue = GetNode<Label>("Info/Column/Stats/HpChip/Row/Value");
pointsLabel = GetNode<Label>("InfoPanel/Labels/PointsLabel"); _defValue = GetNode<Label>("Info/Column/Stats/DefChip/Row/Value");
_atkValue = GetNode<Label>("Info/Column/Stats/AtkChip/Row/Value");
_apValue = GetNode<Label>("Info/Column/Stats/ApChip/Row/Value");
_status = GetNode<VBoxContainer>("Status");
_stunBadge = GetNode<PanelContainer>("Status/StunBadge");
_stunIcon = GetNode<TextureRect>("Status/StunBadge/Row/StunIcon");
_stunLabel = GetNode<Label>("Status/StunBadge/Row/StunLabel");
_zap = GetNode<TextureRect>("Zap");
_fx = GetNode<Panel>("Fx");
_selection = GetNode<TextureRect>("Selection");
Size = CustomMinimumSize; Size = CustomMinimumSize;
_baseScale = Scale;
defaultWidth = 230f; if (!_texturesLoaded)
selectionState = new Color(0, 0, 0, 0); {
_cachedSelectTex = Textures.Resolve("select");
_texturesLoaded = true;
}
if (!selectTexLoaded) if (_cachedSelectTex != null)
{ _selection.Texture = _cachedSelectTex;
cachedSelectTex = Textures.Resolve("select");
selectTexLoaded = true; ApplyStyles();
} _selectionState = UiTokens.Alpha(UiTokens.White, 0f);
if (cachedSelectTex != null)
{
selection.Texture = cachedSelectTex;
selection.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
selection.StretchMode = TextureRect.StretchModeEnum.Scale;
}
MouseEntered += OnMouseEntered; MouseEntered += OnMouseEntered;
MouseExited += OnMouseExited; MouseExited += OnMouseExited;
} }
/// <summary> /// <summary>
/// Initializes the card with the given <paramref name="characterCard"/> data, sets the /// Binds the card to a character: sets the portrait, resets selection
/// portrait and frame textures, resets selection visuals, and refreshes stat labels. /// visuals and refreshes every stat label through <see cref="UpdateInfo"/>.
/// Called by <see cref="BattleCardPanel.Create"/>. /// Called by <see cref="BattleCardPanel.Create"/>.
/// </summary> /// </summary>
public void Init(Character characterCard) public void Init(Character characterCard)
{ {
Card = characterCard; Card = characterCard;
_baseScale = Scale; _baseScale = Scale;
IsDead = false;
if (image != null) if (_art != null && characterCard?.Image != null)
image.Texture = characterCard.Image; _art.Texture = characterCard.Image;
if (frame != null && characterCard?.Image != null) if (_selection != null)
frame.Texture = characterCard.Image; _selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
if (selection != null) if (_fx != null)
selection.Modulate = new Color(0, 0, 0, 0); _fx.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
if (_zap != null)
selectionState = new Color(0, 0, 0, 0); _zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_selectionState = UiTokens.Alpha(UiTokens.White, 0f);
UpdateInfo(); UpdateInfo();
} }
/// <summary> /// <summary>
/// Applies a team-colored outline: blue for player cards (<see cref="IsPlayer"/> = <c>true</c>), /// Paints the team frame: blue for the party, gold for enemies — the
/// yellow for enemy cards. Called by <see cref="BattleCardPanel.Create"/> after initialization. /// same accent language the mockup uses for card outlines. Called by
/// <see cref="BattleCardPanel.Create"/> after <see cref="Init"/>.
/// </summary> /// </summary>
public void SetTeamColors() public void SetTeamColors()
{ {
if (teamOutline != null) var accent = IsPlayer ? UiTokens.Info : UiTokens.Accent;
teamOutline.Color = IsPlayer var style = UiKit.Frame(UiTokens.RadiusMd, new Color(0f, 0f, 0f, 0f), UiTokens.Alpha(accent, 0.5f), 2);
? new Color(0.2f, 0.4f, 1f, 0.5f) _teamFrame.AddThemeStyleboxOverride("panel", style);
: new Color(1f, 0.8f, 0.1f, 0.5f);
} }
/// <summary> /// <summary>
/// Sets the base Z-index used for layering cards. When not selected, the card renders at /// Refreshes every mutable value from the character model: name, layered
/// this Z-index. Selected cards jump to Z-index 4096 to render above all others. /// HP/defence bar and HP number (health turning red below 40%, blue
/// Called by <see cref="BattleCardPanel.SetCardPosition"/> during layout. /// defence segment drawn past the health edge), defence, attack, action
/// points and the stun badge with its remaining turns. Called after each
/// action, on turn start, and by the panel after re-gridding.
/// </summary>
public void UpdateInfo()
{
if (Card?.Attributes == null)
return;
_nameLabel.Text = TranslationServer.Translate(Card.Name ?? string.Empty).ToString();
var health = Card.Attributes.Health;
var percent = Mathf.Clamp(health.Percent, 0f, 1f);
_hpBar.Value = percent * 100f;
_hpBar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusSm, percent < 0.4f ? UiTokens.HpLow : UiTokens.HpOk));
// Blue defence segment on top of the health fill; collapses onto
// the health edge when the shield is gone.
UiKit.UpdateShieldedHealthBar(
_shieldBar, percent, Card.Attributes.Defence.Value, health.BaseValue);
_hpValue.Text = health.Value + "/" + health.BaseValue;
_defValue.Text = Card.Attributes.Defence.Value.ToString();
_atkValue.Text = Card.Attributes.Attack.Value.ToString();
_apValue.Text = Card.Attributes.Point.Value.ToString();
RefreshStun();
}
/// <summary>
/// Sets the base Z-index used for layering cards. Selected cards jump to
/// Z-index 4096 in <see cref="Select"/>; hovered cards rise by 100 so
/// they cover their neighbours.
/// </summary> /// </summary>
public void SetBaseZIndex(int z) public void SetBaseZIndex(int z)
{ {
@@ -158,120 +216,180 @@ namespace Cthangover.CardBattle.UI
ZIndex = z; ZIndex = z;
} }
/// <summary> /// <summary>Highlights the card as the chosen source or a valid support target (teal).</summary>
/// Applies a green selection highlight, raises Z-index to 4096 to ensure the card
/// is clickable above other cards during drag-and-drop targeting, and animates
/// the selection overlay to green. Called when the player clicks a character card
/// or when a valid support/self target is dragged over.
/// </summary>
public void Select() public void Select()
{ {
_selected = true; _selected = true;
selectionState = new Color(0, 1, 0, 1); _selectionState = UiTokens.TypeTarget;
AnimateSelection(selectionState); AnimateSelection(_selectionState);
if (selection != null) selection.ZIndex = 1;
ZIndex = 4096; ZIndex = 4096;
} }
/// <summary> /// <summary>Clears the selection highlight and restores the base Z-index.</summary>
/// Clears the selection highlight, restores Z-index to <see cref="SetBaseZIndex"/> value,
/// and fades the selection overlay to transparent.
/// </summary>
public void Unselect() public void Unselect()
{ {
_selected = false; _selected = false;
selectionState = new Color(0, 0, 0, 0); _selectionState = UiTokens.Alpha(UiTokens.White, 0f);
AnimateSelection(selectionState); AnimateSelection(_selectionState);
if (selection != null) selection.ZIndex = 0;
ZIndex = _baseZIndex; ZIndex = _baseZIndex;
} }
/// <summary> /// <summary>Highlights a valid enemy target for an attack (red).</summary>
/// Applies a red highlight indicating this card is being targeted for an attack.
/// Called by <see cref="ICardActionStrategy.HighlightTarget"/> during drag-over.
/// </summary>
public void Attack() public void Attack()
{ {
selectionState = new Color(1, 0, 0, 1); _selectionState = UiTokens.TypeEnemy;
AnimateSelection(selectionState); AnimateSelection(_selectionState);
if (selection != null) selection.ZIndex = 1;
} }
/// <summary> /// <summary>Grays out the card as an invalid target for the dragged action.</summary>
/// Applies a gray dimming highlight indicating this card is an invalid target
/// for the currently dragged action card. Called by <see cref="CardController.OnDrag"/>
/// when the strategy's <c>Check</c> returns <c>false</c>.
/// </summary>
public void Invalid() public void Invalid()
{ {
selectionState = new Color(0.4f, 0.4f, 0.4f, 0.8f); _selectionState = UiTokens.Alpha(UiTokens.Muted, 0.8f);
AnimateSelection(selectionState); AnimateSelection(_selectionState);
if (selection != null) selection.ZIndex = 1; }
/// <summary>Plays the electric stun flash over the portrait (additive blend).</summary>
public void PlayZap()
{
if (_zap == null)
return;
_zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
var tween = CreateTween();
tween.TweenProperty(_zap, "modulate:a", 1f, 0.12f);
tween.TweenProperty(_zap, "modulate:a", 0f, 0.45f).SetDelay(0.25f);
}
/// <summary>Pulses the whole card with a colour (damage red, shield blue).</summary>
public void Flash(Color color)
{
_fx.Modulate = UiTokens.Alpha(color, 0f);
var tween = CreateTween();
tween.TweenProperty(_fx, "modulate:a", 0.55f, 0.12f);
tween.TweenProperty(_fx, "modulate:a", 0f, 0.38f);
}
private void ApplyStyles()
{
var infoStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Alpha(UiTokens.Surface2, 0.93f), UiTokens.Line);
infoStyle.CornerRadiusTopLeft = 0;
infoStyle.CornerRadiusTopRight = 0;
infoStyle.ContentMarginLeft = 8;
infoStyle.ContentMarginRight = 8;
infoStyle.ContentMarginTop = 6;
infoStyle.ContentMarginBottom = 8;
GetNode<PanelContainer>("Info").AddThemeStyleboxOverride("panel", infoStyle);
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
// Layered pie: the shield bar owns the track and the blue defence
// fill; the health bar paints only its fill on top of it.
UiKit.StyleShieldedHealthBar(_shieldBar, _hpBar);
StyleChip("Info/Column/Stats/HpChip", UiTokens.HpText);
StyleChip("Info/Column/Stats/DefChip", UiTokens.Ink);
StyleChip("Info/Column/Stats/AtkChip", UiTokens.Ink);
StyleChip("Info/Column/Stats/ApChip", UiTokens.AccentBright);
var stunStyle = UiKit.Frame(UiTokens.RadiusXs, UiTokens.CooldownSoft, UiTokens.CooldownLine);
stunStyle.ContentMarginLeft = 5;
stunStyle.ContentMarginRight = 8;
stunStyle.ContentMarginTop = 3;
stunStyle.ContentMarginBottom = 3;
_stunBadge.AddThemeStyleboxOverride("panel", stunStyle);
_stunLabel.AddThemeColorOverride("font_color", UiTokens.Cooldown);
_zap.Material = new CanvasItemMaterial { BlendMode = CanvasItemMaterial.BlendModeEnum.Add };
_zap.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_fx.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
private void StyleChip(string path, Color valueColor)
{
var chip = GetNode<PanelContainer>(path);
var style = UiKit.Frame(UiTokens.RadiusXs, UiTokens.StatBg, UiTokens.StatLine);
style.ContentMarginLeft = 6;
style.ContentMarginRight = 6;
style.ContentMarginTop = 2;
style.ContentMarginBottom = 2;
chip.AddThemeStyleboxOverride("panel", style);
var row = chip.GetNode<HBoxContainer>("Row");
var key = row.GetNode<Label>("Key");
key.Text = TranslationServer.Translate(key.Text).ToString();
key.AddThemeColorOverride("font_color", UiTokens.Muted);
row.GetNode<Label>("Value").AddThemeColorOverride("font_color", valueColor);
}
private void RefreshStun()
{
var stun = StatusEffectService.Of(Card)?.ActiveStatusEffects?
.FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
if (stun == null)
{
_stunBadge.Visible = false;
return;
}
_stunBadge.Visible = true;
if (stun.Icon != null)
{
// Текстура приходит из данных эффекта: группа "characters" не
// входит в texture_groups, поэтому Textures.Resolve("stun") её
// не видит, а StatusEffectInfo.Icon грузится через TextureUtils.
_stunIcon.Texture = stun.Icon;
_zap.Texture = stun.Icon;
}
_stunLabel.Text = string.Format(
TranslationServer.Translate("battle/stun_status").ToString(), stun.RemainingTurns);
} }
private void AnimateSelection(Color target) private void AnimateSelection(Color target)
{ {
if (selection == null) return; if (_selection == null)
selectionTween?.Kill(); return;
selectionTween = CreateTween();
selectionTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
selectionTween.TweenProperty(selection, "modulate", target, 0.15f);
}
/// <summary> _selectionTween?.Kill();
/// Refreshes all displayed stats from the underlying <see cref="Character"/> data: _selectionTween = CreateTween();
/// name, defence, attack, action points, and health bar width (based on health percent). _selectionTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
/// Called after every action execution and during initial setup. _selectionTween.TweenProperty(_selection, "modulate", target, 0.15f);
/// </summary>
public void UpdateInfo()
{
if (healthRect == null || Card == null) return;
if (nameLabel != null)
nameLabel.Text = TranslationServer.Translate(Card.Name);
if (defenceLabel != null)
defenceLabel.Text = Card.Attributes.Defence.Value.ToString();
if (attackLabel != null)
attackLabel.Text = Card.Attributes.Attack.Value.ToString();
if (pointsLabel != null)
pointsLabel.Text = Card.Attributes.Point.Value.ToString();
var sizeDelta = healthRect.Size;
sizeDelta.X = Card.Attributes.Health.Percent * defaultWidth;
healthRect.SetDeferred(Control.PropertyName.Size, sizeDelta);
} }
private void OnMouseEntered() private void OnMouseEntered()
{ {
if (isHovered) return; if (_isHovered)
isHovered = true; return;
baseY = Position.Y; _isHovered = true;
_baseY = Position.Y;
if (!_selected) if (!_selected)
ZIndex = _baseZIndex + 100; ZIndex = _baseZIndex + 100;
hoverTween?.Kill(); _hoverTween?.Kill();
hoverTween = CreateTween(); _hoverTween = CreateTween();
hoverTween.SetParallel(true); _hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); _hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY - 10f, 0.15f); _hoverTween.TweenProperty(this, "position:y", _baseY - HoverLift, 0.15f);
hoverTween.TweenProperty(this, "scale", _baseScale * 1.15f, 0.15f); _hoverTween.TweenProperty(this, "scale", _baseScale * HoverScale, 0.15f);
} }
private void OnMouseExited() private void OnMouseExited()
{ {
if (!isHovered) return; if (!_isHovered)
isHovered = false; return;
_isHovered = false;
if (!_selected) if (!_selected)
ZIndex = _baseZIndex; ZIndex = _baseZIndex;
hoverTween?.Kill(); _hoverTween?.Kill();
hoverTween = CreateTween(); _hoverTween = CreateTween();
hoverTween.SetParallel(true); _hoverTween.SetParallel(true);
hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); _hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
hoverTween.TweenProperty(this, "position:y", baseY, 0.15f); _hoverTween.TweenProperty(this, "position:y", _baseY, 0.15f);
hoverTween.TweenProperty(this, "scale", _baseScale, 0.15f); _hoverTween.TweenProperty(this, "scale", _baseScale, 0.15f);
} }
} }
} }
+9 -2
View File
@@ -1,6 +1,8 @@
using Cthangover.Core.UI; using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot; using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.CardBattle.UI namespace Cthangover.CardBattle.UI
{ {
/// <summary> /// <summary>
@@ -18,16 +20,21 @@ namespace Cthangover.CardBattle.UI
/// </summary> /// </summary>
public event System.Action OnPressed; public event System.Action OnPressed;
/// <summary>Builds the end-turn button and binds its pressed event.</summary>
protected override void Construct() protected override void Construct()
{ {
CthangoverTheme.Apply(this);
_button = GetNode<Button>("BtnEndTurn"); _button = GetNode<Button>("BtnEndTurn");
CthangoverTheme.ApplyPrimary(_button);
Size = CustomMinimumSize; Size = CustomMinimumSize;
_button.Text = TranslationServer.Translate("battle/end_turn"); _button.Pressed += RaisePressed;
_button.Pressed += () => OnPressed?.Invoke();
} }
private void RaisePressed() => OnPressed?.Invoke();
/// <summary> /// <summary>
/// Shows or hides the underlying <see cref="Button"/> control. /// Shows or hides the underlying <see cref="Button"/> control.
/// Called by <see cref="CardBattleCore"/> during turn transitions and battle cleanup. /// Called by <see cref="CardBattleCore"/> during turn transitions and battle cleanup.