card editor, menu updated, screen changes
This commit is contained in:
@@ -11,7 +11,25 @@
|
|||||||
"Bash(timeout 5 godot4:*)",
|
"Bash(timeout 5 godot4:*)",
|
||||||
"Bash(curl:*)",
|
"Bash(curl:*)",
|
||||||
"Bash(timeout 3 godot4:*)",
|
"Bash(timeout 3 godot4:*)",
|
||||||
"Bash(git -C /home/ckoch/Documents/Development/FFCardGame status background_1*)"
|
"Bash(git -C /home/ckoch/Documents/Development/FFCardGame status background_1*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(du:*)",
|
||||||
|
"Bash(pip3 show:*)",
|
||||||
|
"Bash(pip3 install:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(apt list:*)",
|
||||||
|
"Bash(dpkg:*)",
|
||||||
|
"Bash(sudo apt-get install:*)",
|
||||||
|
"Bash(source:*)",
|
||||||
|
"Bash(pip install:*)",
|
||||||
|
"Bash(# Try using pipx, conda, or any other Python package manager which pipx conda uv ; ls /usr/bin/python* ; ls /home/ckoch/.local/bin/)",
|
||||||
|
"Bash(export ANTHROPIC_API_KEY=\"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\")",
|
||||||
|
"Bash(sort:*)",
|
||||||
|
"Bash(if [ -f /home/ckoch/Documents/Development/FFCardGame/data/scan_errors.log ])",
|
||||||
|
"Bash(then wc -l /home/ckoch/Documents/Development/FFCardGame/data/scan_errors.log)",
|
||||||
|
"Bash(else echo \"No errors logged\")",
|
||||||
|
"Bash(fi)",
|
||||||
|
"Bash(git -C /home/ckoch/Documents/Development/FFCardGame log --oneline -20)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[folding]
|
||||||
|
|
||||||
|
sections_unfolded=PackedStringArray()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[folding]
|
||||||
|
|
||||||
|
sections_unfolded=PackedStringArray()
|
||||||
@@ -19,8 +19,8 @@ dock_filesystem_split=0
|
|||||||
dock_filesystem_display_mode=0
|
dock_filesystem_display_mode=0
|
||||||
dock_filesystem_file_sort=0
|
dock_filesystem_file_sort=0
|
||||||
dock_filesystem_file_list_display_mode=1
|
dock_filesystem_file_list_display_mode=1
|
||||||
dock_filesystem_selected_paths=PackedStringArray("res://scripts/ui/GameUI.gd")
|
dock_filesystem_selected_paths=PackedStringArray("res://scripts/ui/MainMenu.gd")
|
||||||
dock_filesystem_uncollapsed_paths=PackedStringArray("Favorites", "res://", "res://scripts/", "res://scripts/visual/", "res://scripts/ui/")
|
dock_filesystem_uncollapsed_paths=PackedStringArray("Favorites", "res://", "res://scripts/", "res://scripts/visual/", "res://scripts/ui/", "res://scripts/game/", "res://scenes/")
|
||||||
dock_3="Scene,Import"
|
dock_3="Scene,Import"
|
||||||
dock_4="FileSystem"
|
dock_4="FileSystem"
|
||||||
dock_5="Inspector,Node,History"
|
dock_5="Inspector,Node,History"
|
||||||
@@ -29,15 +29,15 @@ dock_5="Inspector,Node,History"
|
|||||||
|
|
||||||
open_scenes=PackedStringArray("res://scenes/main.tscn")
|
open_scenes=PackedStringArray("res://scenes/main.tscn")
|
||||||
current_scene="res://scenes/main.tscn"
|
current_scene="res://scenes/main.tscn"
|
||||||
center_split_offset=0
|
center_split_offset=-491
|
||||||
selected_default_debugger_tab_idx=0
|
selected_default_debugger_tab_idx=0
|
||||||
selected_main_editor_idx=2
|
selected_main_editor_idx=2
|
||||||
selected_bottom_panel_item=0
|
selected_bottom_panel_item=0
|
||||||
|
|
||||||
[ScriptEditor]
|
[ScriptEditor]
|
||||||
|
|
||||||
open_scripts=["res://scripts/ui/ActionLog.gd", "res://scripts/autoload/CardDatabase.gd", "res://scripts/game/CardInstance.gd", "res://scripts/game/CPPool.gd", "res://scripts/game/GameState.gd", "res://scripts/ui/GameUI.gd", "res://scripts/ui/HandDisplay.gd", "res://scripts/Main.gd", "res://scripts/ui/PauseMenu.gd", "res://scripts/game/Player.gd", "res://scripts/visual/TableCamera.gd", "res://scripts/visual/TableSetup.gd", "res://scripts/game/UndoSystem.gd"]
|
open_scripts=["res://scripts/ui/ActionLog.gd", "res://scripts/autoload/CardDatabase.gd", "res://scripts/game/CardInstance.gd", "res://scripts/visual/CardVisual.gd", "res://scripts/game/CPPool.gd", "res://scripts/GameController.gd", "res://scripts/game/GameState.gd", "res://scripts/ui/GameUI.gd", "res://scripts/ui/HandDisplay.gd", "res://scripts/Main.gd", "res://scripts/ui/MainMenu.gd", "res://scripts/ui/PauseMenu.gd", "res://scripts/game/Player.gd", "res://scripts/visual/PlaymatRenderer.gd", "res://scripts/visual/TableCamera.gd", "res://scripts/visual/TableSetup.gd", "res://scripts/game/UndoSystem.gd"]
|
||||||
selected_script="res://scripts/ui/GameUI.gd"
|
selected_script="res://scripts/ui/MainMenu.gd"
|
||||||
open_help=[]
|
open_help=[]
|
||||||
script_split_offset=140
|
script_split_offset=140
|
||||||
list_split_offset=0
|
list_split_offset=0
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
ea4bc82a6ad023ab7ee23ee620429895
|
ea4bc82a6ad023ab7ee23ee620429895
|
||||||
::res://::1769470366
|
::res://::1769555243
|
||||||
background_1.png::CompressedTexture2D::259206091835802070::1769464008::1769464212::1::::<><>::
|
background_1.png::CompressedTexture2D::259206091835802070::1769464008::1769464212::1::::<><>::
|
||||||
card_back.png::CompressedTexture2D::4833498016096001590::1769466370::1769466517::1::::<><>::
|
card_back.png::CompressedTexture2D::4833498016096001590::1769466370::1769466517::1::::<><>::
|
||||||
FF14_Playmat__12516.webp::CompressedTexture2D::1641665221299209414::1769277769::1769280957::1::::<><>::
|
FF14_Playmat__12516.webp::CompressedTexture2D::1641665221299209414::1769277769::1769280957::1::::<><>::
|
||||||
FF_mat_option_1.png::CompressedTexture2D::4359709237641823626::1769451897::1769453057::1::::<><>::
|
FF_mat_option_1.png::CompressedTexture2D::4359709237641823626::1769451897::1769453057::1::::<><>::
|
||||||
|
JimNightshade-Regular.ttf::FontFile::7644275900508645331::1757609064::1769555265::1::::<><>::
|
||||||
README.md::TextFile::-1::1769279531::0::1::::<><>::
|
README.md::TextFile::-1::1769279531::0::1::::<><>::
|
||||||
Screenshot 2026-01-24 at 12-53-03 Untitled-3 - fftcgrulesheet-en.pdf.png::CompressedTexture2D::5958662832102035034::1769277183::1769280957::1::::<><>::
|
Screenshot 2026-01-24 at 12-53-03 Untitled-3 - fftcgrulesheet-en.pdf.png::CompressedTexture2D::5958662832102035034::1769277183::1769280957::1::::<><>::
|
||||||
|
title_menu.png::CompressedTexture2D::4103292590061137586::1769543314::1769543405::1::::<><>::
|
||||||
::res://assets/::1769279430
|
::res://assets/::1769279430
|
||||||
::res://assets/cards/::1769466517
|
::res://assets/cards/::1769466517
|
||||||
1-003C_eg.jpg::CompressedTexture2D::3078340571116611252::1769279471::1769280956::1::::<><>::
|
1-003C_eg.jpg::CompressedTexture2D::3078340571116611252::1769279471::1769280956::1::::<><>::
|
||||||
@@ -16,47 +18,50 @@ card_back.png::CompressedTexture2D::7787125851359297441::1769466418::1769466517:
|
|||||||
::res://assets/table/::1769464212
|
::res://assets/table/::1769464212
|
||||||
background_1.png::CompressedTexture2D::102728058489724503::1769464097::1769464212::1::::<><>::
|
background_1.png::CompressedTexture2D::102728058489724503::1769464097::1769464212::1::::<><>::
|
||||||
playmat.webp::CompressedTexture2D::3235866490631872101::1769279471::1769280957::1::::<><>::
|
playmat.webp::CompressedTexture2D::3235866490631872101::1769279471::1769280957::1::::<><>::
|
||||||
::res://assets/ui/::1769280956
|
::res://assets/ui/::1769542991
|
||||||
icon.svg::CompressedTexture2D::2912283608529879130::1769280588::1769280956::1::::<><>::
|
icon.svg::CompressedTexture2D::2912283608529879130::1769280588::1769280956::1::::<><>::
|
||||||
::res://data/::1769309289
|
title_menu.png::CompressedTexture2D::8625156175856392101::1769542458::1769542991::1::::<><>::
|
||||||
cards.json::JSON::-1::1769309289::0::1::::<><>::
|
::res://data/::1769541933
|
||||||
|
cards.json::JSON::-1::1769541579::0::1::::<><>::
|
||||||
|
cards_progress.json::JSON::-1::1769539572::0::1::::<><>::
|
||||||
|
scan_errors.log::TextFile::-1::1769539203::0::1::::<><>::
|
||||||
::res://docs/::1769279608
|
::res://docs/::1769279608
|
||||||
CARD_FORMAT.md::TextFile::-1::1769279608::0::1::::<><>::
|
CARD_FORMAT.md::TextFile::-1::1769279608::0::1::::<><>::
|
||||||
DESIGN.md::TextFile::-1::1769279572::0::1::::<><>::
|
DESIGN.md::TextFile::-1::1769279572::0::1::::<><>::
|
||||||
::res://scenes/::1769466609
|
::res://scenes/::1769555163
|
||||||
game_controller.tscn::PackedScene::3882700613993784342::1769285267::0::1::::<><>::res://scripts/GameController.gd
|
game_controller.tscn::PackedScene::3882700613993784342::1769285267::0::1::::<><>::res://scripts/GameController.gd
|
||||||
main.tscn::PackedScene::5942992277112036945::1769466609::0::1::::<><>::res://scripts/Main.gd
|
main.tscn::PackedScene::5942992277112036945::1769555163::0::1::::<><>::res://scripts/Main.gd
|
||||||
::res://scenes/card/::1769279430
|
::res://scenes/card/::1769279430
|
||||||
::res://scenes/main/::1769279430
|
::res://scenes/main/::1769279430
|
||||||
::res://scenes/table/::1769279430
|
::res://scenes/table/::1769279430
|
||||||
::res://scenes/ui/::1769279430
|
::res://scenes/ui/::1769279430
|
||||||
::res://scripts/::1769454504
|
::res://scripts/::1769554938
|
||||||
GameController.gd::GDScript::-1::1769288668::0::1::::<>Node<>::
|
GameController.gd::GDScript::-1::1769542879::0::1::::<>Node<>::
|
||||||
Main.gd::GDScript::-1::1769454504::0::1::::<>Node3D<>::
|
Main.gd::GDScript::-1::1769473058::0::1::::<>Node3D<>::
|
||||||
::res://scripts/autoload/::1769308378
|
::res://scripts/autoload/::1769308378
|
||||||
CardDatabase.gd::GDScript::-1::1769308329::0::1::::<>Node<>::
|
CardDatabase.gd::GDScript::-1::1769308329::0::1::::<>Node<>::
|
||||||
GameManager.gd::GDScript::-1::1769308378::0::1::::<>Node<>::
|
GameManager.gd::GDScript::-1::1769308378::0::1::::<>Node<>::
|
||||||
::res://scripts/game/::1769302515
|
::res://scripts/game/::1769471419
|
||||||
CardInstance.gd::GDScript::-1::1769279755::0::1::::CardInstance<>RefCounted<>::
|
CardInstance.gd::GDScript::-1::1769279755::0::1::::CardInstance<>RefCounted<>::
|
||||||
CPPool.gd::GDScript::-1::1769302515::0::1::::CPPool<>RefCounted<>::
|
CPPool.gd::GDScript::-1::1769302515::0::1::::CPPool<>RefCounted<>::
|
||||||
Enums.gd::GDScript::-1::1769281049::0::1::::Enums<>RefCounted<>::
|
Enums.gd::GDScript::-1::1769281049::0::1::::Enums<>RefCounted<>::
|
||||||
GameState.gd::GDScript::-1::1769302308::0::1::::GameState<>RefCounted<>::
|
GameState.gd::GDScript::-1::1769471419::0::1::::GameState<>RefCounted<>::
|
||||||
Player.gd::GDScript::-1::1769302256::0::1::::Player<>RefCounted<>::
|
Player.gd::GDScript::-1::1769302256::0::1::::Player<>RefCounted<>::
|
||||||
TurnManager.gd::GDScript::-1::1769302284::0::1::::TurnManager<>RefCounted<>::
|
TurnManager.gd::GDScript::-1::1769302284::0::1::::TurnManager<>RefCounted<>::
|
||||||
UndoSystem.gd::GDScript::-1::1769301595::0::1::::UndoSystem<>RefCounted<>::
|
UndoSystem.gd::GDScript::-1::1769301595::0::1::::UndoSystem<>RefCounted<>::
|
||||||
Zone.gd::GDScript::-1::1769302225::0::1::::Zone<>RefCounted<>::
|
Zone.gd::GDScript::-1::1769302225::0::1::::Zone<>RefCounted<>::
|
||||||
::res://scripts/ui/::1769381830
|
::res://scripts/ui/::1769555036
|
||||||
ActionLog.gd::GDScript::-1::1769298563::0::1::::ActionLog<>Control<>::
|
ActionLog.gd::GDScript::-1::1769298563::0::1::::ActionLog<>Control<>::
|
||||||
DamageDisplay.gd::GDScript::-1::1769280183::0::1::::DamageDisplay<>Control<>::
|
DamageDisplay.gd::GDScript::-1::1769280183::0::1::::DamageDisplay<>Control<>::
|
||||||
GameUI.gd::GDScript::-1::1769379370::0::1::::GameUI<>CanvasLayer<>::
|
GameUI.gd::GDScript::-1::1769472787::0::1::::GameUI<>CanvasLayer<>::
|
||||||
HandDisplay.gd::GDScript::-1::1769381830::0::1::::HandDisplay<>Control<>::
|
HandDisplay.gd::GDScript::-1::1769381830::0::1::::HandDisplay<>Control<>::
|
||||||
MainMenu.gd::GDScript::-1::1769285226::0::1::::MainMenu<>CanvasLayer<>::
|
MainMenu.gd::GDScript::-1::1769555036::0::1::::MainMenu<>CanvasLayer<>::
|
||||||
PauseMenu.gd::GDScript::-1::1769287615::0::1::::PauseMenu<>CanvasLayer<>::
|
PauseMenu.gd::GDScript::-1::1769287615::0::1::::PauseMenu<>CanvasLayer<>::
|
||||||
::res://scripts/visual/::1769466440
|
::res://scripts/visual/::1769471729
|
||||||
CardVisual.gd::GDScript::-1::1769460118::0::1::::CardVisual<>Node3D<>::
|
CardVisual.gd::GDScript::-1::1769460118::0::1::::CardVisual<>Node3D<>::
|
||||||
PlaymatRenderer.gd::GDScript::-1::1769452774::0::1::::PlaymatRenderer<>Node<>::
|
PlaymatRenderer.gd::GDScript::-1::1769452774::0::1::::PlaymatRenderer<>Node<>::
|
||||||
TableCamera.gd::GDScript::-1::1769461608::0::1::::TableCamera<>Camera3D<>::
|
TableCamera.gd::GDScript::-1::1769471382::0::1::::TableCamera<>Camera3D<>::
|
||||||
TableSetup.gd::GDScript::-1::1769466440::0::1::::TableSetup<>Node3D<>::
|
TableSetup.gd::GDScript::-1::1769471729::0::1::::TableSetup<>Node3D<>::
|
||||||
ZoneVisual.gd::GDScript::-1::1769454229::0::1::::ZoneVisual<>Node3D<>::
|
ZoneVisual.gd::GDScript::-1::1769454229::0::1::::ZoneVisual<>Node3D<>::
|
||||||
::res://source-cards/::1769308626
|
::res://source-cards/::1769308626
|
||||||
1-001H.jpg::CompressedTexture2D::2056726104879484109::1769306016::1769308625::1::::<><>::
|
1-001H.jpg::CompressedTexture2D::2056726104879484109::1769306016::1769308625::1::::<><>::
|
||||||
@@ -4075,3 +4080,4 @@ Re-197C-13-125R.jpg::CompressedTexture2D::6616505751106391433::1769306351::17693
|
|||||||
Re-198H-13-127H.jpg::CompressedTexture2D::5413433616482804995::1769306351::1769308611::1::::<><>::
|
Re-198H-13-127H.jpg::CompressedTexture2D::5413433616482804995::1769306351::1769308611::1::::<><>::
|
||||||
Re-199H-19-125H.jpg::CompressedTexture2D::4308488034767344289::1769306351::1769308619::1::::<><>::
|
Re-199H-19-125H.jpg::CompressedTexture2D::4308488034767344289::1769306351::1769308619::1::::<><>::
|
||||||
Re-200L-19-128L.jpg::CompressedTexture2D::8097057901487536182::1769306356::1769308614::1::::<><>::
|
Re-200L-19-128L.jpg::CompressedTexture2D::8097057901487536182::1769306356::1769308614::1::::<><>::
|
||||||
|
::res://tools/::1769541891
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
res://scenes/main.tscn
|
res://scenes/main.tscn
|
||||||
|
res://scripts/ui/MainMenu.gd
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ run_reload_scripts=true
|
|||||||
[recent_files]
|
[recent_files]
|
||||||
|
|
||||||
scenes=["res://scenes/main.tscn"]
|
scenes=["res://scenes/main.tscn"]
|
||||||
scripts=["res://scripts/ui/GameUI.gd", "res://scripts/game/Player.gd", "res://scripts/game/CardInstance.gd", "res://scripts/game/CPPool.gd", "res://scripts/game/GameState.gd", "res://scripts/visual/TableCamera.gd", "res://scripts/ui/ActionLog.gd", "res://scripts/game/UndoSystem.gd", "res://scripts/ui/HandDisplay.gd", "res://scripts/ui/PauseMenu.gd"]
|
scripts=["res://scripts/visual/CardVisual.gd", "res://scripts/GameController.gd", "res://scripts/visual/PlaymatRenderer.gd", "res://scripts/ui/MainMenu.gd", "res://scripts/ui/GameUI.gd", "res://scripts/game/Player.gd", "res://scripts/game/CardInstance.gd", "res://scripts/game/CPPool.gd", "res://scripts/game/GameState.gd", "res://scripts/visual/TableCamera.gd"]
|
||||||
|
|
||||||
[linked_properties]
|
[linked_properties]
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
state={
|
state={
|
||||||
"bookmarks": PackedInt32Array(),
|
"bookmarks": PackedInt32Array(),
|
||||||
"breakpoints": PackedInt32Array(),
|
"breakpoints": PackedInt32Array(),
|
||||||
"column": 0,
|
"column": 18,
|
||||||
"folded_lines": Array[int]([]),
|
"folded_lines": Array[int]([]),
|
||||||
"h_scroll_position": 0,
|
"h_scroll_position": 0,
|
||||||
"row": 54,
|
"row": 305,
|
||||||
"scroll_position": 50.0,
|
"scroll_position": 332.0,
|
||||||
"selection": false,
|
"selection": false,
|
||||||
"syntax_highlighter": "GDScript"
|
"syntax_highlighter": "GDScript"
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ state={
|
|||||||
"folded_lines": Array[int]([]),
|
"folded_lines": Array[int]([]),
|
||||||
"h_scroll_position": 0,
|
"h_scroll_position": 0,
|
||||||
"row": 225,
|
"row": 225,
|
||||||
"scroll_position": 220.0,
|
"scroll_position": 0.0,
|
||||||
"selection": false,
|
"selection": false,
|
||||||
"syntax_highlighter": "GDScript"
|
"syntax_highlighter": "GDScript"
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ state={
|
|||||||
"folded_lines": Array[int]([]),
|
"folded_lines": Array[int]([]),
|
||||||
"h_scroll_position": 0,
|
"h_scroll_position": 0,
|
||||||
"row": 0,
|
"row": 0,
|
||||||
"scroll_position": 0.0,
|
"scroll_position": 39.0,
|
||||||
"selection": false,
|
"selection": false,
|
||||||
"syntax_highlighter": "GDScript"
|
"syntax_highlighter": "GDScript"
|
||||||
}
|
}
|
||||||
@@ -168,6 +168,62 @@ state={
|
|||||||
|
|
||||||
[res://scripts/ui/GameUI.gd]
|
[res://scripts/ui/GameUI.gd]
|
||||||
|
|
||||||
|
state={
|
||||||
|
"bookmarks": PackedInt32Array(),
|
||||||
|
"breakpoints": PackedInt32Array(),
|
||||||
|
"column": 24,
|
||||||
|
"folded_lines": Array[int]([]),
|
||||||
|
"h_scroll_position": 0,
|
||||||
|
"row": 5,
|
||||||
|
"scroll_position": 0.0,
|
||||||
|
"selection": false,
|
||||||
|
"syntax_highlighter": "GDScript"
|
||||||
|
}
|
||||||
|
|
||||||
|
[res://scripts/ui/MainMenu.gd]
|
||||||
|
|
||||||
|
state={
|
||||||
|
"bookmarks": PackedInt32Array(),
|
||||||
|
"breakpoints": PackedInt32Array(),
|
||||||
|
"column": 14,
|
||||||
|
"folded_lines": Array[int]([]),
|
||||||
|
"h_scroll_position": 0,
|
||||||
|
"row": 29,
|
||||||
|
"scroll_position": 19.0,
|
||||||
|
"selection": false,
|
||||||
|
"syntax_highlighter": "GDScript"
|
||||||
|
}
|
||||||
|
|
||||||
|
[res://scripts/visual/PlaymatRenderer.gd]
|
||||||
|
|
||||||
|
state={
|
||||||
|
"bookmarks": PackedInt32Array(),
|
||||||
|
"breakpoints": PackedInt32Array(),
|
||||||
|
"column": 15,
|
||||||
|
"folded_lines": Array[int]([]),
|
||||||
|
"h_scroll_position": 0,
|
||||||
|
"row": 150,
|
||||||
|
"scroll_position": 138.0,
|
||||||
|
"selection": false,
|
||||||
|
"syntax_highlighter": "GDScript"
|
||||||
|
}
|
||||||
|
|
||||||
|
[res://scripts/GameController.gd]
|
||||||
|
|
||||||
|
state={
|
||||||
|
"bookmarks": PackedInt32Array(),
|
||||||
|
"breakpoints": PackedInt32Array(),
|
||||||
|
"column": 0,
|
||||||
|
"folded_lines": Array[int]([]),
|
||||||
|
"h_scroll_position": 0,
|
||||||
|
"row": 0,
|
||||||
|
"scroll_position": 0.0,
|
||||||
|
"selection": false,
|
||||||
|
"syntax_highlighter": "GDScript"
|
||||||
|
}
|
||||||
|
|
||||||
|
[res://scripts/visual/CardVisual.gd]
|
||||||
|
|
||||||
state={
|
state={
|
||||||
"bookmarks": PackedInt32Array(),
|
"bookmarks": PackedInt32Array(),
|
||||||
"breakpoints": PackedInt32Array(),
|
"breakpoints": PackedInt32Array(),
|
||||||
|
|||||||
Binary file not shown.
BIN
JimNightshade-Regular.ttf
Normal file
BIN
JimNightshade-Regular.ttf
Normal file
Binary file not shown.
33
JimNightshade-Regular.ttf.import
Normal file
33
JimNightshade-Regular.ttf.import
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="font_data_dynamic"
|
||||||
|
type="FontFile"
|
||||||
|
uid="uid://dg57dv756nqdv"
|
||||||
|
path="res://.godot/imported/JimNightshade-Regular.ttf-31d934ca6497e3c6d8be7326f6db6a02.fontdata"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://JimNightshade-Regular.ttf"
|
||||||
|
dest_files=["res://.godot/imported/JimNightshade-Regular.ttf-31d934ca6497e3c6d8be7326f6db6a02.fontdata"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
Rendering=null
|
||||||
|
antialiasing=1
|
||||||
|
generate_mipmaps=false
|
||||||
|
multichannel_signed_distance_field=false
|
||||||
|
msdf_pixel_range=8
|
||||||
|
msdf_size=48
|
||||||
|
allow_system_fallback=true
|
||||||
|
force_autohinter=false
|
||||||
|
hinting=1
|
||||||
|
subpixel_positioning=1
|
||||||
|
oversampling=0.0
|
||||||
|
Fallbacks=null
|
||||||
|
fallbacks=[]
|
||||||
|
Compress=null
|
||||||
|
compress=true
|
||||||
|
preload=[]
|
||||||
|
language_support={}
|
||||||
|
script_support={}
|
||||||
|
opentype_features={}
|
||||||
BIN
assets/ui/title_menu.png
Normal file
BIN
assets/ui/title_menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
34
assets/ui/title_menu.png.import
Normal file
34
assets/ui/title_menu.png.import
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://du5dcd0obyccj"
|
||||||
|
path="res://.godot/imported/title_menu.png-dfccdc1fc32258c60ee61ef245428559.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/title_menu.png"
|
||||||
|
dest_files=["res://.godot/imported/title_menu.png-dfccdc1fc32258c60ee61ef245428559.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
93617
data/cards.json
93617
data/cards.json
File diff suppressed because it is too large
Load Diff
93866
data/cards_progress.json
Normal file
93866
data/cards_progress.json
Normal file
File diff suppressed because it is too large
Load Diff
51
data/scan_errors.log
Normal file
51
data/scan_errors.log
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
2026-01-27 10:09:46 | 1-109R | VALIDATION: Unknown card type: Companion
|
||||||
|
2026-01-27 10:12:52 | 1-176H | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 10:14:45 | 1-214S | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 10:14:59 | 10-003C | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 10:17:39 | 10-053C | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 10:20:02 | 10-100C | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 10:25:28 | 11-061L | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 10:26:37 | 11-083R-PR-051 | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 10:28:04 | 11-110L | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 10:28:48 | 11-123R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 10:40:07 | 13-065R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 10:45:18 | 14-018C | VALIDATION: Unknown element: Chaos
|
||||||
|
2026-01-27 10:45:54 | 14-029R | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 10:56:18 | 15-083L | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:02:01 | 16-041C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:03:30 | 16-068C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:04:53 | 16-090R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:07:23 | 16-133S | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:07:27 | 16-134S | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:12:33 | 17-083C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:25:24 | 19-039R | VALIDATION: Unknown card type: Weapon
|
||||||
|
2026-01-27 11:26:15 | 19-055R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:29:38 | 19-120C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:33:59 | 2-070R | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 11:35:03 | 2-094H | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:44:33 | 20-117L | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:47:02 | 21-032R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:54:04 | 22-026C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 11:58:34 | 22-106R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:06:12 | 23-124L | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:09:32 | 24-052L | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:24:13 | 26-067H | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:26:30 | 26-105C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:29:33 | 27-028H | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:31:17 | 27-058R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:33:15 | 27-094C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:33:55 | 27-107R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:38:25 | 3-071H | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 12:41:16 | 3-127R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 12:47:17 | 4-093R | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 12:48:16 | 4-114L | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 12:51:33 | 5-032H | VALIDATION: Forward has no power value
|
||||||
|
2026-01-27 13:06:44 | 7-040C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 13:11:21 | 7-127L | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 13:17:55 | 8-112R | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 13:17:58 | 8-113C | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 13:23:35 | 9-077L | VALIDATION: Unknown card type: Summoner
|
||||||
|
2026-01-27 13:27:05 | B-016 | VALIDATION: Unknown element: Purple
|
||||||
|
2026-01-27 13:29:18 | C-006 | VALIDATION: Missing required field: cost
|
||||||
|
2026-01-27 13:29:19 | C-007 | VALIDATION: Missing required field: cost
|
||||||
|
2026-01-27 13:40:03 | Re-192C-19-120C | VALIDATION: Unknown card type: Summoner
|
||||||
@@ -24,10 +24,10 @@ CardDatabase="*res://scripts/autoload/CardDatabase.gd"
|
|||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
window/size/viewport_width=460
|
||||||
window/size/viewport_height=1080
|
window/size/viewport_height=689
|
||||||
window/size/mode=2
|
window/size/borderless=true
|
||||||
window/stretch/mode="viewport"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ enum State {
|
|||||||
|
|
||||||
var current_state: State = State.MENU
|
var current_state: State = State.MENU
|
||||||
|
|
||||||
|
# Menu window size (matches project.godot viewport)
|
||||||
|
const MENU_SIZE := Vector2i(460, 689)
|
||||||
|
# Game window size
|
||||||
|
const GAME_SIZE := Vector2i(2160, 980)
|
||||||
|
|
||||||
# Scene references
|
# Scene references
|
||||||
var main_menu: MainMenu = null
|
var main_menu: MainMenu = null
|
||||||
var game_scene: Node3D = null
|
var game_scene: Node3D = null
|
||||||
@@ -41,11 +46,22 @@ func _show_main_menu() -> void:
|
|||||||
pause_menu.queue_free()
|
pause_menu.queue_free()
|
||||||
pause_menu = null
|
pause_menu = null
|
||||||
|
|
||||||
|
# Switch back to small borderless menu window
|
||||||
|
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED)
|
||||||
|
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, true)
|
||||||
|
DisplayServer.window_set_size(MENU_SIZE)
|
||||||
|
var screen := DisplayServer.screen_get_size()
|
||||||
|
DisplayServer.window_set_position(Vector2i(
|
||||||
|
(screen.x - MENU_SIZE.x) / 2,
|
||||||
|
(screen.y - MENU_SIZE.y) / 2
|
||||||
|
))
|
||||||
|
|
||||||
# Create main menu
|
# Create main menu
|
||||||
if not main_menu:
|
if not main_menu:
|
||||||
main_menu = MainMenu.new()
|
main_menu = MainMenu.new()
|
||||||
add_child(main_menu)
|
add_child(main_menu)
|
||||||
main_menu.start_game.connect(_on_start_game)
|
main_menu.quick_play.connect(_on_start_game)
|
||||||
|
main_menu.play_game.connect(_on_start_game)
|
||||||
|
|
||||||
main_menu.visible = true
|
main_menu.visible = true
|
||||||
current_state = State.MENU
|
current_state = State.MENU
|
||||||
@@ -55,6 +71,15 @@ func _on_start_game() -> void:
|
|||||||
if main_menu:
|
if main_menu:
|
||||||
main_menu.visible = false
|
main_menu.visible = false
|
||||||
|
|
||||||
|
# Switch to windowed gameplay size
|
||||||
|
DisplayServer.window_set_flag(DisplayServer.WINDOW_FLAG_BORDERLESS, false)
|
||||||
|
DisplayServer.window_set_size(GAME_SIZE)
|
||||||
|
var screen := DisplayServer.screen_get_size()
|
||||||
|
DisplayServer.window_set_position(Vector2i(
|
||||||
|
(screen.x - GAME_SIZE.x) / 2,
|
||||||
|
(screen.y - GAME_SIZE.y) / 2
|
||||||
|
))
|
||||||
|
|
||||||
# Create game scene
|
# Create game scene
|
||||||
_start_new_game()
|
_start_new_game()
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +1,168 @@
|
|||||||
class_name MainMenu
|
class_name MainMenu
|
||||||
extends CanvasLayer
|
extends CanvasLayer
|
||||||
|
|
||||||
## MainMenu - Main menu screen
|
## MainMenu - Title menu using pre-designed background image (title_menu.png)
|
||||||
|
## The window is sized to match the image (67% of 1024x1536).
|
||||||
|
## The image fills the entire window; buttons overlay the pre-drawn slots.
|
||||||
|
|
||||||
signal start_game
|
signal quick_play
|
||||||
|
signal play_game
|
||||||
|
signal online_game
|
||||||
|
signal open_settings
|
||||||
signal quit_game
|
signal quit_game
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
var title_label: Label
|
var bg_texture: TextureRect
|
||||||
var start_button: Button
|
var buttons_container: Control
|
||||||
var options_button: Button
|
var quick_play_button: Button
|
||||||
|
var play_button: Button
|
||||||
|
var online_button: Button
|
||||||
|
var settings_button: Button
|
||||||
var quit_button: Button
|
var quit_button: Button
|
||||||
var version_label: Label
|
var version_label: Label
|
||||||
|
|
||||||
|
# Custom font
|
||||||
|
var custom_font: Font = preload("res://JimNightshade-Regular.ttf")
|
||||||
|
|
||||||
|
# Per-button pixel rects: Rect2(x, y, width, height) at 460x689 window size
|
||||||
|
# Measured from the pre-drawn button slots in title_menu.png
|
||||||
|
const BUTTON_RECTS := [
|
||||||
|
Rect2(130, 78, 200, 38), # Quick Play
|
||||||
|
Rect2(130, 163, 200, 38), # Play
|
||||||
|
Rect2(130, 229, 200, 38), # Online
|
||||||
|
Rect2(130, 295, 200, 38), # Settings
|
||||||
|
Rect2(130, 360, 200, 38), # Exit
|
||||||
|
]
|
||||||
|
const DESIGN_SIZE := Vector2(460, 689)
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
# Center the window on screen
|
||||||
|
var screen := DisplayServer.screen_get_size()
|
||||||
|
var win_size := get_tree().root.size
|
||||||
|
DisplayServer.window_set_position(Vector2i(
|
||||||
|
(screen.x - win_size.x) / 2,
|
||||||
|
(screen.y - win_size.y) / 2
|
||||||
|
))
|
||||||
_create_menu()
|
_create_menu()
|
||||||
|
|
||||||
func _create_menu() -> void:
|
func _create_menu() -> void:
|
||||||
# Background
|
var win_size := get_tree().root.get_visible_rect().size
|
||||||
var bg = ColorRect.new()
|
|
||||||
add_child(bg)
|
|
||||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
||||||
bg.color = Color(0.08, 0.08, 0.12)
|
|
||||||
|
|
||||||
# Center container
|
# Background image fills the whole window
|
||||||
var center = CenterContainer.new()
|
bg_texture = TextureRect.new()
|
||||||
add_child(center)
|
add_child(bg_texture)
|
||||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
bg_texture.texture = load("res://assets/ui/title_menu.png")
|
||||||
|
bg_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
bg_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
bg_texture.position = Vector2.ZERO
|
||||||
|
bg_texture.size = win_size
|
||||||
|
bg_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
var vbox = VBoxContainer.new()
|
# Container for buttons
|
||||||
center.add_child(vbox)
|
buttons_container = Control.new()
|
||||||
vbox.add_theme_constant_override("separation", 20)
|
add_child(buttons_container)
|
||||||
|
buttons_container.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
# Title
|
# Create buttons overlaying the pre-drawn slots
|
||||||
title_label = Label.new()
|
quick_play_button = _create_overlay_button("Quick Play", 0)
|
||||||
title_label.text = "FF-TCG Digital"
|
quick_play_button.add_theme_color_override("font_color", Color(0.15, 0.13, 0.1))
|
||||||
title_label.add_theme_font_size_override("font_size", 48)
|
quick_play_button.add_theme_color_override("font_hover_color", Color(0.3, 0.25, 0.2))
|
||||||
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
quick_play_button.add_theme_color_override("font_pressed_color", Color(0.05, 0.05, 0.05))
|
||||||
vbox.add_child(title_label)
|
quick_play_button.pressed.connect(_on_quick_play_pressed)
|
||||||
|
|
||||||
# Subtitle
|
play_button = _create_overlay_button("Play", 1)
|
||||||
var subtitle = Label.new()
|
play_button.pressed.connect(_on_play_pressed)
|
||||||
subtitle.text = "Final Fantasy Trading Card Game"
|
|
||||||
subtitle.add_theme_font_size_override("font_size", 18)
|
|
||||||
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
||||||
subtitle.add_theme_color_override("font_color", Color(0.6, 0.6, 0.7))
|
|
||||||
vbox.add_child(subtitle)
|
|
||||||
|
|
||||||
# Spacer
|
online_button = _create_overlay_button("Online", 2)
|
||||||
var spacer = Control.new()
|
online_button.disabled = true
|
||||||
spacer.custom_minimum_size = Vector2(0, 40)
|
|
||||||
vbox.add_child(spacer)
|
|
||||||
|
|
||||||
# Start button
|
settings_button = _create_overlay_button("Settings", 3)
|
||||||
start_button = _create_menu_button("Start Game")
|
settings_button.disabled = true
|
||||||
vbox.add_child(start_button)
|
|
||||||
start_button.pressed.connect(_on_start_pressed)
|
|
||||||
|
|
||||||
# Options button (placeholder)
|
quit_button = _create_overlay_button("Exit", 4)
|
||||||
options_button = _create_menu_button("Options")
|
|
||||||
options_button.disabled = true
|
|
||||||
vbox.add_child(options_button)
|
|
||||||
|
|
||||||
# Quit button
|
|
||||||
quit_button = _create_menu_button("Quit")
|
|
||||||
vbox.add_child(quit_button)
|
|
||||||
quit_button.pressed.connect(_on_quit_pressed)
|
quit_button.pressed.connect(_on_quit_pressed)
|
||||||
|
|
||||||
# Version label at bottom
|
# Version label
|
||||||
version_label = Label.new()
|
version_label = Label.new()
|
||||||
version_label.text = "v0.1.0 - Development Build"
|
version_label.text = "v0.1.0"
|
||||||
version_label.add_theme_font_size_override("font_size", 12)
|
version_label.add_theme_font_override("font", custom_font)
|
||||||
version_label.add_theme_color_override("font_color", Color(0.4, 0.4, 0.5))
|
version_label.add_theme_font_size_override("font_size", 11)
|
||||||
|
version_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.7, 0.5))
|
||||||
add_child(version_label)
|
add_child(version_label)
|
||||||
version_label.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
|
||||||
version_label.offset_left = -200
|
|
||||||
version_label.offset_top = -30
|
|
||||||
version_label.offset_right = -10
|
|
||||||
version_label.offset_bottom = -10
|
|
||||||
|
|
||||||
func _create_menu_button(text: String) -> Button:
|
# Position everything
|
||||||
|
_reposition_elements()
|
||||||
|
|
||||||
|
func _create_overlay_button(text: String, slot_index: int) -> Button:
|
||||||
var button = Button.new()
|
var button = Button.new()
|
||||||
button.text = text
|
button.text = text
|
||||||
button.custom_minimum_size = Vector2(200, 50)
|
button.add_theme_font_override("font", custom_font)
|
||||||
button.add_theme_font_size_override("font_size", 20)
|
button.add_theme_font_size_override("font_size", 20)
|
||||||
|
button.add_theme_color_override("font_color", Color(0.85, 0.82, 0.72))
|
||||||
|
button.add_theme_color_override("font_hover_color", Color(1.0, 0.95, 0.8))
|
||||||
|
button.add_theme_color_override("font_pressed_color", Color(0.7, 0.65, 0.55))
|
||||||
|
button.add_theme_color_override("font_disabled_color", Color(0.45, 0.42, 0.38))
|
||||||
|
|
||||||
|
# Transparent background so the pre-drawn button art shows through
|
||||||
|
var transparent = StyleBoxFlat.new()
|
||||||
|
transparent.bg_color = Color(0, 0, 0, 0)
|
||||||
|
transparent.set_border_width_all(0)
|
||||||
|
transparent.set_content_margin_all(0)
|
||||||
|
button.add_theme_stylebox_override("normal", transparent)
|
||||||
|
|
||||||
|
var hover_style = StyleBoxFlat.new()
|
||||||
|
hover_style.bg_color = Color(1, 1, 1, 0.1)
|
||||||
|
hover_style.set_border_width_all(0)
|
||||||
|
hover_style.set_content_margin_all(0)
|
||||||
|
button.add_theme_stylebox_override("hover", hover_style)
|
||||||
|
|
||||||
|
var pressed_style = StyleBoxFlat.new()
|
||||||
|
pressed_style.bg_color = Color(0, 0, 0, 0.2)
|
||||||
|
pressed_style.set_border_width_all(0)
|
||||||
|
pressed_style.set_content_margin_all(0)
|
||||||
|
button.add_theme_stylebox_override("pressed", pressed_style)
|
||||||
|
|
||||||
|
var disabled_style = StyleBoxFlat.new()
|
||||||
|
disabled_style.bg_color = Color(0, 0, 0, 0.35)
|
||||||
|
disabled_style.set_border_width_all(0)
|
||||||
|
disabled_style.set_content_margin_all(0)
|
||||||
|
button.add_theme_stylebox_override("disabled", disabled_style)
|
||||||
|
|
||||||
|
button.set_meta("slot_index", slot_index)
|
||||||
|
buttons_container.add_child(button)
|
||||||
return button
|
return button
|
||||||
|
|
||||||
func _on_start_pressed() -> void:
|
func _reposition_elements() -> void:
|
||||||
start_game.emit()
|
var win_size := get_tree().root.get_visible_rect().size
|
||||||
|
|
||||||
|
# Image fills window
|
||||||
|
bg_texture.size = win_size
|
||||||
|
|
||||||
|
# Scale factor from design size to actual window size
|
||||||
|
var scale := Vector2(win_size.x / DESIGN_SIZE.x, win_size.y / DESIGN_SIZE.y)
|
||||||
|
var base_font := int(22.0 * scale.y)
|
||||||
|
|
||||||
|
# Position each button using its individual rect
|
||||||
|
for child in buttons_container.get_children():
|
||||||
|
if child is Button:
|
||||||
|
var slot: int = child.get_meta("slot_index", -1)
|
||||||
|
if slot < 0 or slot >= BUTTON_RECTS.size():
|
||||||
|
continue
|
||||||
|
var rect: Rect2 = BUTTON_RECTS[slot]
|
||||||
|
child.position = Vector2(rect.position.x * scale.x, rect.position.y * scale.y)
|
||||||
|
child.size = Vector2(rect.size.x * scale.x, rect.size.y * scale.y)
|
||||||
|
child.add_theme_font_override("font", custom_font)
|
||||||
|
child.add_theme_font_size_override("font_size", base_font)
|
||||||
|
|
||||||
|
# Version label bottom-right
|
||||||
|
version_label.position = Vector2(win_size.x - 80, win_size.y - 24)
|
||||||
|
version_label.size = Vector2(72, 18)
|
||||||
|
|
||||||
|
func _on_quick_play_pressed() -> void:
|
||||||
|
quick_play.emit()
|
||||||
|
|
||||||
|
func _on_play_pressed() -> void:
|
||||||
|
play_game.emit()
|
||||||
|
|
||||||
func _on_quit_pressed() -> void:
|
func _on_quit_pressed() -> void:
|
||||||
quit_game.emit()
|
quit_game.emit()
|
||||||
|
|||||||
BIN
title_menu.png
Normal file
BIN
title_menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
34
title_menu.png.import
Normal file
34
title_menu.png.import
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://bypwvnwjy7aas"
|
||||||
|
path="res://.godot/imported/title_menu.png-610c74a1891ed43c60230cd5b2d5c825.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://title_menu.png"
|
||||||
|
dest_files=["res://.godot/imported/title_menu.png-610c74a1891ed43c60230cd5b2d5c825.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
1
tools/.venv/bin/python
Symbolic link
1
tools/.venv/bin/python
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
1
tools/.venv/bin/python3
Symbolic link
1
tools/.venv/bin/python3
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/usr/bin/python3
|
||||||
1
tools/.venv/bin/python3.12
Symbolic link
1
tools/.venv/bin/python3.12
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
1
tools/.venv/lib64
Symbolic link
1
tools/.venv/lib64
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
lib
|
||||||
5
tools/.venv/pyvenv.cfg
Normal file
5
tools/.venv/pyvenv.cfg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
home = /usr/bin
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.12.3
|
||||||
|
executable = /usr/bin/python3.12
|
||||||
|
command = /usr/bin/python3 -m venv /home/ckoch/Documents/Development/FFCardGame/tools/.venv
|
||||||
997
tools/card_reviewer.py
Normal file
997
tools/card_reviewer.py
Normal file
@@ -0,0 +1,997 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
FFTCG Card Data Reviewer — Local web tool for browsing and editing scanned card data.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/card_reviewer.py # Start on port 8080
|
||||||
|
python tools/card_reviewer.py --port 9000 # Custom port
|
||||||
|
|
||||||
|
Opens a browser showing each card's image alongside its detected data.
|
||||||
|
Edit fields and save changes back to data/cards.json.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import webbrowser
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
CARDS_FILE = PROJECT_ROOT / "data" / "cards.json"
|
||||||
|
REVIEWED_FILE = PROJECT_ROOT / "data" / "reviewed.json"
|
||||||
|
SOURCE_CARDS_DIR = PROJECT_ROOT / "source-cards"
|
||||||
|
|
||||||
|
# Load cards into memory
|
||||||
|
cards_data = None
|
||||||
|
reviewed_data = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_cards():
|
||||||
|
global cards_data
|
||||||
|
with open(CARDS_FILE, "r") as f:
|
||||||
|
cards_data = json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_cards():
|
||||||
|
with open(CARDS_FILE, "w") as f:
|
||||||
|
json.dump(cards_data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def load_reviewed():
|
||||||
|
global reviewed_data
|
||||||
|
if REVIEWED_FILE.exists():
|
||||||
|
with open(REVIEWED_FILE, "r") as f:
|
||||||
|
reviewed_data = json.load(f)
|
||||||
|
else:
|
||||||
|
reviewed_data = {"reviewed": []}
|
||||||
|
|
||||||
|
|
||||||
|
def save_reviewed():
|
||||||
|
with open(REVIEWED_FILE, "w") as f:
|
||||||
|
json.dump(reviewed_data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
HTML_PAGE = r"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FFTCG Card Reviewer</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* Top bar */
|
||||||
|
.topbar { background: #16213e; padding: 8px 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; border-bottom: 1px solid #333; }
|
||||||
|
.topbar label { font-size: 12px; color: #888; }
|
||||||
|
.topbar select, .topbar input { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 4px 8px; border-radius: 4px; font-size: 13px; }
|
||||||
|
.topbar select:focus, .topbar input:focus { outline: none; border-color: #e94560; }
|
||||||
|
.filter-group { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.search-input { width: 160px; }
|
||||||
|
.card-counter { margin-left: auto; font-size: 13px; color: #888; }
|
||||||
|
|
||||||
|
/* Main area — grid */
|
||||||
|
.main { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; }
|
||||||
|
.grid-card { cursor: pointer; border-radius: 8px; overflow: hidden; background: #16213e; border: 2px solid transparent; transition: border-color 0.15s, transform 0.15s; position: relative; }
|
||||||
|
.grid-card:hover { border-color: #e94560; transform: translateY(-2px); }
|
||||||
|
.grid-card img { width: 100%; display: block; }
|
||||||
|
.grid-card .grid-label { padding: 6px 8px; font-size: 11px; line-height: 1.3; }
|
||||||
|
.grid-card .grid-id { color: #e94560; font-weight: bold; }
|
||||||
|
.grid-card .grid-name { color: #ccc; }
|
||||||
|
.grid-card .grid-meta { color: #666; font-size: 10px; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination { background: #16213e; padding: 10px 16px; display: flex; align-items: center; justify-content: center; gap: 8px; border-top: 1px solid #333; }
|
||||||
|
.page-btn { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||||
|
.page-btn:hover { background: #e94560; border-color: #e94560; }
|
||||||
|
.page-btn:disabled { background: #222; color: #555; cursor: not-allowed; border-color: #333; }
|
||||||
|
.page-btn.active { background: #e94560; border-color: #e94560; font-weight: bold; }
|
||||||
|
.page-info { font-size: 13px; color: #888; margin: 0 8px; }
|
||||||
|
.page-size-group { margin-left: 20px; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.page-size-group label { font-size: 12px; color: #888; }
|
||||||
|
.page-size-group select { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 4px 8px; border-radius: 4px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Modal overlay */
|
||||||
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center; }
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal { background: #1a1a2e; border: 1px solid #333; border-radius: 12px; width: 90vw; max-width: 900px; max-height: 90vh; display: flex; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.6); }
|
||||||
|
|
||||||
|
/* Modal left — card image */
|
||||||
|
.modal-image { width: 360px; min-width: 360px; display: flex; align-items: center; justify-content: center; padding: 16px; background: #0f0f23; }
|
||||||
|
.modal-image img { max-width: 100%; max-height: calc(90vh - 40px); object-fit: contain; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
/* Modal right — editor */
|
||||||
|
.modal-editor { flex: 1; overflow-y: auto; padding: 20px; }
|
||||||
|
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||||
|
.card-id-display { font-size: 22px; font-weight: bold; color: #e94560; }
|
||||||
|
.close-btn { background: none; border: 1px solid #555; color: #aaa; font-size: 20px; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.close-btn:hover { background: #e94560; color: white; border-color: #e94560; }
|
||||||
|
|
||||||
|
.field-group { margin-bottom: 12px; }
|
||||||
|
.field-group label { display: block; font-size: 11px; text-transform: uppercase; color: #666; margin-bottom: 3px; letter-spacing: 0.5px; }
|
||||||
|
.field-group input, .field-group select { width: 100%; background: #16213e; color: #e0e0e0; border: 1px solid #333; padding: 8px 10px; border-radius: 4px; font-size: 14px; }
|
||||||
|
.field-group input:focus, .field-group select:focus { outline: none; border-color: #e94560; }
|
||||||
|
.field-row { display: flex; gap: 12px; }
|
||||||
|
.field-row .field-group { flex: 1; }
|
||||||
|
|
||||||
|
.checkbox-group { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.checkbox-group input[type="checkbox"] { width: 18px; height: 18px; accent-color: #e94560; }
|
||||||
|
.checkbox-group label { font-size: 13px; color: #ccc; }
|
||||||
|
|
||||||
|
.abilities-section { margin-top: 16px; }
|
||||||
|
.abilities-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||||
|
.abilities-header h3 { font-size: 13px; color: #888; text-transform: uppercase; }
|
||||||
|
.add-ability-btn { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||||
|
.add-ability-btn:hover { background: #e94560; border-color: #e94560; }
|
||||||
|
.ability-card { background: #16213e; border: 1px solid #333; border-radius: 6px; padding: 10px; margin-bottom: 8px; position: relative; }
|
||||||
|
.ability-card .ab-top-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||||
|
.ability-card .ab-top-row select, .ability-card .ab-top-row input { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
||||||
|
.ability-card .ab-top-row select { width: 90px; }
|
||||||
|
.ability-card .ab-top-row input[type="text"] { flex: 1; }
|
||||||
|
.ability-card .ab-top-row .ab-ex-label { font-size: 11px; color: #888; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||||
|
.ability-card .ab-top-row .ab-ex-label input { width: 14px; height: 14px; accent-color: #e94560; }
|
||||||
|
.ability-card .ab-remove { background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 0 4px; line-height: 1; }
|
||||||
|
.ability-card .ab-remove:hover { color: #e94560; }
|
||||||
|
.ability-card .ab-field { margin-top: 4px; }
|
||||||
|
.ability-card .ab-field label { display: block; font-size: 10px; color: #555; text-transform: uppercase; margin-bottom: 2px; }
|
||||||
|
.ability-card .ab-field input, .ability-card .ab-field textarea { width: 100%; background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 6px 8px; border-radius: 4px; font-size: 12px; font-family: inherit; }
|
||||||
|
.ability-card .ab-field textarea { resize: vertical; min-height: 50px; }
|
||||||
|
|
||||||
|
/* Modal nav bar */
|
||||||
|
.modal-nav { display: flex; align-items: center; gap: 12px; margin-top: 16px; padding-top: 12px; border-top: 1px solid #333; }
|
||||||
|
.nav-btn { background: #e94560; color: white; border: none; padding: 8px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: bold; }
|
||||||
|
.nav-btn:hover { background: #c73650; }
|
||||||
|
.nav-btn:disabled { background: #444; cursor: not-allowed; }
|
||||||
|
.save-btn { background: #0f9b58; }
|
||||||
|
.save-btn:hover { background: #0d8a4d; }
|
||||||
|
.save-btn.saved { background: #666; }
|
||||||
|
.nav-spacer { flex: 1; }
|
||||||
|
.status-msg { font-size: 12px; color: #0f9b58; transition: opacity 0.5s; }
|
||||||
|
|
||||||
|
/* Null power toggle */
|
||||||
|
.null-toggle { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
|
||||||
|
.null-toggle label { font-size: 11px; color: #888; }
|
||||||
|
|
||||||
|
/* Element color pills on grid cards */
|
||||||
|
.elem-pill { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; margin-right: 3px; }
|
||||||
|
.elem-Fire { background: #c0392b; color: #fff; }
|
||||||
|
.elem-Ice { background: #2980b9; color: #fff; }
|
||||||
|
.elem-Wind { background: #27ae60; color: #fff; }
|
||||||
|
.elem-Earth { background: #d4a017; color: #fff; }
|
||||||
|
.elem-Lightning { background: #8e44ad; color: #fff; }
|
||||||
|
.elem-Water { background: #1abc9c; color: #fff; }
|
||||||
|
.elem-Light { background: #f1c40f; color: #333; }
|
||||||
|
.elem-Dark { background: #2c3e50; color: #ccc; }
|
||||||
|
|
||||||
|
/* Grid card badges */
|
||||||
|
.grid-badge { position: absolute; top: 4px; right: 4px; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; z-index: 1; }
|
||||||
|
.badge-issue { background: #e94560; color: #fff; }
|
||||||
|
.badge-reviewed { background: #0f9b58; color: #fff; }
|
||||||
|
.grid-card.has-issue { border-color: #e94560; }
|
||||||
|
|
||||||
|
/* Multi-element editor */
|
||||||
|
.multi-elem { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
|
.multi-elem label { display: flex; align-items: center; gap: 3px; font-size: 12px; cursor: pointer; padding: 3px 8px; border-radius: 4px; border: 1px solid #333; background: #0f3460; }
|
||||||
|
.multi-elem label:hover { border-color: #e94560; }
|
||||||
|
.multi-elem input[type="checkbox"] { width: 14px; height: 14px; accent-color: #e94560; }
|
||||||
|
.multi-elem label.checked { border-color: #e94560; background: #2a1040; }
|
||||||
|
|
||||||
|
/* Reviewed toggle in modal */
|
||||||
|
.reviewed-toggle { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 8px 12px; background: #0f3460; border-radius: 6px; }
|
||||||
|
.reviewed-toggle input { width: 18px; height: 18px; accent-color: #0f9b58; }
|
||||||
|
.reviewed-toggle label { font-size: 13px; color: #ccc; }
|
||||||
|
.reviewed-toggle .review-count { margin-left: auto; font-size: 11px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Top filter bar -->
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Set:</label>
|
||||||
|
<select id="filterSet"><option value="">All</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Element:</label>
|
||||||
|
<select id="filterElement">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option>Fire</option><option>Ice</option><option>Wind</option><option>Earth</option>
|
||||||
|
<option>Lightning</option><option>Water</option><option>Light</option><option>Dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Type:</label>
|
||||||
|
<select id="filterType">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option>Forward</option><option>Backup</option><option>Summon</option><option>Monster</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Category:</label>
|
||||||
|
<select id="filterCategory"><option value="">All</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Issues:</label>
|
||||||
|
<select id="filterIssues">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="any">Has Issues</option>
|
||||||
|
<option value="fwd_no_power">Forward w/o Power</option>
|
||||||
|
<option value="backup_power">Backup/Summon w/ Power</option>
|
||||||
|
<option value="no_abilities">No Abilities</option>
|
||||||
|
<option value="no_cost">No Cost</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Reviewed:</label>
|
||||||
|
<select id="filterReviewed">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="no">Not Reviewed</option>
|
||||||
|
<option value="yes">Reviewed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Search:</label>
|
||||||
|
<input type="text" id="filterSearch" class="search-input" placeholder="Name, job, ID...">
|
||||||
|
</div>
|
||||||
|
<span class="card-counter" id="cardCounter">0 cards</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main grid area -->
|
||||||
|
<div class="main" id="mainArea">
|
||||||
|
<div class="card-grid" id="cardGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="pagination" id="paginationBar">
|
||||||
|
<button class="page-btn" id="pageFirst" title="First page">«</button>
|
||||||
|
<button class="page-btn" id="pagePrev">‹ Prev</button>
|
||||||
|
<span class="page-info" id="pageInfo">Page 1 / 1</span>
|
||||||
|
<button class="page-btn" id="pageNext">Next ›</button>
|
||||||
|
<button class="page-btn" id="pageLast" title="Last page">»</button>
|
||||||
|
<div class="page-size-group">
|
||||||
|
<label>Per page:</label>
|
||||||
|
<select id="pageSize">
|
||||||
|
<option value="24">24</option>
|
||||||
|
<option value="48" selected>48</option>
|
||||||
|
<option value="96">96</option>
|
||||||
|
<option value="192">192</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail/edit modal -->
|
||||||
|
<div class="modal-overlay" id="modalOverlay">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-image">
|
||||||
|
<img id="cardImage" src="" alt="Card Image">
|
||||||
|
</div>
|
||||||
|
<div class="modal-editor">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="card-id-display" id="cardId"></div>
|
||||||
|
<button class="close-btn" id="modalClose" title="Escape">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" id="fieldName" data-field="name">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Type</label>
|
||||||
|
<select id="fieldType" data-field="type">
|
||||||
|
<option>Forward</option><option>Backup</option><option>Summon</option><option>Monster</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Element(s)</label>
|
||||||
|
<div class="multi-elem" id="elemCheckboxes">
|
||||||
|
<label><input type="checkbox" value="Fire"> Fire</label>
|
||||||
|
<label><input type="checkbox" value="Ice"> Ice</label>
|
||||||
|
<label><input type="checkbox" value="Wind"> Wind</label>
|
||||||
|
<label><input type="checkbox" value="Earth"> Earth</label>
|
||||||
|
<label><input type="checkbox" value="Lightning"> Lightning</label>
|
||||||
|
<label><input type="checkbox" value="Water"> Water</label>
|
||||||
|
<label><input type="checkbox" value="Light"> Light</label>
|
||||||
|
<label><input type="checkbox" value="Dark"> Dark</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Cost</label>
|
||||||
|
<input type="number" id="fieldCost" data-field="cost" min="0" max="20">
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Power</label>
|
||||||
|
<input type="number" id="fieldPower" data-field="power" min="0" step="1000">
|
||||||
|
<div class="null-toggle">
|
||||||
|
<input type="checkbox" id="powerNull">
|
||||||
|
<label for="powerNull">No power (null)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Job</label>
|
||||||
|
<input type="text" id="fieldJob" data-field="job">
|
||||||
|
</div>
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<input type="text" id="fieldCategory" data-field="category">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap: 24px; margin: 12px 0;">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="fieldGeneric" data-field="is_generic">
|
||||||
|
<label for="fieldGeneric">Generic</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<input type="checkbox" id="fieldExBurst" data-field="has_ex_burst">
|
||||||
|
<label for="fieldExBurst">EX Burst</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="abilities-section">
|
||||||
|
<div class="abilities-header">
|
||||||
|
<h3>Abilities</h3>
|
||||||
|
<button class="add-ability-btn" id="btnAddAbility">+ Add Ability</button>
|
||||||
|
</div>
|
||||||
|
<div id="abilitiesList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reviewed-toggle">
|
||||||
|
<input type="checkbox" id="fieldReviewed">
|
||||||
|
<label for="fieldReviewed">Reviewed</label>
|
||||||
|
<span class="review-count" id="reviewCount"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-nav">
|
||||||
|
<button class="nav-btn" id="btnPrev" title="Left Arrow">← Prev</button>
|
||||||
|
<button class="nav-btn" id="btnNext" title="Right Arrow">Next →</button>
|
||||||
|
<span class="nav-spacer"></span>
|
||||||
|
<span class="status-msg" id="statusMsg"></span>
|
||||||
|
<button class="nav-btn save-btn" id="btnSave" title="Ctrl+S">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allCards = [];
|
||||||
|
let filteredCards = [];
|
||||||
|
let currentPage = 0;
|
||||||
|
let pageSize = 48;
|
||||||
|
let modalCardIndex = -1; // index within filteredCards
|
||||||
|
let hasUnsavedChanges = false;
|
||||||
|
let reviewedSet = new Set(); // card IDs that have been reviewed
|
||||||
|
|
||||||
|
function hasIssues(card, which) {
|
||||||
|
if (which === 'fwd_no_power') return card.type === 'Forward' && !card.power;
|
||||||
|
if (which === 'backup_power') return (card.type === 'Backup' || card.type === 'Summon') && card.power;
|
||||||
|
if (which === 'no_abilities') return !card.abilities || card.abilities.length === 0;
|
||||||
|
if (which === 'no_cost') return !card.cost && card.cost !== 0;
|
||||||
|
// 'any' — check all
|
||||||
|
return (card.type === 'Forward' && !card.power) ||
|
||||||
|
((card.type === 'Backup' || card.type === 'Summon') && card.power) ||
|
||||||
|
(!card.abilities || card.abilities.length === 0) ||
|
||||||
|
(!card.cost && card.cost !== 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const resp = await fetch('/api/cards');
|
||||||
|
const data = await resp.json();
|
||||||
|
allCards = data.cards;
|
||||||
|
|
||||||
|
// Load reviewed state
|
||||||
|
try {
|
||||||
|
const rResp = await fetch('/api/reviewed');
|
||||||
|
const rData = await rResp.json();
|
||||||
|
reviewedSet = new Set(rData.reviewed || []);
|
||||||
|
} catch(e) { reviewedSet = new Set(); }
|
||||||
|
|
||||||
|
populateSetFilter();
|
||||||
|
populateCategoryFilter();
|
||||||
|
updateReviewCount();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReviewCount() {
|
||||||
|
const el = document.getElementById('reviewCount');
|
||||||
|
if (el) el.textContent = reviewedSet.size + ' / ' + allCards.length + ' reviewed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSetFilter() {
|
||||||
|
const sets = new Set();
|
||||||
|
allCards.forEach(c => sets.add(c.id.split('-')[0]));
|
||||||
|
const sel = document.getElementById('filterSet');
|
||||||
|
[...sets].sort((a, b) => {
|
||||||
|
const aNum = parseInt(a), bNum = parseInt(b);
|
||||||
|
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
|
||||||
|
if (!isNaN(aNum)) return -1;
|
||||||
|
if (!isNaN(bNum)) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}).forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s; opt.textContent = s;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateCategoryFilter() {
|
||||||
|
const cats = new Set();
|
||||||
|
allCards.forEach(c => { if (c.category) cats.add(c.category); });
|
||||||
|
const sel = document.getElementById('filterCategory');
|
||||||
|
[...cats].sort().forEach(c => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = c; opt.textContent = c;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const setVal = document.getElementById('filterSet').value;
|
||||||
|
const elemVal = document.getElementById('filterElement').value;
|
||||||
|
const typeVal = document.getElementById('filterType').value;
|
||||||
|
const catVal = document.getElementById('filterCategory').value;
|
||||||
|
const issuesVal = document.getElementById('filterIssues').value;
|
||||||
|
const reviewedVal = document.getElementById('filterReviewed').value;
|
||||||
|
const searchVal = document.getElementById('filterSearch').value.toLowerCase();
|
||||||
|
|
||||||
|
filteredCards = allCards.filter(c => {
|
||||||
|
if (setVal && c.id.split('-')[0] !== setVal) return false;
|
||||||
|
if (elemVal) {
|
||||||
|
const el = Array.isArray(c.element) ? c.element : [c.element];
|
||||||
|
if (!el.includes(elemVal)) return false;
|
||||||
|
}
|
||||||
|
if (typeVal && c.type !== typeVal) return false;
|
||||||
|
if (catVal && c.category !== catVal) return false;
|
||||||
|
if (issuesVal && !hasIssues(c, issuesVal)) return false;
|
||||||
|
if (reviewedVal === 'yes' && !reviewedSet.has(c.id)) return false;
|
||||||
|
if (reviewedVal === 'no' && reviewedSet.has(c.id)) return false;
|
||||||
|
if (searchVal) {
|
||||||
|
const hay = [c.name, c.job, c.category, c.id].join(' ').toLowerCase();
|
||||||
|
if (!hay.includes(searchVal)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPage = 0;
|
||||||
|
updateCounter();
|
||||||
|
renderGrid();
|
||||||
|
updatePagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
document.getElementById('cardCounter').textContent = filteredCards.length + ' cards';
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalPages() {
|
||||||
|
return Math.max(1, Math.ceil(filteredCards.length / pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('cardGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const start = currentPage * pageSize;
|
||||||
|
const end = Math.min(start + pageSize, filteredCards.length);
|
||||||
|
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const card = filteredCards[i];
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'grid-card';
|
||||||
|
div.dataset.index = i;
|
||||||
|
|
||||||
|
const elems = Array.isArray(card.element) ? card.element : [card.element];
|
||||||
|
const pills = elems.map(e => `<span class="elem-pill elem-${e}">${e}</span>`).join('');
|
||||||
|
|
||||||
|
const isIssue = hasIssues(card, 'any');
|
||||||
|
const isReviewed = reviewedSet.has(card.id);
|
||||||
|
if (isIssue) div.classList.add('has-issue');
|
||||||
|
|
||||||
|
let badges = '';
|
||||||
|
if (isIssue) badges += '<span class="grid-badge badge-issue">!</span>';
|
||||||
|
if (isReviewed) badges += '<span class="grid-badge badge-reviewed" style="' + (isIssue ? 'top:22px' : '') + '">OK</span>';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
${badges}
|
||||||
|
<img src="/images/${card.image}" alt="${card.name}" loading="lazy">
|
||||||
|
<div class="grid-label">
|
||||||
|
<div class="grid-id">${card.id}</div>
|
||||||
|
<div class="grid-name">${card.name || ''}</div>
|
||||||
|
<div class="grid-meta">${card.type} ${pills} ${card.power ? card.power/1000 + 'k' : ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
div.addEventListener('click', () => openModal(i));
|
||||||
|
grid.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to top of grid
|
||||||
|
document.getElementById('mainArea').scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination() {
|
||||||
|
const total = totalPages();
|
||||||
|
document.getElementById('pageInfo').textContent = `Page ${currentPage + 1} / ${total}`;
|
||||||
|
document.getElementById('pageFirst').disabled = (currentPage <= 0);
|
||||||
|
document.getElementById('pagePrev').disabled = (currentPage <= 0);
|
||||||
|
document.getElementById('pageNext').disabled = (currentPage >= total - 1);
|
||||||
|
document.getElementById('pageLast').disabled = (currentPage >= total - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(page) {
|
||||||
|
const total = totalPages();
|
||||||
|
page = Math.max(0, Math.min(page, total - 1));
|
||||||
|
if (page === currentPage) return;
|
||||||
|
currentPage = page;
|
||||||
|
renderGrid();
|
||||||
|
updatePagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modal / Detail editor ---
|
||||||
|
|
||||||
|
function openModal(filteredIndex) {
|
||||||
|
modalCardIndex = filteredIndex;
|
||||||
|
showCardInModal(filteredIndex);
|
||||||
|
document.getElementById('modalOverlay').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modalOverlay').classList.remove('open');
|
||||||
|
modalCardIndex = -1;
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCardInModal(index) {
|
||||||
|
if (index < 0 || index >= filteredCards.length) return;
|
||||||
|
modalCardIndex = index;
|
||||||
|
const card = filteredCards[index];
|
||||||
|
|
||||||
|
document.getElementById('cardImage').src = '/images/' + card.image;
|
||||||
|
document.getElementById('cardId').textContent = card.id;
|
||||||
|
|
||||||
|
document.getElementById('fieldName').value = card.name || '';
|
||||||
|
document.getElementById('fieldType').value = card.type || 'Forward';
|
||||||
|
|
||||||
|
// Multi-element checkboxes
|
||||||
|
const elems = Array.isArray(card.element) ? card.element : [card.element || 'Fire'];
|
||||||
|
document.querySelectorAll('#elemCheckboxes input[type="checkbox"]').forEach(cb => {
|
||||||
|
cb.checked = elems.includes(cb.value);
|
||||||
|
cb.parentElement.classList.toggle('checked', cb.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('fieldCost').value = card.cost || 0;
|
||||||
|
|
||||||
|
const powerInput = document.getElementById('fieldPower');
|
||||||
|
const powerNull = document.getElementById('powerNull');
|
||||||
|
if (card.power === null || card.power === undefined) {
|
||||||
|
powerInput.value = '';
|
||||||
|
powerInput.disabled = true;
|
||||||
|
powerNull.checked = true;
|
||||||
|
} else {
|
||||||
|
powerInput.value = card.power;
|
||||||
|
powerInput.disabled = false;
|
||||||
|
powerNull.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('fieldJob').value = card.job || '';
|
||||||
|
document.getElementById('fieldCategory').value = card.category || '';
|
||||||
|
document.getElementById('fieldGeneric').checked = !!card.is_generic;
|
||||||
|
document.getElementById('fieldExBurst').checked = !!card.has_ex_burst;
|
||||||
|
|
||||||
|
const abList = document.getElementById('abilitiesList');
|
||||||
|
abList.innerHTML = '';
|
||||||
|
(card.abilities || []).forEach(ab => {
|
||||||
|
abList.appendChild(renderAbilityCard(ab));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reviewed state
|
||||||
|
document.getElementById('fieldReviewed').checked = reviewedSet.has(card.id);
|
||||||
|
updateReviewCount();
|
||||||
|
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
document.getElementById('btnSave').classList.remove('saved');
|
||||||
|
document.getElementById('statusMsg').textContent = '';
|
||||||
|
|
||||||
|
document.getElementById('btnPrev').disabled = (index <= 0);
|
||||||
|
document.getElementById('btnNext').disabled = (index >= filteredCards.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAbilityCard(ab) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'ability-card';
|
||||||
|
|
||||||
|
const esc = s => (s || '').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="ab-top-row">
|
||||||
|
<select class="ab-type">
|
||||||
|
<option value="field"${ab.type==='field'?' selected':''}>Field</option>
|
||||||
|
<option value="auto"${ab.type==='auto'?' selected':''}>Auto</option>
|
||||||
|
<option value="action"${ab.type==='action'?' selected':''}>Action</option>
|
||||||
|
<option value="special"${ab.type==='special'?' selected':''}>Special</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="ab-name" value="${esc(ab.name)}" placeholder="Ability name">
|
||||||
|
<label class="ab-ex-label"><input type="checkbox" class="ab-ex-burst"${ab.is_ex_burst?' checked':''}> EX</label>
|
||||||
|
<button class="ab-remove" title="Remove ability">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="ab-field">
|
||||||
|
<label>Trigger</label>
|
||||||
|
<input type="text" class="ab-trigger" value="${esc(ab.trigger)}" placeholder="e.g. When this card enters the field...">
|
||||||
|
</div>
|
||||||
|
<div class="ab-field">
|
||||||
|
<label>Effect</label>
|
||||||
|
<textarea class="ab-effect" placeholder="Effect text...">${esc(ab.effect)}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
div.querySelector('.ab-remove').addEventListener('click', () => {
|
||||||
|
div.remove();
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
div.querySelectorAll('input, select, textarea').forEach(el => {
|
||||||
|
el.addEventListener('input', () => { hasUnsavedChanges = true; });
|
||||||
|
el.addEventListener('change', () => { hasUnsavedChanges = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAbilitiesFromDOM() {
|
||||||
|
const cards = document.querySelectorAll('#abilitiesList .ability-card');
|
||||||
|
return Array.from(cards).map(div => ({
|
||||||
|
type: div.querySelector('.ab-type').value,
|
||||||
|
name: div.querySelector('.ab-name').value,
|
||||||
|
trigger: div.querySelector('.ab-trigger').value,
|
||||||
|
effect: div.querySelector('.ab-effect').value,
|
||||||
|
is_ex_burst: div.querySelector('.ab-ex-burst').checked,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedElements() {
|
||||||
|
const checked = [];
|
||||||
|
document.querySelectorAll('#elemCheckboxes input[type="checkbox"]:checked').forEach(cb => {
|
||||||
|
checked.push(cb.value);
|
||||||
|
});
|
||||||
|
// Return string if single element, array if multi
|
||||||
|
if (checked.length === 1) return checked[0];
|
||||||
|
if (checked.length > 1) return checked;
|
||||||
|
return 'Fire'; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentEdits() {
|
||||||
|
const card = filteredCards[modalCardIndex];
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
|
const powerNull = document.getElementById('powerNull').checked;
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
name: document.getElementById('fieldName').value,
|
||||||
|
type: document.getElementById('fieldType').value,
|
||||||
|
element: getSelectedElements(),
|
||||||
|
cost: parseInt(document.getElementById('fieldCost').value) || 0,
|
||||||
|
power: powerNull ? null : (parseInt(document.getElementById('fieldPower').value) || null),
|
||||||
|
job: document.getElementById('fieldJob').value,
|
||||||
|
category: document.getElementById('fieldCategory').value,
|
||||||
|
is_generic: document.getElementById('fieldGeneric').checked,
|
||||||
|
has_ex_burst: document.getElementById('fieldExBurst').checked,
|
||||||
|
abilities: getAbilitiesFromDOM(),
|
||||||
|
image: card.image,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCard() {
|
||||||
|
const edits = getCurrentEdits();
|
||||||
|
if (!edits) return;
|
||||||
|
|
||||||
|
const resp = await fetch('/api/cards/' + encodeURIComponent(edits.id), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(edits),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const idx = allCards.findIndex(c => c.id === edits.id);
|
||||||
|
if (idx >= 0) allCards[idx] = edits;
|
||||||
|
const fIdx = filteredCards.findIndex(c => c.id === edits.id);
|
||||||
|
if (fIdx >= 0) filteredCards[fIdx] = edits;
|
||||||
|
|
||||||
|
// Update the grid card if visible
|
||||||
|
const gridCard = document.querySelector(`.grid-card[data-index="${modalCardIndex}"]`);
|
||||||
|
if (gridCard) {
|
||||||
|
const elems = Array.isArray(edits.element) ? edits.element : [edits.element];
|
||||||
|
const pills = elems.map(e => `<span class="elem-pill elem-${e}">${e}</span>`).join('');
|
||||||
|
gridCard.querySelector('.grid-name').textContent = edits.name || '';
|
||||||
|
gridCard.querySelector('.grid-meta').innerHTML = `${edits.type} ${pills} ${edits.power ? edits.power/1000 + 'k' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
document.getElementById('btnSave').classList.add('saved');
|
||||||
|
document.getElementById('statusMsg').textContent = 'Saved!';
|
||||||
|
setTimeout(() => { document.getElementById('statusMsg').textContent = ''; }, 2000);
|
||||||
|
} else {
|
||||||
|
document.getElementById('statusMsg').textContent = 'Save failed!';
|
||||||
|
document.getElementById('statusMsg').style.color = '#e94560';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power null toggle
|
||||||
|
document.getElementById('powerNull').addEventListener('change', function() {
|
||||||
|
const powerInput = document.getElementById('fieldPower');
|
||||||
|
if (this.checked) {
|
||||||
|
powerInput.value = '';
|
||||||
|
powerInput.disabled = true;
|
||||||
|
} else {
|
||||||
|
powerInput.disabled = false;
|
||||||
|
powerInput.focus();
|
||||||
|
}
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track changes
|
||||||
|
document.querySelectorAll('[data-field]').forEach(el => {
|
||||||
|
el.addEventListener('input', () => { hasUnsavedChanges = true; });
|
||||||
|
el.addEventListener('change', () => { hasUnsavedChanges = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multi-element checkbox styling + change tracking
|
||||||
|
document.querySelectorAll('#elemCheckboxes input[type="checkbox"]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
cb.parentElement.classList.toggle('checked', cb.checked);
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reviewed toggle — saves immediately via API
|
||||||
|
document.getElementById('fieldReviewed').addEventListener('change', async function() {
|
||||||
|
const card = filteredCards[modalCardIndex];
|
||||||
|
if (!card) return;
|
||||||
|
if (this.checked) {
|
||||||
|
reviewedSet.add(card.id);
|
||||||
|
} else {
|
||||||
|
reviewedSet.delete(card.id);
|
||||||
|
}
|
||||||
|
updateReviewCount();
|
||||||
|
// Persist reviewed state
|
||||||
|
await fetch('/api/reviewed', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ reviewed: [...reviewedSet] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add ability
|
||||||
|
document.getElementById('btnAddAbility').addEventListener('click', () => {
|
||||||
|
const abList = document.getElementById('abilitiesList');
|
||||||
|
abList.appendChild(renderAbilityCard({ type: 'field', name: '', trigger: '', effect: '', is_ex_burst: false }));
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
// Scroll to the new ability
|
||||||
|
abList.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal navigation
|
||||||
|
document.getElementById('btnPrev').addEventListener('click', () => showCardInModal(modalCardIndex - 1));
|
||||||
|
document.getElementById('btnNext').addEventListener('click', () => showCardInModal(modalCardIndex + 1));
|
||||||
|
document.getElementById('btnSave').addEventListener('click', saveCard);
|
||||||
|
document.getElementById('modalClose').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('modalOverlay').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
document.getElementById('pageFirst').addEventListener('click', () => goToPage(0));
|
||||||
|
document.getElementById('pagePrev').addEventListener('click', () => goToPage(currentPage - 1));
|
||||||
|
document.getElementById('pageNext').addEventListener('click', () => goToPage(currentPage + 1));
|
||||||
|
document.getElementById('pageLast').addEventListener('click', () => goToPage(totalPages() - 1));
|
||||||
|
document.getElementById('pageSize').addEventListener('change', function() {
|
||||||
|
pageSize = parseInt(this.value);
|
||||||
|
currentPage = 0;
|
||||||
|
renderGrid();
|
||||||
|
updatePagination();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
['filterSet', 'filterElement', 'filterType', 'filterCategory', 'filterIssues', 'filterReviewed'].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('change', applyFilters);
|
||||||
|
});
|
||||||
|
document.getElementById('filterSearch').addEventListener('input', applyFilters);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
const tag = e.target.tagName;
|
||||||
|
const modalOpen = document.getElementById('modalOverlay').classList.contains('open');
|
||||||
|
|
||||||
|
// Escape closes modal
|
||||||
|
if (e.key === 'Escape' && modalOpen) {
|
||||||
|
closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+S saves in modal
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (modalOpen) saveCard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't trigger navigation when typing in inputs
|
||||||
|
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
if (modalOpen) {
|
||||||
|
if (e.key === 'ArrowLeft') showCardInModal(modalCardIndex - 1);
|
||||||
|
else if (e.key === 'ArrowRight') showCardInModal(modalCardIndex + 1);
|
||||||
|
} else {
|
||||||
|
if (e.key === 'ArrowLeft') goToPage(currentPage - 1);
|
||||||
|
else if (e.key === 'ArrowRight') goToPage(currentPage + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CardReviewerHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
# Suppress default access logging
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send_json(self, data, status=200):
|
||||||
|
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_html(self, html):
|
||||||
|
body = html.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_404(self):
|
||||||
|
self.send_response(404)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"Not Found")
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = parsed.path
|
||||||
|
|
||||||
|
# Serve main page
|
||||||
|
if path == "/" or path == "":
|
||||||
|
self._send_html(HTML_PAGE)
|
||||||
|
return
|
||||||
|
|
||||||
|
# API: get all cards
|
||||||
|
if path == "/api/cards":
|
||||||
|
self._send_json(cards_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# API: get reviewed state
|
||||||
|
if path == "/api/reviewed":
|
||||||
|
self._send_json(reviewed_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# API: get single card
|
||||||
|
if path.startswith("/api/cards/"):
|
||||||
|
card_id = path[len("/api/cards/"):]
|
||||||
|
card = next((c for c in cards_data["cards"] if c["id"] == card_id), None)
|
||||||
|
if card:
|
||||||
|
self._send_json(card)
|
||||||
|
else:
|
||||||
|
self._send_404()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Serve card images
|
||||||
|
if path.startswith("/images/"):
|
||||||
|
filename = path[len("/images/"):]
|
||||||
|
filepath = SOURCE_CARDS_DIR / filename
|
||||||
|
if filepath.exists() and filepath.is_file():
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
self.send_response(200)
|
||||||
|
ext = filepath.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
self.send_header("Content-Type", "image/jpeg")
|
||||||
|
elif ext == ".png":
|
||||||
|
self.send_header("Content-Type", "image/png")
|
||||||
|
else:
|
||||||
|
self.send_header("Content-Type", "application/octet-stream")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Cache-Control", "public, max-age=86400")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
else:
|
||||||
|
self._send_404()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send_404()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = parsed.path
|
||||||
|
|
||||||
|
# API: save reviewed state
|
||||||
|
if path == "/api/reviewed":
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(content_length)
|
||||||
|
try:
|
||||||
|
global reviewed_data
|
||||||
|
reviewed_data = json.loads(body)
|
||||||
|
save_reviewed()
|
||||||
|
self._send_json({"ok": True})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self._send_json({"error": "Invalid JSON"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# API: save card
|
||||||
|
if path.startswith("/api/cards/"):
|
||||||
|
card_id = path[len("/api/cards/"):]
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(content_length)
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_data = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self._send_json({"error": "Invalid JSON"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find and update card
|
||||||
|
for i, card in enumerate(cards_data["cards"]):
|
||||||
|
if card["id"] == card_id:
|
||||||
|
# Preserve abilities from existing card if not provided
|
||||||
|
if "abilities" not in new_data:
|
||||||
|
new_data["abilities"] = card.get("abilities", [])
|
||||||
|
cards_data["cards"][i] = new_data
|
||||||
|
save_cards()
|
||||||
|
self._send_json({"ok": True})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send_json({"error": "Card not found"}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send_404()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="FFTCG Card Data Reviewer")
|
||||||
|
parser.add_argument("--port", type=int, default=8080, help="Port to serve on")
|
||||||
|
parser.add_argument("--no-browser", action="store_true", help="Don't open browser automatically")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
load_cards()
|
||||||
|
load_reviewed()
|
||||||
|
print(f"Loaded {len(cards_data['cards'])} cards from {CARDS_FILE}")
|
||||||
|
print(f"Reviewed: {len(reviewed_data.get('reviewed', []))} cards")
|
||||||
|
print(f"Serving card images from {SOURCE_CARDS_DIR}")
|
||||||
|
print(f"Starting server at http://localhost:{args.port}")
|
||||||
|
|
||||||
|
server = HTTPServer(("localhost", args.port), CardReviewerHandler)
|
||||||
|
|
||||||
|
if not args.no_browser:
|
||||||
|
webbrowser.open(f"http://localhost:{args.port}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down.")
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
589
tools/scan_cards.py
Normal file
589
tools/scan_cards.py
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
FFTCG Card Scanner - Extracts card data from images using Claude's vision API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/scan_cards.py # Scan all cards (resumes from progress)
|
||||||
|
python tools/scan_cards.py --stats # Show scan progress stats
|
||||||
|
python tools/scan_cards.py --finalize # Generate final cards.json from progress
|
||||||
|
python tools/scan_cards.py --validate # Validate scanned data
|
||||||
|
python tools/scan_cards.py --limit N # Scan only N cards (for testing)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
SOURCE_CARDS_DIR = PROJECT_ROOT / "source-cards"
|
||||||
|
DATA_DIR = PROJECT_ROOT / "data"
|
||||||
|
PROGRESS_FILE = DATA_DIR / "cards_progress.json"
|
||||||
|
FINAL_FILE = DATA_DIR / "cards.json"
|
||||||
|
ERROR_LOG = DATA_DIR / "scan_errors.log"
|
||||||
|
|
||||||
|
# API settings
|
||||||
|
MODEL = "claude-haiku-4-5-20251001"
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_DELAY = 2.0 # seconds, doubles on each retry
|
||||||
|
RATE_LIMIT_DELAY = 0.5 # seconds between requests
|
||||||
|
|
||||||
|
SCAN_PROMPT = """Analyze this FFTCG card image. Extract ALL data into JSON.
|
||||||
|
|
||||||
|
CRITICAL ELEMENT DETECTION RULES — The element is determined ONLY by visual color cues. DO NOT guess element from the character name. "Warrior of Light" can be Fire element. "Garland" can be Fire element. A card named "Shiva" can be Fire element. ONLY use these visual indicators:
|
||||||
|
|
||||||
|
Look at the CARD BORDER COLOR and the COST CRYSTAL (top-left gem with a number):
|
||||||
|
- "Fire" = RED/WARM RED border, RED crystal
|
||||||
|
- "Ice" = LIGHT BLUE/CYAN border, LIGHT BLUE crystal
|
||||||
|
- "Wind" = GREEN border, GREEN crystal
|
||||||
|
- "Earth" = YELLOW/BROWN/ORANGE border, YELLOW-BROWN crystal
|
||||||
|
- "Lightning" = PURPLE/MAGENTA border, PURPLE crystal
|
||||||
|
- "Water" = DARK BLUE/NAVY border, DARK BLUE crystal
|
||||||
|
- "Light" = WHITE/VERY PALE border, WHITE/PALE GOLD crystal (extremely rare, very distinctive pale/white look)
|
||||||
|
- "Dark" = VERY DARK/BLACK border, DARK PURPLE/BLACK crystal (extremely rare, very distinctive dark look)
|
||||||
|
|
||||||
|
IMPORTANT: Light and Dark elements are RARE (only a few cards per set). Most cards are Fire/Ice/Wind/Earth/Lightning/Water. If the border has any clear hue (red, blue, green, etc.), it is NOT Light or Dark.
|
||||||
|
|
||||||
|
For multi-element cards (split-color border/gem), return an array like ["Fire", "Ice"].
|
||||||
|
|
||||||
|
Extract these fields:
|
||||||
|
1. **id**: Serial number at very bottom of card (e.g., "1-001H", "10-055R").
|
||||||
|
2. **name**: Card name at top center.
|
||||||
|
3. **type**: "Forward", "Backup", "Summon", or "Monster" — from left side of middle bar.
|
||||||
|
4. **element**: Use the VISUAL COLOR rules above. Do NOT infer from character name.
|
||||||
|
5. **cost**: Number inside top-left crystal (integer).
|
||||||
|
6. **power**: Number at bottom-right (integer), or null if absent (Backups/Summons have none).
|
||||||
|
7. **job**: Center of middle bar (e.g., "Guardian", "Standard Unit"). Empty string if none.
|
||||||
|
8. **category**: Right side of middle bar — Roman numeral or abbreviation (e.g., "X", "VII", "FFT", "DFF-I"). Empty string if none.
|
||||||
|
9. **is_generic**: true for generic units (Standard Unit, Ranger, Black Mage, etc.), false otherwise.
|
||||||
|
10. **has_ex_burst**: true if "EX" marker in top-right area.
|
||||||
|
11. **abilities**: Array of objects with:
|
||||||
|
- **type**: "field" (passive), "auto" (triggered, "When..."), "action" (activated, has cost), "special" (S-ability)
|
||||||
|
- **name**: Ability name if present, else "".
|
||||||
|
- **trigger**: Trigger condition for auto abilities, else "".
|
||||||
|
- **effect**: Full effect text as printed.
|
||||||
|
- **is_ex_burst**: true if preceded by "EX BURST".
|
||||||
|
- **cost**: Activation cost object if present (e.g., {"fire": 1, "dull": true}).
|
||||||
|
|
||||||
|
Return ONLY valid JSON. No markdown. No explanation.
|
||||||
|
{"id":"1-001H","name":"Auron","type":"Forward","element":"Fire","cost":6,"power":9000,"job":"Guardian","category":"X","is_generic":false,"has_ex_burst":false,"abilities":[{"type":"auto","name":"","trigger":"When Auron deals damage to your opponent","effect":"You may play 1 Fire Backup from your hand onto the field dull.","is_ex_burst":false}]}"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_progress() -> dict:
|
||||||
|
"""Load existing scan progress."""
|
||||||
|
if PROGRESS_FILE.exists():
|
||||||
|
with open(PROGRESS_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_progress(progress: dict) -> None:
|
||||||
|
"""Save scan progress incrementally."""
|
||||||
|
with open(PROGRESS_FILE, "w") as f:
|
||||||
|
json.dump(progress, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(card_id: str, error: str) -> None:
|
||||||
|
"""Log scanning errors."""
|
||||||
|
with open(ERROR_LOG, "a") as f:
|
||||||
|
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {card_id} | {error}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def get_card_images() -> list[Path]:
|
||||||
|
"""Get all card image files sorted by ID, skipping duplicates like _eg files."""
|
||||||
|
images = []
|
||||||
|
for f in SOURCE_CARDS_DIR.iterdir():
|
||||||
|
if f.suffix.lower() in (".jpg", ".jpeg", ".png"):
|
||||||
|
# Skip example/duplicate files (e.g., 1-003C_eg.jpg)
|
||||||
|
if "_eg" in f.stem:
|
||||||
|
continue
|
||||||
|
images.append(f)
|
||||||
|
images.sort(key=lambda p: p.stem)
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def encode_image(image_path: Path) -> str:
|
||||||
|
"""Read and base64 encode an image file."""
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
return base64.standard_b64encode(f.read()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_type(image_path: Path) -> str:
|
||||||
|
"""Get MIME type for image."""
|
||||||
|
ext = image_path.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
return "image/jpeg"
|
||||||
|
elif ext == ".png":
|
||||||
|
return "image/png"
|
||||||
|
return "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def scan_card(client: anthropic.Anthropic, image_path: Path) -> dict | None:
|
||||||
|
"""Scan a single card image and return parsed card data."""
|
||||||
|
image_data = encode_image(image_path)
|
||||||
|
media_type = get_media_type(image_path)
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
response = client.messages.create(
|
||||||
|
model=MODEL,
|
||||||
|
max_tokens=1024,
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": media_type,
|
||||||
|
"data": image_data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": SCAN_PROMPT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract JSON from response
|
||||||
|
text = response.content[0].text.strip()
|
||||||
|
|
||||||
|
# Handle potential markdown wrapping
|
||||||
|
if text.startswith("```"):
|
||||||
|
lines = text.split("\n")
|
||||||
|
# Remove first and last lines (```json and ```)
|
||||||
|
text = "\n".join(lines[1:-1]).strip()
|
||||||
|
|
||||||
|
card_data = json.loads(text)
|
||||||
|
return card_data
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_DELAY * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
log_error(image_path.stem, f"JSON parse error: {e} | Raw: {text[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except anthropic.RateLimitError:
|
||||||
|
wait = RETRY_DELAY * (2 ** (attempt + 1))
|
||||||
|
print(f" Rate limited, waiting {wait}s...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except anthropic.APIError as e:
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(RETRY_DELAY * (2 ** attempt))
|
||||||
|
continue
|
||||||
|
log_error(image_path.stem, f"API error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(image_path.stem, f"Unexpected error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# FFTCG Opus sets group cards by element in sequential order.
|
||||||
|
# Each set has a known element ordering by card number ranges.
|
||||||
|
# This maps set_number -> list of (max_card_number, element) tuples.
|
||||||
|
# Cards are numbered within each set: Fire first, then Ice, Wind, Earth, Lightning, Water, Light, Dark.
|
||||||
|
# These ranges are approximate but cover the vast majority of cards.
|
||||||
|
OPUS_ELEMENT_RANGES = {
|
||||||
|
# Sets 1-9 verified from FFTCG Fandom wiki, sets 10-27 from Square Enix official API
|
||||||
|
1: [(30, "Fire"), (60, "Ice"), (90, "Wind"), (120, "Earth"), (150, "Lightning"), (180, "Water"), (183, "Light"), (186, "Dark")],
|
||||||
|
2: [(24, "Fire"), (48, "Ice"), (72, "Wind"), (96, "Earth"), (120, "Lightning"), (144, "Water"), (146, "Light"), (148, "Dark")],
|
||||||
|
3: [(24, "Fire"), (48, "Ice"), (72, "Wind"), (96, "Earth"), (120, "Lightning"), (144, "Water"), (146, "Light"), (148, "Dark")],
|
||||||
|
4: [(24, "Fire"), (48, "Ice"), (72, "Wind"), (96, "Earth"), (120, "Lightning"), (144, "Water"), (146, "Light"), (148, "Dark")],
|
||||||
|
5: [(24, "Fire"), (48, "Ice"), (72, "Wind"), (96, "Earth"), (120, "Lightning"), (144, "Water"), (146, "Light"), (148, "Dark")],
|
||||||
|
6: [(21, "Fire"), (42, "Ice"), (64, "Wind"), (84, "Earth"), (106, "Lightning"), (127, "Water"), (128, "Light"), (130, "Dark")],
|
||||||
|
7: [(21, "Fire"), (42, "Ice"), (64, "Wind"), (84, "Earth"), (106, "Lightning"), (127, "Water"), (128, "Light"), (130, "Dark")],
|
||||||
|
8: [(22, "Fire"), (44, "Ice"), (67, "Wind"), (88, "Earth"), (110, "Lightning"), (133, "Water"), (135, "Light"), (137, "Dark")],
|
||||||
|
9: [(20, "Fire"), (40, "Ice"), (60, "Wind"), (80, "Earth"), (100, "Lightning"), (120, "Water"), (122, "Light"), (124, "Dark")],
|
||||||
|
10: [(21, "Fire"), (42, "Ice"), (63, "Wind"), (84, "Earth"), (105, "Lightning"), (126, "Water"), (128, "Light"), (130, "Dark"), (132, "Fire"), (134, "Wind"), (136, "Earth"), (138, "Lightning"), (139, "Light"), (140, "Dark")],
|
||||||
|
11: [(21, "Fire"), (42, "Ice"), (63, "Wind"), (84, "Earth"), (105, "Lightning"), (126, "Water"), (128, "Light"), (130, "Dark"), (132, "Fire"), (134, "Ice"), (136, "Earth"), (138, "Lightning"), (139, "Light"), (140, "Dark")],
|
||||||
|
12: [(18, "Fire"), (36, "Ice"), (54, "Wind"), (72, "Earth"), (90, "Lightning"), (108, "Water"), (109, "Light"), (110, "Dark")],
|
||||||
|
13: [(17, "Fire"), (34, "Ice"), (51, "Wind"), (68, "Earth"), (85, "Lightning"), (102, "Water"), (103, "Light"), (128, "Dark"), (130, "Fire"), (132, "Ice"), (134, "Earth"), (136, "Lightning"), (138, "Light")],
|
||||||
|
14: [(19, "Fire"), (38, "Ice"), (57, "Wind"), (76, "Earth"), (95, "Lightning"), (114, "Water"), (116, "Light"), (118, "Dark")],
|
||||||
|
15: [(21, "Fire"), (43, "Ice"), (63, "Wind"), (84, "Earth"), (105, "Lightning"), (126, "Water"), (128, "Light"), (130, "Dark"), (134, "Earth"), (138, "Lightning")],
|
||||||
|
16: [(21, "Fire"), (42, "Ice"), (63, "Wind"), (84, "Earth"), (105, "Lightning"), (126, "Water"), (128, "Light"), (130, "Dark"), (133, "Fire"), (135, "Wind"), (138, "Water"), (139, "Light"), (140, "Dark")],
|
||||||
|
17: [(21, "Fire"), (42, "Ice"), (63, "Wind"), (84, "Earth"), (105, "Lightning"), (126, "Water"), (128, "Light"), (130, "Dark")],
|
||||||
|
18: [(17, "Fire"), (34, "Ice"), (51, "Wind"), (68, "Earth"), (85, "Lightning"), (102, "Water"), (104, "Light"), (130, "Dark"), (132, "Fire"), (134, "Wind"), (136, "Earth"), (138, "Lightning")],
|
||||||
|
19: [(17, "Fire"), (34, "Ice"), (51, "Wind"), (68, "Earth"), (85, "Lightning"), (102, "Water"), (104, "Light"), (128, "Dark"), (131, "Fire"), (134, "Ice"), (137, "Lightning"), (138, "Light")],
|
||||||
|
20: [(21, "Fire"), (42, "Ice"), (63, "Wind"), (84, "Earth"), (105, "Lightning"), (126, "Water"), (128, "Light"), (130, "Dark")],
|
||||||
|
21: [(20, "Fire"), (40, "Ice"), (60, "Wind"), (80, "Earth"), (100, "Lightning"), (120, "Water"), (122, "Light"), (124, "Dark"), (127, "Fire"), (130, "Ice"), (133, "Wind"), (134, "Light")],
|
||||||
|
22: [(18, "Fire"), (36, "Ice"), (54, "Wind"), (72, "Earth"), (90, "Lightning"), (108, "Water"), (110, "Light"), (111, "Dark"), (113, "Fire"), (115, "Ice"), (117, "Wind"), (119, "Earth"), (121, "Lightning"), (123, "Water"), (124, "Dark")],
|
||||||
|
23: [(19, "Fire"), (38, "Ice"), (57, "Wind"), (76, "Earth"), (95, "Lightning"), (114, "Water"), (115, "Light"), (117, "Dark"), (119, "Fire"), (121, "Ice"), (123, "Wind"), (125, "Earth"), (127, "Lightning"), (129, "Water"), (130, "Light")],
|
||||||
|
24: [(18, "Fire"), (36, "Ice"), (54, "Wind"), (72, "Earth"), (90, "Lightning"), (108, "Water"), (111, "Light"), (114, "Dark"), (115, "Fire"), (116, "Ice"), (117, "Wind"), (118, "Earth"), (119, "Lightning"), (120, "Water")],
|
||||||
|
25: [(17, "Fire"), (34, "Ice"), (51, "Wind"), (68, "Earth"), (85, "Lightning"), (102, "Water"), (104, "Light"), (105, "Dark"), (107, "Fire"), (109, "Ice"), (111, "Wind"), (113, "Earth"), (115, "Lightning"), (117, "Water"), (118, "Dark")],
|
||||||
|
26: [(20, "Fire"), (40, "Ice"), (60, "Wind"), (80, "Earth"), (100, "Lightning"), (120, "Water"), (121, "Light"), (123, "Dark"), (124, "Fire"), (125, "Ice"), (126, "Wind"), (127, "Earth"), (128, "Lightning"), (129, "Water"), (130, "Light")],
|
||||||
|
27: [(18, "Fire"), (36, "Ice"), (54, "Wind"), (72, "Earth"), (90, "Lightning"), (108, "Water"), (110, "Light"), (112, "Dark")],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_expected_element(card_id: str) -> str | None:
|
||||||
|
"""Get the expected element for a card based on its set and number in FFTCG ordering.
|
||||||
|
Returns None if we can't determine it (non-standard set, promo, etc.)."""
|
||||||
|
try:
|
||||||
|
# Parse card ID: "1-001H" -> set=1, number=1
|
||||||
|
parts = card_id.split("-")
|
||||||
|
if len(parts) != 2:
|
||||||
|
return None
|
||||||
|
set_num = int(parts[0])
|
||||||
|
# Extract number portion (strip rarity suffix: H, R, C, L, S)
|
||||||
|
num_str = ""
|
||||||
|
for ch in parts[1]:
|
||||||
|
if ch.isdigit():
|
||||||
|
num_str += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if not num_str:
|
||||||
|
return None
|
||||||
|
card_num = int(num_str)
|
||||||
|
|
||||||
|
ranges = OPUS_ELEMENT_RANGES.get(set_num)
|
||||||
|
if not ranges:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for max_num, element in ranges:
|
||||||
|
if card_num <= max_num:
|
||||||
|
return element
|
||||||
|
|
||||||
|
return None
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_card(card_data: dict, filename: str, card_id: str) -> dict:
|
||||||
|
"""Normalize card data to match the expected schema."""
|
||||||
|
# Use the filename-based ID as canonical (matches image lookup)
|
||||||
|
card_data["id"] = card_id
|
||||||
|
|
||||||
|
# Normalize card type — AI sometimes returns "Summoner" instead of "Summon"
|
||||||
|
TYPE_FIXES = {
|
||||||
|
"Summoner": "Summon",
|
||||||
|
"Companion": "Monster",
|
||||||
|
"Weapon": "Monster",
|
||||||
|
}
|
||||||
|
raw_type = card_data.get("type", "")
|
||||||
|
if raw_type in TYPE_FIXES:
|
||||||
|
card_data["type"] = TYPE_FIXES[raw_type]
|
||||||
|
|
||||||
|
# Normalize element — AI sometimes returns wrong names
|
||||||
|
ELEMENT_FIXES = {
|
||||||
|
"Chaos": "Dark",
|
||||||
|
"Purple": "Lightning",
|
||||||
|
}
|
||||||
|
raw_element = card_data.get("element", "")
|
||||||
|
if isinstance(raw_element, str) and raw_element in ELEMENT_FIXES:
|
||||||
|
card_data["element"] = ELEMENT_FIXES[raw_element]
|
||||||
|
|
||||||
|
# Element cross-check using card numbering
|
||||||
|
element = card_data.get("element", "")
|
||||||
|
expected = get_expected_element(card_id)
|
||||||
|
if expected and isinstance(element, str) and element != expected:
|
||||||
|
# The AI got it wrong — use the expected element from card numbering
|
||||||
|
print(f"\n ELEMENT FIX: AI said '{element}', expected '{expected}' for {card_id}", end="")
|
||||||
|
card_data["element"] = expected
|
||||||
|
elif isinstance(element, list):
|
||||||
|
# Multi-element: keep as array
|
||||||
|
card_data["element"] = element
|
||||||
|
elif isinstance(element, str):
|
||||||
|
card_data["element"] = element
|
||||||
|
|
||||||
|
# Ensure power is int or null
|
||||||
|
power = card_data.get("power")
|
||||||
|
if power is not None:
|
||||||
|
try:
|
||||||
|
card_data["power"] = int(power)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
card_data["power"] = None
|
||||||
|
|
||||||
|
# Ensure cost is int
|
||||||
|
try:
|
||||||
|
card_data["cost"] = int(card_data.get("cost", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
card_data["cost"] = 0
|
||||||
|
|
||||||
|
# Set image path
|
||||||
|
card_data["image"] = filename
|
||||||
|
|
||||||
|
# Ensure boolean fields
|
||||||
|
card_data["is_generic"] = bool(card_data.get("is_generic", False))
|
||||||
|
card_data["has_ex_burst"] = bool(card_data.get("has_ex_burst", False))
|
||||||
|
|
||||||
|
# Ensure abilities is a list
|
||||||
|
if not isinstance(card_data.get("abilities"), list):
|
||||||
|
card_data["abilities"] = []
|
||||||
|
|
||||||
|
# Clean up abilities
|
||||||
|
cleaned_abilities = []
|
||||||
|
for ability in card_data["abilities"]:
|
||||||
|
cleaned = {
|
||||||
|
"type": ability.get("type", "field"),
|
||||||
|
"effect": ability.get("effect", ""),
|
||||||
|
}
|
||||||
|
# Only include non-empty optional fields
|
||||||
|
if ability.get("name"):
|
||||||
|
cleaned["name"] = ability["name"]
|
||||||
|
if ability.get("trigger"):
|
||||||
|
cleaned["trigger"] = ability["trigger"]
|
||||||
|
if ability.get("is_ex_burst"):
|
||||||
|
cleaned["is_ex_burst"] = True
|
||||||
|
if ability.get("cost") and isinstance(ability["cost"], dict):
|
||||||
|
cleaned["cost"] = ability["cost"]
|
||||||
|
cleaned_abilities.append(cleaned)
|
||||||
|
|
||||||
|
card_data["abilities"] = cleaned_abilities
|
||||||
|
|
||||||
|
return card_data
|
||||||
|
|
||||||
|
|
||||||
|
def validate_card(card_data: dict, expected_id: str) -> list[str]:
|
||||||
|
"""Validate card data and return list of warnings."""
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
# Check ID match (case-insensitive since card printing varies)
|
||||||
|
scanned_id = card_data.get("id", "")
|
||||||
|
if scanned_id.upper() != expected_id.upper():
|
||||||
|
warnings.append(f"ID mismatch: scanned '{scanned_id}' vs filename '{expected_id}'")
|
||||||
|
|
||||||
|
# Required fields
|
||||||
|
for field in ["name", "type", "element", "cost"]:
|
||||||
|
if not card_data.get(field):
|
||||||
|
warnings.append(f"Missing required field: {field}")
|
||||||
|
|
||||||
|
# Type-specific checks
|
||||||
|
card_type = card_data.get("type", "")
|
||||||
|
power = card_data.get("power")
|
||||||
|
|
||||||
|
if card_type == "Forward" and power is None:
|
||||||
|
warnings.append("Forward has no power value")
|
||||||
|
if card_type in ("Backup", "Summon") and power is not None and power > 0:
|
||||||
|
warnings.append(f"{card_type} has power value: {power}")
|
||||||
|
|
||||||
|
# Valid type
|
||||||
|
if card_type not in ("Forward", "Backup", "Summon", "Monster", ""):
|
||||||
|
warnings.append(f"Unknown card type: {card_type}")
|
||||||
|
|
||||||
|
# Valid element
|
||||||
|
valid_elements = {"Fire", "Ice", "Wind", "Earth", "Lightning", "Water", "Light", "Dark"}
|
||||||
|
element = card_data.get("element", "")
|
||||||
|
if isinstance(element, str) and element not in valid_elements:
|
||||||
|
warnings.append(f"Unknown element: {element}")
|
||||||
|
elif isinstance(element, list):
|
||||||
|
for e in element:
|
||||||
|
if e not in valid_elements:
|
||||||
|
warnings.append(f"Unknown element in multi-element: {e}")
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def run_scan(limit: int = 0) -> None:
|
||||||
|
"""Main scan loop."""
|
||||||
|
client = anthropic.Anthropic()
|
||||||
|
|
||||||
|
images = get_card_images()
|
||||||
|
progress = load_progress()
|
||||||
|
|
||||||
|
total = len(images)
|
||||||
|
if limit > 0:
|
||||||
|
images = images[:limit]
|
||||||
|
print(f"Limiting scan to {limit} cards")
|
||||||
|
|
||||||
|
already_done = sum(1 for img in images if img.stem in progress)
|
||||||
|
remaining = len(images) - already_done
|
||||||
|
|
||||||
|
print(f"Found {total} card images total")
|
||||||
|
print(f"Already scanned: {already_done}")
|
||||||
|
print(f"Remaining: {remaining}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if remaining == 0:
|
||||||
|
print("All cards already scanned! Use --finalize to generate cards.json")
|
||||||
|
return
|
||||||
|
|
||||||
|
scanned = 0
|
||||||
|
failed = 0
|
||||||
|
warnings_count = 0
|
||||||
|
|
||||||
|
for i, image_path in enumerate(images):
|
||||||
|
card_id = image_path.stem
|
||||||
|
|
||||||
|
# Skip already scanned
|
||||||
|
if card_id in progress:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{already_done + scanned + 1}/{len(images)}] Scanning {card_id}...", end=" ", flush=True)
|
||||||
|
|
||||||
|
card_data = scan_card(client, image_path)
|
||||||
|
|
||||||
|
if card_data:
|
||||||
|
card_data = normalize_card(card_data, image_path.name, card_id)
|
||||||
|
warnings = validate_card(card_data, card_id)
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
warnings_count += len(warnings)
|
||||||
|
for w in warnings:
|
||||||
|
print(f"\n WARNING: {w}", end="")
|
||||||
|
log_error(card_id, f"VALIDATION: {w}")
|
||||||
|
|
||||||
|
progress[card_id] = card_data
|
||||||
|
save_progress(progress)
|
||||||
|
scanned += 1
|
||||||
|
print(f"OK - {card_data.get('name', '?')} ({card_data.get('type', '?')})")
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
print("FAILED")
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
time.sleep(RATE_LIMIT_DELAY)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Scan complete: {scanned} scanned, {failed} failed, {warnings_count} warnings")
|
||||||
|
print(f"Total in progress file: {len(progress)} cards")
|
||||||
|
|
||||||
|
if failed > 0:
|
||||||
|
print(f"Check {ERROR_LOG} for error details")
|
||||||
|
|
||||||
|
|
||||||
|
def show_stats() -> None:
|
||||||
|
"""Show scan progress statistics."""
|
||||||
|
progress = load_progress()
|
||||||
|
images = get_card_images()
|
||||||
|
|
||||||
|
total_images = len(images)
|
||||||
|
total_scanned = len(progress)
|
||||||
|
remaining = total_images - total_scanned
|
||||||
|
|
||||||
|
print(f"Total card images: {total_images}")
|
||||||
|
print(f"Scanned: {total_scanned}")
|
||||||
|
print(f"Remaining: {remaining}")
|
||||||
|
print(f"Progress: {total_scanned / total_images * 100:.1f}%")
|
||||||
|
|
||||||
|
if total_scanned > 0:
|
||||||
|
# Type breakdown
|
||||||
|
types = {}
|
||||||
|
elements = {}
|
||||||
|
for card in progress.values():
|
||||||
|
t = card.get("type", "Unknown")
|
||||||
|
types[t] = types.get(t, 0) + 1
|
||||||
|
e = card.get("element", "Unknown")
|
||||||
|
if isinstance(e, list):
|
||||||
|
e = "/".join(e)
|
||||||
|
elements[e] = elements.get(e, 0) + 1
|
||||||
|
|
||||||
|
print("\nBy type:")
|
||||||
|
for t, count in sorted(types.items()):
|
||||||
|
print(f" {t}: {count}")
|
||||||
|
|
||||||
|
print("\nBy element:")
|
||||||
|
for e, count in sorted(elements.items()):
|
||||||
|
print(f" {e}: {count}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_validate() -> None:
|
||||||
|
"""Validate all scanned data."""
|
||||||
|
progress = load_progress()
|
||||||
|
|
||||||
|
if not progress:
|
||||||
|
print("No scanned data found. Run scan first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(progress)
|
||||||
|
cards_with_warnings = 0
|
||||||
|
total_warnings = 0
|
||||||
|
|
||||||
|
for card_id, card_data in sorted(progress.items()):
|
||||||
|
warnings = validate_card(card_data, card_id)
|
||||||
|
if warnings:
|
||||||
|
cards_with_warnings += 1
|
||||||
|
total_warnings += len(warnings)
|
||||||
|
print(f"{card_id} ({card_data.get('name', '?')}):")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" - {w}")
|
||||||
|
|
||||||
|
print(f"\nValidation: {total} cards, {cards_with_warnings} with warnings, {total_warnings} total warnings")
|
||||||
|
|
||||||
|
|
||||||
|
def finalize() -> None:
|
||||||
|
"""Generate final cards.json from progress data."""
|
||||||
|
progress = load_progress()
|
||||||
|
|
||||||
|
if not progress:
|
||||||
|
print("No scanned data found. Run scan first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Convert to array format matching existing schema
|
||||||
|
cards = []
|
||||||
|
for card_id in sorted(progress.keys()):
|
||||||
|
card = progress[card_id]
|
||||||
|
|
||||||
|
# Build output card matching existing schema
|
||||||
|
output = {
|
||||||
|
"id": card.get("id", card_id),
|
||||||
|
"name": card.get("name", ""),
|
||||||
|
"type": card.get("type", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle element — CardDatabase.gd expects "element" key (string or array)
|
||||||
|
element = card.get("element", "")
|
||||||
|
if isinstance(element, list) and len(element) == 1:
|
||||||
|
output["element"] = element[0]
|
||||||
|
elif isinstance(element, list):
|
||||||
|
output["element"] = element # GDScript parser handles arrays
|
||||||
|
else:
|
||||||
|
output["element"] = element
|
||||||
|
|
||||||
|
output["cost"] = card.get("cost", 0)
|
||||||
|
output["power"] = card.get("power")
|
||||||
|
output["job"] = card.get("job", "")
|
||||||
|
output["category"] = card.get("category", "")
|
||||||
|
output["is_generic"] = card.get("is_generic", False)
|
||||||
|
output["has_ex_burst"] = card.get("has_ex_burst", False)
|
||||||
|
output["abilities"] = card.get("abilities", [])
|
||||||
|
output["image"] = card.get("image", f"{card_id}.jpg")
|
||||||
|
|
||||||
|
cards.append(output)
|
||||||
|
|
||||||
|
final = {
|
||||||
|
"version": "2.0",
|
||||||
|
"cards": cards,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(FINAL_FILE, "w") as f:
|
||||||
|
json.dump(final, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
print(f"Written {len(cards)} cards to {FINAL_FILE}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="FFTCG Card Scanner")
|
||||||
|
parser.add_argument("--stats", action="store_true", help="Show scan progress stats")
|
||||||
|
parser.add_argument("--finalize", action="store_true", help="Generate final cards.json")
|
||||||
|
parser.add_argument("--validate", action="store_true", help="Validate scanned data")
|
||||||
|
parser.add_argument("--limit", type=int, default=0, help="Limit number of cards to scan")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.stats:
|
||||||
|
show_stats()
|
||||||
|
elif args.finalize:
|
||||||
|
finalize()
|
||||||
|
elif args.validate:
|
||||||
|
run_validate()
|
||||||
|
else:
|
||||||
|
run_scan(limit=args.limit)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user