Compare commits

...
15 Commits
Author SHA1 Message Date
cthangover e1819339b0 Add mod documentation in seven languages 2026-10-29 09:25:56 +01:00
iisaev 0e46f9697e gitignore file 2026-10-28 15:20:05 +03:00
cthangover cd9c05afb3 Record defeated enemy rewards before victory checks 2026-10-27 01:07:30 +01:00
cthangoverandClaude Opus 5.5 f1572374f5 Battle HUD: extra unit gauges (MP and others from BattleUnitGauges) under the HP bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:01:06 +02:00
cthangover 4e38e5c936 Localization in es, zh, ja, ko, vi 2026-10-23 20:54:38 +02:00
cthangover c6063cb9e3 Mod cover 2026-10-22 17:09:44 +02:00
cthangover ad57e6e634 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:42 +02:00
cthangover 18616852e1 Commit pending working-tree changes (local edits, line endings) 2026-10-21 12:31:54 +02:00
cthangover 6f61990b71 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:26 +02:00
cthangover 704b69d93d Drop the unneeded visual_novel_core dependency
ff_battle needs only the battle platform and the interface; depending on the content mod pulled the whole novel into every game that uses this core.
2026-10-17 14:27:51 +00:00
iisaevandClaude Opus 5.5 00aa70e302 upd: sync local changes
Fix enemy turn hanging when a lethal hit frees the target widget:
UpdateInfo skips freed widgets, animations finish early and restore
the attacker only while it is alive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:18:14 +00:00
cthangover ba2c08b1dc new battle gui 2026-09-28 10:48:06 +03:00
cthangover 86e3de904e ui stule update 2026-09-26 08:05:13 +03:00
cthangover 28046f901e upd 2026-09-24 11:17:03 +03:00
cthangover 68f763510a move ui to mods 2026-09-24 10:42:43 +03:00
64 changed files with 2602 additions and 158 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/
obj/
bin/
art_sources/
*.tres~
*.tscn~
+9
View File
@@ -0,0 +1,9 @@
{
"options": [
{
"key": "battle.core",
"value": "ff_battle",
"name": "collection_settings/ff"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+56
View File
@@ -0,0 +1,56 @@
# FF Battle — battles with a command menu
FF Battle is a battle system in the style of classic console RPGs: the party at the bottom left, the enemies at the top, and a command menu — Attack, Items, Defend, Escape. It is the battle system of RPG Plus. The battle itself — its start, backdrop, victory and defeat screens, experience and loot — comes from Turn Battle Base; FF Battle is the way the player fights.
## The battle screen
The RPG Plus warrior meets a wolf in the village:
![The FF battle screen: the wolf's card at the top (31/31, ATK 7, DEF 2), the hero's small card at the bottom left (42/42, MP 6/6, DEF 4, one yellow action point), the Battle panel at the top left (Round 1, Enemies 1, Party 1), the speed buttons and the battle log on the right.](assets/ff-start.jpg)
- **Cards.** Each character shows health, the attack and defence (DEF is a shield that absorbs damage first), mana if the game has it, and the yellow dots of action points (AP).
- **Battle panel** — the round, how many enemies and party members are left, and whose turn it is.
- **Battle log** — every action and its result, newest at the bottom.
## A turn
1. **Pick a character** — click the card, or press **Enter**/**Space** for the first one with action points.
2. **Choose a command** with the mouse or **W/S**/arrows and **Enter**:
- **Attack** — the character's battle actions;
- **Items** — usable items from the inventory, 1 AP each;
- **Defend** — the character's defence action on itself;
- **Escape** — 70% chance; on failure the character loses 1 AP.
3. **Choose the action.** The panel next to the menu shows the action card and what it will do:
![The Hero's actions menu — Basic attack (1P), Defence (1P), Power strike (1P), Back — and the Basic attack card with the hint "Damage 12 (ATK 12 × 1,0) · cost 1 AP".](assets/ff-actions.jpg)
4. **Click the target.** "Select a target · RMB / Esc — cancel" appears and the enemy cards light up:
![The wolf's card highlighted in red and the hint "Select a target · RMB / Esc — cancel".](assets/ff-target.jpg)
When the party has spent its action points, the enemies take their turn, then a new round starts and the points are restored.
## What the log says
After one basic attack and the wolf's answer:
![The battle log: Hero: Basic attack → Wolf; Wolf's shield absorbed 2 · left 0; Basic attack: Hero → Wolf · -10 HP; The party is out of AP; Enemy turn · round 1; Wolf: Defence.](assets/ff-log.jpg)
The hero's attack is 12. The wolf's shield of 2 took the first 2 points and was used up, so the wolf lost 10 health: 31 → 21. The wolf then used Defence and raised its shield to 4 for the next blow.
## Actions
The menu lists the actions the character has (see Characters Engine). FF Battle itself carries out the basic kinds:
- `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;
- item use.
Actions written as formulas, such as Classic RPG Rules' Power strike or Fireball, work through Battle Formulas; mana costs (`Cost=mp:4`) are spent from the character's mana.
## If something goes wrong
1. **The menu does not open.** The character has no action points left this round, or it is the enemies' turn.
2. **Items is greyed out.** There are no items in the inventory that can be used in battle.
3. **A skill does nothing.** Its values need Battle Formulas, or it is not marked as usable in battle.
+56
View File
@@ -0,0 +1,56 @@
# FF Battle — combates con menú de comandos
FF Battle es un sistema de combate al estilo de las consolas RPG clásicas: el grupo abajo a la izquierda, los enemigos arriba y un menú de comandos — Attack, Items, Defend, Escape. Es el sistema de combate de RPG Plus. 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; FF Battle es la manera de luchar del jugador.
## La pantalla de combate
El guerrero de RPG Plus se encuentra con un lobo en la aldea:
![La pantalla de combate de FF Battle: la carta del lobo arriba (31/31, ATK 7, DEF 2), la carta pequeña del héroe abajo a la izquierda (42/42, MP 6/6, DEF 4, un punto de acción amarillo), el panel Battle arriba a la izquierda (Round 1, Enemies 1, Party 1), y a la derecha los botones de velocidad y el registro de combate.](assets/ff-start.jpg)
- **Las cartas.** Cada personaje muestra la salud, el ataque y la defensa (DEF es un escudo que absorbe el daño primero), el maná si el juego lo tiene, y los puntos amarillos de acción (AP).
- **El panel Battle** — la ronda, cuántos enemigos y miembros del grupo quedan, y de quién es el turno.
- **El registro de combate** — cada acción y su resultado, lo más reciente abajo.
## Un turno
1. **Elige un personaje** — haz clic en su carta o pulsa **Enter**/**Space** para tomar al primero que tenga puntos de acción.
2. **Elige un comando** con el ratón o con **W/S**/las flechas y **Enter**:
- **Attack** — las acciones de combate del personaje;
- **Items** — objetos usables del inventario, 1 AP cada uno;
- **Defend** — la acción de defensa del personaje sobre sí mismo;
- **Escape** — 70% de probabilidad; si falla, el personaje pierde 1 AP.
3. **Elige la acción.** El panel junto al menú muestra la carta de acción y lo que hará:
![El menú de acciones del héroe — Basic attack (1P), Defence (1P), Power strike (1P), Back — y la carta de Basic attack con la pista «Daño 12 (ATK 12 × 1,0) · coste 1 AP».](assets/ff-actions.jpg)
4. **Haz clic en el objetivo.** Aparece «Selecciona un objetivo · RMB / Esc — cancelar» y las cartas enemigas se iluminan:
![La carta del lobo resaltada en rojo y la pista «Selecciona un objetivo · RMB / Esc — cancelar».](assets/ff-target.jpg)
Cuando el grupo gasta sus puntos de acción, los enemigos hacen su turno; después empieza una ronda nueva y los puntos se restauran.
## Lo que dice el registro
Tras un ataque básico y la respuesta del lobo:
![El registro de combate: Hero: Basic attack → Wolf; Wolf's shield absorbed 2 · left 0; Basic attack: Hero → Wolf · -10 HP; The party is out of AP; Enemy turn · round 1; Wolf: Defence.](assets/ff-log.jpg)
El ataque del héroe es 12. El escudo del lobo (2) absorbió los primeros 2 puntos y se agotó, así que el lobo perdió 10 de salud: 31 → 21. Después el lobo usó Defence y subió su escudo a 4 para el próximo golpe.
## Las acciones
El menú enumera las acciones que tiene el personaje (véase Characters Engine). Los tipos básicos los ejecuta el propio 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;
- el uso de objetos.
Las acciones escritas como fórmulas, como el Power strike o el Fireball de Classic RPG Rules, funcionan mediante Battle Formulas; los costes de maná (`Cost=mp:4`) se pagan con el maná del personaje.
## Si algo sale mal
1. **El menú no se abre.** El personaje se quedó sin puntos de acción en esta ronda, o es el turno de los enemigos.
2. **Items aparece en gris.** No hay objetos en el inventario que se puedan usar en combate.
3. **Una habilidad no hace nada.** Sus valores necesitan Battle Formulas, o no está marcada como usable en combate.
+56
View File
@@ -0,0 +1,56 @@
# FF Battle —— コマンドメニューで戦う戦闘
FF Battle は、古典的なコンソールRPG風のバトルシステムです。左下にパーティ、上に敵、そして Attack・Items・Defend・Escape のコマンドメニュー。RPG Plus のバトルシステムです。戦闘そのもの——開始、背景、勝利・敗北の画面、経験値と戦利品——は Turn Battle Base が担い、FF Battle はプレイヤーの戦い方を担当します。
## 戦闘画面
RPG Plus の戦士が村で狼に出くわします:
![FF Battle の戦闘画面:上に狼のカード(31/31、ATK 7、DEF 2)、左下に英雄の小さなカード(42/42、MP 6/6、DEF 4、黄色い行動ポイントが1つ)、左上に Battle パネル(Round 1、Enemies 1、Party 1)、右側に速度ボタンと戦闘ログ。](assets/ff-start.jpg)
- **カード。** キャラクターごとに、体力、攻撃と防御(DEF はダメージを最初に受け止めるシールド)、ゲームにあればマナ、そして行動ポイント(AP)の黄色い点が表示されます。
- **Battle パネル**——ラウンド数、残りの敵とパーティメンバーの数、そして今誰のターンかを示します。
- **戦闘ログ**——すべてのアクションとその結果を記録します。新しいものが下に追加されます。
## ターンの流れ
1. **キャラクターを選びます**——カードをクリックするか、**Enter**/**Space** で行動ポイントのある最初のキャラクターを選べます。
2. **コマンドを選びます**——マウス、または **W/S**/方向キーと **Enter** で:
- **Attack**——キャラクターの戦闘アクション;
- **Items**——インベントリから使えるアイテム。それぞれ 1 AP;
- **Defend**——自分自身への防御アクション;
- **Escape**——成功率 70%。失敗するとキャラクターは 1 AP 失います。
3. **アクションを選びます。** メニューの横のパネルにアクションカードとその効果が表示されます:
![英雄のアクションメニュー——Basic attack(1P)、Defence(1P)、Power strike(1P)、Back——と、ヒント付きの Basic attack カード「ダメージ 12(ATK 12 × 1,0)· 消費 1 AP」。](assets/ff-actions.jpg)
4. **ターゲットをクリックします。**「対象を選択 · 右クリック / Esc — キャンセル」というヒントが出て、敵のカードが光ります:
![赤く光った狼のカードとヒント「対象を選択 · 右クリック / Esc — キャンセル」。](assets/ff-target.jpg)
パーティが行動ポイントを使い切ると敵のターンになり、その後新しいラウンドが始まってポイントが回復します。
## ログの読み方
通常攻撃を一度と、それに対する狼の応答のあと:
![戦闘ログ:Hero: Basic attack → Wolf/Wolf's shield absorbed 2 · left 0/Basic attack: Hero → Wolf · -10 HP/The party is out of AP/Enemy turn · round 1/Wolf: Defence.](assets/ff-log.jpg)
英雄の攻撃は 12。狼のシールド(2)が最初の 2 点を受け止めて使い切ったため、狼の体力は 10 減って 31 → 21。その後狼は Defence を使い、次の一撃に備えてシールドを 4 まで上げます。
## アクション
メニューには、そのキャラクターが持つアクションが並びます(Characters Engine を参照)。基本の種類そのものは FF Battle が処理します:
- `Attack=1.5`——攻撃ダメージに数値を掛けたもの。まず対象のシールドが受け止めます;
- `Defence=4`——味方にシールドを付与;
- `Turn=2`——そのターン数だけスタン;
- アイテムの使用。
数式で書かれたアクション(Classic RPG Rules の Power strike や Fireball など)は Battle Formulas を通して機能します。マナのコスト(`Cost=mp:4`)はキャラクターのマナから支払われます。
## うまくいかないときは
1. **メニューが開かない。** そのキャラクターの行動ポイントがこのラウンドでもうないか、敵のターンです。
2. **Items がグレーになっている。** インベントリに戦闘で使えるアイテムがありません。
3. **スキルが何もしない。** その値には Battle Formulas が必要か、戦闘で使える印が付いていません。
+56
View File
@@ -0,0 +1,56 @@
# FF Battle — 커맨드 메뉴 방식의 전투
FF Battle은 고전 콘솔 RPG 스타일의 전투 시스템입니다. 왼쪽 아래에 파티, 위에 적, 그리고 Attack·Items·Defend·Escape 커맨드 메뉴가 있습니다. RPG Plus의 전투 시스템이죠. 전투 자체 — 시작, 배경, 승리·패배 화면, 경험치와 전리품 — 은 Turn Battle Base가 맡고, FF Battle은 플레이어의 싸움 방식을 담당합니다.
## 전투 화면
RPG Plus의 전사가 마을에서 늑대를 만납니다:
![FF Battle의 전투 화면: 위에 늑대의 카드(31/31, ATK 7, DEF 2), 왼쪽 아래에 영웅의 작은 카드(42/42, MP 6/6, DEF 4, 노란 행동점 하나), 왼쪽 위에 Battle 패널(Round 1, Enemies 1, Party 1), 오른쪽에 속도 버튼과 전투 로그.](assets/ff-start.jpg)
- **카드.** 캐릭터마다 체력, 공격력과 방어력(DEF은 피해를 먼저 흡수하는 방어막), 게임에 있다면 마나, 그리고 행동점(AP)을 나타내는 노란 점이 표시됩니다.
- **Battle 패널** — 라운드, 남은 적과 파티원 수, 지금 누구의 차례인지 보여줍니다.
- **전투 로그** — 모든 행동과 그 결과를 기록하며, 최신 기록이 아래에 쌓입니다.
## 한 턴의 진행
1. **캐릭터를 고릅니다** — 카드를 클릭하거나 **Enter**/**Space**를 눌러 행동점이 남은 첫 캐릭터를 선택하세요.
2. **커맨드를 고릅니다** — 마우스나 **W/S**/방향키, **Enter**로 선택하세요:
- **Attack** — 캐릭터의 전투 행동;
- **Items** — 인벤토리에서 사용할 수 있는 아이템, 각 1 AP;
- **Defend** — 자기 자신에게 거는 방어 행동;
- **Escape** — 성공 확률 70%. 실패하면 캐릭터가 1 AP를 잃습니다.
3. **행동을 고릅니다.** 메뉴 옆 패널에 행동 카드와 그 효과가 표시됩니다:
![영웅의 행동 메뉴 — Basic attack(1P), Defence(1P), Power strike(1P), Back — 와 Basic attack 카드. 힌트: "피해 12 (ATK 12 × 1,0) · 소모 1 AP".](assets/ff-actions.jpg)
4. **대상을 클릭합니다.** "대상 선택 · 오른쪽 버튼 / Esc — 취소" 안내가 뜨고 적 카드가 밝게 표시됩니다:
![붉게 표시된 늑대의 카드와 "대상 선택 · 오른쪽 버튼 / Esc — 취소" 안내.](assets/ff-target.jpg)
파티가 행동점을 다 쓰면 적들의 차례가 되고, 이후 새 라운드가 시작되며 행동점이 회복됩니다.
## 로그가 말해주는 것
기본 공격 한 번과 늑대의 대답 이후:
![전투 로그: Hero: Basic attack → Wolf; Wolf's shield absorbed 2 · left 0; Basic attack: Hero → Wolf · -10 HP; The party is out of AP; Enemy turn · round 1; Wolf: Defence.](assets/ff-log.jpg)
영웅의 공격은 12입니다. 늑대의 방어막(2)이 처음 2점을 흡수하고 소진되어, 늑대는 체력을 10 잃고 31 → 21이 됐습니다. 이어서 늑대는 Defence를 써서 다음 공격에 대비해 방어막을 4까지 올립니다.
## 행동
메뉴에는 캐릭터가 가진 행동이 나열됩니다(Characters Engine 참조). 기본 유형은 FF Battle 자체가 처리합니다:
- `Attack=1.5` — 공격 피해에 그 수를 곱한 값입니다. 대상의 방어막이 먼저 흡수합니다;
- `Defence=4` — 아군에게 방어막을 겁니다;
- `Turn=2` — 그만큼의 턴 동안 기절;
- 아이템 사용.
수식으로 쓰인 행동(Classic RPG Rules의 Power strike, Fireball 등)은 Battle Formulas를 통해 작동하며, 마나 비용(`Cost=mp:4`)은 캐릭터의 마나에서 차감됩니다.
## 문제가 생겼을 때
1. **메뉴가 열리지 않음.** 캐릭터의 행동점이 이번 라운드에 바닥났거나, 지금은 적의 차례입니다.
2. **Items가 회색으로 비활성화됨.** 인벤토리에 전투에서 쓸 수 있는 아이템이 없습니다.
3. **스킬이 아무 효과가 없음.** 수치에 Battle Formulas가 필요하거나, 전투 사용 가능 표시가 없는 스킬입니다.
+56
View File
@@ -0,0 +1,56 @@
# FF Battle — бои с меню команд
FF Battle — боевая система в стиле классических консольных RPG: отряд слева внизу, враги вверху и меню команд — Attack, Items, Defend, Escape. Это боевая система RPG Plus. Сам бой — начало, фон, экраны победы и поражения, опыт и добыча — берётся из Turn Battle Base, а FF Battle определяет, как именно сражается игрок.
## Экран боя
Воин из RPG Plus встречает волка в деревне:
![Экран боя FF Battle: карта волка вверху (31/31, ATK 7, DEF 2), маленькая карта героя слева внизу (42/42, MP 6/6, DEF 4, одно жёлтое очко действий), панель Battle слева вверху (Round 1, Enemies 1, Party 1), справа — кнопки скорости и журнал боя.](assets/ff-start.jpg)
- **Карты.** У каждого персонажа видны здоровье, атака и защита (DEF — щит, который поглощает урон первым), мана, если она есть в игре, и жёлтые точки очков действий (AP).
- **Панель Battle** — номер раунда, сколько врагов и бойцов отряда осталось и чей сейчас ход.
- **Журнал боя** — каждое действие и его результат; свежие записи внизу.
## Ход
1. **Выберите персонажа** — щёлкните по его карте или нажмите **Enter**/**Space**, чтобы взять первого, у кого есть очки действий.
2. **Выберите команду** мышью или клавишами **W/S**, стрелками и **Enter**:
- **Attack** — боевые действия персонажа;
- **Items** — предметы из инвентаря, которые можно использовать; по 1 AP;
- **Defend** — защитное действие персонажа на самого себя;
- **Escape** — шанс 70%; при неудаче персонаж теряет 1 AP.
3. **Выберите действие.** Панель рядом с меню покажет карту действия и то, что она сделает:
![Меню действий героя — Basic attack (1P), Defence (1P), Power strike (1P), Back — и карта Basic attack с подсказкой «Урон 12 (ATK 12 × 1,0) · затраты 1 AP».](assets/ff-actions.jpg)
4. **Щёлкните по цели.** Появится подсказка «Выберите цель · ПКМ / Esc — отмена», и карты врагов подсветятся:
![Карта волка, подсвеченная красным, и подсказка «Выберите цель · ПКМ / Esc — отмена».](assets/ff-target.jpg)
Когда отряд потратит очки действий, ход переходит к врагам; затем начинается новый раунд, и очки восстанавливаются.
## Что говорит журнал
После одной обычной атаки и ответа волка:
![Журнал боя: Hero: Basic attack → Wolf; Wolf's shield absorbed 2 · left 0; Basic attack: Hero → Wolf · -10 HP; The party is out of AP; Enemy turn · round 1; Wolf: Defence.](assets/ff-log.jpg)
Атака героя — 12. Щит волка (2) взял на себя первые 2 очка и исчерпался, так что волк потерял 10 здоровья: 31 → 21. Затем волк применил Defence и поднял щит до 4 перед следующим ударом.
## Действия
В меню перечислены действия персонажа (см. Characters Engine). Сами базовые виды действий выполняет FF Battle:
- `Attack=1.5` — урон атаки, умноженный на число; первым его поглощает щит цели;
- `Defence=4` — щит на союзнике;
- `Turn=2` — оглушение на столько ходов;
- использование предметов.
Действия-формулы, например Power strike или Fireball из Classic RPG Rules, работают через Battle Formulas; затраты маны (`Cost=mp:4`) списываются с маны персонажа.
## Если что-то пошло не так
1. **Меню не открывается.** У персонажа не осталось очков действий в этом раунде либо сейчас ход врагов.
2. **Пункт Items неактивен.** В инвентаре нет предметов, которые можно использовать в бою.
3. **Навык ничего не делает.** Его значениям нужен Battle Formulas, либо навык не помечен как пригодный для боя.
+56
View File
@@ -0,0 +1,56 @@
# FF Battle — chiến đấu bằng menu lệnh
FF Battle là hệ thống chiến đấu mang phong cách RPG máy chơi game cổ điển: đội của bạn ở góc dưới bên trái, kẻ địch ở trên, cùng một menu lệnh — Attack, Items, Defend, Escape. Đây là hệ thống chiến đấu của RPG Plus. 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; còn FF Battle quyết định người chơi đánh thế nào.
## Màn hình chiến đấu
Chiến binh RPG Plus chạm trán một con sói trong làng:
![Màn hình chiến đấu FF Battle: thẻ của con sói ở trên (31/31, ATK 7, DEF 2), thẻ nhỏ của anh hùng dưới trái (42/42, MP 6/6, DEF 4, một điểm hành động màu vàng), bảng Battle trên trái (Round 1, Enemies 1, Party 1), bên phải là các nút tốc độ và nhật ký chiến đấu.](assets/ff-start.jpg)
- **Thẻ.** Mỗi nhân vật cho thấy máu, sát thương và phòng thủ (DEF là lớp khiên hấp thụ sát thương trước), mana nếu game có, và các chấm vàng là điểm hành động (AP).
- **Bảng Battle** — vòng đấu, số kẻ địch và thành viên đội còn lại, và đến lượt của ai.
- **Nhật ký chiến đấu** — ghi lại từng hành động và kết quả của nó, cái mới nhất nằm dưới cùng.
## Diễn biến một lượt
1. **Chọn nhân vật** — nhấp vào thẻ, hoặc nhấn **Enter**/**Space** để chọn nhân vật đầu tiên còn điểm hành động.
2. **Chọn một lệnh** bằng chuột hoặc **W/S**/các phím mũi tên và **Enter**:
- **Attack** — các hành động chiến đấu của nhân vật;
- **Items** — vật phẩm dùng được từ túi đồ, mỗi món 1 AP;
- **Defend** — hành động phòng thủ của nhân vật lên chính nó;
- **Escape** — tỉ lệ thành công 70%; nếu hụt, nhân vật mất 1 AP.
3. **Chọn hành động.** Bảng cạnh menu sẽ hiện thẻ hành động và hiệu ứng của nó:
![Menu hành động của anh hùng — Basic attack (1P), Defence (1P), Power strike (1P), Back — và thẻ Basic attack kèm gợi ý "Sát thương 12 (ATK 12 × 1,0) · tốn 1 AP".](assets/ff-actions.jpg)
4. **Nhấp vào mục tiêu.** Dòng gợi ý "Chọn mục tiêu · chuột phải / Esc — hủy" xuất hiện và các thẻ địch sáng lên:
![Thẻ của con sói được tô đỏ và dòng gợi ý "Chọn mục tiêu · chuột phải / Esc — hủy".](assets/ff-target.jpg)
Khi đội dùng hết điểm hành động, quân địch sẽ đến lượt hành động; sau đó một vòng mới bắt đầu và điểm được hồi lại.
## Đọc nhật ký
Sau một đòn đánh thường và đòn đáp trả của con sói:
![Nhật ký chiến đấu: Hero: Basic attack → Wolf; Wolf's shield absorbed 2 · left 0; Basic attack: Hero → Wolf · -10 HP; The party is out of AP; Enemy turn · round 1; Wolf: Defence.](assets/ff-log.jpg)
Đòn đánh của anh hùng là 12. Lớp khiên 2 điểm của sói hấp thụ phần đầu rồi cạn, nên con sói mất 10 máu: 31 → 21. Sau đó nó dùng Defence, nâng lớp khiên lên 4 để đỡ đòn tiếp theo.
## Các hành động
Menu liệt kê các hành động mà nhân vật có (xem Characters Engine). Còn các loại cơ bản thì FF Battle tự xử lý:
- `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 đó;
- dùng vật phẩm.
Các hành động viết bằng công thức, chẳng hạn Power strike hay Fireball của Classic RPG Rules, hoạt động thông qua Battle Formulas; chi phí mana (`Cost=mp:4`) trừ vào mana của nhân vật.
## Khi có sự cố
1. **Menu không mở ra.** Nhân vật đã hết điểm hành động trong vòng này, hoặc đang là lượt của quân địch.
2. **Items bị mờ đi.** Trong túi đồ không còn vật phẩm nào dùng được trong chiến đấu.
3. **Kỹ năng chẳng có tác dụng.** Giá trị của nó cần Battle Formulas, hoặc nó không được đánh dấu là dùng được trong chiến đấu.
+56
View File
@@ -0,0 +1,56 @@
# FF Battle —— 指令菜单式战斗
FF Battle 是一套经典主机 RPG 风格的战斗系统:队伍在左下角,敌人在上方,还有一个指令菜单——Attack、Items、Defend、Escape。它是 RPG Plus 的战斗系统。战斗本身——发起、背景、胜利与失败画面、经验和掉落——来自 Turn Battle Base;FF Battle 决定的是玩家的战斗方式。
## 战斗画面
RPG Plus 的战士在村庄里遭遇一只狼:
![FF Battle 的战斗画面:狼的卡牌在上方(31/31、ATK 7、DEF 2),英雄的小卡牌在左下角(42/42、MP 6/6、DEF 4、一个黄色行动点),左上角是 Battle 面板(Round 1、Enemies 1、Party 1),右侧是倍速按钮和战斗日志。](assets/ff-start.jpg)
- **卡牌。** 每个角色显示生命、攻击和防御(DEF 是优先吸收伤害的护盾)、魔力(若游戏有此设定),以及代表行动点的黄色圆点(AP)。
- **Battle 面板**——显示回合数、敌我双方剩余数量,以及当前轮到谁行动。
- **战斗日志**——记录每个动作及其结果,最新的在最下方。
## 一个回合的流程
1. **选择角色**——点击其卡牌,或按 **Enter**/**Space** 选中第一个还有行动点的角色。
2. **选择指令**——可用鼠标,或用 **W/S**、方向键加 **Enter**:
- **Attack**——角色的战斗动作;
- **Items**——背包中可使用的物品,每件 1 AP;
- **Defend**——角色对自身的防御动作;
- **Escape**——70% 概率逃跑;失败则该角色失去 1 AP。
3. **选择动作。** 菜单旁边的面板会显示动作卡及其效果:
![英雄的动作菜单——Basic attack(1P)、Defence(1P)、Power strike(1P)、Back——以及 Basic attack 卡牌,提示为「伤害 12(ATK 12 × 1,0)· 消耗 1 AP」。](assets/ff-actions.jpg)
4. **点击目标。** 屏幕上会出现「选择目标 · 鼠标右键 / Esc — 取消」的提示,敌人卡牌随之高亮:
![狼的卡牌以红色高亮,提示为「选择目标 · 鼠标右键 / Esc — 取消」。](assets/ff-target.jpg)
队伍用完行动点后,轮到敌人行动;随后新回合开始,行动点也会恢复。
## 日志里写了什么
一次普通攻击和狼的回击之后:
![战斗日志:Hero: Basic attack → Wolf;Wolf's shield absorbed 2 · left 0;Basic attack: Hero → Wolf · -10 HP;The party is out of AP;Enemy turn · round 1;Wolf: Defence.](assets/ff-log.jpg)
英雄的攻击为 12。狼的护盾(2)先吸收了最初的 2 点并耗尽,因此狼损失了 10 点生命:31 → 21。随后狼使用 Defence,把护盾提升到 4 以迎接下一击。
## 动作类型
菜单里列出角色拥有的动作(参见 Characters Engine)。而以下几种基础类型由 FF Battle 自身处理:
- `Attack=1.5`——攻击伤害乘以该数值;优先被目标的护盾吸收;
- `Defence=4`——给队友加护盾;
- `Turn=2`——眩晕相应的回合数;
- 使用物品。
以公式书写的动作,比如 Classic RPG Rules 的 Power strike 或 Fireball,通过 Battle Formulas 生效;魔力消耗(`Cost=mp:4`)从角色的魔力中扣除。
## 出问题怎么办
1. **菜单打不开。** 该角色本回合的行动点已用完,或者当前是敌人的回合。
2. **Items 呈灰色。** 背包里没有可在战斗中使用的物品。
3. **技能没有任何效果。** 它的数值需要 Battle Formulas,或者它没有被标记为可在战斗中使用。
+1
View File
@@ -0,0 +1 @@
collection_settings/ff = Final Fantasy battles
+1
View File
@@ -0,0 +1 @@
collection_settings/ff = Combate Final Fantasy
+1
View File
@@ -0,0 +1 @@
collection_settings/ff = Final Fantasy式バトル
+1
View File
@@ -0,0 +1 @@
collection_settings/ff = 파이널 판타지 전투
+1
View File
@@ -0,0 +1 @@
collection_settings/ff = Бои Final Fantasy
+1
View File
@@ -0,0 +1 @@
collection_settings/ff = Chiến đấu Final Fantasy
+1
View File
@@ -0,0 +1 @@
collection_settings/ff = 最终幻想式战斗
+2
View File
@@ -0,0 +1,2 @@
mod_ff_battle_name = FF Battle
mod_ff_battle_desc = A battle system in the style of classic console RPGs: the party, the enemies and a command menu — Attack, Items, Defend, Escape. The battle of RPG Plus.
+2
View File
@@ -0,0 +1,2 @@
mod_ff_battle_name = FF Battle
mod_ff_battle_desc = Un sistema de combate al estilo de las consolas RPG clásicas: el grupo, los enemigos y un menú de comandos — Attack, Items, Defend, Escape. El combate de RPG Plus.
+2
View File
@@ -0,0 +1,2 @@
mod_ff_battle_name = FFバトル
mod_ff_battle_desc = 古典的なコンソールRPG風のバトルシステム。パーティ、敵、そして Attack・Items・Defend・Escape のコマンドメニュー。RPG Plus の戦闘です。
+2
View File
@@ -0,0 +1,2 @@
mod_ff_battle_name = FF 배틀
mod_ff_battle_desc = 고전 콘솔 RPG 스타일의 전투 시스템입니다. 파티, 적, 그리고 Attack·Items·Defend·Escape 커맨드 메뉴. RPG Plus의 전투죠.
+2
View File
@@ -0,0 +1,2 @@
mod_ff_battle_name = FF Battle
mod_ff_battle_desc = Боевая система в стиле классических консольных RPG: отряд, враги и меню команд — Attack, Items, Defend, Escape. Бой игры RPG Plus.
+46 -2
View File
@@ -1,5 +1,3 @@
mod_ff_battle_name = FFBattle
mod_ff_battle_desc = Final Fantasy style battle system
ff_battle/menu_attack = Attack
ff_battle/menu_items = Items
@@ -12,3 +10,49 @@ ff_battle/select_target = Select a target
ff_battle/escape_fail = Escape failed!
ff_battle/escape_success = You escaped!
ff_battle/no_actions = No actions
ff_battle/hud_title = Battle
ff_battle/round = Round
ff_battle/enemies = Enemies
ff_battle/allies = Party
ff_battle/phase_player = Player turn
ff_battle/phase_enemy = Enemy turn
ff_battle/banner_player = Player turn
ff_battle/banner_enemy = Enemy turn
ff_battle/hint_target = Select a target · RMB / Esc — cancel
ff_battle/hint_ally = Select an ally · RMB / Esc — cancel
ff_battle/menu_caption = {0} · AP {1}/{2}
ff_battle/menu_actions = Actions
ff_battle/log_title = Battle log
ff_battle/log_round = round {0}
ff_battle/log_battle_start = Battle started
ff_battle/log_turn_player = — Player turn · round {0} —
ff_battle/log_turn_enemy = — Enemy turn · round {0} —
ff_battle/log_action = {0}: {1} → {2}
ff_battle/log_action_self = {0}: {1}
ff_battle/log_damage = {0}: {1} → {2} · -{3} HP
ff_battle/log_shield = {0}'s shield absorbed {1} · left {2}
ff_battle/log_defence = Defend: {0} · +{1} DEF (total {2})
ff_battle/log_stun = Stunned: {0} · {1} turns
ff_battle/log_stun_skip = {0} is stunned — skips the turn
ff_battle/log_heal = {0}: {1} restored {2} HP
ff_battle/log_death = {0} defeated
ff_battle/log_escape_success = Escape succeeded
ff_battle/log_escape_failed = Escape failed! {0} loses 1 AP
ff_battle/log_no_points = The party is out of AP
ff_battle/detail_attack = Character action submenu. AP cost is shown next to the name.
ff_battle/detail_items = 1 AP per item. Targeted items require an ally.
ff_battle/detail_defend = Shortcut: the ally-target action is applied to self. Shield +4 DEF.
ff_battle/detail_escape = 70% success chance. On failure the character loses 1 AP.
ff_battle/detail_back = Return to the main command menu.
ff_battle/detail_action = Special action without numeric parameters.
ff_battle/detail_item = An item from the shared inventory.
ff_battle/detail_damage = Damage {0} (ATK {1} × {2}) · cost {3} AP
ff_battle/detail_stun = Stun for {0} turns · cost {1} AP
ff_battle/detail_defence = Target shield +{0} · cost {1} AP
ff_battle/status_stun = Stunned {0}
ff_battle/badge_turns = {0} turns
ff_battle/badge_self = Self
+58
View File
@@ -0,0 +1,58 @@
ff_battle/menu_attack = Atacar
ff_battle/menu_items = Objetos
ff_battle/menu_defend = Defender
ff_battle/menu_escape = Huir
ff_battle/end_turn = Terminar turno
ff_battle/back = Atrás
ff_battle/no_items = No hay objetos
ff_battle/select_target = Elige un objetivo
ff_battle/escape_fail = ¡No has podido huir!
ff_battle/escape_success = ¡Has huido!
ff_battle/no_actions = No hay acciones
ff_battle/hud_title = Combate
ff_battle/round = Ronda
ff_battle/enemies = Enemigos
ff_battle/allies = Grupo
ff_battle/phase_player = Turno del jugador
ff_battle/phase_enemy = Turno del enemigo
ff_battle/banner_player = Turno del jugador
ff_battle/banner_enemy = Turno del enemigo
ff_battle/hint_target = Elige un objetivo · clic derecho / Esc: cancelar
ff_battle/hint_ally = Elige un aliado · clic derecho / Esc: cancelar
ff_battle/menu_caption = {0} · PA {1}/{2}
ff_battle/menu_actions = Acciones
ff_battle/log_title = Registro de combate
ff_battle/log_round = ronda {0}
ff_battle/log_battle_start = Comienza el combate
ff_battle/log_turn_player = — Turno del jugador · ronda {0} —
ff_battle/log_turn_enemy = — Turno del enemigo · ronda {0} —
ff_battle/log_action = {0}: {1} → {2}
ff_battle/log_action_self = {0}: {1}
ff_battle/log_damage = {0}: {1} → {2} · -{3} PV
ff_battle/log_shield = El escudo de {0} absorbió {1} · quedan {2}
ff_battle/log_defence = Defensa: {0} · +{1} DEF (total {2})
ff_battle/log_stun = Aturdido: {0} · {1} turnos
ff_battle/log_stun_skip = {0} está aturdido: pierde el turno
ff_battle/log_heal = {0}: {1} restauró {2} PV
ff_battle/log_death = {0} derrotado
ff_battle/log_escape_success = Huida con éxito
ff_battle/log_escape_failed = ¡No has podido huir! {0} pierde 1 PA
ff_battle/log_no_points = Al grupo no le quedan PA
ff_battle/detail_attack = Submenú de acciones del personaje. El coste en PA aparece junto al nombre.
ff_battle/detail_items = 1 PA por objeto. Los objetos con objetivo requieren un aliado.
ff_battle/detail_defend = Atajo: la acción sobre un aliado se aplica a uno mismo. Escudo +4 DEF.
ff_battle/detail_escape = 70 % de probabilidad de éxito. Si falla, el personaje pierde 1 PA.
ff_battle/detail_back = Volver al menú principal de órdenes.
ff_battle/detail_action = Acción especial sin parámetros numéricos.
ff_battle/detail_item = Un objeto del inventario compartido.
ff_battle/detail_damage = Daño {0} (ATQ {1} × {2}) · coste {3} PA
ff_battle/detail_stun = Aturde {0} turnos · coste {1} PA
ff_battle/detail_defence = Escudo del objetivo +{0} · coste {1} PA
ff_battle/status_stun = Aturdido {0}
ff_battle/badge_turns = {0} turnos
ff_battle/badge_self = A sí mismo
+58
View File
@@ -0,0 +1,58 @@
ff_battle/menu_attack = たたかう
ff_battle/menu_items = アイテム
ff_battle/menu_defend = ぼうぎょ
ff_battle/menu_escape = にげる
ff_battle/end_turn = ターン終了
ff_battle/back = 戻る
ff_battle/no_items = アイテムがない
ff_battle/select_target = 対象を選択
ff_battle/escape_fail = 逃げられなかった!
ff_battle/escape_success = うまく逃げきった!
ff_battle/no_actions = アクションがない
ff_battle/hud_title = バトル
ff_battle/round = ラウンド
ff_battle/enemies = 敵
ff_battle/allies = パーティ
ff_battle/phase_player = プレイヤーのターン
ff_battle/phase_enemy = 敵のターン
ff_battle/banner_player = プレイヤーのターン
ff_battle/banner_enemy = 敵のターン
ff_battle/hint_target = 対象を選択 · 右クリック / Esc——キャンセル
ff_battle/hint_ally = 味方を選択 · 右クリック / Esc——キャンセル
ff_battle/menu_caption = {0} · AP {1}/{2}
ff_battle/menu_actions = アクション
ff_battle/log_title = バトルログ
ff_battle/log_round = ラウンド {0}
ff_battle/log_battle_start = バトル開始
ff_battle/log_turn_player = — プレイヤーのターン · ラウンド {0} —
ff_battle/log_turn_enemy = — 敵のターン · ラウンド {0} —
ff_battle/log_action = {0}:{1} → {2}
ff_battle/log_action_self = {0}:{1}
ff_battle/log_damage = {0}:{1} → {2} · -{3} HP
ff_battle/log_shield = {0} のシールドが {1} を吸収 · 残り {2}
ff_battle/log_defence = 防御:{0} · +{1} DEF(合計 {2})
ff_battle/log_stun = スタン:{0} · {1} ターン
ff_battle/log_stun_skip = {0} はスタン中——ターンを飛ばす
ff_battle/log_heal = {0}:{1} が HP を {2} 回復
ff_battle/log_death = {0} を倒した
ff_battle/log_escape_success = 逃走成功
ff_battle/log_escape_failed = 逃げられなかった! {0} は AP を 1 失う
ff_battle/log_no_points = パーティの AP が尽きた
ff_battle/detail_attack = キャラクターのアクションのサブメニュー。名前の横に AP コストを表示。
ff_battle/detail_items = アイテム 1 つにつき 1 AP。対象付きのアイテムには味方が必要。
ff_battle/detail_defend = ショートカット:味方対象のアクションを自分に使う。シールド +4 DEF。
ff_battle/detail_escape = 成功率 70%。失敗するとキャラクターは AP を 1 失う。
ff_battle/detail_back = メインのコマンドメニューに戻る。
ff_battle/detail_action = 数値パラメーターのない特殊アクション。
ff_battle/detail_item = 共有インベントリのアイテム。
ff_battle/detail_damage = ダメージ {0}(攻撃 {1} × {2})· コスト {3} AP
ff_battle/detail_stun = {0} ターンのスタン · コスト {1} AP
ff_battle/detail_defence = 対象のシールド +{0} · コスト {1} AP
ff_battle/status_stun = スタン {0}
ff_battle/badge_turns = {0} ターン
ff_battle/badge_self = 自分
+58
View File
@@ -0,0 +1,58 @@
ff_battle/menu_attack = 공격
ff_battle/menu_items = 아이템
ff_battle/menu_defend = 방어
ff_battle/menu_escape = 도망
ff_battle/end_turn = 턴 종료
ff_battle/back = 뒤로
ff_battle/no_items = 아이템 없음
ff_battle/select_target = 대상 선택
ff_battle/escape_fail = 도망치지 못했다!
ff_battle/escape_success = 도망쳤다!
ff_battle/no_actions = 액션 없음
ff_battle/hud_title = 전투
ff_battle/round = 라운드
ff_battle/enemies = 적
ff_battle/allies = 파티
ff_battle/phase_player = 플레이어 턴
ff_battle/phase_enemy = 적 턴
ff_battle/banner_player = 플레이어 턴
ff_battle/banner_enemy = 적 턴
ff_battle/hint_target = 대상 선택 · 우클릭 / Esc — 취소
ff_battle/hint_ally = 아군 선택 · 우클릭 / Esc — 취소
ff_battle/menu_caption = {0} · AP {1}/{2}
ff_battle/menu_actions = 액션
ff_battle/log_title = 전투 로그
ff_battle/log_round = {0}라운드
ff_battle/log_battle_start = 전투 시작
ff_battle/log_turn_player = — 플레이어 턴 · {0}라운드 —
ff_battle/log_turn_enemy = — 적 턴 · {0}라운드 —
ff_battle/log_action = {0}: {1} → {2}
ff_battle/log_action_self = {0}: {1}
ff_battle/log_damage = {0}: {1} → {2} · -{3} HP
ff_battle/log_shield = {0}의 보호막이 {1}을(를) 흡수 · 남은 양 {2}
ff_battle/log_defence = 방어: {0} · +{1} DEF (합계 {2})
ff_battle/log_stun = 기절: {0} · {1}턴
ff_battle/log_stun_skip = {0}은(는) 기절 — 턴을 건너뜀
ff_battle/log_heal = {0}: {1}이(가) HP {2} 회복
ff_battle/log_death = {0} 쓰러짐
ff_battle/log_escape_success = 도망 성공
ff_battle/log_escape_failed = 도망 실패! {0}은(는) AP를 1 잃는다
ff_battle/log_no_points = 파티의 AP가 바닥났다
ff_battle/detail_attack = 캐릭터의 액션 하위 메뉴. 이름 옆에 AP 비용이 표시됩니다.
ff_battle/detail_items = 아이템 하나에 1 AP. 대상이 있는 아이템은 아군이 필요합니다.
ff_battle/detail_defend = 단축키: 아군 대상 액션을 자신에게 사용. 보호막 +4 DEF.
ff_battle/detail_escape = 성공 확률 70%. 실패하면 캐릭터가 AP를 1 잃습니다.
ff_battle/detail_back = 메인 명령 메뉴로 돌아갑니다.
ff_battle/detail_action = 수치 매개변수가 없는 특수 액션.
ff_battle/detail_item = 공용 인벤토리의 아이템.
ff_battle/detail_damage = 피해 {0} (공격 {1} × {2}) · 비용 {3} AP
ff_battle/detail_stun = {0}턴 기절 · 비용 {1} AP
ff_battle/detail_defence = 대상 보호막 +{0} · 비용 {1} AP
ff_battle/status_stun = 기절 {0}
ff_battle/badge_turns = {0}턴
ff_battle/badge_self = 자신
+46 -2
View File
@@ -1,5 +1,3 @@
mod_ff_battle_name=FF Битва
mod_ff_battle_desc=Боевая система в стиле Final Fantasy
ff_battle/menu_attack=Атака
ff_battle/menu_items=Предметы
@@ -12,3 +10,49 @@ ff_battle/select_target=Выберите цель
ff_battle/escape_fail=Побег не удался!
ff_battle/escape_success=Вы сбежали!
ff_battle/no_actions=Нет действий
ff_battle/hud_title=Бой
ff_battle/round=Раунд
ff_battle/enemies=Врагов
ff_battle/allies=Отряд
ff_battle/phase_player=Ход игрока
ff_battle/phase_enemy=Ход врага
ff_battle/banner_player=Ход игрока
ff_battle/banner_enemy=Ход врага
ff_battle/hint_target=Выберите цель · ПКМ / Esc — отмена
ff_battle/hint_ally=Выберите союзника · ПКМ / Esc — отмена
ff_battle/menu_caption={0} · ОД {1}/{2}
ff_battle/menu_actions=Действия
ff_battle/log_title=Журнал боя
ff_battle/log_round=раунд {0}
ff_battle/log_battle_start=Бой начался
ff_battle/log_turn_player=— Ход игрока · раунд {0} —
ff_battle/log_turn_enemy=— Ход врага · раунд {0} —
ff_battle/log_action={0}: {1} → {2}
ff_battle/log_action_self={0}: {1}
ff_battle/log_damage={0}: {1} → {2} · −{3} HP
ff_battle/log_shield=Щит {0} поглотил {1} · осталось {2}
ff_battle/log_defence=Защита: {0} · +{1} ЗАЩ (всего {2})
ff_battle/log_stun=Ошеломление: {0} · {1} хода
ff_battle/log_stun_skip={0} ошеломлён — пропускает ход
ff_battle/log_heal={0}: {1} восстановил {2} HP
ff_battle/log_death={0} повержен
ff_battle/log_escape_success=Побег удался
ff_battle/log_escape_failed=Побег не удался! {0} теряет 1 ОД
ff_battle/log_no_points=У отряда закончились ОД
ff_battle/detail_attack=Подменю действий персонажа. Стоимость в ОД указана справа от названия.
ff_battle/detail_items=Расход 1 ОД за предмет. Предметы «по цели» требуют выбора союзника.
ff_battle/detail_defend=Шорткат: действие «по союзнику» применяется на себя. Щит +4 ЗАЩ.
ff_battle/detail_escape=Шанс успеха 70%. При провале персонаж теряет 1 ОД.
ff_battle/detail_back=Вернуться в главное меню команд.
ff_battle/detail_action=Особое действие без числовых параметров.
ff_battle/detail_item=Предмет из общего инвентаря.
ff_battle/detail_damage=Урон {0} (АТК {1} × {2}) · расход {3} ОД
ff_battle/detail_stun=Ошеломление на {0} хода · расход {1} ОД
ff_battle/detail_defence=Щит цели +{0} · расход {1} ОД
ff_battle/status_stun=Ошеломлён {0}
ff_battle/badge_turns={0} хода
ff_battle/badge_self=На себя
+58
View File
@@ -0,0 +1,58 @@
ff_battle/menu_attack = Tấn công
ff_battle/menu_items = Vật phẩm
ff_battle/menu_defend = Phòng thủ
ff_battle/menu_escape = Bỏ chạy
ff_battle/end_turn = Kết thúc lượt
ff_battle/back = Quay lại
ff_battle/no_items = Không có vật phẩm
ff_battle/select_target = Chọn mục tiêu
ff_battle/escape_fail = Bỏ chạy thất bại!
ff_battle/escape_success = Bạn đã chạy thoát!
ff_battle/no_actions = Không có hành động
ff_battle/hud_title = Trận đấu
ff_battle/round = Vòng
ff_battle/enemies = Kẻ địch
ff_battle/allies = Tổ đội
ff_battle/phase_player = Lượt người chơi
ff_battle/phase_enemy = Lượt kẻ địch
ff_battle/banner_player = Lượt người chơi
ff_battle/banner_enemy = Lượt kẻ địch
ff_battle/hint_target = Chọn mục tiêu · chuột phải / Esc — hủy
ff_battle/hint_ally = Chọn đồng đội · chuột phải / Esc — hủy
ff_battle/menu_caption = {0} · AP {1}/{2}
ff_battle/menu_actions = Hành động
ff_battle/log_title = Nhật ký trận đấu
ff_battle/log_round = vòng {0}
ff_battle/log_battle_start = Trận đấu bắt đầu
ff_battle/log_turn_player = — Lượt người chơi · vòng {0} —
ff_battle/log_turn_enemy = — Lượt kẻ địch · vòng {0} —
ff_battle/log_action = {0}: {1} → {2}
ff_battle/log_action_self = {0}: {1}
ff_battle/log_damage = {0}: {1} → {2} · -{3} HP
ff_battle/log_shield = Khiên của {0} hấp thụ {1} · còn {2}
ff_battle/log_defence = Phòng thủ: {0} · +{1} DEF (tổng {2})
ff_battle/log_stun = Bị choáng: {0} · {1} lượt
ff_battle/log_stun_skip = {0} bị choáng — mất lượt
ff_battle/log_heal = {0}: {1} hồi {2} HP
ff_battle/log_death = {0} đã bị hạ
ff_battle/log_escape_success = Bỏ chạy thành công
ff_battle/log_escape_failed = Bỏ chạy thất bại! {0} mất 1 AP
ff_battle/log_no_points = Tổ đội đã hết AP
ff_battle/detail_attack = Menu con hành động của nhân vật. Chi phí AP hiển thị cạnh tên.
ff_battle/detail_items = 1 AP cho mỗi vật phẩm. Vật phẩm có mục tiêu cần một đồng đội.
ff_battle/detail_defend = Phím tắt: hành động nhắm đồng đội được áp dụng cho bản thân. Khiên +4 DEF.
ff_battle/detail_escape = Tỉ lệ thành công 70%. Nếu thất bại, nhân vật mất 1 AP.
ff_battle/detail_back = Quay lại menu lệnh chính.
ff_battle/detail_action = Hành động đặc biệt không có tham số số.
ff_battle/detail_item = Một vật phẩm trong túi đồ chung.
ff_battle/detail_damage = Sát thương {0} (ATK {1} × {2}) · tốn {3} AP
ff_battle/detail_stun = Choáng {0} lượt · tốn {1} AP
ff_battle/detail_defence = Khiên mục tiêu +{0} · tốn {1} AP
ff_battle/status_stun = Bị choáng {0}
ff_battle/badge_turns = {0} lượt
ff_battle/badge_self = Bản thân
+58
View File
@@ -0,0 +1,58 @@
ff_battle/menu_attack = 攻击
ff_battle/menu_items = 物品
ff_battle/menu_defend = 防御
ff_battle/menu_escape = 逃跑
ff_battle/end_turn = 结束回合
ff_battle/back = 返回
ff_battle/no_items = 没有物品
ff_battle/select_target = 选择目标
ff_battle/escape_fail = 逃跑失败!
ff_battle/escape_success = 你逃走了!
ff_battle/no_actions = 没有行动
ff_battle/hud_title = 战斗
ff_battle/round = 回合
ff_battle/enemies = 敌人
ff_battle/allies = 队伍
ff_battle/phase_player = 玩家回合
ff_battle/phase_enemy = 敌方回合
ff_battle/banner_player = 玩家回合
ff_battle/banner_enemy = 敌方回合
ff_battle/hint_target = 选择目标 · 右键 / Esc——取消
ff_battle/hint_ally = 选择队友 · 右键 / Esc——取消
ff_battle/menu_caption = {0} · AP {1}/{2}
ff_battle/menu_actions = 行动
ff_battle/log_title = 战斗日志
ff_battle/log_round = 第 {0} 回合
ff_battle/log_battle_start = 战斗开始
ff_battle/log_turn_player = — 玩家回合 · 第 {0} 回合 —
ff_battle/log_turn_enemy = — 敌方回合 · 第 {0} 回合 —
ff_battle/log_action = {0}:{1} → {2}
ff_battle/log_action_self = {0}:{1}
ff_battle/log_damage = {0}:{1} → {2} · -{3} HP
ff_battle/log_shield = {0} 的护盾吸收了 {1} · 剩余 {2}
ff_battle/log_defence = 防御:{0} · +{1} 防御(共 {2})
ff_battle/log_stun = 眩晕:{0} · {1} 回合
ff_battle/log_stun_skip = {0} 处于眩晕——跳过回合
ff_battle/log_heal = {0}:{1} 恢复了 {2} HP
ff_battle/log_death = {0} 被击败
ff_battle/log_escape_success = 逃跑成功
ff_battle/log_escape_failed = 逃跑失败!{0} 失去 1 AP
ff_battle/log_no_points = 队伍的 AP 用完了
ff_battle/detail_attack = 角色的行动子菜单。名称旁显示 AP 消耗。
ff_battle/detail_items = 每个物品消耗 1 AP。需要目标的物品要选择队友。
ff_battle/detail_defend = 快捷方式:对队友的行动作用于自己。护盾 +4 防御。
ff_battle/detail_escape = 成功率 70%。失败时角色失去 1 AP。
ff_battle/detail_back = 返回主指令菜单。
ff_battle/detail_action = 没有数值参数的特殊行动。
ff_battle/detail_item = 共享背包中的物品。
ff_battle/detail_damage = 伤害 {0}(攻击 {1} × {2})· 消耗 {3} AP
ff_battle/detail_stun = 眩晕 {0} 回合 · 消耗 {1} AP
ff_battle/detail_defence = 目标护盾 +{0} · 消耗 {1} AP
ff_battle/status_stun = 眩晕 {0}
ff_battle/badge_turns = {0} 回合
ff_battle/badge_self = 自己
+2
View File
@@ -0,0 +1,2 @@
mod_ff_battle_name = FF Battle
mod_ff_battle_desc = Hệ thống chiến đấu mang phong cách RPG máy chơi game cổ điển: đội của bạn, kẻ địch và một menu lệnh — Attack, Items, Defend, Escape. Trận chiến của RPG Plus.
+2
View File
@@ -0,0 +1,2 @@
mod_ff_battle_name = FF 战斗
mod_ff_battle_desc = 经典主机 RPG 风格的战斗系统:队伍、敌人和一个指令菜单——Attack、Items、Defend、Escape。RPG Plus 的战斗。
+4 -3
View File
@@ -1,11 +1,12 @@
{
"id": "ff_battle",
"name": "FFBattle",
"name": "FF Battle",
"version": "1.0.0",
"author": "ct",
"description": "Final Fantasy style battle system",
"description": "A battle system in the style of classic console RPGs: the party, the enemies and a command menu — Attack, Items, Defend, Escape. The battle of RPG Plus.",
"category": "gameplay",
"sources": [
"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: 123 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+94
View File
@@ -0,0 +1,94 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFActionDetailPanel.cs" id="1_script"]
[node name="FFActionDetailPanel" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(300, 160)
layout_mode = 3
mouse_filter = 2
[node name="Box" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_override_constants/separation = 10
[node name="Card" type="PanelContainer" parent="Box"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="CardRow" type="HBoxContainer" parent="Box/Card"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 0
[node name="Stripe" type="ColorRect" parent="Box/Card/CardRow"]
custom_minimum_size = Vector2(4, 0)
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="IconMargin" type="MarginContainer" parent="Box/Card/CardRow"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 8
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
[node name="CardIcon" type="TextureRect" parent="Box/Card/CardRow/IconMargin"]
custom_minimum_size = Vector2(52, 52)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
expand_mode = 1
stretch_mode = 5
[node name="Body" type="VBoxContainer" parent="Box/Card/CardRow"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
mouse_filter = 2
theme_override_constants/separation = 4
[node name="NameRow" type="HBoxContainer" parent="Box/Card/CardRow/Body"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="NameLabel" type="Label" parent="Box/Card/CardRow/Body/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="Box/Card/CardRow/Body/NameRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 9
[node name="Badges" type="HFlowContainer" parent="Box/Card/CardRow/Body"]
layout_mode = 2
theme_override_constants/h_separation = 5
theme_override_constants/v_separation = 5
[node name="Props" type="HFlowContainer" parent="Box/Card/CardRow/Body"]
layout_mode = 2
theme_override_constants/h_separation = 5
theme_override_constants/v_separation = 3
[node name="Note" type="PanelContainer" parent="Box"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="NoteLabel" type="Label" parent="Box/Note"]
layout_mode = 2
theme_override_font_sizes/font_size = 12
autowrap_mode = 3
+26
View File
@@ -0,0 +1,26 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFBattleBanner.cs" id="1_script"]
[node name="FFBattleBanner" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Center" type="CenterContainer" 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="Panel" type="PanelContainer" parent="Center"]
layout_mode = 2
mouse_filter = 2
[node name="Label" type="Label" parent="Center/Panel"]
layout_mode = 2
theme_override_font_sizes/font_size = 34
horizontal_alignment = 1
uppercase = true
+88
View File
@@ -0,0 +1,88 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFBattleHud.cs" id="1_script"]
[node name="FFBattleHud" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(380, 92)
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
theme_override_constants/separation = 9
[node name="TopRow" type="HBoxContainer" parent="Panel/Column"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="Panel/Column/TopRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "ff_battle/hud_title"
auto_translate = true
uppercase = true
[node name="PhaseBadge" type="PanelContainer" parent="Panel/Column/TopRow"]
layout_mode = 2
size_flags_horizontal = 8
size_flags_vertical = 4
mouse_filter = 2
[node name="PhaseLabel" type="Label" parent="Panel/Column/TopRow/PhaseBadge"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
uppercase = true
[node name="MetaRow" type="HBoxContainer" parent="Panel/Column"]
layout_mode = 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 = "ff_battle/round"
auto_translate = true
[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 = "ff_battle/enemies"
auto_translate = true
[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"
[node name="AlliesCaption" type="Label" parent="Panel/Column/MetaRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 12
text = "ff_battle/allies"
auto_translate = true
[node name="AlliesValue" 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/FFBattleLogPanel.cs" id="1_script"]
[node name="FFBattleLogPanel" 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
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 = "ff_battle/log_title"
auto_translate = true
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
theme_override_constants/separation = 0
mouse_filter = 2
+35
View File
@@ -0,0 +1,35 @@
[gd_scene format=3]
[ext_resource type="Script" path="src/UI/FFBattleTargetHint.cs" id="1_script"]
[node name="FFBattleTargetHint" type="Control" script=ExtResource("1_script")]
layout_mode = 3
mouse_filter = 2
[node name="Center" type="CenterContainer" 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="Toast" type="PanelContainer" parent="Center"]
layout_mode = 2
mouse_filter = 2
[node name="Row" type="HBoxContainer" parent="Center/Toast"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="IconLabel" type="Label" parent="Center/Toast/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 14
text = "◎"
[node name="TextLabel" type="Label" parent="Center/Toast/Row"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 13
+150 -28
View File
@@ -5,51 +5,173 @@
[node name="FFCharacterWidget" type="Control" script=ExtResource("1_script")]
custom_minimum_size = Vector2(180, 260)
layout_mode = 3
mouse_filter = 0
[node name="Sprite" type="TextureRect" parent="."]
[node name="TeamLine" type="ColorRect" parent="."]
layout_mode = 1
anchor_right = 1.0
offset_bottom = 2.0
mouse_filter = 2
[node name="Art" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 3.0
offset_top = 3.0
offset_right = -3.0
offset_bottom = -92.0
grow_horizontal = 2
grow_vertical = 2
offset_left = 8.0
offset_top = 8.0
offset_right = -8.0
offset_bottom = -44.0
mouse_filter = 2
expand_mode = 1
stretch_mode = 5
[node name="HpBg" type="ColorRect" parent="."]
layout_mode = 0
offset_left = 4.0
offset_top = 218.0
offset_right = 176.0
offset_bottom = 236.0
[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
color = Color(0.15, 0.15, 0.15, 1)
[node name="HpFill" type="ColorRect" parent="."]
layout_mode = 0
offset_left = 4.0
offset_top = 218.0
offset_right = 176.0
offset_bottom = 236.0
[node name="Column" type="VBoxContainer" parent="Info"]
layout_mode = 2
theme_override_constants/separation = 5
[node name="NameRow" type="HBoxContainer" parent="Info/Column"]
layout_mode = 2
theme_override_constants/separation = 6
[node name="TeamDot" type="Panel" parent="Info/Column/NameRow"]
custom_minimum_size = Vector2(7, 7)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
color = Color(0.1, 0.85, 0.1, 1)
[node name="NameLabel" type="Label" parent="."]
layout_mode = 0
offset_left = 4.0
offset_top = 238.0
offset_right = 176.0
offset_bottom = 256.0
[node name="NameLabel" type="Label" parent="Info/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="HpNumLabel" type="Label" parent="Info/Column/NameRow"]
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, 8)
layout_mode = 2
mouse_filter = 2
horizontal_alignment = 1
theme_override_font_sizes/font_size = 13
[node name="Selection" type="TextureRect" parent="."]
[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="Foot" type="HBoxContainer" parent="Info/Column"]
layout_mode = 2
theme_override_constants/separation = 6
[node name="PipsBox" type="HBoxContainer" parent="Info/Column/Foot"]
layout_mode = 2
size_flags_vertical = 4
theme_override_constants/separation = 4
[node name="AtkLabel" type="Label" parent="Info/Column/Foot"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
[node name="Spacer" type="Control" parent="Info/Column/Foot"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="ShieldChip" type="PanelContainer" parent="Info/Column/Foot"]
visible = false
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="ShieldRow" type="HBoxContainer" parent="Info/Column/Foot/ShieldChip"]
layout_mode = 2
theme_override_constants/separation = 4
[node name="ShieldKey" type="Label" parent="Info/Column/Foot/ShieldChip/ShieldRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
text = "ui/character_panel/stat_def"
auto_translate = true
uppercase = true
[node name="ShieldValue" type="Label" parent="Info/Column/Foot/ShieldChip/ShieldRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 11
text = "0"
[node name="StatusBox" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -140.0
offset_top = 8.0
offset_right = -8.0
offset_bottom = 40.0
grow_horizontal = 0
mouse_filter = 2
theme_override_constants/separation = 4
[node name="StunBadge" type="PanelContainer" parent="StatusBox"]
visible = false
layout_mode = 2
size_flags_horizontal = 8
mouse_filter = 2
[node name="StunRow" type="HBoxContainer" parent="StatusBox/StunBadge"]
layout_mode = 2
theme_override_constants/separation = 5
[node name="StunIcon" type="TextureRect" parent="StatusBox/StunBadge/StunRow"]
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="StatusBox/StunBadge/StunRow"]
layout_mode = 2
size_flags_vertical = 4
theme_override_font_sizes/font_size = 10
uppercase = true
[node name="Selection" type="Panel" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
+13 -4
View File
@@ -13,9 +13,9 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
color = Color(0.02, 0.05, 0.12, 0.88)
color = Color(0.062745, 0.066667, 0.094118, 1)
[node name="Border" type="ColorRect" parent="."]
[node name="Border" type="Panel" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -27,11 +27,20 @@ offset_top = -3.0
offset_right = 3.0
offset_bottom = 3.0
mouse_filter = 2
color = Color(0.3, 0.5, 0.9, 0.9)
[node name="Cursor" type="Label" parent="."]
text = "►"
mouse_filter = 2
visible = false
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(1, 0.95, 0.7, 1)
theme_override_colors/font_color = Color(0.941176, 0.756863, 0.411765, 1)
[node name="Caption" type="Label" parent="."]
offset_left = 2.0
offset_top = -25.0
offset_right = 218.0
offset_bottom = -7.0
mouse_filter = 2
visible = false
theme_override_font_sizes/font_size = 11
uppercase = true
+11
View File
@@ -49,9 +49,17 @@ namespace Cthangover.FFBattle.Actions
{
if (Source == null || Target == null || Action == null)
return true;
// The target's death animation may free its widget mid-animation;
// finish early and let DoEnd snap the source back.
if (!IsAlive(Source) || !IsAlive(Target))
return true;
return DoInternalAction();
}
/// <summary>True while the widget is still in the tree and not queued for deletion.</summary>
protected static bool IsAlive(FFCharacterWidget widget)
=> GodotObject.IsInstanceValid(widget) && !widget.IsQueuedForDeletion();
/// <summary>Captures the initial world positions of source and target, then delegates to subclass setup.</summary>
public void DoStart()
{
@@ -71,8 +79,11 @@ namespace Cthangover.FFBattle.Actions
DoInternalEnd();
}
/// <summary>Frame tick of the animation; implementors advance phases and return true when finished.</summary>
protected abstract bool DoInternalAction();
/// <summary>Phase setup called once by <see cref="DoStart"/> after positions are captured.</summary>
protected abstract void DoInternalStart();
/// <summary>Cleanup called once by <see cref="DoEnd"/> — restore captured positions here.</summary>
protected abstract void DoInternalEnd();
/// <summary>Quadratic ease-out: fast start, decelerating to the target. Used for return-to-start motion.</summary>
+13 -6
View File
@@ -1,7 +1,9 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
using Cthangover.TurnBattle;
using Cthangover.FFBattle.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.Actions
@@ -27,9 +29,10 @@ namespace Cthangover.FFBattle.Actions
public FFAttackAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f)
: base(source, target, action, speed) { }
/// <summary>Starts the attack sequence: compute the stop point short of the target and reset the phase clock.</summary>
protected override void DoInternalStart()
{
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
_damageApplied = false;
_phase = Phase.MoveForward;
@@ -37,9 +40,10 @@ namespace Cthangover.FFBattle.Actions
_midPoint = TargetPos - dir * 30f;
}
/// <summary>Advances move-in → impact (damage via <see cref="ActionExecutorHub"/>, hit flash/shake, damage popup) → move-back.</summary>
protected override bool DoInternalAction()
{
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
var elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
switch (_phase)
{
@@ -51,7 +55,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.Impact;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -69,7 +73,7 @@ namespace Cthangover.FFBattle.Actions
if (result.Result)
{
Target.Flash(new Color(1f, 0.3f, 0.3f, 1f), 0.2f);
Target.Flash(UiTokens.Danger, 0.2f);
Target.Shake(4f, 0.25f);
if (result.Target.Damage > 0)
@@ -84,7 +88,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.MoveBack;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -106,11 +110,14 @@ namespace Cthangover.FFBattle.Actions
return false;
}
/// <summary>Returns the attacker to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd()
{
Source.GlobalPosition = SourcePos;
if (IsAlive(Source))
Source.GlobalPosition = SourcePos;
Source.UpdateInfo();
Target.UpdateInfo();
}
}
}
+5 -2
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Utils;
using Godot;
@@ -15,16 +16,18 @@ namespace Cthangover.FFBattle.Actions
/// </summary>
public class FFDamageExecutor : ActionBase
{
/// <summary>Hub registry key for this executor.</summary>
public override string ID => "FFDamageExecutor";
/// <summary>Applies attack damage with status hooks and defence absorption; consumes the action's required points.</summary>
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
return new ChangedAttributes { Result = false };
var damageDelta = Mathf.RoundToInt(action.GetFloat("Attack", 1f) * user.Attributes.Attack.Value);
target.StatusEffectQueue.OnTakeDamage(user, ref damageDelta);
user.StatusEffectQueue.OnDealDamage(target, ref damageDelta);
StatusEffectService.Of(target).OnTakeDamage(user, ref damageDelta);
StatusEffectService.Of(user).OnDealDamage(target, ref damageDelta);
var defenceDelta = 0;
var ignoreArmor = user.HasPassiveEffect("IgnoreArmor");
+2
View File
@@ -12,8 +12,10 @@ namespace Cthangover.FFBattle.Actions
/// </summary>
public class FFDefenceExecutor : ActionBase
{
/// <summary>Hub registry key for this executor.</summary>
public override string ID => "FFDefenceExecutor";
/// <summary>Adds the action's "Defence" value (default 4) to the target's defence shield.</summary>
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
+13 -6
View File
@@ -1,7 +1,9 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Characters;
using Cthangover.TurnBattle;
using Cthangover.FFBattle.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.Actions
@@ -28,17 +30,19 @@ namespace Cthangover.FFBattle.Actions
public FFDefendAnimation(FFCharacterWidget source, FFCharacterWidget target, ActionCharacter action, float speed = 1f)
: base(source, target, action, speed) { }
/// <summary>Starts the defend sequence from the bounce phase and captures the glow state.</summary>
protected override void DoInternalStart()
{
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
_effectApplied = false;
_phase = Phase.Bounce;
_bounceOffset = new Vector2(0, -12f);
}
/// <summary>Advances bounce → glow (defence application with a green flash) → settle.</summary>
protected override bool DoInternalAction()
{
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
var elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
switch (_phase)
{
@@ -51,7 +55,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.Glow;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -69,7 +73,7 @@ namespace Cthangover.FFBattle.Actions
if (result.Result)
{
Target.Flash(new Color(0.3f, 1f, 0.3f, 1f), 0.4f);
Target.Flash(UiTokens.Success, 0.4f);
if (result.Target.Defence > 0)
ShowDamageBehaviour.SpawnDefence(result.Target.Defence, Target, Target.GlobalPosition);
@@ -82,7 +86,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.Recover;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -104,11 +108,14 @@ namespace Cthangover.FFBattle.Actions
return false;
}
/// <summary>Returns the defender to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd()
{
Source.GlobalPosition = SourcePos;
if (IsAlive(Source))
Source.GlobalPosition = SourcePos;
Source.UpdateInfo();
Target.UpdateInfo();
}
}
}
+14 -7
View File
@@ -1,6 +1,7 @@
using Cthangover.Core.Characters;
using Cthangover.Core.Items;
using Cthangover.FFBattle.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle.Actions
@@ -30,9 +31,10 @@ namespace Cthangover.FFBattle.Actions
_item = item;
}
/// <summary>Spawns the raised item icon above the user and starts the item sequence.</summary>
protected override void DoInternalStart()
{
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
_effectApplied = false;
_phase = Phase.RaiseItem;
@@ -48,9 +50,10 @@ namespace Cthangover.FFBattle.Actions
}
}
/// <summary>Advances raise-item → apply (item use with effect flash) → lower-icon.</summary>
protected override bool DoInternalAction()
{
var elapsed = (float)(Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Engine.TimeScale;
var elapsed = (float)(Godot.Time.GetTicksUsec() / 1_000_000.0 - Timestamp) * Speed * (float)Godot.Engine.TimeScale;
switch (_phase)
{
@@ -70,7 +73,7 @@ namespace Cthangover.FFBattle.Actions
if (progress >= 1f)
{
_phase = Phase.ApplyEffect;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -84,7 +87,7 @@ namespace Cthangover.FFBattle.Actions
var localTarget = Target.GlobalPosition - Source.Position;
var iconStart = Source.GlobalPosition - Source.Position + new Vector2(20, -70);
_itemIcon.Position = iconStart.Lerp(localTarget + new Vector2(20, -20), EaseInOutQuad(progress));
_itemIcon.Modulate = new Color(1, 1, 1, 1f - progress * 0.5f);
_itemIcon.Modulate = UiTokens.Alpha(UiTokens.White, 1f - progress * 0.5f);
}
if (!_effectApplied && progress >= 0.3f)
@@ -96,13 +99,13 @@ namespace Cthangover.FFBattle.Actions
Source.UpdateInfo();
Target.UpdateInfo();
Target.Flash(new Color(0.5f, 1f, 0.5f, 1f), 0.3f);
Target.Flash(UiTokens.Success, 0.3f);
}
if (progress >= 1f)
{
_phase = Phase.Recover;
Timestamp = Time.GetTicksUsec() / 1_000_000.0;
Timestamp = Godot.Time.GetTicksUsec() / 1_000_000.0;
}
break;
}
@@ -124,9 +127,11 @@ namespace Cthangover.FFBattle.Actions
return false;
}
/// <summary>Frees the item icon, returns the user to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd()
{
Source.GlobalPosition = SourcePos;
if (IsAlive(Source))
Source.GlobalPosition = SourcePos;
if (_itemIcon != null)
{
@@ -139,3 +144,5 @@ namespace Cthangover.FFBattle.Actions
}
}
}
+4 -2
View File
@@ -1,3 +1,4 @@
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Utils;
@@ -12,8 +13,10 @@ namespace Cthangover.FFBattle.Actions
/// </summary>
public class FFStunExecutor : ActionBase
{
/// <summary>Hub registry key for this executor.</summary>
public override string ID => "FFStunExecutor";
/// <summary>Adds the stun effect for the action's "Turn" count (default 3); stun-immune targets consume the points but stay un-stunned.</summary>
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
{
if (target == null || user == null || !CheckRequiredAndUsePoint(action, user))
@@ -23,8 +26,7 @@ namespace Cthangover.FFBattle.Actions
return new ChangedAttributes { Result = true };
var turns = action.GetInt("Turn", 3);
target.StatusEffectQueue.Add("effect/physics/stun", turns);
StatusEffectService.Of(target).Add("effect/physics/stun", turns);
GameLogger.Log("FF_BATTLE",
$"{user.Name} stuns {target.Name} for {turns} turns",
LogLevel.Debug);
+297 -12
View File
@@ -1,15 +1,19 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Cthangover.Core.Battle;
using Cthangover.Core.Battle.Actions;
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Items;
using Cthangover.Core.Settings;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.Utils;
using Cthangover.FFBattle.UI;
using Cthangover.Core.UI.Theming;
using Godot;
namespace Cthangover.FFBattle
@@ -37,6 +41,11 @@ namespace Cthangover.FFBattle
private FFPlayerPanel _playerPanel;
private FFEnemyPanel _enemyPanel;
private FFMenuPanel _menuPanel;
private FFActionDetailPanel _detailPanel;
private FFBattleHud _hud;
private FFBattleLogPanel _logPanel;
private FFBattleBanner _banner;
private FFBattleTargetHint _targetHint;
private FFBattleController _controller;
private Control _toolPanel;
@@ -48,6 +57,12 @@ namespace Cthangover.FFBattle
private bool _targetSelectMode;
private bool _targetAlly;
private System.Action<FFCharacterWidget> _onTargetSelected;
private int _round;
private int _detailKind;
private const int DetailMain = 0;
private const int DetailActions = 1;
private const int DetailItems = 2;
private const float PLAYER_SCALE = 0.85f;
@@ -90,6 +105,33 @@ namespace Cthangover.FFBattle
_menuPanel.HideMenu();
panel.AddChild(_menuPanel);
_detailPanel = (FFActionDetailPanel)TscnScenes.LoadAndBuild("scenes/ff_action_detail_panel.tscn");
_detailPanel.Name = "FFActionDetailPanel";
_detailPanel.EnsureConstructed();
_detailPanel.HidePassport();
panel.AddChild(_detailPanel);
_hud = (FFBattleHud)TscnScenes.LoadAndBuild("scenes/ff_battle_hud.tscn");
_hud.Name = "FFBattleHud";
_hud.EnsureConstructed();
panel.AddChild(_hud);
_logPanel = (FFBattleLogPanel)TscnScenes.LoadAndBuild("scenes/ff_battle_log_panel.tscn");
_logPanel.Name = "FFBattleLogPanel";
_logPanel.EnsureConstructed();
panel.AddChild(_logPanel);
_banner = (FFBattleBanner)TscnScenes.LoadAndBuild("scenes/ff_battle_banner.tscn");
_banner.Name = "FFBattleBanner";
_banner.EnsureConstructed();
panel.AddChild(_banner);
_targetHint = (FFBattleTargetHint)TscnScenes.LoadAndBuild("scenes/ff_battle_target_hint.tscn");
_targetHint.Name = "FFBattleTargetHint";
_targetHint.EnsureConstructed();
_targetHint.HideHint();
panel.AddChild(_targetHint);
_endTurnButton = new Button();
_endTurnButton.Text = TranslationServer.Translate("ff_battle/end_turn");
_endTurnButton.Pressed += OnEndTurnPressed;
@@ -110,7 +152,9 @@ namespace Cthangover.FFBattle
_playerPanel.OnWidgetClicked += OnWidgetClicked;
_enemyPanel.OnEnemyClicked += OnEnemyClicked;
_enemyPanel.OnEnemyDead += OnEnemyDied;
_enemyPanel.OnEnemyDefeated += OnEnemyDefeated;
_menuPanel.OnItemSelected += OnMenuItemSelected;
_menuPanel.OnSelectionChanged += OnMenuSelectionChanged;
_menuPanel.OnCancelled += OnMenuCancel;
}
@@ -137,6 +181,20 @@ namespace Cthangover.FFBattle
_menuPanel.Position = new Vector2(viewportSize.X * 0.66f, viewportSize.Y * 0.58f);
_menuPanel.Size = new Vector2(viewportSize.X * 0.3f, 160f);
_detailPanel.Position = new Vector2(viewportSize.X * 0.66f - 320f, viewportSize.Y * 0.58f);
_detailPanel.Size = new Vector2(300f, 190f);
_hud.Position = new Vector2(26f, 20f);
_logPanel.Position = new Vector2(viewportSize.X - 466f, 64f);
_logPanel.Size = new Vector2(440f, 440f);
_banner.Position = new Vector2(0f, viewportSize.Y * 0.43f);
_banner.Size = new Vector2(viewportSize.X, 70f);
_targetHint.Position = new Vector2(0f, viewportSize.Y * 0.48f);
_targetHint.Size = new Vector2(viewportSize.X, 40f);
if (_endTurnButton != null)
_endTurnButton.Position = new Vector2(viewportSize.X - 180f, viewportSize.Y * 0.92f);
@@ -146,6 +204,9 @@ namespace Cthangover.FFBattle
BattleSceneContext.Instance.OnBattleCleared += OnBattleCleared;
_isPlayerTurn = true;
_round = 0;
UpdateHud();
_logPanel.AddEntry(Translate("ff_battle/log_battle_start"), FFBattleLogKind.Turn);
StartPlayerTurn();
}
@@ -159,13 +220,15 @@ namespace Cthangover.FFBattle
_currentCharacter = null;
_targetSelectMode = false;
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_targetHint.HideHint();
BattleSceneContext.Instance.ActiveCharacter = null;
foreach (var widget in _playerPanel.Widgets)
{
if (!widget.IsDead)
{
widget.Card.StatusEffectQueue.OnTurnStart();
StatusEffectService.Of(widget.Card).OnTurnStart();
widget.Card.Attributes.Point.Value = widget.Card.Attributes.Point.BaseValue;
}
widget.UpdateInfo();
@@ -179,6 +242,13 @@ namespace Cthangover.FFBattle
foreach (var widget in _playerPanel.Widgets)
widget.ClearHighlight();
_round += 1;
UpdateHud();
_hud.SetPhase(true);
_banner.Show(Translate("ff_battle/banner_player"));
_logPanel.SetRound(_round);
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_player"), _round), FFBattleLogKind.Turn);
}
private void OnWidgetClicked(FFCharacterWidget widget)
@@ -200,6 +270,7 @@ namespace Cthangover.FFBattle
_targetSelectMode = false;
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
_targetHint.HideHint();
ExecuteMenuAction(widget);
return;
@@ -249,12 +320,14 @@ namespace Cthangover.FFBattle
};
_currentMenuAction = null;
_menuPanel.ShowMenu(entries);
_detailKind = DetailMain;
_menuPanel.ShowMenu(entries, string.Format(Translate("ff_battle/menu_caption"),
ActorName(), _currentCharacter.Card.Attributes.Point.Value, _currentCharacter.Card.Attributes.Point.BaseValue));
_controller.State = FFBattleState.MenuOpen;
foreach (var widget in _playerPanel.Widgets)
widget.ClearHighlight();
_currentCharacter?.Highlight(new Color(0, 0.6f, 1f, 0.6f));
_currentCharacter?.Highlight(UiTokens.Accent);
}
private void OnMenuItemSelected(int index)
@@ -366,7 +439,8 @@ namespace Cthangover.FFBattle
Enabled = true
});
_menuPanel.ShowMenu(entries);
_detailKind = DetailActions;
_menuPanel.ShowMenu(entries, ActorName() + " · " + Translate("ff_battle/menu_actions"));
_controller.State = FFBattleState.SubMenuOpen;
}
@@ -407,7 +481,8 @@ namespace Cthangover.FFBattle
Enabled = true
});
_menuPanel.ShowMenu(entries);
_detailKind = DetailItems;
_menuPanel.ShowMenu(entries, ActorName() + " · " + Translate("ff_battle/menu_items"));
_controller.State = FFBattleState.SubMenuOpen;
}
@@ -417,6 +492,7 @@ namespace Cthangover.FFBattle
_targetAlly = targetAlly;
_menuPanel.HideMenu();
_controller.State = FFBattleState.TargetSelect;
_targetHint.ShowHint(Translate(targetAlly ? "ff_battle/hint_ally" : "ff_battle/hint_target"));
foreach (var widget in _playerPanel.Widgets)
widget.ClearHighlight();
@@ -427,13 +503,13 @@ namespace Cthangover.FFBattle
{
foreach (var widget in _playerPanel.Widgets)
if (!widget.IsDead)
widget.Highlight(new Color(0.3f, 1f, 0.3f, 0.5f));
widget.Highlight(UiTokens.Success);
}
else
{
foreach (var widget in _enemyPanel.Widgets)
if (!widget.IsDead)
widget.Highlight(new Color(1f, 0.3f, 0.3f, 0.5f));
widget.Highlight(UiTokens.Danger);
}
_onTargetSelected = null;
@@ -450,6 +526,7 @@ namespace Cthangover.FFBattle
_targetSelectMode = false;
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
_targetHint.HideHint();
ExecuteMenuAction(widget);
}
@@ -468,6 +545,7 @@ namespace Cthangover.FFBattle
_targetSelectMode = false;
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
_targetHint.HideHint();
ExecuteMenuAction(widget);
}
@@ -479,12 +557,22 @@ namespace Cthangover.FFBattle
_controller.State = FFBattleState.AnimationPlaying;
_endTurnButton.Visible = false;
_detailPanel.HidePassport();
_targetHint.HideHint();
var source = _currentCharacter;
var actionName = string.Empty;
var hpBefore = target.Card.Attributes.Health.Value;
var defBefore = target.Card.Attributes.Defence.Value;
var stunBefore = CountStunTurns(target.Card);
switch (_currentMenuAction.Key)
{
case "action":
{
var action = _currentMenuAction.Data as ActionCharacter;
actionName = TranslationServer.Translate(action.Name).ToString();
LogActionDeclared(source, actionName, target);
IBattleAction anim = null;
if (action.Type == ActionCharacterType.ToEnemy)
@@ -503,6 +591,8 @@ namespace Cthangover.FFBattle
case "item":
{
var item = _currentMenuAction.Data as IItem;
actionName = TranslationServer.Translate(item.Name).ToString();
LogActionDeclared(source, actionName, target);
var dummyAction = new ActionCharacter { ID = "ff/item" };
var anim = new Actions.FFItemAnimation(_currentCharacter, target, dummyAction, item);
await RunAnimation(anim);
@@ -510,6 +600,8 @@ namespace Cthangover.FFBattle
}
}
LogOutcome(source, target, actionName, hpBefore, defBefore, stunBefore);
_currentMenuAction = null;
_endTurnButton.Visible = true;
@@ -536,12 +628,15 @@ namespace Cthangover.FFBattle
private void DoEscapeAction()
{
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_controller.State = FFBattleState.Idle;
var escapeRoll = (float)GD.RandRange(0, 100);
if (escapeRoll < 70f)
{
GameLogger.Log("FF_BATTLE", "Escape succeeded", LogLevel.Debug);
_banner.Show(Translate("ff_battle/escape_success"));
_logPanel.AddEntry(Translate("ff_battle/log_escape_success"), FFBattleLogKind.Turn);
_ctx.EndBattle(BattleSide.Player);
}
else
@@ -549,6 +644,8 @@ namespace Cthangover.FFBattle
GameLogger.Log("FF_BATTLE", "Escape failed", LogLevel.Debug);
_currentCharacter.Card.Attributes.Point.Value -= 1;
_currentCharacter.UpdateInfo();
_banner.Show(Translate("ff_battle/escape_fail"));
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_escape_failed"), NameOf(_currentCharacter)), FFBattleLogKind.Status);
_currentCharacter = null;
BattleSceneContext.Instance.ActiveCharacter = null;
TryNextCharacterOrEndTurn();
@@ -587,6 +684,7 @@ namespace Cthangover.FFBattle
_targetSelectMode = false;
foreach (var w in _enemyPanel.Widgets) w.ClearHighlight();
foreach (var w in _playerPanel.Widgets) w.ClearHighlight();
_targetHint.HideHint();
ShowMainMenu();
return;
}
@@ -598,6 +696,7 @@ namespace Cthangover.FFBattle
}
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_controller.State = FFBattleState.Idle;
_currentCharacter = null;
_currentMenuAction = null;
@@ -634,11 +733,12 @@ namespace Cthangover.FFBattle
if (widget.IsDead || widget.Card?.Attributes?.Point?.Value <= 0)
continue;
if (widget.Card.StatusEffectQueue.HasStun())
if (StatusEffectService.Of(widget.Card).HasStun())
{
GameLogger.Log("FF_BATTLE", $"{widget.Card.Name} is stunned, skipping", LogLevel.Debug);
widget.Card.Attributes.Point.Value = 0;
widget.UpdateInfo();
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"), NameOf(widget)), FFBattleLogKind.Status);
continue;
}
@@ -648,6 +748,7 @@ namespace Cthangover.FFBattle
return;
}
_logPanel.AddEntry(Translate("ff_battle/log_no_points"), FFBattleLogKind.Info);
OnPlayerTurnEnd();
}
@@ -667,6 +768,8 @@ namespace Cthangover.FFBattle
_isPlayerTurn = false;
_endTurnButton.Visible = false;
_menuPanel.HideMenu();
_detailPanel.HidePassport();
_targetHint.HideHint();
_controller.State = FFBattleState.AnimationPlaying;
BattleSceneContext.Instance.IsWait = true;
@@ -675,6 +778,10 @@ namespace Cthangover.FFBattle
foreach (var widget in _enemyPanel.Widgets)
widget.ClearHighlight();
_hud.SetPhase(false);
_banner.Show(Translate("ff_battle/banner_enemy"));
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_enemy"), _round), FFBattleLogKind.Turn);
var root = (Node)_ctx.RootNode;
await root.ToSignal(root.GetTree(), "process_frame");
await root.ToSignal(root.GetTree().CreateTimer(0.5f), "timeout");
@@ -693,11 +800,13 @@ namespace Cthangover.FFBattle
if (enemyWidget.IsDead || enemyWidget.Card?.Actions == null || enemyWidget.Card.Actions.Count == 0)
continue;
enemyWidget.Card.StatusEffectQueue.OnTurnStart();
StatusEffectService.Of(enemyWidget.Card).OnTurnStart();
enemyWidget.UpdateInfo();
if (enemyWidget.Card.StatusEffectQueue.HasStun())
if (StatusEffectService.Of(enemyWidget.Card).HasStun())
{
GameLogger.Log("FF_BATTLE", $"{enemyWidget.Card.Name} is stunned, skipping", LogLevel.Debug);
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"), NameOf(enemyWidget)), FFBattleLogKind.Status);
continue;
}
@@ -715,6 +824,12 @@ namespace Cthangover.FFBattle
$"Enemy: {enemyWidget.Card.Name} → {action.Name} → {target.Card.Name}",
LogLevel.Debug);
var actionName = TranslationServer.Translate(action.Name).ToString();
LogActionDeclared(enemyWidget, actionName, target);
var hpBefore = target.Card.Attributes.Health.Value;
var defBefore = target.Card.Attributes.Defence.Value;
var stunBefore = CountStunTurns(target.Card);
var anim = CreateEnemyAnimation(enemyWidget, target, action);
if (anim != null)
{
@@ -724,6 +839,8 @@ namespace Cthangover.FFBattle
anim.DoEnd();
}
LogOutcome(enemyWidget, target, actionName, hpBefore, defBefore, stunBefore);
await root.ToSignal(root.GetTree().CreateTimer(0.2f), "timeout");
if (!_playerPanel.HasAlive())
@@ -793,7 +910,7 @@ namespace Cthangover.FFBattle
OnPlayerTurnEnd();
}
private void OnEnemyDied(FFCharacterWidget widget)
private void OnEnemyDefeated(FFCharacterWidget widget)
{
var ctx = BattleSceneContext.Instance;
if (ctx != null && widget?.Card != null)
@@ -801,6 +918,11 @@ namespace Cthangover.FFBattle
ctx.RecordEnemyDefeated(widget.Card);
ctx.NotifyCharacterDied(widget.Card);
}
}
private void OnEnemyDied(FFCharacterWidget widget)
{
UpdateHudCounts();
if (!_enemyPanel.HasAlive())
{
@@ -851,10 +973,170 @@ namespace Cthangover.FFBattle
_playerPanel?.ClearAll();
_enemyPanel?.ClearAll();
_menuPanel?.HideMenu();
_detailPanel?.HidePassport();
_targetHint?.HideHint();
_logPanel?.Clear();
if (_endTurnButton != null)
_endTurnButton.Visible = false;
}
private void OnMenuSelectionChanged(int index)
{
if (_menuPanel.Entries == null || index < 0 || index >= _menuPanel.Entries.Count)
{
_detailPanel.HidePassport();
return;
}
var entry = _menuPanel.Entries[index];
_detailPanel.ShowPassport();
if (_detailKind == DetailActions)
{
if (entry.Data is ActionCharacter action)
_detailPanel.ShowAction(action, BuildActionNote(action));
else
_detailPanel.ShowHint(Translate("ff_battle/detail_back"));
return;
}
if (_detailKind == DetailItems)
{
if (entry.Data is IItem item)
_detailPanel.ShowItem(item, item.ItemType.HasFlag(ItemType.TargetUsed), BuildItemNote(item));
else
_detailPanel.ShowHint(Translate("ff_battle/detail_back"));
return;
}
_detailPanel.ShowHint(MainHintFor(entry.Key));
}
private string BuildActionNote(ActionCharacter action)
{
if (action.Properties == null)
return TranslationServer.Translate("ff_battle/detail_action").ToString();
var cost = action.GetInt("RequiredPoint", 1);
var attack = action.GetFloat("Attack", 0f);
if (attack > 0f)
{
var attacker = _currentCharacter?.Card?.Attributes?.Attack?.Value ?? 0;
return string.Format(Translate("ff_battle/detail_damage"),
Mathf.RoundToInt(attack * attacker), attacker, attack.ToString("0.0"), cost);
}
var turns = action.GetInt("Turn", 0);
if (turns > 0)
return string.Format(Translate("ff_battle/detail_stun"), turns, cost);
var defence = action.GetInt("Defence", 0);
if (defence > 0)
return string.Format(Translate("ff_battle/detail_defence"), defence, cost);
var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(action.Description));
return !string.IsNullOrEmpty(description) ? description : Translate("ff_battle/detail_action");
}
private static string BuildItemNote(IItem item)
{
var description = ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(item.Description));
return !string.IsNullOrEmpty(description)
? description
: TranslationServer.Translate("ff_battle/detail_item").ToString();
}
private static string MainHintFor(string key)
{
switch (key)
{
case "attack": return TranslationServer.Translate("ff_battle/detail_attack").ToString();
case "items": return TranslationServer.Translate("ff_battle/detail_items").ToString();
case "defend": return TranslationServer.Translate("ff_battle/detail_defend").ToString();
case "escape": return TranslationServer.Translate("ff_battle/detail_escape").ToString();
default: return TranslationServer.Translate("ff_battle/detail_back").ToString();
}
}
private void LogActionDeclared(FFCharacterWidget source, string actionName, FFCharacterWidget target)
{
var entry = source == target
? string.Format(Translate("ff_battle/log_action_self"), NameOf(source), actionName)
: string.Format(Translate("ff_battle/log_action"), NameOf(source), actionName, NameOf(target));
_logPanel.AddEntry(entry, FFBattleLogKind.Info);
}
private void LogOutcome(FFCharacterWidget source, FFCharacterWidget target, string actionName,
int hpBefore, int defBefore, int stunBefore)
{
var hpAfter = target.Card.Attributes.Health.Value;
var defAfter = target.Card.Attributes.Defence.Value;
var stunAfter = CountStunTurns(target.Card);
if (defAfter > defBefore)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_defence"),
NameOf(target), defAfter - defBefore, defAfter), FFBattleLogKind.Buff);
var shieldLost = defBefore - defAfter;
if (shieldLost > 0)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_shield"),
NameOf(target), shieldLost, defAfter), FFBattleLogKind.Shield);
if (stunAfter > stunBefore)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun"),
NameOf(target), stunAfter), FFBattleLogKind.Status);
if (hpAfter > hpBefore)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_heal"),
actionName, NameOf(target), hpAfter - hpBefore), FFBattleLogKind.Buff);
if (hpAfter < hpBefore)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_damage"),
actionName, NameOf(source), NameOf(target), hpBefore - hpAfter), FFBattleLogKind.Damage);
if (hpAfter <= 0 && hpBefore > 0)
_logPanel.AddEntry(string.Format(Translate("ff_battle/log_death"), NameOf(target)), FFBattleLogKind.Dead);
UpdateHudCounts();
}
private static int CountStunTurns(Character character)
{
var stun = StatusEffectService.Of(character)?.ActiveStatusEffects?
.FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
return stun?.RemainingTurns ?? 0;
}
private string ActorName()
=> _currentCharacter?.Card == null
? string.Empty
: TranslationServer.Translate(_currentCharacter.Card.Name).ToString();
private static string NameOf(FFCharacterWidget widget)
=> widget?.Card == null
? string.Empty
: TranslationServer.Translate(widget.Card.Name).ToString();
private void UpdateHud()
{
if (_hud == null)
return;
_hud.SetRound(_round);
UpdateHudCounts();
}
private void UpdateHudCounts()
{
if (_hud == null)
return;
_hud.SetCounts(
_enemyPanel.Widgets.Count(w => !w.IsDead),
_playerPanel.Widgets.Count(w => !w.IsDead));
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
/// <summary>
/// <c>true</c> while the core accepts a player turn advance; flips to
/// <c>false</c> synchronously at the start of <see cref="OnPlayerTurnEnd"/>.
@@ -868,3 +1150,6 @@ namespace Cthangover.FFBattle
public void AdvancePlayerTurn() => OnPlayerTurnEnd();
}
}
+182
View File
@@ -0,0 +1,182 @@
using Cthangover.Core.Characters;
using Cthangover.Core.Items;
using Cthangover.Core.UI;
using Cthangover.Core.UI.CharacterPanel;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Action/item passport shown to the left of the command menu. It mirrors the
/// action-card component of the unified style guide: a type accent stripe, the
/// action icon, name and ID, type/cost/cooldown badges, property chips and a
/// note line with the computed effect preview. The panel exists so the small
/// FF-style menu can stay a compact list while the player still sees what the
/// highlighted command actually does. Node structure lives in
/// <c>scenes/ff_action_detail_panel.tscn</c>; the class fills it and reuses
/// <see cref="ActionCharacterUi"/> for badges and properties so the battle and
/// the character panel can never disagree about an action's presentation.
/// </summary>
public partial class FFActionDetailPanel : ModWidget
{
private PanelContainer _card;
private ColorRect _stripe;
private TextureRect _icon;
private Label _nameLabel;
private Label _idLabel;
private HFlowContainer _badges;
private HFlowContainer _props;
private PanelContainer _note;
private Label _noteLabel;
/// <summary>Builds the action detail passport panel (description plate shown when an action is inspected).</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_card = GetNode<PanelContainer>("Box/Card");
_stripe = GetNode<ColorRect>("Box/Card/CardRow/Stripe");
_icon = GetNode<TextureRect>("Box/Card/CardRow/IconMargin/CardIcon");
_nameLabel = GetNode<Label>("Box/Card/CardRow/Body/NameRow/NameLabel");
_idLabel = GetNode<Label>("Box/Card/CardRow/Body/NameRow/IdLabel");
_badges = GetNode<HFlowContainer>("Box/Card/CardRow/Body/Badges");
_props = GetNode<HFlowContainer>("Box/Card/CardRow/Body/Props");
_note = GetNode<PanelContainer>("Box/Note");
_noteLabel = GetNode<Label>("Box/Note/NoteLabel");
var cardStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Alpha(UiTokens.Surface1, 0.92f), UiTokens.LineCard);
cardStyle.ContentMarginLeft = 0;
cardStyle.ContentMarginRight = 0;
cardStyle.ContentMarginTop = 0;
cardStyle.ContentMarginBottom = 0;
_card.AddThemeStyleboxOverride("panel", cardStyle);
var noteStyle = UiKit.Frame(UiTokens.RadiusMd, UiTokens.Alpha(UiTokens.Surface2, 0.9f), UiTokens.Line);
noteStyle.ContentMarginLeft = 12;
noteStyle.ContentMarginRight = 12;
noteStyle.ContentMarginTop = 9;
noteStyle.ContentMarginBottom = 9;
_note.AddThemeStyleboxOverride("panel", noteStyle);
_nameLabel.AddThemeColorOverride("font_color", UiTokens.InkBright);
_idLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
_noteLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
}
/// <summary>
/// Fills the card with an action passport. <paramref name="note"/> is a
/// ready localised effect preview built by the core, because only the core
/// knows the acting character's attack value for the damage estimate.
/// </summary>
public void ShowAction(ActionCharacter action, string note)
{
if (action == null)
{
ShowHint(note);
return;
}
_stripe.Color = ActionCharacterUi.AccentColor(action.Type);
_icon.Texture = action.Image;
_nameLabel.Text = ActionCharacterUi.Translate(action.Name);
_idLabel.Text = action.ID ?? string.Empty;
_card.Visible = true;
ResetBody();
AddBadge(ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(action.Type)), ActionCharacterUi.AccentColor(action.Type));
var costText = ActionCharacterUi.CostText(action);
if (!string.IsNullOrEmpty(costText))
AddBadge(costText, UiTokens.TypeResource);
var turns = action.GetInt("Turn", 0);
if (turns > 0)
AddBadge(string.Format(Translate("ff_battle/badge_turns"), turns), UiTokens.Cooldown);
foreach (var pair in ActionCharacterUi.SortedProperties(action))
{
if (pair.Key == "RequiredPoint")
continue;
_props.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key, pair.Value));
}
SetNote(note);
}
/// <summary>Fills the card with an inventory item passport for the item sub-menu.</summary>
public void ShowItem(IItem item, bool targetAlly, string note)
{
if (item == null)
{
ShowHint(note);
return;
}
_stripe.Color = UiTokens.TypeUsed;
_icon.Texture = item.Sprite;
_nameLabel.Text = ActionCharacterUi.Translate(item.Name);
_idLabel.Text = item.ID ?? string.Empty;
_card.Visible = true;
ResetBody();
if (item.ItemType.HasFlag(ItemType.Used))
AddBadge(ActionCharacterUi.Translate("ui/inventory/type_used"), UiTokens.TypeUsed);
AddBadge(ActionCharacterUi.Translate(targetAlly ? "ui/inventory/type_target" : "ff_battle/badge_self"),
targetAlly ? UiTokens.TypeTarget : UiTokens.TypeSelf);
SetNote(note);
}
/// <summary>Shows a plain directive note under the card, used for highlighted menu hints.</summary>
public void ShowHint(string note)
{
ResetBody();
_card.Visible = false;
SetNote(note);
}
/// <summary>
/// Hides the whole passport; the core calls it when the menu closes. The
/// inherited <see cref="CanvasItem.Hide"/> semantics are enough, so the
/// method is only a named alias for readability at call sites.
/// </summary>
public void HidePassport()
=> Visible = false;
/// <summary>Shows the panel; the core calls it before filling any content.</summary>
public void ShowPassport()
=> Visible = true;
private void ResetBody()
{
foreach (var child in _badges.GetChildren())
{
_badges.RemoveChild(child);
child.QueueFree();
}
foreach (var child in _props.GetChildren())
{
_props.RemoveChild(child);
child.QueueFree();
}
}
private void AddBadge(string text, Color color)
=> _badges.AddChild(ActionCharacterUi.MakeBadge(text, color));
private void SetNote(string note)
{
var hasNote = !string.IsNullOrEmpty(note);
_note.Visible = hasNote;
_noteLabel.Text = note ?? string.Empty;
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+55
View File
@@ -0,0 +1,55 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Centered announcement strip ("Player turn", "Enemy turn", escape results).
/// The node tree lives in <c>scenes/ff_battle_banner.tscn</c>; this class
/// dresses it with tokens and owns the fade in/hold/fade out tween. The banner
/// is purely informational, so it ignores the mouse and the core places it on
/// its own anchored strip between the enemy grid and the party row.
/// </summary>
public partial class FFBattleBanner : ModWidget
{
private Label _label;
private Tween _tween;
/// <summary>Builds the centre-screen turn/outcome banner label.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_label = GetNode<Label>("Center/Panel/Label");
_label.AddThemeColorOverride("font_color", UiTokens.InkBright);
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;
GetNode<PanelContainer>("Center/Panel").AddThemeStyleboxOverride("panel", style);
Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
/// <summary>Shows the given caption and restarts the 1.6s fade cycle.</summary>
public void Show(string text)
{
_label.Text = text;
_tween?.Kill();
Modulate = UiTokens.Alpha(UiTokens.White, 0f);
_tween = CreateTween();
_tween.TweenProperty(this, "modulate:a", 1f, 0.2f);
_tween.TweenInterval(1.1f);
_tween.TweenProperty(this, "modulate:a", 0f, 0.3f);
}
}
}
+2
View File
@@ -1,6 +1,7 @@
using Cthangover.Core.UI;
using Godot;
using Cthangover.Mods.Interface.Base;
namespace Cthangover.FFBattle.UI
{
/// <summary>
@@ -77,6 +78,7 @@ namespace Cthangover.FFBattle.UI
ProcessMode = ProcessModeEnum.Always;
}
/// <summary>Routes menu/confirm input into the FF battle state machine (target select, turn advance).</summary>
protected override void OnInput(InputEvent @event)
{
if (State == FFBattleState.AnimationPlaying || State == FFBattleState.Ended)
+92
View File
@@ -0,0 +1,92 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Top-left battle HUD: title, phase badge and the round / roster counters.
/// The node tree comes from <c>scenes/ff_battle_hud.tscn</c>; this class only
/// fetches nodes, dresses them with <see cref="UiTokens"/> styles and exposes
/// narrow setters so <see cref="FFBattleCore"/> never assembles controls by
/// hand. The phase badge reuses the type-accent palette: blue for the player
/// turn, red for the enemy turn.
/// </summary>
public partial class FFBattleHud : ModWidget
{
private Label _title;
private PanelContainer _phaseBadge;
private Label _phaseLabel;
private Label _roundValue;
private Label _enemiesValue;
private Label _alliesValue;
/// <summary>Builds the FF battle HUD frame: command menu host, speed control and log toggle.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_title = GetNode<Label>("Panel/Column/TopRow/TitleLabel");
_phaseBadge = GetNode<PanelContainer>("Panel/Column/TopRow/PhaseBadge");
_phaseLabel = GetNode<Label>("Panel/Column/TopRow/PhaseBadge/PhaseLabel");
_roundValue = GetNode<Label>("Panel/Column/MetaRow/RoundValue");
_enemiesValue = GetNode<Label>("Panel/Column/MetaRow/EnemiesValue");
_alliesValue = GetNode<Label>("Panel/Column/MetaRow/AlliesValue");
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);
_title.AddThemeColorOverride("font_color", UiTokens.InkBright);
foreach (var name in new[] { "RoundCaption", "EnemiesCaption", "AlliesCaption" })
GetNode<Label>("Panel/Column/MetaRow/" + name).AddThemeColorOverride("font_color", UiTokens.Muted);
foreach (var name in new[] { "RoundValue", "EnemiesValue", "AlliesValue" })
GetNode<Label>("Panel/Column/MetaRow/" + name).AddThemeColorOverride("font_color", UiTokens.AccentBright);
ApplyPhaseStyle(true);
}
/// <summary>Switches the phase badge between the player and enemy turns.</summary>
public void SetPhase(bool playerTurn)
{
_phaseLabel.Text = Translate(playerTurn ? "ff_battle/phase_player" : "ff_battle/phase_enemy");
ApplyPhaseStyle(playerTurn);
}
/// <summary>Updates the round counter.</summary>
public void SetRound(int round)
=> _roundValue.Text = round.ToString();
/// <summary>Updates living combatant counters.</summary>
public void SetCounts(int enemies, int allies)
{
_enemiesValue.Text = enemies.ToString();
_alliesValue.Text = allies.ToString();
}
private void ApplyPhaseStyle(bool playerTurn)
{
var accent = playerTurn ? UiTokens.TypeSelf : 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);
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Semantic categories of <see cref="FFBattleLogPanel"/> entries. The kind
/// selects the row colour and the left border accent: turn separators are
/// gold, damage red, absorbed shield blue, buffs green, statuses amber and
/// deaths grey. <see cref="Info"/> is the neutral narration fallback.
/// </summary>
public enum FFBattleLogKind
{
Info,
Turn,
Damage,
Shield,
Buff,
Status,
Dead
}
}
+133
View File
@@ -0,0 +1,133 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Battle journal docked to the upper right: a window with a header carrying
/// the round counter and a scrollable list of colour-coded rows. The panel is
/// a proposal on top of the bare battle screen — it gives damage, shield
/// absorption, status and death events a readable history that the original
/// FF-style UI lacked. Node structure lives in
/// <c>scenes/ff_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.
/// </summary>
public partial class FFBattleLogPanel : ModWidget
{
private const int MaxRows = 80;
private Label _title;
private Label _roundLabel;
private ScrollContainer _scroll;
private VBoxContainer _rows;
/// <summary>Builds the scrollable FF battle log with coloured entries per <see cref="FFBattleLogKind"/>.</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.AddThemeColorOverride("font_color", UiTokens.Muted);
_roundLabel.AddThemeColorOverride("font_color", UiTokens.Faint);
}
/// <summary>Updates the round caption in the header.</summary>
public void SetRound(int round)
=> _roundLabel.Text = string.Format(Translate("ff_battle/log_round"), round);
/// <summary>Drops every row; used 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 coloured row and trims the oldest entries past the cap.
/// The scroll is pushed to the bottom so the newest event is always visible.
/// </summary>
public void AddEntry(string text, FFBattleLogKind kind)
{
var style = UiKit.Frame(0, BackgroundOf(kind), new Color(0f, 0f, 0f, 0f));
style.BorderWidthLeft = 2;
style.BorderColor = BorderOf(kind);
style.ContentMarginLeft = 16;
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(FFBattleLogKind kind)
=> kind == FFBattleLogKind.Turn ? UiTokens.AccentSoft : new Color(0f, 0f, 0f, 0f);
private static Color BorderOf(FFBattleLogKind kind)
{
switch (kind)
{
case FFBattleLogKind.Turn: return UiTokens.Accent;
case FFBattleLogKind.Damage: return UiTokens.Danger;
case FFBattleLogKind.Shield: return UiTokens.TypeResource;
case FFBattleLogKind.Buff: return UiTokens.TypePassive;
case FFBattleLogKind.Status: return UiTokens.Cooldown;
case FFBattleLogKind.Dead: return UiTokens.LineFaint;
default: return UiTokens.Line;
}
}
private static Color TextOf(FFBattleLogKind kind)
{
switch (kind)
{
case FFBattleLogKind.Turn: return UiTokens.AccentBright;
case FFBattleLogKind.Damage: return UiTokens.DangerText;
case FFBattleLogKind.Shield: return UiTokens.Info;
case FFBattleLogKind.Buff: return UiTokens.SuccessText;
case FFBattleLogKind.Status: return UiTokens.Cooldown;
case FFBattleLogKind.Dead: return UiTokens.Faint;
default: return UiTokens.Muted;
}
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+54
View File
@@ -0,0 +1,54 @@
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Toast-style directive shown while the player picks a battle target
/// ("Select a target · RMB / Esc — cancel"). It is a deliberate addition over
/// the bare FF UI: the original mod highlighted widgets without ever telling
/// the player what the highlight meant, which is easy to miss on a large enemy
/// grid. The node tree lives in <c>scenes/ff_battle_target_hint.tscn</c>; the
/// class dresses it with tokens and toggles visibility.
/// </summary>
public partial class FFBattleTargetHint : ModWidget
{
private Label _icon;
private Label _text;
/// <summary>Builds the floating hint shown above the hovered target.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_icon = GetNode<Label>("Center/Toast/Row/IconLabel");
_text = GetNode<Label>("Center/Toast/Row/TextLabel");
_icon.AddThemeColorOverride("font_color", UiTokens.AccentBright);
_text.AddThemeColorOverride("font_color", UiTokens.Ink);
var style = UiKit.Frame(UiTokens.RadiusMd, UiTokens.ToastBg, UiTokens.Line);
style.ContentMarginLeft = 14;
style.ContentMarginRight = 14;
style.ContentMarginTop = 10;
style.ContentMarginBottom = 10;
style.ShadowColor = new Color(0f, 0f, 0f, 0.5f);
style.ShadowSize = 10;
style.ShadowOffset = new Vector2(0f, 4f);
GetNode<PanelContainer>("Center/Toast").AddThemeStyleboxOverride("panel", style);
}
/// <summary>Shows the hint with the given caption.</summary>
public void ShowHint(string text)
{
_text.Text = text;
Visible = true;
}
/// <summary>Hides the hint; called when a target is confirmed or selection is cancelled.</summary>
public void HideHint()
=> Visible = false;
}
}
+331 -75
View File
@@ -1,30 +1,63 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Battle;
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.FFBattle.UI
{
/// <summary>
/// Visual representation of a single character (player or enemy) in the FF battle UI.
/// Composite widget containing a sprite, HP bar, name label, and selection overlay.
/// Supports highlight, flash, shake, and dissolve-death animations via Godot tweens.
/// Created per-character by <see cref="FFPlayerPanel.Init"/> and
/// <see cref="FFEnemyPanel.Init"/>. Clickable via <c>GuiInput</c> events;
/// embedding panels wire these to <see cref="FFBattleCore"/> for action flow.
/// Battle card of a single combatant, shared by the party and enemy panels.
/// The node tree lives in <c>scenes/ff_character_widget.tscn</c>; this class
/// only fetches the nodes, dresses them with <see cref="UiTokens"/> styles and
/// refreshes their content. Visually the card follows the unified style guide:
/// a team accent line on top, a sprite well, status badges in the upper right
/// and an info block holding name, HP numbers, the layered health bar
/// (<see cref="UiKit.StyleShieldedHealthBar"/>: track → health fill → blue
/// defence segment drawn past the health edge while
/// <see cref="CharacterAttributes.Defence"/> absorbs damage), action point pips
/// (party) or attack caption (enemies) and the defence shield chip.
/// <para/>
/// The public surface is animation-facing, not layout-facing:
/// <see cref="Highlight"/> paints the selection frame, <see cref="Flash"/> and
/// <see cref="Shake"/> give impact feedback, <see cref="PlayDeathAnimation"/>
/// dissolves the card. Battle code never touches labels directly — it mutates
/// the <see cref="Character"/> and calls <see cref="UpdateInfo"/>, which
/// re-reads health, defence, points and stun in one place. Panels must assign
/// <see cref="IsPlayer"/> before <see cref="Init"/>, because the team colour is
/// applied during initialisation.
/// </summary>
public partial class FFCharacterWidget : ModWidget
{
private TextureRect _sprite;
private TextureRect _selection;
/// <summary>Diameter of one action point pip.</summary>
private const int PipSize = 9;
private TextureRect _art;
private ColorRect _teamLine;
private Panel _selection;
private Panel _teamDot;
private Label _nameLabel;
private ColorRect _hpBg;
private ColorRect _hpFill;
private Label _hpNumLabel;
private ProgressBar _shieldBar;
private ProgressBar _hpBar;
private HBoxContainer _pipsBox;
private Label _atkLabel;
private PanelContainer _shieldChip;
private Label _shieldValue;
private VBoxContainer _statusBox;
private PanelContainer _stunBadge;
private TextureRect _stunIcon;
private Label _stunLabel;
private Tween _animTween;
private static Texture2D _cachedSelectTex;
private static bool _selectTexLoaded;
private VBoxContainer _gaugesBox;
private bool _gaugesSubscribed;
private static Shader _dissolveShader;
/// <summary>Whether this widget belongs to the player party (affects highlight/targeting logic).</summary>
@@ -33,40 +66,47 @@ namespace Cthangover.FFBattle.UI
public bool IsDead { get; set; }
/// <summary>The character data model backing this widget: stats, actions, status effects.</summary>
public Character Card { get; set; }
/// <summary>Original scale captured at construction; used to restore after death animation.</summary>
/// <summary>Original scale captured at initialisation; used to restore after death animation.</summary>
public Vector2 BaseScale { get; private set; }
/// <summary>Exposes this widget as a <see cref="Godot.Control"/> for layout calculations.</summary>
public Control ControlNode => this;
/// <summary>Builds the character widget: portrait frame, HP bar and status chips.</summary>
protected override void Construct()
{
_sprite = GetNode<TextureRect>("Sprite");
_hpBg = GetNode<ColorRect>("HpBg");
_hpFill = GetNode<ColorRect>("HpFill");
_nameLabel = GetNode<Label>("NameLabel");
_selection = GetNode<TextureRect>("Selection");
CthangoverTheme.Apply(this);
Size = CustomMinimumSize;
_teamLine = GetNode<ColorRect>("TeamLine");
_art = GetNode<TextureRect>("Art");
_nameLabel = GetNode<Label>("Info/Column/NameRow/NameLabel");
_teamDot = GetNode<Panel>("Info/Column/NameRow/TeamDot");
_hpNumLabel = GetNode<Label>("Info/Column/NameRow/HpNumLabel");
_shieldBar = GetNode<ProgressBar>("Info/Column/HpBarStack/ShieldBar");
_hpBar = GetNode<ProgressBar>("Info/Column/HpBarStack/HpBar");
_pipsBox = GetNode<HBoxContainer>("Info/Column/Foot/PipsBox");
_atkLabel = GetNode<Label>("Info/Column/Foot/AtkLabel");
_shieldChip = GetNode<PanelContainer>("Info/Column/Foot/ShieldChip");
_shieldValue = GetNode<Label>("Info/Column/Foot/ShieldChip/ShieldRow/ShieldValue");
_statusBox = GetNode<VBoxContainer>("StatusBox");
_stunBadge = GetNode<PanelContainer>("StatusBox/StunBadge");
_stunIcon = GetNode<TextureRect>("StatusBox/StunBadge/StunRow/StunIcon");
_stunLabel = GetNode<Label>("StatusBox/StunBadge/StunRow/StunLabel");
_selection = GetNode<Panel>("Selection");
if (!_selectTexLoaded)
{
_cachedSelectTex = Textures.Resolve("select");
_selectTexLoaded = true;
}
if (_cachedSelectTex != null)
{
_selection.Texture = _cachedSelectTex;
_selection.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize;
_selection.StretchMode = TextureRect.StretchModeEnum.Scale;
_selection.Modulate = new Color(0, 0, 0, 0);
}
// Extra bars of other mods (mana of battle_formulas…) go under the health bar.
var column = GetNode<VBoxContainer>("Info/Column");
_gaugesBox = new VBoxContainer { Name = "Gauges", MouseFilter = MouseFilterEnum.Ignore, Visible = false };
_gaugesBox.AddThemeConstantOverride("separation", 1);
column.AddChild(_gaugesBox);
column.MoveChild(_gaugesBox, GetNode<Control>("Info/Column/HpBarStack").GetIndex() + 1);
ApplyStyles();
}
/// <summary>
/// Binds this widget to a <see cref="Character"/> model. Sets the sprite from
/// <see cref="Character.Image"/>, localises the name via
/// <see cref="TranslationServer"/>, and calls <see cref="UpdateInfo"/>
/// to refresh the HP bar.
/// Binds the widget to a <see cref="Character"/>: sprite, localised name,
/// team colour and per-side info layout. The team colour is derived from
/// <see cref="IsPlayer"/>, which is why callers set it before calling here.
/// </summary>
public void Init(Character character)
{
@@ -74,33 +114,149 @@ namespace Cthangover.FFBattle.UI
BaseScale = Scale;
IsDead = false;
if (_sprite != null && character.Image != null)
_sprite.Texture = character.Image;
if (_art != null && character?.Image != null)
_art.Texture = character.Image;
if (_nameLabel != null)
_nameLabel.Text = TranslationServer.Translate(character.Name);
_nameLabel.Text = TranslationServer.Translate(character?.Name ?? string.Empty).ToString();
var teamColor = IsPlayer ? UiTokens.Info : UiTokens.Danger;
if (_teamLine != null)
_teamLine.Color = UiTokens.Alpha(teamColor, 0.55f);
if (_teamDot != null)
_teamDot.AddThemeStyleboxOverride("panel", UiKit.Frame(4, UiTokens.Alpha(teamColor, 0.9f)));
if (_pipsBox != null)
_pipsBox.Visible = IsPlayer;
if (_atkLabel != null)
_atkLabel.Visible = !IsPlayer;
if (!_gaugesSubscribed)
{
_gaugesSubscribed = true;
BattleUnitGauges.Changed += OnGaugesChanged;
TreeExiting += () => BattleUnitGauges.Changed -= OnGaugesChanged;
}
UpdateInfo();
}
/// <summary>Refreshes the HP bar fill width and colour gradient based on current health percentage.</summary>
/// <summary>
/// Single refresh entry point for all mutable character state: layered
/// HP/defence bar and numbers with threshold colours, action point pips,
/// shield chip, attack caption and the stun badge with its remaining
/// turns. Called by battle code and by animations after each resolved
/// action.
/// </summary>
public void UpdateInfo()
{
if (_hpFill == null || Card == null)
// A lethal hit frees the widget through the death animation while
// the attack animation and Health.OnChange subscribers still hold it.
if (!IsInstanceValid(this) || IsQueuedForDeletion() || Card?.Attributes == null)
return;
var hpPercent = Card.Attributes.Health.Percent;
_hpFill.Size = new Vector2((Size.X - 8) * hpPercent, 18);
var health = Card.Attributes.Health;
var percent = Mathf.Clamp(health.Percent, 0f, 1f);
var defence = Card.Attributes.Defence?.Value ?? 0;
var fillColor = percent > 0.5f ? UiTokens.HpOk : percent > 0.25f ? UiTokens.Warning : UiTokens.HpLow;
var textColor = percent > 0.5f ? UiTokens.HpText : percent > 0.25f ? UiTokens.Warning : UiTokens.DangerText;
if (hpPercent > 0.5f)
_hpFill.Color = new Color(0.1f, 0.85f, 0.1f, 1f);
else if (hpPercent > 0.25f)
_hpFill.Color = new Color(0.85f, 0.85f, 0.1f, 1f);
else
_hpFill.Color = new Color(0.85f, 0.1f, 0.1f, 1f);
if (_hpBar != null)
{
_hpBar.Value = percent * 100f;
_hpBar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusSm, fillColor));
}
// Blue defence segment on top of the health fill; collapses onto
// the health edge when the shield is gone.
UiKit.UpdateShieldedHealthBar(_shieldBar, percent, defence, health.BaseValue);
if (_hpNumLabel != null)
{
_hpNumLabel.Text = $"{health.Value}/{health.BaseValue}";
_hpNumLabel.AddThemeColorOverride("font_color", textColor);
}
if (_pipsBox != null && IsPlayer)
RefreshPips();
if (_atkLabel != null && !IsPlayer)
_atkLabel.Text = string.Format("{0} {1}", Translate("ui/character_panel/stat_atk"), Card.Attributes.Attack.Value);
if (_shieldChip != null)
{
_shieldChip.Visible = defence > 0;
if (_shieldValue != null)
_shieldValue.Text = defence.ToString();
}
RefreshStun();
RefreshGauges();
}
/// <summary>Shows a coloured selection overlay with a 0.15s tween fade-in. Used for target highlighting and selection indication.</summary>
private void OnGaugesChanged(string unitId)
{
if (unitId == null || unitId == Card?.ID)
RefreshGauges();
}
/// <summary>
/// Draws the <see cref="BattleUnitGauges"/> of a party member, one thin bar with its label
/// and numbers per gauge; rows are reused by position.
/// </summary>
private void RefreshGauges()
{
if (_gaugesBox == null || !IsInstanceValid(this) || IsQueuedForDeletion())
return;
var gauges = IsPlayer && Card != null ? BattleUnitGauges.Read(Card) : new List<BattleGauge>();
_gaugesBox.Visible = gauges.Count > 0;
while (_gaugesBox.GetChildCount() > gauges.Count)
{
var extra = _gaugesBox.GetChild(_gaugesBox.GetChildCount() - 1);
_gaugesBox.RemoveChild(extra);
extra.QueueFree();
}
for (var i = 0; i < gauges.Count; i++)
{
var row = i < _gaugesBox.GetChildCount() ? (HBoxContainer)_gaugesBox.GetChild(i) : CreateGaugeRow();
var gauge = gauges[i];
row.GetNode<Label>("Key").Text = TranslationServer.Translate(gauge.Label ?? gauge.Id ?? string.Empty).ToString();
var bar = row.GetNode<ProgressBar>("Bar");
bar.Value = Mathf.Clamp(gauge.Current / (float)gauge.Max, 0f, 1f) * 100f;
var color = string.IsNullOrWhiteSpace(gauge.Color) ? UiTokens.Info : Color.FromString(gauge.Color, UiTokens.Info);
bar.AddThemeStyleboxOverride("fill", UiKit.Frame(UiTokens.RadiusSm, color));
row.GetNode<Label>("Value").Text = $"{gauge.Current}/{gauge.Max}";
}
}
private HBoxContainer CreateGaugeRow()
{
var row = new HBoxContainer { MouseFilter = MouseFilterEnum.Ignore };
row.AddThemeConstantOverride("separation", 4);
var key = new Label { Name = "Key", MouseFilter = MouseFilterEnum.Ignore };
key.AddThemeFontSizeOverride("font_size", UiTokens.FontTiny);
key.AddThemeColorOverride("font_color", UiTokens.Muted);
var bar = new ProgressBar
{
Name = "Bar",
ShowPercentage = false,
MaxValue = 100,
CustomMinimumSize = new Vector2(0, 5),
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ShrinkCenter,
MouseFilter = MouseFilterEnum.Ignore,
};
bar.AddThemeStyleboxOverride("background", UiKit.Frame(UiTokens.RadiusSm, UiTokens.StatBg));
var value = new Label { Name = "Value", MouseFilter = MouseFilterEnum.Ignore };
value.AddThemeFontSizeOverride("font_size", UiTokens.FontTiny);
value.AddThemeColorOverride("font_color", UiTokens.Info);
row.AddChild(key);
row.AddChild(bar);
row.AddChild(value);
_gaugesBox.AddChild(row);
return row;
}
/// <summary>Shows a coloured selection frame over the card with a 0.15s fade.</summary>
public void Highlight(Color color)
{
if (_selection == null)
@@ -112,7 +268,7 @@ namespace Cthangover.FFBattle.UI
_animTween.TweenProperty(_selection, "modulate", color, 0.15f);
}
/// <summary>Fades out the selection overlay to transparent via a 0.15s tween.</summary>
/// <summary>Fades the selection frame out via a 0.15s tween.</summary>
public void ClearHighlight()
{
if (_selection == null)
@@ -121,10 +277,10 @@ namespace Cthangover.FFBattle.UI
_animTween?.Kill();
_animTween = CreateTween();
_animTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);
_animTween.TweenProperty(_selection, "modulate", new Color(0, 0, 0, 0), 0.15f);
_animTween.TweenProperty(_selection, "modulate", UiTokens.Alpha(UiTokens.White, 0f), 0.15f);
}
/// <summary>Rapidly oscillates the widget's position with random offsets (6 iterations) to simulate impact feedback.</summary>
/// <summary>Rapidly oscillates the widget with random offsets to simulate impact feedback.</summary>
public void Shake(float intensity, float duration)
{
var originalPos = Position;
@@ -142,7 +298,7 @@ namespace Cthangover.FFBattle.UI
}
}
/// <summary>Briefly tints the entire widget with a colour pulse (30% rise, 70% fall) for damage/heal feedback.</summary>
/// <summary>Briefly tints the whole card with a colour pulse (30% rise, 70% fall).</summary>
public void Flash(Color color, float duration)
{
var originalModulate = Modulate;
@@ -153,11 +309,11 @@ namespace Cthangover.FFBattle.UI
}
/// <summary>
/// Plays a dissolve-and-shrink death sequence. If a <c>"scene_transition"</c>
/// shader is available, applies a dissolve material to the sprite. Fades the
/// widget alpha, scales it to zero, and hides the HP bar and name label.
/// Invokes <paramref name="onComplete"/> when finished so the parent panel
/// can remove the widget from the grid.
/// Plays a dissolve-and-shrink death sequence. The sprite gets the
/// <c>scene_transition</c> shader with a growing dissolve progress while the
/// card fades and squashes; the info block fades with it. Invokes
/// <paramref name="onComplete"/> when finished so the parent panel can
/// remove the widget and re-grid the survivors.
/// </summary>
public void PlayDeathAnimation(Action onComplete)
{
@@ -177,21 +333,20 @@ namespace Cthangover.FFBattle.UI
if (_dissolveShader == null)
_dissolveShader = Shaders.Resolve("scene_transition");
if (_dissolveShader != null && _sprite?.Texture != null)
if (_dissolveShader != null && _art?.Texture != null)
{
var mat = new ShaderMaterial { Shader = _dissolveShader };
mat.SetShaderParameter("noise_scale", 5f);
mat.SetShaderParameter("smoothness", 0.25f);
mat.SetShaderParameter("distortion", 0.092f);
mat.SetShaderParameter("glow_intensity", 0f);
mat.SetShaderParameter("hue_shift", 0f);
mat.SetShaderParameter("invert_direction", false);
mat.SetShaderParameter("progress", 0f);
_sprite.Material = mat;
var material = new ShaderMaterial { Shader = _dissolveShader };
material.SetShaderParameter("noise_scale", 5f);
material.SetShaderParameter("smoothness", 0.25f);
material.SetShaderParameter("distortion", 0.092f);
material.SetShaderParameter("glow_intensity", 0f);
material.SetShaderParameter("hue_shift", 0f);
material.SetShaderParameter("invert_direction", false);
material.SetShaderParameter("progress", 0f);
_art.Material = material;
var matTween = CreateTween();
matTween.TweenMethod(
Callable.From<float>(t => mat.SetShaderParameter("progress", t)),
CreateTween().TweenMethod(
Callable.From<float>(t => material.SetShaderParameter("progress", t)),
0f, 1f, duration);
}
@@ -199,9 +354,14 @@ namespace Cthangover.FFBattle.UI
tween.SetParallel(true);
tween.TweenProperty(this, "modulate:a", 0f, duration * 0.5f);
tween.TweenProperty(this, "scale", BaseScale * new Vector2(0.1f, 1.2f), duration);
tween.TweenProperty(_hpBg, "modulate:a", 0f, duration);
tween.TweenProperty(_hpFill, "modulate:a", 0f, duration);
tween.TweenProperty(_shieldBar, "modulate:a", 0f, duration);
tween.TweenProperty(_hpBar, "modulate:a", 0f, duration);
tween.TweenProperty(_hpNumLabel, "modulate:a", 0f, duration);
tween.TweenProperty(_nameLabel, "modulate:a", 0f, duration);
tween.TweenProperty(_pipsBox, "modulate:a", 0f, duration);
tween.TweenProperty(_atkLabel, "modulate:a", 0f, duration);
tween.TweenProperty(_shieldChip, "modulate:a", 0f, duration);
tween.TweenProperty(_statusBox, "modulate:a", 0f, duration);
tween.Chain().TweenProperty(this, "scale", Vector2.Zero, 0.15f);
@@ -210,5 +370,101 @@ namespace Cthangover.FFBattle.UI
onComplete?.Invoke();
};
}
private void ApplyStyles()
{
_teamLine.Color = UiTokens.Alpha(UiTokens.Danger, 0.55f);
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);
_hpNumLabel.AddThemeColorOverride("font_color", UiTokens.HpText);
_atkLabel.AddThemeColorOverride("font_color", UiTokens.Muted);
_teamDot.AddThemeStyleboxOverride("panel", UiKit.Frame(4, UiTokens.Danger));
var hpFill = UiKit.Frame(UiTokens.RadiusSm, UiTokens.HpOk);
_hpBar.AddThemeStyleboxOverride("fill", hpFill);
// 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);
var shieldStyle = UiKit.Frame(UiTokens.RadiusXs, UiTokens.StatBg, UiTokens.StatLine);
shieldStyle.ContentMarginLeft = 7;
shieldStyle.ContentMarginRight = 7;
shieldStyle.ContentMarginTop = 2;
shieldStyle.ContentMarginBottom = 2;
_shieldChip.AddThemeStyleboxOverride("panel", shieldStyle);
GetNode<Label>("Info/Column/Foot/ShieldChip/ShieldRow/ShieldKey").AddThemeColorOverride("font_color", UiTokens.Muted);
_shieldValue.AddThemeColorOverride("font_color", UiTokens.Info);
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);
var selectionStyle = UiKit.Frame(UiTokens.RadiusMd, new Color(1f, 1f, 1f, 0.07f), Colors.White, 2);
_selection.AddThemeStyleboxOverride("panel", selectionStyle);
_selection.Modulate = UiTokens.Alpha(UiTokens.White, 0f);
}
private void RefreshPips()
{
foreach (var child in _pipsBox.GetChildren())
{
_pipsBox.RemoveChild(child);
child.QueueFree();
}
var basePoints = Mathf.Max(0, Card.Attributes.Point.BaseValue);
var points = Card.Attributes.Point.Value;
for (var i = 0; i < basePoints; i++)
{
var on = i < points;
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));
_pipsBox.AddChild(pip);
}
}
private void RefreshStun()
{
if (_stunBadge == null)
return;
var stun = StatusEffectService.Of(Card)?.ActiveStatusEffects?
.FirstOrDefault(effect => effect.EffectType == StatusEffectType.Stun);
if (stun == null)
{
_stunBadge.Visible = false;
return;
}
_stunBadge.Visible = true;
if (_stunIcon != null)
_stunIcon.Texture = stun.Icon;
if (_stunLabel != null)
_stunLabel.Text = string.Format(Translate("ff_battle/status_stun"), stun.RemainingTurns);
}
private static string Translate(string key)
=> TranslationServer.Translate(key).ToString();
}
}
+13 -2
View File
@@ -4,8 +4,10 @@ using System.Linq;
using Cthangover.Core.Characters;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.FFBattle.UI
{
/// <summary>
@@ -29,10 +31,16 @@ namespace Cthangover.FFBattle.UI
/// <summary>Raised when a widget's death animation completes and the widget is removed.</summary>
public event System.Action<FFCharacterWidget> OnEnemyDead;
/// <summary>Raised at lethal damage, before any victory check can consume the loot ledger.</summary>
public event System.Action<FFCharacterWidget> OnEnemyDefeated;
/// <summary>Raised when an enemy widget is clicked (used for target selection).</summary>
public event System.Action<FFCharacterWidget> OnEnemyClicked;
protected override void Construct() { }
/// <summary>Builds the enemy grid panel and lays out enemy widgets.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
}
/// <summary>Number of enemy widget instances currently managed (including dead).</summary>
public int EnemyCount => Widgets.Count;
@@ -53,8 +61,8 @@ namespace Cthangover.FFBattle.UI
var widget = (FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn");
widget.EnsureConstructed();
widget.Scale = new Vector2(scale, scale);
widget.Init(enemies[i]);
widget.IsPlayer = false;
widget.Init(enemies[i]);
widget.MouseFilter = MouseFilterEnum.Stop;
widget.GuiInput += (evt) =>
{
@@ -162,6 +170,9 @@ namespace Cthangover.FFBattle.UI
GridLayout(true);
OnEnemyDead?.Invoke(widget);
});
// Victory can be resolved while the dissolve is still running.
// Record rewards synchronously instead of waiting for its callback.
OnEnemyDefeated?.Invoke(widget);
}
/// <summary>Removes a widget from the panel, frees it, and re-grids the remaining widgets.</summary>
+44 -5
View File
@@ -1,7 +1,10 @@
using System.Collections.Generic;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
using Cthangover.Mods.Interface.Theming;
namespace Cthangover.FFBattle.UI
{
/// <summary>
@@ -36,8 +39,11 @@ namespace Cthangover.FFBattle.UI
public partial class FFMenuPanel : ModWidget
{
private ColorRect _background;
private ColorRect _border;
private Panel _border;
private Label _cursorLabel;
private Label _caption;
private StyleBoxFlat _rowActiveStyle;
private StyleBoxFlat _rowIdleStyle;
private List<Label> _itemLabels = new();
private Tween _cursorTween;
private int _selectedIndex;
@@ -69,14 +75,31 @@ namespace Cthangover.FFBattle.UI
/// <summary>Raised when an entry is confirmed (Enter/Space or left click).</summary>
public event System.Action<int> OnItemSelected;
/// <summary>Raised when the highlighted entry changes (keyboard or mouse hover); carries the new index.</summary>
public event System.Action<int> OnSelectionChanged;
/// <summary>Raised when the menu is cancelled (right click, or Escape handled externally).</summary>
public event System.Action OnCancelled;
/// <summary>Builds the command menu list (Attack/Defence/Item/...) used during the player turn.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
_background = GetNode<ColorRect>("Background");
_border = GetNode<ColorRect>("Border");
_background.Color = UiTokens.Surface2;
_border = GetNode<Panel>("Border");
_border.AddThemeStyleboxOverride("panel",
UiKit.Frame(UiTokens.RadiusSm, UiTokens.Alpha(UiTokens.White, 0f), UiTokens.AccentLine, 2));
_cursorLabel = GetNode<Label>("Cursor");
_cursorLabel.AddThemeColorOverride("font_color", UiTokens.AccentBright);
_caption = GetNode<Label>("Caption");
_caption.AddThemeColorOverride("font_color", UiTokens.Muted);
_rowActiveStyle = UiKit.Frame(UiTokens.RadiusSm, UiTokens.AccentTrace, UiTokens.Alpha(UiTokens.White, 0f));
_rowIdleStyle = UiKit.Frame(UiTokens.RadiusSm, UiTokens.Alpha(UiTokens.White, 0f), UiTokens.Alpha(UiTokens.White, 0f));
GuiInput += OnPanelGuiInput;
}
@@ -101,8 +124,9 @@ namespace Cthangover.FFBattle.UI
label.Text = entries[i].Label;
label.AddThemeFontSizeOverride("font_size", 17);
label.AddThemeColorOverride("font_color", entries[i].Enabled
? new Color(1f, 1f, 1f, 1f)
: new Color(0.5f, 0.5f, 0.5f, 1f));
? UiTokens.Ink
: UiTokens.Faint);
label.AddThemeStyleboxOverride("normal", _rowIdleStyle);
label.MouseFilter = MouseFilterEnum.Stop;
label.Position = new Vector2(PADDING + 24, PADDING + i * ITEM_HEIGHT + CURSOR_OFFSET_Y);
label.Size = new Vector2(width - PADDING * 2 - 24, ITEM_HEIGHT);
@@ -136,16 +160,19 @@ namespace Cthangover.FFBattle.UI
_selectedIndex = 0;
MoveCursor(0);
_cursorLabel.Visible = true;
_caption.Text = title ?? string.Empty;
_caption.Visible = !string.IsNullOrEmpty(title);
_visible = true;
Visible = true;
}
/// <summary>Hides the menu and cursor. Does not clear entries — call <see cref="ShowMenu"/> to repopulate.</summary>
/// <summary>Hides the menu, caption and cursor. Does not clear entries — call <see cref="ShowMenu"/> to repopulate.</summary>
public void HideMenu()
{
_visible = false;
Visible = false;
_cursorLabel.Visible = false;
_caption.Visible = false;
}
/// <summary>Moves selection down, wrapping around and skipping disabled entries.</summary>
@@ -199,6 +226,17 @@ namespace Cthangover.FFBattle.UI
_cursorTween.SetLoops();
_cursorTween.TweenProperty(_cursorLabel, "position:x", CURSOR_OFFSET_X + 3f, 0.3f);
_cursorTween.TweenProperty(_cursorLabel, "position:x", CURSOR_OFFSET_X, 0.3f);
for (var i = 0; i < _itemLabels.Count; i++)
{
var enabled = i < Entries.Count && Entries[i].Enabled;
var selected = i == index && enabled;
_itemLabels[i].AddThemeStyleboxOverride("normal", selected ? _rowActiveStyle : _rowIdleStyle);
_itemLabels[i].AddThemeColorOverride("font_color",
selected ? UiTokens.AccentBright : enabled ? UiTokens.Ink : UiTokens.Faint);
}
OnSelectionChanged?.Invoke(index);
}
private void OnPanelGuiInput(InputEvent evt)
@@ -216,6 +254,7 @@ namespace Cthangover.FFBattle.UI
_itemLabels.Clear();
}
/// <summary>Frees the menu entries and disconnects selection handlers when the panel closes.</summary>
protected override void Destruct()
{
ClearEntries();
+8 -2
View File
@@ -3,8 +3,10 @@ using System.Linq;
using Cthangover.Core.Characters;
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Core.UI.Theming;
using Godot;
using Cthangover.Mods.Interface.Base.Lists;
namespace Cthangover.FFBattle.UI
{
/// <summary>
@@ -25,7 +27,11 @@ namespace Cthangover.FFBattle.UI
/// <summary>Raised when a player widget is clicked — routed to <see cref="FFBattleCore"/> for character selection.</summary>
public event System.Action<FFCharacterWidget> OnWidgetClicked;
protected override void Construct() { }
/// <summary>Builds the bottom player-party panel hosting one widget per party member.</summary>
protected override void Construct()
{
CthangoverTheme.Apply(this);
}
/// <summary>
/// Creates <see cref="FFCharacterWidget"/> instances for each player, scales
@@ -43,8 +49,8 @@ namespace Cthangover.FFBattle.UI
var widget = (FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn");
widget.EnsureConstructed();
widget.Scale = new Vector2(scale, scale);
widget.Init(players[i]);
widget.IsPlayer = true;
widget.Init(players[i]);
widget.MouseFilter = MouseFilterEnum.Stop;
widget.GuiInput += (evt) =>
{