MENU

Godot 4 でWiz系ダンジョンRPG開発 (キャラクター作成) [012]

今回はダンジョンRPGのキャラクター作成について、お伝えします。この記事で言う「キャラクター」とは、ダンジョンを探索する冒険者のことです。この「キャラクター作成」は、以下の流れで行います。詳しくは各チャプターをご確認ください。(上記画像をクリックすると、この記事で実現できることがイメージできると思います。)

  1. キャラクター設計
  2. 「キャラクター作成」工程の設計/実装
  3. 「キャラクタークラス」の実装

前回は「ダンジョンのオートマッピング」を行いました。今まで作成してきたのは以下の通りです。詳しくは過去記事をご確認ください。

  • ダンジョンの壁と床の設置
  • プレイヤーの現在座標とその表記
  • イベントメッセージの表示
  • 敵とのランダムエンカウント
  • ダンジョンの扉と階段の設置
  • ダンジョンのB1から街に戻る処理
  • ダンジョンのB1からB2に降りる処理
  • ダンジョン画像の置き換え
  • 座標データ管理 (階段)
  • 敵データ作成 (JSON)
  • 敵の複数体ランダム出現処理
  • 敵の名前切り替え処理 (英語 / 日本語)
  • 街の各場所のメニュー表示
  • 街のBGM処理
  • オートマッピング
目次

キャラクター設計

それではキャラクターの設計から始めます。キャラクターとは、ダンジョン探索をする冒険者のことです。このゲームでは、冒険の最初にキャラクターを作成することになります。

キャラクターの仕様決定

キャラクターの仕様を決定します。以下の仕様でキャラクターを作成することにします。

項目 (日本語) 項目 (英語) 表示 説明
名前 Name 表示 プレイヤーが自由に入力
職業 Class 表示 戦士、魔法使い、僧侶、盗賊など
種族 Race 表示 人間だけ
(以下のコードではエルフとドワーフの拡張が可能)
見た目 Appearance 表示 いくつかの画像から選択
レベル LV 表示 レベルが上がると最大HP、MP、ステータスが上がる
HP HP 表示 生命力。なくなると死ぬ。道具・魔法・街に戻ると回復
MP MP 表示 魔法力。魔法を使うときに消費。道具・街に戻ると回復。回数制ではなく総量制。
経験値 EXP 表示 敵を倒すと増えるもの。一定数値でレベルが上がる
CP CP 表示 クラスポイント。クラスレベルを上げるために必要なポイント。敵を倒すことで増える。
STR 表示 攻撃力に影響
器用さ DEX 表示 クリティカルヒット率、逃げる確率に影響
体力 VIT 表示 防御力、HPに影響
素早さ AGI 表示 行動順、逃げる確率に影響
知恵 INT 表示 MPに影響
精神 MND 表示 MPに影響
LUC 表示 状態異常耐性、クリティカルヒット率、逃げる確率に影響
攻撃力 ATK 表示 武器で敵にダメージを与える力。大きければダメージが上がる。ステータス画面で見える値。
防御力 DEF 表示 敵からの物理攻撃でダメージを抑える力。大きければダメージも少ない。
状態 Condition 表示 毒、マヒなどの状態異常のこと。Condition: Normal というような表記
物理攻撃力 (近接) Melee Attack 非表示 近接武器での攻撃力。計算用
物理攻撃力 (遠隔) Ranged Attack 非表示 遠隔武器での攻撃力。計算用
後列攻撃可否 isBackRowAttackAllowed 非表示 弓などの一部の攻撃武器では前列・後列どちらからでも攻撃可能。それを判定するフラグ
両手武器所持 isWieldingTwoHanded 非表示 弓や大剣などでは盾装備や二刀流が使えない。それを判定するフラグ
命中率 Accuracy 非表示 武器攻撃での命中率。%で管理。計算用
物理防御力 Defense 非表示 物理攻撃をされたときの防御力。計算用
物理回避率 Evasion 非表示 物理攻撃をされたときの回避率。%で管理。計算用
状態異常耐性 Status Ailments Resistance 非表示 敵からの状態異常攻撃への耐性。かからない確率。%で管理。
クリティカルヒット率 Critical Rate 非表示 物理攻撃でクリティカルヒットが出る確率。高いと出る確率が上がる。%で管理。武器・防具で変わる。

上記はあくまで一例です。実際のRPGではもっと多いでしょう。

「キャラクター作成」工程の設計 / 実装

以下の流れでキャラクターが作られるとします。この流れに沿って設計をしていきます。

  1. 街から「冒険者ギルド」に行く
  2. メニューから「冒険者を作成する」を選択する
  3. 「名前」を付ける
  4. 「種族」を選択する
  5. 初期ステータスを表示し、ボーナスポイントを振り分ける
  6. 「職業」を選択する
  7. 「見た目」を選択する
  8. 「確認画面」を表示する
  9. 作成後「冒険者ギルド」に戻る

街から「冒険者ギルド」に行く

この記事を記述している段階では、すでに実装済みです。詳しくは別記事をご確認ください。

メニューから「冒険者を作成する」を選択する

この時点では「冒険者を作成する」の枠組みだけあり、選択しても何も起きない状態です。ここから先を作る必要があります。

「名前」を付ける

そのままキャラクターの「名前」を付ける工程です。ここでは以下のような仕様で進めていきます。

言語 設定言語が英語なら英語キーボードで名前を付ける。設定言語が日本語なら日本語キーボードで名前を付ける。 このゲームのデフォルト言語は英語
名前の長さ 英語は最大16文字。日本語は最大8文字

ノード構成の作成

  • CanvasLayer (CanvasLayer)
    • (他は省略)
    • CharacterCreation (Control) ・・・ CanvasLayer の下に作成
      • Background (ColorRect)
      • NameLayout (VBoxContainer)
        • TitleLabel (Label)
        • NameInputField (LineEdit)
        • KeyboardGrid (GridContainer)
        • BottomMenu (HBoxContainer)
          • BackspaceButton (Button)
          • ConfirmButton (Button)
CharacterCreation CanvasLayer の下に作成。キャラクター作成画面全体。ここにスクリプト (character_creation.gd) をアタッチします。デフォルトで非表示です。
Background 色は黒 (#000000)、Layoutを Full Rect にして画面全体を覆う。
NameLayout 名前入力用のコンテナ。Layoutは中央付近に配置
TitleLabel Enter your name. (名前を入力してください)
画面上部に配置を想定
NameInputField 実際の文字入力欄
KeyboardGrid ここにスクリプトからボタンを自動生成します。 Columns を 10 に設定しておいてください。
BottomMenu 下部ボタン用
BackspaceButton 一字消す / BS ボタン
ConfirmButton 決定 / OK ボタン

ツリーを上記のように作成してください。CanvasLayer はゲーム全体のUIを司るノードです。その下に今回のキャラクター作成のUIがあります。CharacterCreation ノードは非表示であることに注意が必要です。

SEやプログラマーであれば気づくと思いますが、このノード設計はあまりよくはありません。これだとこの先 CanvasLayer の直下に冒険者ギルドの機能ごとの Control ノードができあがるからです。すべてAI任せの実装で、設計に触れづらくなってから気づいてしまいました。この文章の意味がわかる方であれば、理想の設計で進められることをおススメします。(私はまだまだ未熟です)

街処理用コードの修正

# ==============================================================================
# town.gd : 拠点(街)のUI操作と各場所のメニュー管理
# ==============================================================================
class_name TownSystem
extends RefCounted

signal dungeon_entered

var town_graphic: TextureRect
var town_menu: Control
var dungeon_button: Button
var guild_button: Button
var creation_node: Control
var tavern_button: Button
var shop_button: Button
var temple_button: Button
var library_button: Button

# ギルド専用のメニュー項目
const GUILD_MENU_KEYS = [
	"GUILD_MENU_JOIN", 
	"GUILD_MENU_REMOVE", 
	"GUILD_MENU_CREATE", 
	"GUILD_MENU_CHANGE_CLASS", 
	"GUILD_MENU_DELETE", 
	"GUILD_MENU_CHANGE_NAME", 
	"GUILD_MENU_CHANGE_APPEARANCE", 
	"GUILD_MENU_BACK"
]

# 酒場専用のメニュー項目キー
const TAVERN_MENU_KEYS = [
	"TAVERN_MENU_ACCEPT",
	"TAVERN_MENU_REPORT",
	"TAVERN_MENU_TALK",
	"TAVERN_MENU_BACK"
]

# 店専用のメニュー項目キー
const SHOP_MENU_KEYS = [
	"SHOP_MENU_WEAPON",
	"SHOP_MENU_ARMOR",
	"SHOP_MENU_ITEM",
	"SHOP_MENU_SELL",
	"SHOP_MENU_BACK"
]

# 教会専用のメニュー項目キー
const TEMPLE_MENU_KEYS = [
	"TEMPLE_MENU_BENEDICT",
	"TEMPLE_MENU_DEPETRIFY",
	"TEMPLE_MENU_RESURRECT",
	"TEMPLE_MENU_BACK"
]

# 図書館専用のメニュー項目キー
const LIBRARY_MENU_KEYS = [
	"LIBRARY_MENU_RECORDS",
	"LIBRARY_MENU_STORIES",
	"LIBRARY_MENU_TUTORIAL",
	"LIBRARY_MENU_BACK"
]

const TOWN_IMAGES = {
	"base": "res://image/town/town_background.png",
	"guild": "res://image/town/guild.png",
	"tavern": "res://image/town/tavern.png",
	"shop": "res://image/town/shop.png",
	"temple": "res://image/town/temple.png",
	"library": "res://image/town/library.png"
}

func _init(main_node: Node) -> void:
	town_graphic = main_node.get_node_or_null("CanvasLayer/TownGraphic")
	town_menu = main_node.get_node_or_null("CanvasLayer/TownGraphic/TownMenu")
	
	dungeon_button = main_node.get_node_or_null("CanvasLayer/TownGraphic/TownMenu/DungeonButton")
	guild_button = main_node.get_node_or_null("CanvasLayer/TownGraphic/TownMenu/GuildButton")
	creation_node = main_node.get_node_or_null("CanvasLayer/CharacterCreation")
	
	tavern_button = main_node.get_node_or_null("CanvasLayer/TownGraphic/TownMenu/TavernButton")
	shop_button = main_node.get_node_or_null("CanvasLayer/TownGraphic/TownMenu/ShopButton")
	temple_button = main_node.get_node_or_null("CanvasLayer/TownGraphic/TownMenu/TempleButton")
	library_button = main_node.get_node_or_null("CanvasLayer/TownGraphic/TownMenu/LibraryButton")
	
	# ボタンの配列を作る(エディタ上の配置順序に合わせてください)
	var buttons = [guild_button, tavern_button, shop_button, temple_button, library_button, dungeon_button]
	
	# フォーカスの循環設定
	for i in range(buttons.size()):
		var prev = buttons[(i - 1 + buttons.size()) % buttons.size()]
		var next = buttons[(i + 1) % buttons.size()]
		if buttons[i]:
			buttons[i].focus_neighbor_top = prev.get_path()
			buttons[i].focus_neighbor_bottom = next.get_path()
	
	# ローカライゼーション
	if guild_button: guild_button.text = tr("TOWN_MENU_GUILD")
	if tavern_button: tavern_button.text = tr("TOWN_MENU_TAVERN")
	if shop_button: shop_button.text = tr("TOWN_MENU_SHOP")
	if temple_button: temple_button.text = tr("TOWN_MENU_TEMPLE")
	if library_button: library_button.text = tr("TOWN_MENU_LIBRARY")
	if dungeon_button: dungeon_button.text = tr("TOWN_MENU_DUNGEON")
	
	# 各ボタンのシグナル接続
	if dungeon_button: dungeon_button.pressed.connect(_on_dungeon_button_pressed)
	if guild_button: guild_button.pressed.connect(func(): _move_to("guild"))
	if tavern_button: tavern_button.pressed.connect(func(): _move_to("tavern"))
	if shop_button: shop_button.pressed.connect(func(): _move_to("shop"))
	if temple_button: temple_button.pressed.connect(func(): _move_to("temple"))
	if library_button: library_button.pressed.connect(func(): _move_to("library"))

	# ★追加:名前入力画面からのキャンセル通知を受け取る設定
	if creation_node and creation_node.has_signal("creation_canceled"):
		creation_node.creation_canceled.connect(_on_creation_canceled)

func start_town_mode() -> void:
	if town_graphic: town_graphic.show()
	_reset_to_main_town()

func end_town_mode() -> void:
	if town_graphic: town_graphic.hide()
	_cleanup_dynamic_menus()

# メインメニューに戻る
func _reset_to_main_town() -> void:
	_update_graphic("base")
	if town_menu:
		town_menu.show()
		dungeon_button.grab_focus()
	_cleanup_dynamic_menus()

# 場所移動とメニュー生成
func _move_to(location: String) -> void:
	_update_graphic(location)
	if town_menu:
		town_menu.hide()
	
	_cleanup_dynamic_menus()
	
	match location:
		"guild":
			_show_dynamic_menu(GUILD_MENU_KEYS)
		"tavern":
			_show_dynamic_menu(TAVERN_MENU_KEYS)
		"shop": 
			_show_dynamic_menu(SHOP_MENU_KEYS)
		"temple": 
			_show_dynamic_menu(TEMPLE_MENU_KEYS)
		"library":
			_show_dynamic_menu(LIBRARY_MENU_KEYS)
		_:
			_show_return_button()

# 汎用的なメニュー生成メソッド(共通利用)
func _show_dynamic_menu(menu_keys: Array) -> void:
	var center_container = CenterContainer.new()
	center_container.name = "DynamicMenu"
	center_container.set_anchors_preset(Control.PRESET_FULL_RECT)
	town_graphic.add_child(center_container)
	
	var menu_panel = VBoxContainer.new()
	center_container.add_child(menu_panel)
	
	var buttons = []
	for key in menu_keys:
		var btn = Button.new()
		btn.text = tr(key)
		btn.pressed.connect(func(): _on_menu_selected(key))
		menu_panel.add_child(btn)
		buttons.append(btn)
	
	# 循環フォーカス設定
	for i in range(buttons.size()):
		var prev = buttons[(i - 1 + buttons.size()) % buttons.size()]
		var next = buttons[(i + 1) % buttons.size()]
		buttons[i].focus_neighbor_top = prev.get_path()
		buttons[i].focus_neighbor_bottom = next.get_path()
	
	buttons[0].grab_focus()

# ギルド専用メニューの生成
func _show_guild_menu() -> void:
	var center_container = CenterContainer.new()
	center_container.name = "DynamicMenu"
	center_container.set_anchors_preset(Control.PRESET_FULL_RECT)
	town_graphic.add_child(center_container)
	
	var menu_panel = VBoxContainer.new()
	center_container.add_child(menu_panel)
	
	var buttons = []
	for key in GUILD_MENU_KEYS:
		var btn = Button.new()
		btn.text = tr(key)
		btn.pressed.connect(func(): _on_menu_selected(key))
		menu_panel.add_child(btn)
		buttons.append(btn)
	
	# ギルドメニュー内の循環フォーカス設定
	for i in range(buttons.size()):
		var prev = buttons[(i - 1 + buttons.size()) % buttons.size()]
		var next = buttons[(i + 1) % buttons.size()]
		buttons[i].focus_neighbor_top = prev.get_path()
		buttons[i].focus_neighbor_bottom = next.get_path()
	
	buttons[0].grab_focus()

# 簡易的な戻るボタン
func _show_return_button() -> void:
	var btn = Button.new()
	btn.name = "DynamicMenu"
	btn.text = "街へ戻る"
	btn.set_anchors_preset(Control.PRESET_CENTER)
	btn.pressed.connect(_reset_to_main_town)
	town_graphic.add_child(btn)
	btn.grab_focus()

# メニュー選択時の共通処理
func _on_menu_selected(key: String) -> void:
	var back_keys = [
		"GUILD_MENU_BACK", 
		"TAVERN_MENU_BACK", 
		"SHOP_MENU_BACK", 
		"TEMPLE_MENU_BACK", 
		"LIBRARY_MENU_BACK"
	]
	
	if key in back_keys:
		_reset_to_main_town()
	else:
		print(tr(key) + " が選択されました")
		
		match key:
			"GUILD_MENU_CREATE":
				_on_create_adventurer_selected()

# 動的生成したメニューを削除
func _cleanup_dynamic_menus() -> void:
	var menu = town_graphic.get_node_or_null("DynamicMenu")
	if menu:
		menu.queue_free()

func _update_graphic(location: String) -> void:
	if town_graphic and TOWN_IMAGES.has(location):
		town_graphic.texture = load(TOWN_IMAGES[location])

func _on_dungeon_button_pressed() -> void:
	dungeon_entered.emit()
	
# ギルドメニューで「冒険者を作成する」が選ばれたとき
func _on_create_adventurer_selected() -> void:
	# 既存のギルドメニューUI(街グラフィックごと)を非表示にする
	town_graphic.hide()
	
	# キャラクター作成画面を表示する
	if creation_node:
		creation_node.activate_name_input()

# 名前入力側でキャンセルが起きたときに呼び出される関数
func _on_creation_canceled() -> void:
	# 1. 非表示にしていた街・ギルドのグラフィックを再表示する
	if town_graphic:
		town_graphic.show()
	
	# 2. 現在のギルドの動的メニュー(DynamicMenuの中のボタン群)を取得して、
	#    最初の子ノード(通常は一番上のボタン)に再度フォーカスを合わせる
	var dynamic_menu = town_graphic.get_node_or_null("DynamicMenu")
	if dynamic_menu:
		# CenterContainer -> VBoxContainer -> Button群 とたどる
		var menu_panel = dynamic_menu.get_child(0)
		if menu_panel and menu_panel.get_child_count() > 0:
			var first_btn = menu_panel.get_child(0) as Button
			if first_btn:
				first_btn.grab_focus.call_deferred()

街処理用コード (town.gd) を上記のように修正します。これは「冒険者ギルド」から「冒険者を作成する」を選んだときに画面が「冒険者作成画面」に切り替えるものです。また、名前入力時にキャンセルするとギルドに戻る処理も付け加えています。

キャラクター作成スクリプトの新規作成

キャラクター作成用のスクリプトファイルを (character_creation.gd) 作成し、CharacterCreation にアタッチしてください。

extends Control

signal creation_canceled

# 子ノードへの参照
@onready var title_label: Label = $NameLayout/TitleLabel
@onready var name_input_field: LineEdit = $NameLayout/NameInputField
@onready var keyboard_grid: GridContainer = $NameLayout/KeyboardGrid
@onready var backspace_button: Button = $NameLayout/BottomMenu/BackspaceButton
@onready var confirm_button: Button = $NameLayout/BottomMenu/ConfirmButton

# キーボードの文字データ定義
const KEYS_JA = [
	"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ",
	"さ", "し", "す", "せ", "そ", "た", "ち", "つ", "て", "と",
	"な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ",
	"ま", "み", "む", "め", "も", "や", "ゆ", "よ", "わ", "を",
	"ら", "り", "る", "れ", "ろ", "ん", "っ", "ゃ", "ゅ", "ょ",
	"が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ",
	"だ", "ぢ", "づ", "で", "ど", "ば", "び", "ぶ", "べ", "ぼ",
	"ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ー", "〜", "、", "。", " "
]

const KEYS_EN = [
	"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
	"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
	"U", "V", "W", "X", "Y", "Z", " ", " ", " ", " ",
	"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
	"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
	"u", "v", "w", "x", "y", "z", "-", "_", "!", "?"
]

var current_max_length: int = 16
var final_character_name: String = ""
var first_button: Button = null 

func _ready() -> void:
	if backspace_button and not backspace_button.pressed.is_connected(_on_backspace_button_pressed):
		backspace_button.pressed.connect(_on_backspace_button_pressed)
	if confirm_button and not confirm_button.pressed.is_connected(_on_confirm_button_pressed):
		confirm_button.pressed.connect(_on_confirm_button_pressed)
		
	hide()

# ★新設:キャンセルボタン(ui_cancel)の入力を監視する関数
func _unhandled_input(event: InputEvent) -> void:
	# この画面(名前入力レイアウト)が非表示のときは入力を無視する
	if not is_visible_in_tree() or not $NameLayout.is_visible_in_tree():
		return
		
	# 長押しによる連続削除を防ぐため、ボタンが「押された瞬間」だけを検知
	if event.is_action_pressed("ui_cancel"):
		print("ui_cancel")
		# 入力イベントをここで消費(他のノードにイベントが伝播するのを防ぐ)
		get_viewport().set_input_as_handled()
		
		if name_input_field.text.length() > 0:
			print("1文字以上あれば一文字消す")
			# 1文字以上あれば一文字消す
			_on_backspace_button_pressed()
			
		else:
			print("1文字もなければキャラクター作成を中断してギルドに戻る")
			# 1文字もなければキャラクター作成を中断してギルドに戻る
			_cancel_and_return_to_guild()

# 「画面を表示する直前」に呼び出される関数
func activate_name_input() -> void:
	name_input_field.text = ""
	show()
	$NameLayout.show() # 念のためレイアウト自体も表示
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		current_max_length = 8
		_setup_software_keyboard(KEYS_JA)
		
		if title_label: title_label.text = "名前を入力してください"
		if backspace_button: backspace_button.text = "一字消す"
		if confirm_button: confirm_button.text = "決定"
	else:
		current_max_length = 16
		_setup_software_keyboard(KEYS_EN)
		
		if title_label: title_label.text = "Enter your name."
		if backspace_button: backspace_button.text = "BackSpace"
		if confirm_button: confirm_button.text = "Confirm"
		
	name_input_field.max_length = current_max_length
	keyboard_grid.columns = 10
	
	if first_button:
		first_button.grab_focus.call_deferred()

# ソフトウェアキーボードのボタンを動的に生成する関数
func _setup_software_keyboard(keys_array: Array) -> void:
	first_button = null
	
	for child in keyboard_grid.get_children():
		child.queue_free()
		
	for i in range(keys_array.size()):
		var key = keys_array[i]
		
		if key == " " and TranslationServer.get_locale().left(2) == "en":
			var spacer = Control.new()
			keyboard_grid.add_child(spacer)
			continue
			
		var btn = Button.new()
		btn.text = key
		btn.custom_minimum_size = Vector2(40, 40) 
		btn.focus_mode = Control.FOCUS_ALL 
		
		btn.pressed.connect(_on_key_pressed.bind(key))
		keyboard_grid.add_child(btn)
		
		if first_button == null:
			first_button = btn

# 文字ボタンが押された時
func _on_key_pressed(char_text: String) -> void:
	if name_input_field.text.length() < current_max_length:
		name_input_field.text += char_text

# 一字消す(BackspaceButton)が押された時
func _on_backspace_button_pressed() -> void:
	if name_input_field.text.length() > 0:
		name_input_field.text = name_input_field.text.left(name_input_field.text.length() - 1)

# 決定(ConfirmButton)が押された時
func _on_confirm_button_pressed() -> void:
	var cleaned_name = name_input_field.text.strip_edges()
	
	if cleaned_name.is_empty():
		if TranslationServer.get_locale().left(2) == "ja":
			print("名前が空欄です")
		else:
			print("Name is empty")
		return
		
	final_character_name = cleaned_name
	print("確定された名前: ", final_character_name)
	
	_proceed_to_race_selection()

# 作成をキャンセルして冒険者ギルドメニューに戻る処理
func _cancel_and_return_to_guild() -> void:
	hide() # キャラクター作成画面全体を非表示にする
	print("キャラクター作成がキャンセルされました。冒険者ギルドに戻ります。")
	
	# 街側に「キャンセルされた」ことをシグナルで通知する
	creation_canceled.emit()

func _proceed_to_race_selection() -> void:
	$NameLayout.hide()
	print("次のステップ:種族選択へ進みます(背景の黒は維持)")

キャラクター作成用スクリプト (character_creation.gd) の上記のようにしてください。これは、キャラクターの名前を付ける部分の実装です。種族の選択以降もこのスクリプトファイルを使います。

「種族」を選択する

「種族」の選択を行います。この記事では「人間」しか選べません。エルフやドワーフなどを選べるようにしたい場合はご注意ください。

ノード構成の追加

  • CanvasLayer (CanvasLayer)
    • (他は省略)
    • CharacterCreation (Control)
      • Background (ColorRect)
      • NameLayout (VBoxContainer)
      • RaceLayout (VBoxContainer) 新規
        • RaceTitleLabel (Label) 新規
        • RaceButtonContainer (VBoxContainer) 新規
RaceLayout新規追加 (非表示)
RaceTitleLableSelect a race. (種族を選択してください)
RaceButtonContainerここにスクリプトから選択肢ボタンを自動生成します

上記の通りに種族選択用の新規ノードを作成してください。

キャラクター作成用コード修正

extends Control

signal creation_canceled

# 子ノードへの参照
@onready var title_label: Label = $NameLayout/TitleLabel
@onready var name_input_field: LineEdit = $NameLayout/NameInputField
@onready var keyboard_grid: GridContainer = $NameLayout/KeyboardGrid
@onready var backspace_button: Button = $NameLayout/BottomMenu/BackspaceButton
@onready var confirm_button: Button = $NameLayout/BottomMenu/ConfirmButton

# ★追加:「種族選択」用の子ノード参照
@onready var race_layout: VBoxContainer = $RaceLayout
@onready var race_title_label: Label = $RaceLayout/RaceTitleLabel
@onready var race_button_container: VBoxContainer = $RaceLayout/RaceButtonContainer

# キーボードの文字データ定義
const KEYS_JA = [
	"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ",
	"さ", "し", "す", "せ", "そ", "た", "ち", "つ", "て", "と",
	"な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ",
	"ま", "み", "む", "め", "も", "や", "ゆ", "よ", "わ", "を",
	"ら", "り", "る", "れ", "ろ", "ん", "っ", "ゃ", "ゅ", "ょ",
	"が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ",
	"だ", "ぢ", "づ", "で", "ど", "ば", "び", "ぶ", "べ", "ぼ",
	"ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ー", "〜", "、", "。", " "
]

const KEYS_EN = [
	"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
	"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
	"U", "V", "W", "X", "Y", "Z", " ", " ", " ", " ",
	"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
	"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
	"u", "v", "w", "x", "y", "z", "-", "_", "!", "?"
]

var current_max_length: int = 16
var first_button: Button = null 

# --- キャラクターの作成データ保持用 ---
var final_character_name: String = ""
var final_character_race: String = "" # ★追加:確定した種族名

# --- ゲーム進行フラグ ---
# 本来はセーブデータ等から引っ張りますが、テスト用にここで定義します
# true にすると最初からエルフ・ドワーフが選べるようになります
var is_advanced_races_unlocked: bool = false 

func _ready() -> void:
	if backspace_button and not backspace_button.pressed.is_connected(_on_backspace_button_pressed):
		backspace_button.pressed.connect(_on_backspace_button_pressed)
	if confirm_button and not confirm_button.pressed.is_connected(_on_confirm_button_pressed):
		confirm_button.pressed.connect(_on_confirm_button_pressed)
		
	hide()
	if race_layout: race_layout.hide()

func _unhandled_input(event: InputEvent) -> void:
	if not is_visible_in_tree():
		return
		
	if event.is_action_pressed("ui_cancel"):
		# 1. 名前入力画面が表示されている時のキャンセル処理
		if $NameLayout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if name_input_field.text.length() > 0:
				_on_backspace_button_pressed()
			else:
				_cancel_and_return_to_guild()
				
		# 2. ★追加:種族選択画面が表示されている時のキャンセル処理(名前入力に戻る)
		elif race_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_name_input()

func activate_name_input() -> void:
	name_input_field.text = ""
	show()
	$NameLayout.show()
	if race_layout: race_layout.hide() # 種族画面は隠しておく
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		current_max_length = 8
		_setup_software_keyboard(KEYS_JA)
		if title_label: title_label.text = "名前を入力してください"
		if backspace_button: backspace_button.text = "一字消す"
		if confirm_button: confirm_button.text = "決定"
	else:
		current_max_length = 16
		_setup_software_keyboard(KEYS_EN)
		if title_label: title_label.text = "Enter your name."
		if backspace_button: backspace_button.text = "BackSpace"
		if confirm_button: confirm_button.text = "Confirm"
		
	name_input_field.max_length = current_max_length
	keyboard_grid.columns = 10
	
	if first_button:
		first_button.grab_focus.call_deferred()

func _setup_software_keyboard(keys_array: Array) -> void:
	first_button = null
	for child in keyboard_grid.get_children():
		child.queue_free()
		
	for i in range(keys_array.size()):
		var key = keys_array[i]
		if key == " " and TranslationServer.get_locale().left(2) == "en":
			var spacer = Control.new()
			keyboard_grid.add_child(spacer)
			continue
			
		var btn = Button.new()
		btn.text = key
		btn.custom_minimum_size = Vector2(40, 40) 
		btn.focus_mode = Control.FOCUS_ALL 
		btn.pressed.connect(_on_key_pressed.bind(key))
		keyboard_grid.add_child(btn)
		
		if first_button == null:
			first_button = btn

func _on_key_pressed(char_text: String) -> void:
	if name_input_field.text.length() < current_max_length:
		name_input_field.text += char_text

func _on_backspace_button_pressed() -> void:
	if name_input_field.text.length() > 0:
		name_input_field.text = name_input_field.text.left(name_input_field.text.length() - 1)

func _on_confirm_button_pressed() -> void:
	var cleaned_name = name_input_field.text.strip_edges()
	if cleaned_name.is_empty():
		if TranslationServer.get_locale().left(2) == "ja":
			print("名前が空欄です")
		else:
			print("Name is empty")
		return
		
	final_character_name = cleaned_name
	print("確定された名前: ", final_character_name)
	
	# 名前入力が成功したら種族選択へ進む
	_proceed_to_race_selection()

# ★拡張:名前入力が終わり、種族選択の画面(UI)を作る関数
func _proceed_to_race_selection() -> void:
	$NameLayout.hide()
	race_layout.show()
	
	# 言語に応じたタイトル文字の設定
	var current_locale = TranslationServer.get_locale().left(2)
	if current_locale == "ja":
		race_title_label.text = "種族を選択してください"
	else:
		race_title_label.text = "Select a race."
		
	# コンテナ内の既存ボタンを一度クリア
	for child in race_button_container.get_children():
		child.queue_free()
		
	# --- 動的な種族リストの構築 ---
	var available_races = ["Human"] # 最初は人間のみ
	
	# フラグが真なら、エルフとドワーフをリストに追加する
	if is_advanced_races_unlocked:
		available_races.append("Elf")
		available_races.append("Dwarf")
		
	# ボタンの生成と配置
	var r_buttons = []
	for race_id in available_races:
		var btn = Button.new()
		
		# ボタンの表示名を言語ごとに翻訳・変換
		if current_locale == "ja":
			match race_id:
				"Human": btn.text = "人間"
				"Elf": btn.text = "エルフ"
				"Dwarf": btn.text = "ドワーフ"
		else:
			btn.text = race_id
			
		btn.focus_mode = Control.FOCUS_ALL
		# ボタンが押されたら種族を確定する関数を呼ぶ
		btn.pressed.connect(_on_race_selected.bind(race_id))
		race_button_container.add_child(btn)
		r_buttons.append(btn)
		
	# 種族リストボタン内の上下循環フォーカス設定
	for i in range(r_buttons.size()):
		var prev = r_buttons[(i - 1 + r_buttons.size()) % r_buttons.size()]
		var next = r_buttons[(i + 1) % r_buttons.size()]
		r_buttons[i].focus_neighbor_top = prev.get_path()
		r_buttons[i].focus_neighbor_bottom = next.get_path()
		
	# 最初の種族ボタン(人間)にフォーカスを合わせる
	if r_buttons.size() > 0:
		r_buttons[0].grab_focus.call_deferred()

# ★新設:種族ボタンが選択されたときの処理
func _on_race_selected(race_id: String) -> void:
	final_character_race = race_id
	print("確定された種族: ", final_character_race)
	
	# 【次の工程への布石】
	# ここで race_layout を非表示にして、次の「5. 初期ステータスの振り分け」へ進みます
	_proceed_to_status_allocation()

# ★新設:種族画面から「キャンセル」で名前入力に戻る処理
func _back_to_name_input() -> void:
	race_layout.hide()
	$NameLayout.show()
	# 以前入力していた名前を残したまま、キーボードの先頭にフォーカスを戻す
	if first_button:
		first_button.grab_focus.call_deferred()

func _cancel_and_return_to_guild() -> void:
	hide()
	print("キャラクター作成がキャンセルされました。冒険者ギルドに戻ります。")
	creation_canceled.emit()

# ★新設:ステータス画面への遷移関数(プレースホルダー)
func _proceed_to_status_allocation() -> void:
	race_layout.hide()
	print("次のステップ:初期ステータス画面へ進みます(背景の黒は維持)")

キャラクター作成用スクリプト (character_creation.gd) の上記のように変更してください。これで「種族の選択」ができるようになります。

初期ステータスを表示し、ボーナスポイントを振り分ける

初期ステータスを表示する機能の実装です。ボーナスポイントも振り分けられるようにします。初期ステータス[人間]は以下の通りです。

Lvレベル1
HPHP11~18
MPMP9~16
STR9
DEX器用さ9
VIT体力9
AGI素早さ9
INT知恵9
MND精神9
LUC9
  • STRからLUCまでの数値の横に、上下に動くカーソルがあります。
  • カーソルを合わせて決定ボタン (ui_accept) で値が1増え、残りボーナスポイントが1減ります。
  • カーソルを合わせてキャンセルボタン (ui_canncel) でボーナスポイントで増やした値が1減り、残りボーナスポイントが1増えます。
  • 残りボーナスポイントが 0 になるまで、プレイヤーが自由に値を割り振ることができます。
  • ボーナスポイントは5~12の間のランダムな値。
  • このボーナスポイントはHPとMP以外のステータス値に振り分けられるポイントとします。
  • この初期ステータス画面で「ひとつ前に戻るボタンを押す」か「ボーナスポイントを何も振らずにキャンセルボタン (ui_cancel)を押す」と、種族選択画面に戻ります。
  • 種族選択画面に戻り、再度初期ステータス画面に進んだ場合、HPとMPとボーナスポイントは範囲内でランダムな値になります。
  • そのほかのステータス(Lv, STR, DEX, VIT, AGI, INT, MND, LUC) は固定なので、何度種族選択画面に戻っても同じ値です。
  • この初期ステータス画面で「次に進むボタン」を押すと、職業の選択画面に遷移します。

ノード構成の追加

  • CanvasLayer (CanvasLayer)
    • (他は省略)
    • ChracterCreation (Control)
      • Background (ColorRect)
      • NameLayout (VBoxContainer)
      • RaceLayout (VBoxContainer)
      • StatusLayout (VBoxContainer) 新規
        • StatusInstructionLabel (Label) 新規
        • CharacterSummaryLabel (Label) 新規
        • StatusGridContainer (GridContainer) 新規
        • StatusBottomMenu (HBoxContainer) 新規
          • StatusBackButton (Button) 新規
          • StatusNextButton (Button) 新規
StatusLayout新規追加 (非表示)
StatusInstructionLabel ボーナスポイントを振ってください
CharacterSummaryLabel名前:〇〇 / 種族:〇〇 / 残りボーナス: 〇
StatusGridContainer列数を 4 (項目名、基本値、ボーナス加算値、カーソル用空白など) にしておきます。ここにスクリプトからステータス行を自動生成します。
StatusBottomMenu画面最下部の横並びメニュー
StatusBackButtonひとつ前に戻る
StatusNextButton次に進む

今あるノード構成に上記を追加します。列数を 4 にしておくことに注意してください。

キャラクター作成用コード修正

extends Control

signal creation_canceled

# 子ノードへの参照
@onready var title_label: Label = $NameLayout/TitleLabel
@onready var name_input_field: LineEdit = $NameLayout/NameInputField
@onready var keyboard_grid: GridContainer = $NameLayout/KeyboardGrid
@onready var backspace_button: Button = $NameLayout/BottomMenu/BackspaceButton
@onready var confirm_button: Button = $NameLayout/BottomMenu/ConfirmButton

@onready var race_layout: VBoxContainer = $RaceLayout
@onready var race_title_label: Label = $RaceLayout/RaceTitleLabel
@onready var race_button_container: VBoxContainer = $RaceLayout/RaceButtonContainer

# ★追加:「初期ステータス画面」用の子ノード参照
@onready var status_layout: VBoxContainer = $StatusLayout
@onready var status_instruction_label: Label = $StatusLayout/StatusInstructionLabel
@onready var character_summary_label: Label = $StatusLayout/CharacterSummaryLabel
@onready var status_grid_container: GridContainer = $StatusLayout/StatusGridContainer
@onready var status_back_button: Button = $StatusLayout/StatusBottomMenu/StatusBackButton
@onready var status_next_button: Button = $StatusLayout/StatusBottomMenu/StatusNextButton

# キーボードの文字データ定義
const KEYS_JA = [
	"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ",
	"さ", "し", "す", "せ", "そ", "た", "ち", "つ", "て", "と",
	"な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ",
	"ま", "み", "む", "め", "も", "や", "ゆ", "よ", "わ", "を",
	"ら", "り", "る", "れ", "ろ", "ん", "っ", "ゃ", "ゅ", "ょ",
	"が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ",
	"だ", "ぢ", "づ", "で", "ど", "ば", "び", "ぶ", "べ", "ぼ",
	"ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ー", "〜", "、", "。", " "
]

const KEYS_EN = [
	"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
	"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
	"U", "V", "W", "X", "Y", "Z", " ", " ", " ", " ",
	"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
	"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
	"u", "v", "w", "x", "y", "z", "-", "_", "!", "?"
]

var current_max_length: int = 16
var first_button: Button = null 

# --- キャラクターの作成データ保持用 ---
var final_character_name: String = ""
var final_character_race: String = ""
var final_character_class: String = "" # 次のステップ用

# --- 種族の初期ステータス定義 ---
const RACE_BASE_STATS = {
	"Human": { "LV": 1, "STR": 9, "DEX": 9, "VIT": 9, "AGI": 9, "INT": 9, "MND": 9, "LUC": 9, "HP_MIN": 11, "HP_MAX": 18, "MP_MIN": 9, "MP_MAX": 16 },
	"Elf":   { "LV": 1, "STR": 6, "DEX": 12, "VIT": 6, "AGI": 14, "INT": 14, "MND": 14, "LUC": 6, "HP_MIN": 7, "HP_MAX": 14, "MP_MIN": 15, "MP_MAX": 22 },
	"Dwarf": { "LV": 1, "STR": 14, "DEX": 14, "VIT": 14, "AGI": 6, "INT": 6, "MND": 6, "LUC": 12, "HP_MIN": 15, "HP_MAX": 22, "MP_MIN": 7, "MP_MAX": 14 }
}

# 割り振り対象のステータスリスト(順番指定)
const ALLOCATABLE_STATS = ["STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]

# 現在のステータス実数値と、プレイヤーが割り振ったボーナス値
var current_base_stats = {}
var allocated_bonus_stats = { "STR": 0, "DEX": 0, "VIT": 0, "AGI": 0, "INT": 0, "MND": 0, "LUC": 0 }
var initial_bonus_points: int = 0
var remaining_bonus_points: int = 0

# UI更新用のラベル参照を保持する辞書
var stat_labels_dictionary = {}
# 現在カーソルが合っているボタン(割り振り中のみ使用)
var current_focused_stat_button: Button = null

# --- ゲーム進行フラグ (false: 人間だけ, true: 人間、エルフ、ドワーフ) ---
var is_advanced_races_unlocked: bool = true 

func _ready() -> void:
	if backspace_button and not backspace_button.pressed.is_connected(_on_backspace_button_pressed):
		backspace_button.pressed.connect(_on_backspace_button_pressed)
	if confirm_button and not confirm_button.pressed.is_connected(_on_confirm_button_pressed):
		confirm_button.pressed.connect(_on_confirm_button_pressed)
		
	# ★追加:ステータス画面の下部ボタン接続
	if status_back_button and not status_back_button.pressed.is_connected(_on_status_back_pressed):
		status_back_button.pressed.connect(_on_status_back_pressed)
	if status_next_button and not status_next_button.pressed.is_connected(_on_status_next_pressed):
		status_next_button.pressed.connect(_on_status_next_pressed)
		
	hide()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()

func _unhandled_input(event: InputEvent) -> void:
	if not is_visible_in_tree():
		return
		
	if event.is_action_pressed("ui_cancel"):
		# 1. 名前入力画面の時
		if $NameLayout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if name_input_field.text.length() > 0:
				_on_backspace_button_pressed()
			else:
				_cancel_and_return_to_guild()
				
		# 2. 種族選択画面の時
		elif race_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_name_input()
			
		# 3. ★追加:ステータス画面の時
		elif status_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			# もしボーナスが1ポイントでも振られていたら、マイナス処理を試みる
			if current_focused_stat_button and current_focused_stat_button.has_meta("stat_key"):
				var stat_key = current_focused_stat_button.get_meta("stat_key")
				if allocated_bonus_stats[stat_key] > 0:
					_modify_stat_point(stat_key, -1)
					return
			
			# ボーナスが何も振られていない(初期状態)でキャンセルが押されたら種族選択に戻る
			var total_spent = 0
			for val in allocated_bonus_stats.values():
				total_spent += val
			if total_spent == 0:
				_back_to_race_selection()

func activate_name_input() -> void:
	name_input_field.text = ""
	show()
	$NameLayout.show()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		current_max_length = 8
		_setup_software_keyboard(KEYS_JA)
		if title_label: title_label.text = "名前を入力してください"
		if backspace_button: backspace_button.text = "一字消す"
		if confirm_button: confirm_button.text = "決定"
	else:
		current_max_length = 16
		_setup_software_keyboard(KEYS_EN)
		if title_label: title_label.text = "Enter your name."
		if backspace_button: backspace_button.text = "BackSpace"
		if confirm_button: confirm_button.text = "Confirm"
		
	name_input_field.max_length = current_max_length
	keyboard_grid.columns = 10
	
	if first_button:
		first_button.grab_focus.call_deferred()

func _setup_software_keyboard(keys_array: Array) -> void:
	first_button = null
	for child in keyboard_grid.get_children():
		child.queue_free()
		
	for i in range(keys_array.size()):
		var key = keys_array[i]
		if key == " " and TranslationServer.get_locale().left(2) == "en":
			var spacer = Control.new()
			keyboard_grid.add_child(spacer)
			continue
			
		var btn = Button.new()
		btn.text = key
		btn.custom_minimum_size = Vector2(40, 40) 
		btn.focus_mode = Control.FOCUS_ALL 
		btn.pressed.connect(_on_key_pressed.bind(key))
		keyboard_grid.add_child(btn)
		
		if first_button == null:
			first_button = btn

func _on_key_pressed(char_text: String) -> void:
	if name_input_field.text.length() < current_max_length:
		name_input_field.text += char_text

func _on_backspace_button_pressed() -> void:
	if name_input_field.text.length() > 0:
		name_input_field.text = name_input_field.text.left(name_input_field.text.length() - 1)

func _on_confirm_button_pressed() -> void:
	var cleaned_name = name_input_field.text.strip_edges()
	if cleaned_name.is_empty():
		return
		
	final_character_name = cleaned_name
	_proceed_to_race_selection()

func _proceed_to_race_selection() -> void:
	$NameLayout.hide()
	if status_layout: status_layout.hide()
	race_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	if current_locale == "ja":
		race_title_label.text = "種族を選択してください"
	else:
		race_title_label.text = "Select a race."
		
	for child in race_button_container.get_children():
		child.queue_free()
		
	var available_races = ["Human"]
	if is_advanced_races_unlocked:
		available_races.append("Elf")
		available_races.append("Dwarf")
		
	var r_buttons = []
	for race_id in available_races:
		var btn = Button.new()
		if current_locale == "ja":
			match race_id:
				"Human": btn.text = "人間"
				"Elf": btn.text = "エルフ"
				"Dwarf": btn.text = "ドワーフ"
		else:
			btn.text = race_id
			
		btn.focus_mode = Control.FOCUS_ALL
		btn.pressed.connect(_on_race_selected.bind(race_id))
		race_button_container.add_child(btn)
		r_buttons.append(btn)
		
	for i in range(r_buttons.size()):
		var prev = r_buttons[(i - 1 + r_buttons.size()) % r_buttons.size()]
		var next = r_buttons[(i + 1) % r_buttons.size()]
		r_buttons[i].focus_neighbor_top = prev.get_path()
		r_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if r_buttons.size() > 0:
		r_buttons[0].grab_focus.call_deferred()

func _on_race_selected(race_id: String) -> void:
	final_character_race = race_id
	_proceed_to_status_allocation()

func _back_to_name_input() -> void:
	race_layout.hide()
	$NameLayout.show()
	if first_button:
		first_button.grab_focus.call_deferred()

func _cancel_and_return_to_guild() -> void:
	hide()
	creation_canceled.emit()


# ==============================================================================
# ★新設:5. 初期ステータス画面の実装ロジック
# ==============================================================================

func _proceed_to_status_allocation() -> void:
	race_layout.hide()
	status_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	# 1. テキストのローカライズ
	if current_locale == "ja":
		status_instruction_label.text = "ボーナスポイントを振ってください"
		status_back_button.text = "ひとつ前に戻る"
		status_next_button.text = "次に進む"
	else:
		status_instruction_label.text = "Please allocate your bonus points."
		status_back_button.text = "Back"
		status_next_button.text = "Next"

	# 2. 初期ステータスとランダム値の決定(固定値は種族の定義から取得)
	var base = RACE_BASE_STATS[final_character_race]
	current_base_stats = {
		"LV": base["LV"],
		"STR": base["STR"],
		"DEX": base["DEX"],
		"VIT": base["VIT"],
		"AGI": base["AGI"],
		"INT": base["INT"],
		"MND": base["MND"],
		"LUC": base["LUC"],
		# HPとMPは範囲内で完全ランダム
		"HP": randi_range(base["HP_MIN"], base["HP_MAX"]),
		"MP": randi_range(base["MP_MIN"], base["MP_MAX"])
	}
	
	# ボーナス割り振りの初期化
	for stat in ALLOCATABLE_STATS:
		allocated_bonus_stats[stat] = 0
		
	# ボーナスポイントを 5〜12 の間でランダム決定
	initial_bonus_points = randi_range(5, 12)
	remaining_bonus_points = initial_bonus_points
	
	# 3. 画面中央のステータスグリッド(UI)を動的生成
	_build_status_grid()
	_update_summary_ui()

# ステータス割り振り表の構築
func _build_status_grid() -> void:
	for child in status_grid_container.get_children():
		child.queue_free()
		
	stat_labels_dictionary.clear()
	status_grid_container.columns = 4 # [項目名] [基本値] [ボーナス値] [選択・操作用ボタン]
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	# 先にボーナス対象外の固定値(Lv, HP, MP)を表示
	_add_static_stat_row("Lv", current_base_stats["LV"])
	_add_static_stat_row("HP", current_base_stats["HP"])
	_add_static_stat_row("MP", current_base_stats["MP"])
	
	# 割り振りが可能なステータス行をループ生成
	var focusable_buttons = []
	for stat_key in ALLOCATABLE_STATS:
		# 項目名のローカライズ
		var display_name = stat_key
		if current_locale == "ja":
			match stat_key:
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
		else:
			match stat_key:
				"STR": display_name = "STR"
				"DEX": display_name = "DEX"
				"VIT": display_name = "VIT"
				"AGI": display_name = "AGI"
				"INT": display_name = "INT"
				"MND": display_name = "MND"
				"LUC": display_name = "LUC"
				
		# 1列目: 名前ラベル
		var name_lbl = Label.new()
		name_lbl.text = display_name
		status_grid_container.add_child(name_lbl)
		
		# 2列目: ベース値ラベル
		var base_lbl = Label.new()
		base_lbl.text = str(current_base_stats[stat_key])
		status_grid_container.add_child(base_lbl)
		
		# 3列目: ボーナス加算値ラベル(初期値 "+0")
		var bonus_lbl = Label.new()
		bonus_lbl.text = " +0"
		status_grid_container.add_child(bonus_lbl)
		stat_labels_dictionary[stat_key] = bonus_lbl # 後で書き換えるため記憶
		
		# 4列目: 操作兼カーソル用ボタン
		var opt_btn = Button.new()
		opt_btn.text = " ◀ ▶ " # 視覚的な割り振り用インジケータ
		opt_btn.focus_mode = Control.FOCUS_ALL
		opt_btn.set_meta("stat_key", stat_key) # ボタンにステータス名を紐付け
		
		# イベント接続
		opt_btn.pressed.connect(_on_stat_button_pressed.bind(stat_key))
		opt_btn.focus_entered.connect(func(): current_focused_stat_button = opt_btn)
		
		status_grid_container.add_child(opt_btn)
		focusable_buttons.append(opt_btn)
		
	# 画面下部のボタン(戻る・進む)もフォーカス循環に混ぜる
	focusable_buttons.append(status_back_button)
	focusable_buttons.append(status_next_button)
	
	# グリッド内および下部メニューを含めた上下フォーカスの完全循環構築
	for i in range(focusable_buttons.size()):
		var prev = focusable_buttons[(i - 1 + focusable_buttons.size()) % focusable_buttons.size()]
		var next = focusable_buttons[(i + 1) % focusable_buttons.size()]
		focusable_buttons[i].focus_neighbor_top = prev.get_path()
		focusable_buttons[i].focus_neighbor_bottom = next.get_path()
		
	# 最初にSTRのボタンにフォーカスを合わせる
	if focusable_buttons.size() > 0:
		focusable_buttons[0].grab_focus.call_deferred()

# ボーナス対象外(LV, HP, MP)の行を追加する補助関数
func _add_static_stat_row(stat_title: String, val: int) -> void:
	var name_lbl = Label.new()
	name_lbl.text = stat_title
	var val_lbl = Label.new()
	val_lbl.text = str(val)
	
	status_grid_container.add_child(name_lbl)
	status_grid_container.add_child(val_lbl)
	status_grid_container.add_child(Label.new()) # 3列目(空白)
	status_grid_container.add_child(Label.new()) # 4列目(空白)

# 上部の文字情報(名前、種族、残りボーナス)の更新
func _update_summary_ui() -> void:
	var current_locale = TranslationServer.get_locale().left(2)
	var display_race = final_character_race
	if current_locale == "ja":
		match final_character_race:
			"Human": display_race = "人間"
			"Elf": display_race = "エルフ"
			"Dwarf": display_race = "ドワーフ"
			
	if current_locale == "ja":
		character_summary_label.text = "名前: %s   種族: %s   残りボーナスポイント: %d" % [final_character_name, display_race, remaining_bonus_points]
	else:
		character_summary_label.text = "Name: %s   Race: %s   Bonus Points: %d" % [final_character_name, display_race, remaining_bonus_points]

# 決定ボタン(ui_accept)が押されたときのプラス処理
func _on_stat_button_pressed(stat_key: String) -> void:
	if remaining_bonus_points > 0:
		_modify_stat_point(stat_key, 1)

# ポイントの増減実処理(+1 もしくは -1)
func _modify_stat_point(stat_key: String, amount: int) -> void:
	allocated_bonus_stats[stat_key] += amount
	remaining_bonus_points -= amount
	
	# UIラベルの表記をリアルタイム更新
	var bonus_label = stat_labels_dictionary[stat_key] as Label
	if allocated_bonus_stats[stat_key] > 0:
		bonus_label.text = " +%d" % allocated_bonus_stats[stat_key]
	else:
		bonus_label.text = " +0"
		
	_update_summary_ui()

# 「ひとつ前に戻る」ボタンを押した時、またはポイント0でキャンセルされた時
func _back_to_race_selection() -> void:
	status_layout.hide()
	_proceed_to_race_selection()

func _on_status_back_pressed() -> void:
	_back_to_race_selection()

# 「次に進む」ボタンを押したとき
func _on_status_next_pressed() -> void:
	# 仕様: 残りボーナスポイントが 0 になるまでプレイヤーが自由に割り振る(0でなくても進める仕様か、0必須にするかはここで制御可能。現状はいつでも進める設定にしています)
	_proceed_to_class_selection()

# ★新設:職業選択画面への遷移関数(プレースホルダー)
func _proceed_to_class_selection() -> void:
	status_layout.hide()
	print("確定されたステータスで次のステップ:『職業の選択』画面へ進みます!")

キャラクター作成用スクリプト (character_creation.gd) の上記のように変更してください。これで初期ステータスの確認とボーナスポイントの割り振りができるようになります。

「職業」を選択する

ボーナスポイントを割り振ったら、次は職業です。このゲームではステータスの値に関係なく、どんな職業につけるものとします。この記事で就ける職業は以下の4つです。

  • 戦士 (Fighter)
  • 僧侶 (Cleric)
  • 魔法使い (Wizard)
  • 盗賊 (Rogue)

ノード構成の追加

  • CanvasLayer (CanvasLayer)
    • (他は省略)
    • CharacterCreation (Control)
      • Background (ColorRect)
      • NameLayout (VBoxContainer)
      • RaceLayout (VBoxContainer)
      • StatusLayout (VBoxContainer)
      • ClassLayout (VBoxContainer) 新規
        • ClassTitleLabel (Label) 新規
        • ClassMainContent (HBoxContainer) 新規
          • ClassStatusPreview (GridContainer) 新規
          • ClassSelectionArea (HBoxContainer) 新規
            • BasicClassColumn (VBoxContainer) 新規
            • AdvancedClassColumn (VBoxContainer) 新規
ClassLayout新規追加 (非表示)
ClassTitleLabel職業を選択してください。
ClassMainContent左右に分ける
ClassStatusPreviewステータスの合計値プレビュー用(列数 2
ClassSelectionArea職業選択欄
BasicClassColumn初期職業選択欄
AdvancedClassColumn追加職業選択欄

ClassStatusPreview の列数 (Columns) を 2 にすることに注意が必要です。

キャラクター作成用コード修正

extends Control

signal creation_canceled

# 子ノードへの参照
@onready var title_label: Label = $NameLayout/TitleLabel
@onready var name_input_field: LineEdit = $NameLayout/NameInputField
@onready var keyboard_grid: GridContainer = $NameLayout/KeyboardGrid
@onready var backspace_button: Button = $NameLayout/BottomMenu/BackspaceButton
@onready var confirm_button: Button = $NameLayout/BottomMenu/ConfirmButton

@onready var race_layout: VBoxContainer = $RaceLayout
@onready var race_title_label: Label = $RaceLayout/RaceTitleLabel
@onready var race_button_container: VBoxContainer = $RaceLayout/RaceButtonContainer

@onready var status_layout: VBoxContainer = $StatusLayout
@onready var status_instruction_label: Label = $StatusLayout/StatusInstructionLabel
@onready var character_summary_label: Label = $StatusLayout/CharacterSummaryLabel
@onready var status_grid_container: GridContainer = $StatusLayout/StatusGridContainer
@onready var status_back_button: Button = $StatusLayout/StatusBottomMenu/StatusBackButton
@onready var status_next_button: Button = $StatusLayout/StatusBottomMenu/StatusNextButton

# ★変更:「職業選択画面」の新しい子ノード参照
@onready var class_layout: VBoxContainer = $ClassLayout
@onready var class_title_label: Label = $ClassLayout/ClassTitleLabel
@onready var class_status_preview: GridContainer = $ClassLayout/ClassMainContent/ClassStatusPreview
@onready var basic_class_column: VBoxContainer = $ClassLayout/ClassMainContent/ClassSelectionArea/BasicClassColumn
@onready var advanced_class_column: VBoxContainer = $ClassLayout/ClassMainContent/ClassSelectionArea/AdvancedClassColumn

# キーボードの文字データ定義
const KEYS_JA = [
	"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ",
	"さ", "し", "す", "せ", "そ", "た", "ち", "つ", "て", "と",
	"な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ",
	"ま", "み", "む", "め", "も", "や", "ゆ", "よ", "わ", "を",
	"ら", "り", "る", "れ", "ろ", "ん", "っ", "ゃ", "ゅ", "ょ",
	"が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ",
	"だ", "ぢ", "づ", "で", "ど", "ば", "び", "ぶ", "べ", "ぼ",
	"ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ー", "〜", "、", "。", " "
]

const KEYS_EN = [
	"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
	"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
	"U", "V", "W", "X", "Y", "Z", " ", " ", " ", " ",
	"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
	"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
	"u", "v", "w", "x", "y", "z", "-", "_", "!", "?"
]

var current_max_length: int = 16
var first_button: Button = null 

# --- キャラクターの作成データ保持用 ---
var final_character_name: String = ""
var final_character_race: String = ""
var final_character_class: String = ""

# --- 種族の初期ステータス定義 ---
const RACE_BASE_STATS = {
	"Human": { "LV": 1, "STR": 9, "DEX": 9, "VIT": 9, "AGI": 9, "INT": 9, "MND": 9, "LUC": 9, "HP_MIN": 11, "HP_MAX": 18, "MP_MIN": 9, "MP_MAX": 16 },
	"Elf":   { "LV": 1, "STR": 6, "DEX": 12, "VIT": 6, "AGI": 14, "INT": 14, "MND": 14, "LUC": 6, "HP_MIN": 7, "HP_MAX": 14, "MP_MIN": 15, "MP_MAX": 22 },
	"Dwarf": { "LV": 1, "STR": 14, "DEX": 14, "VIT": 14, "AGI": 6, "INT": 6, "MND": 6, "LUC": 12, "HP_MIN": 15, "HP_MAX": 22, "MP_MIN": 7, "MP_MAX": 14 }
}

const ALLOCATABLE_STATS = ["STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]

var current_base_stats = {}
var allocated_bonus_stats = { "STR": 0, "DEX": 0, "VIT": 0, "AGI": 0, "INT": 0, "MND": 0, "LUC": 0 }
var initial_bonus_points: int = 0
var remaining_bonus_points: int = 0

var stat_labels_dictionary = {}
var current_focused_stat_button: Button = null

# --- ゲーム進行フラグ ---
var is_advanced_races_unlocked: bool = false 

# 職業解放フラグ(テスト時に true にしてご確認ください)
var is_class_group_1_unlocked: bool = true # 狩人, ナイト
var is_class_group_2_unlocked: bool = true # 侍, ワーロック
var is_class_group_3_unlocked: bool = true # 忍者, 賢者

func _ready() -> void:
	if backspace_button and not backspace_button.pressed.is_connected(_on_backspace_button_pressed):
		backspace_button.pressed.connect(_on_backspace_button_pressed)
	if confirm_button and not confirm_button.pressed.is_connected(_on_confirm_button_pressed):
		confirm_button.pressed.connect(_on_confirm_button_pressed)
		
	if status_back_button and not status_back_button.pressed.is_connected(_on_status_back_pressed):
		status_back_button.pressed.connect(_on_status_back_pressed)
	if status_next_button and not status_next_button.pressed.is_connected(_on_status_next_pressed):
		status_next_button.pressed.connect(_on_status_next_pressed)
		
	hide()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()

func _unhandled_input(event: InputEvent) -> void:
	if not is_visible_in_tree():
		return
		
	if event.is_action_pressed("ui_cancel"):
		if $NameLayout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if name_input_field.text.length() > 0:
				_on_backspace_button_pressed()
			else:
				_cancel_and_return_to_guild()
				
		elif race_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_name_input()
			
		elif status_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if current_focused_stat_button and current_focused_stat_button.has_meta("stat_key"):
				var stat_key = current_focused_stat_button.get_meta("stat_key")
				if allocated_bonus_stats[stat_key] > 0:
					_modify_stat_point(stat_key, -1)
					return
			
			var total_spent = 0
			for val in allocated_bonus_stats.values():
				total_spent += val
			if total_spent == 0:
				_back_to_race_selection()
				
		elif class_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_status_allocation()

func activate_name_input() -> void:
	name_input_field.text = ""
	show()
	$NameLayout.show()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		current_max_length = 8
		_setup_software_keyboard(KEYS_JA)
		if title_label: title_label.text = "名前を入力してください"
		if backspace_button: backspace_button.text = "一字消す"
		if confirm_button: confirm_button.text = "決定"
	else:
		current_max_length = 16
		_setup_software_keyboard(KEYS_EN)
		if title_label: title_label.text = "Enter your name."
		if backspace_button: backspace_button.text = "BackSpace"
		if confirm_button: confirm_button.text = "Confirm"
		
	name_input_field.max_length = current_max_length
	keyboard_grid.columns = 10
	
	if first_button:
		first_button.grab_focus.call_deferred()

func _setup_software_keyboard(keys_array: Array) -> void:
	first_button = null
	for child in keyboard_grid.get_children():
		child.queue_free()
		
	for i in range(keys_array.size()):
		var key = keys_array[i]
		if key == " " and TranslationServer.get_locale().left(2) == "en":
			var spacer = Control.new()
			keyboard_grid.add_child(spacer)
			continue
			
		var btn = Button.new()
		btn.text = key
		btn.custom_minimum_size = Vector2(40, 40) 
		btn.focus_mode = Control.FOCUS_ALL 
		btn.pressed.connect(_on_key_pressed.bind(key))
		keyboard_grid.add_child(btn)
		
		if first_button == null:
			first_button = btn

func _on_key_pressed(char_text: String) -> void:
	if name_input_field.text.length() < current_max_length:
		name_input_field.text += char_text

func _on_backspace_button_pressed() -> void:
	if name_input_field.text.length() > 0:
		name_input_field.text = name_input_field.text.left(name_input_field.text.length() - 1)

func _on_confirm_button_pressed() -> void:
	var cleaned_name = name_input_field.text.strip_edges()
	if cleaned_name.is_empty():
		return
		
	final_character_name = cleaned_name
	_proceed_to_race_selection()

func _proceed_to_race_selection() -> void:
	$NameLayout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	race_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	if current_locale == "ja":
		race_title_label.text = "種族を選択してください"
	else:
		race_title_label.text = "Select a race."
		
	for child in race_button_container.get_children():
		child.queue_free()
		
	var available_races = ["Human"]
	if is_advanced_races_unlocked:
		available_races.append("Elf")
		available_races.append("Dwarf")
		
	var r_buttons = []
	for race_id in available_races:
		var btn = Button.new()
		if current_locale == "ja":
			match race_id:
				"Human": btn.text = "人間"
				"Elf": btn.text = "エルフ"
				"Dwarf": btn.text = "ドワーフ"
		else:
			btn.text = race_id
			
		btn.focus_mode = Control.FOCUS_ALL
		btn.pressed.connect(_on_race_selected.bind(race_id))
		race_button_container.add_child(btn)
		r_buttons.append(btn)
		
	for i in range(r_buttons.size()):
		var prev = r_buttons[(i - 1 + r_buttons.size()) % r_buttons.size()]
		var next = r_buttons[(i + 1) % r_buttons.size()]
		r_buttons[i].focus_neighbor_top = prev.get_path()
		r_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if r_buttons.size() > 0:
		r_buttons[0].grab_focus.call_deferred()

func _on_race_selected(race_id: String) -> void:
	final_character_race = race_id
	_proceed_to_status_allocation()

func _back_to_name_input() -> void:
	race_layout.hide()
	$NameLayout.show()
	if first_button:
		first_button.grab_focus.call_deferred()

func _cancel_and_return_to_guild() -> void:
	hide()
	creation_canceled.emit()

func _proceed_to_status_allocation() -> void:
	race_layout.hide()
	if class_layout: class_layout.hide()
	status_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		status_instruction_label.text = "ボーナスポイントを振ってください"
		status_back_button.text = "ひとつ前に戻る"
		status_next_button.text = "次に進む"
	else:
		status_instruction_label.text = "Please allocate your bonus points."
		status_back_button.text = "Back"
		status_next_button.text = "Next"

	var base = RACE_BASE_STATS[final_character_race]
	current_base_stats = {
		"LV": base["LV"],
		"STR": base["STR"],
		"DEX": base["DEX"],
		"VIT": base["VIT"],
		"AGI": base["AGI"],
		"INT": base["INT"],
		"MND": base["MND"],
		"LUC": base["LUC"],
		"HP": randi_range(base["HP_MIN"], base["HP_MAX"]),
		"MP": randi_range(base["MP_MIN"], base["MP_MAX"])
	}
	
	for stat in ALLOCATABLE_STATS:
		allocated_bonus_stats[stat] = 0
		
	initial_bonus_points = randi_range(5, 12)
	remaining_bonus_points = initial_bonus_points
	
	_build_status_grid()
	_update_summary_ui()

func _build_status_grid() -> void:
	for child in status_grid_container.get_children():
		child.queue_free()
		
	stat_labels_dictionary.clear()
	status_grid_container.columns = 4
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	_add_static_stat_row("Lv", current_base_stats["LV"])
	_add_static_stat_row("HP", current_base_stats["HP"])
	_add_static_stat_row("MP", current_base_stats["MP"])
	
	var focusable_buttons = []
	for stat_key in ALLOCATABLE_STATS:
		var display_name = stat_key
		if current_locale == "ja":
			match stat_key:
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
				
		var name_lbl = Label.new()
		name_lbl.text = display_name
		status_grid_container.add_child(name_lbl)
		
		var base_lbl = Label.new()
		base_lbl.text = str(current_base_stats[stat_key])
		status_grid_container.add_child(base_lbl)
		
		var bonus_lbl = Label.new()
		bonus_lbl.text = " +0"
		status_grid_container.add_child(bonus_lbl)
		stat_labels_dictionary[stat_key] = bonus_lbl
		
		var opt_btn = Button.new()
		opt_btn.text = " ◀ ▶ "
		opt_btn.focus_mode = Control.FOCUS_ALL
		opt_btn.set_meta("stat_key", stat_key)
		
		opt_btn.pressed.connect(_on_stat_button_pressed.bind(stat_key))
		opt_btn.focus_entered.connect(func(): current_focused_stat_button = opt_btn)
		
		status_grid_container.add_child(opt_btn)
		focusable_buttons.append(opt_btn)
		
	focusable_buttons.append(status_back_button)
	focusable_buttons.append(status_next_button)
	
	for i in range(focusable_buttons.size()):
		var prev = focusable_buttons[(i - 1 + focusable_buttons.size()) % focusable_buttons.size()]
		var next = focusable_buttons[(i + 1) % focusable_buttons.size()]
		focusable_buttons[i].focus_neighbor_top = prev.get_path()
		focusable_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if focusable_buttons.size() > 0:
		focusable_buttons[0].grab_focus.call_deferred()

func _add_static_stat_row(stat_title: String, val: int) -> void:
	var name_lbl = Label.new()
	name_lbl.text = stat_title
	var val_lbl = Label.new()
	val_lbl.text = str(val)
	status_grid_container.add_child(name_lbl)
	status_grid_container.add_child(val_lbl)
	status_grid_container.add_child(Label.new())
	status_grid_container.add_child(Label.new())

func _update_summary_ui() -> void:
	var current_locale = TranslationServer.get_locale().left(2)
	var display_race = final_character_race
	if current_locale == "ja":
		match final_character_race:
			"Human": display_race = "人間"
			"Elf": display_race = "エルフ"
			"Dwarf": display_race = "ドワーフ"
			
	if current_locale == "ja":
		character_summary_label.text = "名前: %s   種族: %s   残りボーナスポイント: %d" % [final_character_name, display_race, remaining_bonus_points]
	else:
		character_summary_label.text = "Name: %s   Race: %s   Bonus Points: %d" % [final_character_name, display_race, remaining_bonus_points]

func _on_stat_button_pressed(stat_key: String) -> void:
	if remaining_bonus_points > 0:
		_modify_stat_point(stat_key, 1)

func _modify_stat_point(stat_key: String, amount: int) -> void:
	allocated_bonus_stats[stat_key] += amount
	remaining_bonus_points -= amount
	
	var bonus_label = stat_labels_dictionary[stat_key] as Label
	if allocated_bonus_stats[stat_key] > 0:
		bonus_label.text = " +%d" % allocated_bonus_stats[stat_key]
	else:
		bonus_label.text = " +0"
		
	_update_summary_ui()

func _back_to_race_selection() -> void:
	status_layout.hide()
	_proceed_to_race_selection()

func _on_status_back_pressed() -> void:
	_back_to_race_selection()

func _on_status_next_pressed() -> void:
	_proceed_to_class_selection()


# ==============================================================================
# ★仕様変更:6. 職業選択画面(左側ステータス / 右側縦並び2列ボタン)
# ==============================================================================

func _proceed_to_class_selection() -> void:
	status_layout.hide()
	class_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		class_title_label.text = "職業を選んでください"
	else:
		class_title_label.text = "Select a class."
		
	# 1. 【左側】に合計ステータスを表示
	_build_class_status_preview()
	
	# 2. 【右側】の縦並び2列に職業ボタンを生成
	_build_class_columns()

# 左側のウィンドウ:括弧や英語をなくした綺麗な日本語対応
func _build_class_status_preview() -> void:
	for child in class_status_preview.get_children():
		child.queue_free()
		
	class_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
		
		name_lbl.text = display_name
		
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
			
		total_lbl.text = " %d" % total_val
		
		class_status_preview.add_child(name_lbl)
		class_status_preview.add_child(total_lbl)

# 右側のウィンドウ:初期職(1列目)と追加職(2列目)を構築し、上下左右のカーソルを設定
func _build_class_columns() -> void:
	# 既存のボタンをクリア
	for child in basic_class_column.get_children(): child.queue_free()
	for child in advanced_class_column.get_children(): child.queue_free()
		
	var current_locale = TranslationServer.get_locale().left(2)
	
	# 1. 初期職業リスト(1列目用)
	var basic_ids = ["Fighter", "Rogue", "Cleric", "Wizard"]
	
	# 2. 追加職業リスト(2列目用・フラグに応じて動的に構成)
	var adv_ids = []
	if is_class_group_1_unlocked: adv_ids.append_array(["Ranger", "Knight"])
	if is_class_group_2_unlocked: adv_ids.append_array(["Samurai", "Warlock"])
	if is_class_group_3_unlocked: adv_ids.append_array(["Ninja", "Sage"])
	
	var basic_buttons = []
	var adv_buttons = []
	
	# --- 1列目(初期職)の生成 ---
	for cid in basic_ids:
		var btn = _create_class_button(cid, current_locale)
		basic_class_column.add_child(btn)
		basic_buttons.append(btn)
		
	# --- 2列目(追加職)の生成 ---
	for cid in adv_ids:
		var btn = _create_class_button(cid, current_locale)
		advanced_class_column.add_child(btn)
		adv_buttons.append(btn)
		
	# --- 重要:上下左右のクロスフォーカス(循環型)の構築 ---
	
	# 1列目の上下移動を設定
	for i in range(basic_buttons.size()):
		var prev = basic_buttons[(i - 1 + basic_buttons.size()) % basic_buttons.size()]
		var next = basic_buttons[(i + 1) % basic_buttons.size()]
		basic_buttons[i].focus_neighbor_top = prev.get_path()
		basic_buttons[i].focus_neighbor_bottom = next.get_path()
		
		# 右を押したとき:2列目に対応するインデックスがあれば移動、なければ2列目の末尾へ
		if adv_buttons.size() > 0:
			var target_index = min(i, adv_buttons.size() - 1)
			basic_buttons[i].focus_neighbor_right = adv_buttons[target_index].get_path()
			
	# 2列目の上下移動を設定
	for i in range(adv_buttons.size()):
		var prev = adv_buttons[(i - 1 + adv_buttons.size()) % adv_buttons.size()]
		var next = adv_buttons[(i + 1) % adv_buttons.size()]
		adv_buttons[i].focus_neighbor_top = prev.get_path()
		adv_buttons[i].focus_neighbor_bottom = next.get_path()
		
		# 左を押したとき:必ず1列目の対応するボタンへ戻る
		var target_index = min(i, basic_buttons.size() - 1)
		adv_buttons[i].focus_neighbor_left = basic_buttons[target_index].get_path()
		
	# 初期位置として、1列目の一番上(戦士)にフォーカス
	if basic_buttons.size() > 0:
		basic_buttons[0].grab_focus.call_deferred()

# ボタン生成とローカライズ処理の共通化ヘルパー関数
func _create_class_button(class_id: String, locale: String) -> Button:
	var btn = Button.new()
	if locale == "ja":
		match class_id:
			"Fighter": btn.text = "戦士"
			"Rogue": btn.text = "盗賊"
			"Cleric": btn.text = "僧侶"
			"Wizard": btn.text = "魔法使い"
			"Ranger": btn.text = "狩人"
			"Knight": btn.text = "ナイト"
			"Samurai": btn.text = "侍"
			"Warlock": btn.text = "ワーロック"
			"Ninja": btn.text = "忍者"
			"Sage": btn.text = "賢者"
	else:
		btn.text = class_id
		
	btn.focus_mode = Control.FOCUS_ALL
	btn.pressed.connect(_on_class_selected.bind(class_id))
	return btn

func _on_class_selected(class_id: String) -> void:
	final_character_class = class_id
	print("確定された職業: ", final_character_class)
	_proceed_to_appearance_selection()

func _back_to_status_allocation() -> void:
	class_layout.hide()
	_proceed_to_status_allocation()

func _proceed_to_appearance_selection() -> void:
	class_layout.hide()
	print("次のステップ:『見た目の選択』画面へ進みます(背景の黒は維持)")

キャラクター作成用スクリプト (character_creation.gd) の上記のように変更してください。これで職業を選択できるようになりました。

コードを見るとわかりますが、将来の職業追加に対応したものになっています。

「見た目」を選択する

キャラクター作成の最後に見た目を決めます。ここでは「見た目画像」から1つを選ぶようにします。

キャラクター画像用フォルダ作成

まず res://image/face フォルダを作成します。その中に「見た目画像」をインポートしてください。

ノード構成の追加

  • CanvasLayer (CanvasLayer)
    • (他は省略)
    • ChracterCreation (Control)
      • Background (ColorRect)
      • NameLayout (VBoxContainer)
      • RaceLayout (VBoxContainer)
      • StatusLayout (VBoxContainer)
      • ClassLayout (VBoxContainer)
      • AppearanceLayout (VBoxContainer) 新規
        • AppearanceTitleLabel (Label) 新規
        • AppearanceSummaryLabel (Label) 新規
        • AppearanceMainContent (HBoxContainer) 新規
          • AppearanceStatusPreview (GridContainer) 新規
          • AppearanceWheelControl (Control) 新規
        • AppearanceBottomMenu (HBoxContainer) 新規
          • AppearanceRandomButton (Button) 新規
          • AppearanceBackButton (Button) 新規
          • AppearanceNextButton (Button) 新規
AppearanceLayout (VBoxContainer)新規追加 (非表示)
AppearanceTitleLabel (Label)見た目を選んでください
AppearanceSummaryLabel (Label)名前・種族・職業の表示用
AppearanceMainContent (HBoxContainer)画面中央 (左右分割)
AppearanceStatusPreview (GridContainer)ステータス合計値 (列数: 2)
AppearanceWheelControl (Control)ここに画像を円形に動的生成します。サイズ (Custom Minimum Size) を大きめ (例: 512×512) など確保しておいてください。
AppearanceBottomMenu (HBoxContainer)画面最下部
AppearanceRandomButton (Button)ランダムで決める
AppearanceBackButton (Button)職業選択に戻る
AppearanceNextButton (Button)確認画面に進む

ノード構成を上記の通りに追加します。AppearanceWheelControl のサイズは環境に合わせて調整してください。

キャラクター作成用コード修正

extends Control

signal creation_canceled

# 子ノードへの参照
@onready var title_label: Label = $NameLayout/TitleLabel
@onready var name_input_field: LineEdit = $NameLayout/NameInputField
@onready var keyboard_grid: GridContainer = $NameLayout/KeyboardGrid
@onready var backspace_button: Button = $NameLayout/BottomMenu/BackspaceButton
@onready var confirm_button: Button = $NameLayout/BottomMenu/ConfirmButton

@onready var race_layout: VBoxContainer = $RaceLayout
@onready var race_title_label: Label = $RaceLayout/RaceTitleLabel
@onready var race_button_container: VBoxContainer = $RaceLayout/RaceButtonContainer

@onready var status_layout: VBoxContainer = $StatusLayout
@onready var status_instruction_label: Label = $StatusLayout/StatusInstructionLabel
@onready var character_summary_label: Label = $StatusLayout/CharacterSummaryLabel
@onready var status_grid_container: GridContainer = $StatusLayout/StatusGridContainer
@onready var status_back_button: Button = $StatusLayout/StatusBottomMenu/StatusBackButton
@onready var status_next_button: Button = $StatusLayout/StatusBottomMenu/StatusNextButton

@onready var class_layout: VBoxContainer = $ClassLayout
@onready var class_title_label: Label = $ClassLayout/ClassTitleLabel
@onready var class_status_preview: GridContainer = $ClassLayout/ClassMainContent/ClassStatusPreview
@onready var basic_class_column: VBoxContainer = $ClassLayout/ClassMainContent/ClassSelectionArea/BasicClassColumn
@onready var advanced_class_column: VBoxContainer = $ClassLayout/ClassMainContent/ClassSelectionArea/AdvancedClassColumn

# ★追加:「見た目選択画面」用の子ノード参照
@onready var appearance_layout: VBoxContainer = $AppearanceLayout
@onready var appearance_title_label: Label = $AppearanceLayout/AppearanceTitleLabel
@onready var appearance_summary_label: Label = $AppearanceLayout/AppearanceSummaryLabel
@onready var appearance_status_preview: GridContainer = $AppearanceLayout/AppearanceMainContent/AppearanceStatusPreview
@onready var appearance_wheel_control: Control = $AppearanceLayout/AppearanceMainContent/AppearanceWheelControl
@onready var appearance_random_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceRandomButton
@onready var appearance_back_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceBackButton
@onready var appearance_next_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceNextButton

# キーボードの文字データ定義
const KEYS_JA = [
	"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ",
	"さ", "し", "す", "せ", "そ", "た", "ち", "つ", "て", "と",
	"な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ",
	"ま", "み", "む", "め", "も", "や", "ゆ", "よ", "わ", "を",
	"ら", "り", "る", "れ", "ろ", "ん", "っ", "ゃ", "ゅ", "ょ",
	"が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ",
	"だ", "ぢ", "づ", "で", "ど", "ば", "び", "ぶ", "べ", "ぼ",
	"ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ー", "〜", "、", "。", " "
]

const KEYS_EN = [
	"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
	"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
	"U", "V", "W", "X", "Y", "Z", " ", " ", " ", " ",
	"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
	"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
	"u", "v", "w", "x", "y", "z", "-", "_", "!", "?"
]

var current_max_length: int = 16
var first_button: Button = null 

# --- キャラクターの作成データ保持用 ---
var final_character_name: String = ""
var final_character_race: String = ""
var final_character_class: String = ""
var final_character_image_path: String = "" # 確定した見た目画像のパス

# --- 種族の初期ステータス定義 ---
const RACE_BASE_STATS = {
	"Human": { "LV": 1, "STR": 9, "DEX": 9, "VIT": 9, "AGI": 9, "INT": 9, "MND": 9, "LUC": 9, "HP_MIN": 11, "HP_MAX": 18, "MP_MIN": 9, "MP_MAX": 16 },
	"Elf":   { "LV": 1, "STR": 6, "DEX": 12, "VIT": 6, "AGI": 14, "INT": 14, "MND": 14, "LUC": 6, "HP_MIN": 7, "HP_MAX": 14, "MP_MIN": 15, "MP_MAX": 22 },
	"Dwarf": { "LV": 1, "STR": 14, "DEX": 14, "VIT": 14, "AGI": 6, "INT": 6, "MND": 6, "LUC": 12, "HP_MIN": 15, "HP_MAX": 22, "MP_MIN": 7, "MP_MAX": 14 }
}

const ALLOCATABLE_STATS = ["STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]

var current_base_stats = {}
var allocated_bonus_stats = { "STR": 0, "DEX": 0, "VIT": 0, "AGI": 0, "INT": 0, "MND": 0, "LUC": 0 }
var initial_bonus_points: int = 0
var remaining_bonus_points: int = 0

var stat_labels_dictionary = {}
var current_focused_stat_button: Button = null

# --- ゲーム進行フラグ ---
var is_advanced_races_unlocked: bool = false 
var is_class_group_1_unlocked: bool = false 
var is_class_group_2_unlocked: bool = false 
var is_class_group_3_unlocked: bool = false 

# --- 見た目(外見)選択用の変数群 ---
var face_image_paths: Array[String] = [] # フォルダ内の全画像パス
var face_sprites: Array[TextureRect] = [] # 画面上の画像ノード
var current_image_index: int = 0 # 現在選択(正面に表示)されているインデックス
var target_wheel_angle: float = 0.0 # 補間用の目標角度
var current_wheel_angle: float = 0.0 # 現在の角度
var is_wheel_focused: bool = false # 画像選択エリアにフォーカスがあるか
var wheel_tween: Tween = null

func _ready() -> void:
	if backspace_button and not backspace_button.pressed.is_connected(_on_backspace_button_pressed):
		backspace_button.pressed.connect(_on_backspace_button_pressed)
	if confirm_button and not confirm_button.pressed.is_connected(_on_confirm_button_pressed):
		confirm_button.pressed.connect(_on_confirm_button_pressed)
		
	if status_back_button and not status_back_button.pressed.is_connected(_on_status_back_pressed):
		status_back_button.pressed.connect(_on_status_back_pressed)
	if status_next_button and not status_next_button.pressed.is_connected(_on_status_next_pressed):
		status_next_button.pressed.connect(_on_status_next_pressed)
		
	# 見た目画面の下部ボタン接続
	if appearance_random_button and not appearance_random_button.pressed.is_connected(_on_appearance_random_pressed):
		appearance_random_button.pressed.connect(_on_appearance_random_pressed)
	if appearance_back_button and not appearance_back_button.pressed.is_connected(_on_appearance_back_pressed):
		appearance_back_button.pressed.connect(_on_appearance_back_pressed)
	if appearance_next_button and not appearance_next_button.pressed.is_connected(_on_appearance_next_pressed):
		appearance_next_button.pressed.connect(_on_appearance_next_pressed)
		
	hide()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()

func _input(event: InputEvent) -> void:
	if not is_visible_in_tree():
		return
		
	if event.is_action_pressed("ui_cancel"):
		if $NameLayout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if name_input_field.text.length() > 0:
				_on_backspace_button_pressed()
			else:
				_cancel_and_return_to_guild()
				
		elif race_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_name_input()
			
		elif status_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if current_focused_stat_button and current_focused_stat_button.has_meta("stat_key"):
				var stat_key = current_focused_stat_button.get_meta("stat_key")
				if allocated_bonus_stats[stat_key] > 0:
					_modify_stat_point(stat_key, -1)
					return
			
			var total_spent = 0
			for val in allocated_bonus_stats.values():
				total_spent += val
			if total_spent == 0:
				_back_to_race_selection()
				
		elif class_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_status_allocation()
			
		elif appearance_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_class_selection()

	# 見た目画面での操作制御
	if appearance_layout.is_visible_in_tree() and is_wheel_focused and face_image_paths.size() > 0:
		# 左右キーによる回転
		if event.is_action_pressed("ui_right", true) or event.is_action("ui_right"):
			if event.is_pressed():
				get_viewport().set_input_as_handled()
				_rotate_wheel(1) # 順回転
		elif event.is_action_pressed("ui_left", true) or event.is_action("ui_left"):
			if event.is_pressed():
				get_viewport().set_input_as_handled()
				_rotate_wheel(-1) # 逆回転
				
		# ★追加:(2) 画像選択中に決定ボタン (ui_accept) を押したときの挙動
		elif event.is_action_pressed("ui_accept"):
			get_viewport().set_input_as_handled()
			if appearance_next_button:
				appearance_next_button.grab_focus() # 「確認画面に進む」へフォーカスを移動

func activate_name_input() -> void:
	name_input_field.text = ""
	show()
	$NameLayout.show()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		current_max_length = 8
		_setup_software_keyboard(KEYS_JA)
		if title_label: title_label.text = "名前を入力してください"
		if backspace_button: backspace_button.text = "一字消す"
		if confirm_button: confirm_button.text = "決定"
	else:
		current_max_length = 16
		_setup_software_keyboard(KEYS_EN)
		if title_label: title_label.text = "Enter your name."
		if backspace_button: backspace_button.text = "BackSpace"
		if confirm_button: confirm_button.text = "Confirm"
		
	name_input_field.max_length = current_max_length
	keyboard_grid.columns = 10
	
	if first_button:
		first_button.grab_focus.call_deferred()

func _setup_software_keyboard(keys_array: Array) -> void:
	first_button = null
	for child in keyboard_grid.get_children():
		child.queue_free()
		
	for i in range(keys_array.size()):
		var key = keys_array[i]
		if key == " " and TranslationServer.get_locale().left(2) == "en":
			var spacer = Control.new()
			keyboard_grid.add_child(spacer)
			continue
			
		var btn = Button.new()
		btn.text = key
		btn.custom_minimum_size = Vector2(40, 40) 
		btn.focus_mode = Control.FOCUS_ALL 
		btn.pressed.connect(_on_key_pressed.bind(key))
		keyboard_grid.add_child(btn)
		
		if first_button == null:
			first_button = btn

func _on_key_pressed(char_text: String) -> void:
	if name_input_field.text.length() < current_max_length:
		name_input_field.text += char_text

func _on_backspace_button_pressed() -> void:
	if name_input_field.text.length() > 0:
		name_input_field.text = name_input_field.text.left(name_input_field.text.length() - 1)

func _on_confirm_button_pressed() -> void:
	var cleaned_name = name_input_field.text.strip_edges()
	if cleaned_name.is_empty():
		return
		
	final_character_name = cleaned_name
	_proceed_to_race_selection()

func _proceed_to_race_selection() -> void:
	$NameLayout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	race_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	if current_locale == "ja":
		race_title_label.text = "種族を選択してください"
	else:
		race_title_label.text = "Select a race."
		
	for child in race_button_container.get_children():
		child.queue_free()
		
	var available_races = ["Human"]
	if is_advanced_races_unlocked:
		available_races.append("Elf")
		available_races.append("Dwarf")
		
	var r_buttons = []
	for race_id in available_races:
		var btn = Button.new()
		if current_locale == "ja":
			match race_id:
				"Human": btn.text = "人間"
				"Elf": btn.text = "エルフ"
				"Dwarf": btn.text = "ドワーフ"
		else:
			btn.text = race_id
			
		btn.focus_mode = Control.FOCUS_ALL
		btn.pressed.connect(_on_race_selected.bind(race_id))
		race_button_container.add_child(btn)
		r_buttons.append(btn)
		
	for i in range(r_buttons.size()):
		var prev = r_buttons[(i - 1 + r_buttons.size()) % r_buttons.size()]
		var next = r_buttons[(i + 1) % r_buttons.size()]
		r_buttons[i].focus_neighbor_top = prev.get_path()
		r_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if r_buttons.size() > 0:
		r_buttons[0].grab_focus.call_deferred()

func _on_race_selected(race_id: String) -> void:
	final_character_race = race_id
	_proceed_to_status_allocation()

func _back_to_name_input() -> void:
	race_layout.hide()
	$NameLayout.show()
	if first_button:
		first_button.grab_focus.call_deferred()

func _cancel_and_return_to_guild() -> void:
	hide()
	creation_canceled.emit()

func _proceed_to_status_allocation() -> void:
	race_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	status_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		status_instruction_label.text = "ボーナスポイントを振ってください"
		status_back_button.text = "ひとつ前に戻る"
		status_next_button.text = "次に進む"
	else:
		status_instruction_label.text = "Please allocate your bonus points."
		status_back_button.text = "Back"
		status_next_button.text = "Next"

	var base = RACE_BASE_STATS[final_character_race]
	current_base_stats = {
		"LV": base["LV"],
		"STR": base["STR"],
		"DEX": base["DEX"],
		"VIT": base["VIT"],
		"AGI": base["AGI"],
		"INT": base["INT"],
		"MND": base["MND"],
		"LUC": base["LUC"],
		"HP": randi_range(base["HP_MIN"], base["HP_MAX"]),
		"MP": randi_range(base["MP_MIN"], base["MP_MAX"])
	}
	
	for stat in ALLOCATABLE_STATS:
		allocated_bonus_stats[stat] = 0
		
	initial_bonus_points = randi_range(5, 12)
	remaining_bonus_points = initial_bonus_points
	
	_build_status_grid()
	_update_summary_ui()

func _build_status_grid() -> void:
	for child in status_grid_container.get_children():
		child.queue_free()
		
	stat_labels_dictionary.clear()
	status_grid_container.columns = 4
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	_add_static_stat_row("Lv", current_base_stats["LV"])
	_add_static_stat_row("HP", current_base_stats["HP"])
	_add_static_stat_row("MP", current_base_stats["MP"])
	
	var focusable_buttons = []
	for stat_key in ALLOCATABLE_STATS:
		var display_name = stat_key
		if current_locale == "ja":
			match stat_key:
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
				
		var name_lbl = Label.new()
		name_lbl.text = display_name
		status_grid_container.add_child(name_lbl)
		
		var base_lbl = Label.new()
		base_lbl.text = str(current_base_stats[stat_key])
		status_grid_container.add_child(base_lbl)
		
		var bonus_lbl = Label.new()
		bonus_lbl.text = " +0"
		status_grid_container.add_child(bonus_lbl)
		stat_labels_dictionary[stat_key] = bonus_lbl
		
		var opt_btn = Button.new()
		opt_btn.text = " ◀ ▶ "
		opt_btn.focus_mode = Control.FOCUS_ALL
		opt_btn.set_meta("stat_key", stat_key)
		
		opt_btn.pressed.connect(_on_stat_button_pressed.bind(stat_key))
		opt_btn.focus_entered.connect(func(): current_focused_stat_button = opt_btn)
		
		status_grid_container.add_child(opt_btn)
		focusable_buttons.append(opt_btn)
		
	focusable_buttons.append(status_back_button)
	focusable_buttons.append(status_next_button)
	
	for i in range(focusable_buttons.size()):
		var prev = focusable_buttons[(i - 1 + focusable_buttons.size()) % focusable_buttons.size()]
		var next = focusable_buttons[(i + 1) % focusable_buttons.size()]
		focusable_buttons[i].focus_neighbor_top = prev.get_path()
		focusable_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if focusable_buttons.size() > 0:
		focusable_buttons[0].grab_focus.call_deferred()

func _add_static_stat_row(stat_title: String, val: int) -> void:
	var name_lbl = Label.new()
	name_lbl.text = stat_title
	var val_lbl = Label.new()
	val_lbl.text = str(val)
	status_grid_container.add_child(name_lbl)
	status_grid_container.add_child(val_lbl)
	status_grid_container.add_child(Label.new())
	status_grid_container.add_child(Label.new())

func _update_summary_ui() -> void:
	var current_locale = TranslationServer.get_locale().left(2)
	var display_race = final_character_race
	if current_locale == "ja":
		match final_character_race:
			"Human": display_race = "人間"
			"Elf": display_race = "エルフ"
			"Dwarf": display_race = "ドワーフ"
			
	if current_locale == "ja":
		character_summary_label.text = "名前: %s   種族: %s   残りボーナスポイント: %d" % [final_character_name, display_race, remaining_bonus_points]
	else:
		character_summary_label.text = "Name: %s   Race: %s   Bonus Points: %d" % [final_character_name, display_race, remaining_bonus_points]

func _on_stat_button_pressed(stat_key: String) -> void:
	if remaining_bonus_points > 0:
		_modify_stat_point(stat_key, 1)

func _modify_stat_point(stat_key: String, amount: int) -> void:
	allocated_bonus_stats[stat_key] += amount
	remaining_bonus_points -= amount
	
	var bonus_label = stat_labels_dictionary[stat_key] as Label
	if allocated_bonus_stats[stat_key] > 0:
		bonus_label.text = " +%d" % allocated_bonus_stats[stat_key]
	else:
		bonus_label.text = " +0"
		
	_update_summary_ui()

func _back_to_race_selection() -> void:
	status_layout.hide()
	_proceed_to_race_selection()

func _on_status_back_pressed() -> void:
	_back_to_race_selection()

func _on_status_next_pressed() -> void:
	_proceed_to_class_selection()

func _proceed_to_class_selection() -> void:
	status_layout.hide()
	if appearance_layout: appearance_layout.hide()
	class_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		class_title_label.text = "職業を選んでください"
	else:
		class_title_label.text = "Select a class."
		
	_build_class_status_preview()
	_build_class_columns()

func _build_class_status_preview() -> void:
	for child in class_status_preview.get_children():
		child.queue_free()
		
	class_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
		
		name_lbl.text = display_name
		
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
			
		total_lbl.text = " %d" % total_val
		
		class_status_preview.add_child(name_lbl)
		class_status_preview.add_child(total_lbl)

func _build_class_columns() -> void:
	for child in basic_class_column.get_children(): child.queue_free()
	for child in advanced_class_column.get_children(): child.queue_free()
		
	var current_locale = TranslationServer.get_locale().left(2)
	var basic_ids = ["Fighter", "Martial Artist", "Rogue", "Cleric", "Wizard", "Scholar"]
	var adv_ids = []
	if is_class_group_1_unlocked: adv_ids.append_array(["Ranger", "Knight", "Summoner"])
	if is_class_group_2_unlocked: adv_ids.append_array(["Reaper", "Samurai", "Warlock"])
	if is_class_group_3_unlocked: adv_ids.append_array(["Ninja", "Sage"])
	
	var basic_buttons = []
	var adv_buttons = []
	
	for cid in basic_ids:
		var btn = _create_class_button(cid, current_locale)
		basic_class_column.add_child(btn)
		basic_buttons.append(btn)
		
	for cid in adv_ids:
		var btn = _create_class_button(cid, current_locale)
		advanced_class_column.add_child(btn)
		adv_buttons.append(btn)
		
	for i in range(basic_buttons.size()):
		var prev = basic_buttons[(i - 1 + basic_buttons.size()) % basic_buttons.size()]
		var next = basic_buttons[(i + 1) % basic_buttons.size()]
		basic_buttons[i].focus_neighbor_top = prev.get_path()
		basic_buttons[i].focus_neighbor_bottom = next.get_path()
		if adv_buttons.size() > 0:
			var target_index = min(i, adv_buttons.size() - 1)
			basic_buttons[i].focus_neighbor_right = adv_buttons[target_index].get_path()
			
	for i in range(adv_buttons.size()):
		var prev = adv_buttons[(i - 1 + adv_buttons.size()) % adv_buttons.size()]
		var next = adv_buttons[(i + 1) % adv_buttons.size()]
		adv_buttons[i].focus_neighbor_top = prev.get_path()
		adv_buttons[i].focus_neighbor_bottom = next.get_path()
		var target_index = min(i, basic_buttons.size() - 1)
		adv_buttons[i].focus_neighbor_left = basic_buttons[target_index].get_path()
		
	if basic_buttons.size() > 0:
		basic_buttons[0].grab_focus.call_deferred()

func _create_class_button(class_id: String, locale: String) -> Button:
	var btn = Button.new()
	if locale == "ja":
		match class_id:
			"Fighter": btn.text = "戦士"
			"Rogue": btn.text = "盗賊"
			"Cleric": btn.text = "僧侶"
			"Wizard": btn.text = "魔法使い"
			"Ranger": btn.text = "狩人"
			"Knight": btn.text = "ナイト"
			"Samurai": btn.text = "侍"
			"Warlock": btn.text = "ワーロック"
			"Ninja": btn.text = "忍者"
			"Sage": btn.text = "賢者"
	else:
		btn.text = class_id
		
	btn.focus_mode = Control.FOCUS_ALL
	btn.pressed.connect(_on_class_selected.bind(class_id))
	return btn

func _on_class_selected(class_id: String) -> void:
	final_character_class = class_id
	_proceed_to_appearance_selection()

func _back_to_status_allocation() -> void:
	class_layout.hide()
	_proceed_to_status_allocation()


# ==============================================================================
# ★新設:7. 見た目(外見)選択画面の実装ロジック
# ==============================================================================

func _proceed_to_appearance_selection() -> void:
	class_layout.hide()
	appearance_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	# 1. ローカライズ文言の反映
	if current_locale == "ja":
		appearance_title_label.text = "見た目を選んでください"
		appearance_random_button.text = "ランダムで決める"
		appearance_back_button.text = "職業選択に戻る"
		appearance_next_button.text = "確認画面に進む"
	else:
		appearance_title_label.text = "Select your appearance."
		appearance_random_button.text = "Choose Randomly"
		appearance_back_button.text = "Back to Class"
		appearance_next_button.text = "Proceed"

	# 2. サマリー表示(名前・種族・職業)
	var display_race = final_character_race
	var display_class = final_character_class
	if current_locale == "ja":
		match final_character_race:
			"Human": display_race = "人間"
			"Elf": display_race = "エルフ"
			"Dwarf": display_race = "ドワーフ"
		match final_character_class:
			"Fighter": display_class = "戦士"
			"Rogue": display_class = "盗賊"
			"Cleric": display_class = "僧侶"
			"Wizard": display_class = "魔法使い"
			"Ranger": display_class = "狩人"
			"Knight": display_class = "ナイト"
			"Samurai": display_class = "侍"
			"Warlock": display_class = "ワーロック"
			"Ninja": display_class = "忍者"
			"Sage": display_class = "賢者"
			
	if current_locale == "ja":
		appearance_summary_label.text = "名前: %s   種族: %s   職業: %s" % [final_character_name, display_race, display_class]
	else:
		appearance_summary_label.text = "Name: %s   Race: %s   Class: %s" % [final_character_name, display_race, display_class]

	# 3. 左側の「ステータス合計値プレビュー」を表示
	_build_appearance_status_preview()
	
	# 4. 「image/face」フォルダ内を自動スキャンして全PNG画像をロード
	_scan_face_images_folder()
	
	# 5. 円形ホイール(カルーセル)UIの構築
	_build_appearance_wheel()
	
	# 6. フォーカス制御の初期化
	_setup_appearance_focus_neighbors()

# 左側:ステータスプレビュー構築(職業画面と同様)
func _build_appearance_status_preview() -> void:
	for child in appearance_status_preview.get_children():
		child.queue_free()
		
	appearance_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
		
		name_lbl.text = display_name
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
		total_lbl.text = " %d" % total_val
		
		appearance_status_preview.add_child(name_lbl)
		appearance_status_preview.add_child(total_lbl)

# image/face 内のPNGファイルを自動全検索するロジック(画像増減に対応)
func _scan_face_images_folder() -> void:
	face_image_paths.clear()
	var folder_path = "res://image/face/"
	
	if DirAccess.dir_exists_absolute(folder_path):
		var dir = DirAccess.open(folder_path)
		if dir:
			dir.list_dir_begin()
			var file_name = dir.get_next()
			while file_name != "":
				if not dir.current_is_dir() and (file_name.ends_with(".png") or file_name.ends_with(".PNG")):
					face_image_paths.append(folder_path + file_name)
				file_name = dir.get_next()
			dir.list_dir_end()
			
	# アルファベット・数字順でソートをかけて並びを綺麗にする
	face_image_paths.sort()

# 右側:円形カルーセルホイールの生成と配置の計算
func _build_appearance_wheel() -> void:
	for child in appearance_wheel_control.get_children():
		child.queue_free()
	face_sprites.clear()
	
	if face_image_paths.is_empty():
		var error_label = Label.new()
		error_label.text = "No PNG images found in res://image/face/"
		appearance_wheel_control.add_child(error_label)
		return
		
	# 画像ノードを全て生成して中央に重ねて配置(位置はプロセス内で動的計算)
	for i in range(face_image_paths.size()):
		var texture = load(face_image_paths[i])
		var tex_rect = TextureRect.new()
		tex_rect.texture = texture
		tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
		
		# 基本(奥側)サイズは 512x512 の約25%〜30%程度に縮小
		tex_rect.custom_minimum_size = Vector2(120, 120)
		tex_rect.size = Vector2(120, 120)
		
		# 座標計算の基準を真ん中に設定(回転軸のズレ防止)
		tex_rect.pivot_offset = tex_rect.size / 2
		
		appearance_wheel_control.add_child(tex_rect)
		face_sprites.append(tex_rect)
		
	# 初期インデックス設定
	current_image_index = 0
	target_wheel_angle = 0.0
	current_wheel_angle = 0.0
	
	# 初期配置を強制反映
	_update_wheel_positions()

# 毎フレームの描画処理で円形の座標とサイズ(奥行き)を更新
func _process(_delta: float) -> void:
	if appearance_layout.is_visible_in_tree() and face_image_paths.size() > 0:
		# スムーズな回転アニメーションをリアルタイムに更新
		_update_wheel_positions()

# 三角関数(sin/cos)を使用した立体感のある円形レイアウト計算
func _update_wheel_positions() -> void:
	var total_count = face_sprites.size()
	if total_count == 0: return
	
	# カルーセルの半径(ホイールエリアの大きさ等に応じて調整可能)
	var radius_x = 160.0
	var radius_y = 60.0 # 楕円にすることで奥行き感を演出します
	
	# ホイール自体の中心座標を取得
	var center = appearance_wheel_control.size / 2
	
	for i in range(total_count):
		# 各画像の現在の個別角度(ラジアン)を計算
		var angle_offset = (i * 2.0 * PI / total_count)
		var sprite_angle = current_wheel_angle + angle_offset
		
		# 1. 座標の計算(正面を「下側=手前」にするために + PI/2 調整)
		var cos_val = cos(sprite_angle + PI/2)
		var sin_val = sin(sprite_angle + PI/2)
		
		var pos_x = center.x + cos_val * radius_x
		var pos_y = center.y + sin_val * radius_y
		
		var sprite = face_sprites[i]
		# ピボットを考慮して中心に配置
		sprite.position = Vector2(pos_x - sprite.size.x / 2, pos_y - sprite.size.y / 2)
		
		# 2. 奥行き演出(手前(sinがプラス側)にいる画像ほど大きく、明るく表示)
		var depth_ratio = (sin_val + 1.0) / 2.0 # 0.0(真奥) 〜 1.0(真手前)
		
		# サイズ変更(奥は100px、一番手前の選択対象は180pxに拡大)
		var current_size = lerp(100.0, 180.0, depth_ratio)
		sprite.size = Vector2(current_size, current_size)
		sprite.pivot_offset = sprite.size / 2
		
		# 明るさ(奥を少し暗くマスクする)
		var brightness = lerp(0.4, 1.0, depth_ratio)
		sprite.modulate = Color(brightness, brightness, brightness, 1.0)
		
		# 重なり順(Zインデックスの代わりとして、手前にあるものほど描画を後にする)
		# 一番手前を最も高い位置(最前面)にするために、内部的な描画インデックスをソート
		sprite.z_index = int(depth_ratio * 100)

func _rotate_wheel(direction: int) -> void:
	var total_count = face_image_paths.size()
	if total_count == 0: return
	
	# 1. 新しいインデックスを計算
	current_image_index = (current_image_index + direction + total_count) % total_count
	
	# 2. ★重要:(1) 最初の画像と最後の画像の間をシームレスに逆回転・順回転させるロジック
	var step_angle = (2.0 * PI / total_count)
	
	# 本来あるべき理想の絶対角度
	var ideal_target_angle = -current_image_index * step_angle
	
	# 現在の目標角度と、理想の目標角度の「差分」を計算し、-PI 〜 +PI の範囲に丸める (最短経路)
	var angle_diff = ideal_target_angle - target_wheel_angle
	angle_diff = fposmod(angle_diff + PI, 2.0 * PI) - PI
	
	# 現在の目標角度に最短の差分を足すことで、常に1ステップ分だけシームレスに回る
	target_wheel_angle += angle_diff
	
	# Tweenによるなめらかな補間アニメーション
	if wheel_tween:
		wheel_tween.kill()
	wheel_tween = create_tween()
	wheel_tween.set_ease(Tween.EASE_OUT)
	wheel_tween.set_trans(Tween.TRANS_QUAD)
	wheel_tween.tween_property(self, "current_wheel_angle", target_wheel_angle, 0.2)

# 「ランダムで決める」用の回転関数もシームレスに対応
func _rotate_to_specific_index(idx: int) -> void:
	if face_image_paths.is_empty(): return
	
	current_image_index = idx
	var step_angle = (2.0 * PI / face_image_paths.size())
	var ideal_target_angle = -current_image_index * step_angle
	
	# ランダム移動時も、現在の位置から一番近いルートを選んで回転させる
	var angle_diff = ideal_target_angle - target_wheel_angle
	angle_diff = fposmod(angle_diff + PI, 2.0 * PI) - PI
	target_wheel_angle += angle_diff
	
	if wheel_tween: wheel_tween.kill()
	wheel_tween = create_tween()
	wheel_tween.set_ease(Tween.EASE_OUT)
	wheel_tween.set_trans(Tween.TRANS_CUBIC)
	wheel_tween.tween_property(self, "current_wheel_angle", target_wheel_angle, 0.4)

# フォーカス循環の設定(画像選択エリア ↔ 各種ボタン)
func _setup_appearance_focus_neighbors() -> void:
	var wheel_focus_node = appearance_wheel_control
	if appearance_wheel_control.focus_mode == Control.FOCUS_NONE:
		appearance_wheel_control.focus_mode = Control.FOCUS_ALL
		
	if not wheel_focus_node.focus_entered.is_connected(_on_wheel_focus_entered):
		wheel_focus_node.focus_entered.connect(_on_wheel_focus_entered)
	if not wheel_focus_node.focus_exited.is_connected(_on_wheel_focus_exited):
		wheel_focus_node.focus_exited.connect(_on_wheel_focus_exited)
		
	# ★重要:画像エリアにいるときは、左右キーでのフォーカス移動を「自分自身」にして封じ込める
	# これにより、左右キーは純粋に上記の回転ロジック(_input)側だけで処理されるようになります
	wheel_focus_node.focus_neighbor_left = wheel_focus_node.get_path()
	wheel_focus_node.focus_neighbor_right = wheel_focus_node.get_path()
	
	# 画像エリアから「下」を押したときだけ、下部のメニューボタンに移動できる
	wheel_focus_node.focus_neighbor_bottom = appearance_random_button.get_path()
	
	# 下部ボタン側のフォーカス制御(上を押したら画像エリアに戻る)
	appearance_random_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_random_button.focus_neighbor_left = appearance_next_button.get_path()
	appearance_random_button.focus_neighbor_right = appearance_back_button.get_path()
	
	appearance_back_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_back_button.focus_neighbor_left = appearance_random_button.get_path()
	appearance_back_button.focus_neighbor_right = appearance_next_button.get_path()
	
	appearance_next_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_next_button.focus_neighbor_left = appearance_back_button.get_path()
	appearance_next_button.focus_neighbor_right = appearance_random_button.get_path()
	
	wheel_focus_node.grab_focus.call_deferred()

func _on_wheel_focus_entered() -> void:
	is_wheel_focused = true
	# 選択中画像の手前に枠線を出す、などの視覚エフェクトを入れる場合はここに記述できます

func _on_wheel_focus_exited() -> void:
	is_wheel_focused = false

# 「ランダムで決める」ボタンが押されたとき
func _on_appearance_random_pressed() -> void:
	if face_image_paths.is_empty(): return
	var random_idx = randi() % face_image_paths.size()
	
	# 滑らかにその位置まで回転させる
	_rotate_to_specific_index(random_idx)

# 「職業選択に戻る」ボタン
func _on_appearance_back_pressed() -> void:
	_back_to_class_selection()

func _back_to_class_selection() -> void:
	if wheel_tween: wheel_tween.kill()
	appearance_layout.hide()
	_proceed_to_class_selection()

# 「確認画面に進む」ボタン
func _on_appearance_next_pressed() -> void:
	if face_image_paths.is_empty():
		print("エラー: 画像が1枚もないため進めません")
		return
		
	# 仕様: 見た目画像を選択しないで(もしくは初期状態でそのまま)進む場合は現在の真手前の画像が自動適用
	final_character_image_path = face_image_paths[current_image_index]
	print("決定された見た目画像パス: ", final_character_image_path)
	
	_proceed_to_confirmation_screen()

# ★新設:最終確認画面への遷移関数(プレースホルダー)
func _proceed_to_confirmation_screen() -> void:
	appearance_layout.hide()
	print("最終ステップ:『作成キャラクター確認画面』へ進みます!")

キャラクター作成用スクリプト (character_creation.gd) の上記のように変更してください。これでキャラクターの見た目を決めることができます。

この記事の一番最初にある gif 画像をクリックすると「どのように見た目を決めるのか」がわかります。

「確認画面」を表示する

この「キャラクター作成」工程の最後、確認画面です。同様にノードを追加していきます。

ノード構成の追加

  • CanvasLayer (CanvasLayer)
    • (他は省略)
    • ChracterCreation (Control)
      • Background (ColorRect)
      • NameLayout (VBoxContainer)
      • RaceLayout (VBoxContainer)
      • StatusLayout (VBoxContainer)
      • ClassLayout (VBoxContainer)
      • AppearanceLayout (VBoxContainer)
      • ConfirmationLayout (VBoxContainer) 新規
        • ConfirmTitleLabel (Label) 新規
        • ConfirmSummaryLabel (Label) 新規
        • ConfirmMainContent (HBoxContainer) 新規
          • ConfirmStatusPreview (GridContainer) 新規
          • ConfirmFacePreview (TextureRect) 新規
        • ConfirmBottomMenu (HBoxContainer) 新規
          • ConfirmBackButton (Button) 新規
          • ConfirmCreateButton (Button) 新規
      • MessageLayout (VBoxContainer) 新規
        • MessageTextLabel (Label) 新規
ConfirmationLayout (VBoxContainer)新規作成 (非表示)
ConfirmTitleLabel (Label)このキャラクターを作成しますか?
ConfirmSummaryLabel (Label)名前・種族・職業の表示用
ConfirmMainContent (HBoxContainer)画面中央 (左右分割)
ConfirmStatusPreview (GridContainer)ステータス合計値 (列数: 2)
ConfirmFacePreview (TextureRect)選択した見た目画像 サイズ (Custom Minimum Size) を大きめ (例: 256×256) など確保しておいてください。
ConfirmBottomMenu (HBoxContainer)画面最下部
ConfirmBackButton (Button)見た目選択に戻る
ConfirmCreateButton (Button)作成する
MessageLayout (VBoxContainer)新規追加 (非表示)
MessageTextLabel (Label)名前〇〇がギルドに登録されました

ノード構成を上記の通りに追加します。ConfirmFacePreview のサイズは環境に合わせて調整してください。

キャラクター作成用コード修正

extends Control

signal creation_canceled

# 子ノードへの参照
@onready var title_label: Label = $NameLayout/TitleLabel
@onready var name_input_field: LineEdit = $NameLayout/NameInputField
@onready var keyboard_grid: GridContainer = $NameLayout/KeyboardGrid
@onready var backspace_button: Button = $NameLayout/BottomMenu/BackspaceButton
@onready var confirm_button: Button = $NameLayout/BottomMenu/ConfirmButton

@onready var race_layout: VBoxContainer = $RaceLayout
@onready var race_title_label: Label = $RaceLayout/RaceTitleLabel
@onready var race_button_container: VBoxContainer = $RaceLayout/RaceButtonContainer

@onready var status_layout: VBoxContainer = $StatusLayout
@onready var status_instruction_label: Label = $StatusLayout/StatusInstructionLabel
@onready var character_summary_label: Label = $StatusLayout/CharacterSummaryLabel
@onready var status_grid_container: GridContainer = $StatusLayout/StatusGridContainer
@onready var status_back_button: Button = $StatusLayout/StatusBottomMenu/StatusBackButton
@onready var status_next_button: Button = $StatusLayout/StatusBottomMenu/StatusNextButton

@onready var class_layout: VBoxContainer = $ClassLayout
@onready var class_title_label: Label = $ClassLayout/ClassTitleLabel
@onready var class_status_preview: GridContainer = $ClassLayout/ClassMainContent/ClassStatusPreview
@onready var basic_class_column: VBoxContainer = $ClassLayout/ClassMainContent/ClassSelectionArea/BasicClassColumn
@onready var advanced_class_column: VBoxContainer = $ClassLayout/ClassMainContent/ClassSelectionArea/AdvancedClassColumn

@onready var appearance_layout: VBoxContainer = $AppearanceLayout
@onready var appearance_title_label: Label = $AppearanceLayout/AppearanceTitleLabel
@onready var appearance_summary_label: Label = $AppearanceLayout/AppearanceSummaryLabel
@onready var appearance_status_preview: GridContainer = $AppearanceLayout/AppearanceMainContent/AppearanceStatusPreview
@onready var appearance_wheel_control: Control = $AppearanceLayout/AppearanceMainContent/AppearanceWheelControl
@onready var appearance_random_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceRandomButton
@onready var appearance_back_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceBackButton
@onready var appearance_next_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceNextButton

# ★追加:「8. 確認画面・メッセージ画面」用の子ノード参照
@onready var confirmation_layout: VBoxContainer = $ConfirmationLayout
@onready var confirm_title_label: Label = $ConfirmationLayout/ConfirmTitleLabel
@onready var confirm_summary_label: Label = $ConfirmationLayout/ConfirmSummaryLabel
@onready var confirm_status_preview: GridContainer = $ConfirmationLayout/ConfirmMainContent/ConfirmStatusPreview
@onready var confirm_face_preview: TextureRect = $ConfirmationLayout/ConfirmMainContent/ConfirmFacePreview
@onready var confirm_back_button: Button = $ConfirmationLayout/ConfirmBottomMenu/ConfirmBackButton
@onready var confirm_create_button: Button = $ConfirmationLayout/ConfirmBottomMenu/ConfirmCreateButton

@onready var message_layout: VBoxContainer = $MessageLayout
@onready var message_text_label: Label = $MessageLayout/MessageTextLabel

# キーボードの文字データ定義
const KEYS_JA = [
	"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ",
	"さ", "し", "す", "せ", "そ", "た", "ち", "つ", "て", "と",
	"な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ",
	"ま", "み", "む", "め", "も", "や", "ゆ", "よ", "わ", "を",
	"ら", "り", "る", "れ", "ろ", "ん", "っ", "ゃ", "ゅ", "ょ",
	"が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ",
	"だ", "ぢ", "づ", "で", "ど", "ば", "び", "ぶ", "べ", "ぼ",
	"ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ー", "〜", "、", "。", " "
]

const KEYS_EN = [
	"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
	"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
	"U", "V", "W", "X", "Y", "Z", " ", " ", " ", " ",
	"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
	"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
	"u", "v", "w", "x", "y", "z", "-", "_", "!", "?"
]

var current_max_length: int = 16
var first_button: Button = null 

# --- キャラクターの作成データ保持用 ---
var final_character_name: String = ""
var final_character_race: String = ""
var final_character_class: String = ""
var final_character_image_path: String = "" 

# --- 種族の初期ステータス定義 ---
const RACE_BASE_STATS = {
	"Human": { "LV": 1, "STR": 9, "DEX": 9, "VIT": 9, "AGI": 9, "INT": 9, "MND": 9, "LUC": 9, "HP_MIN": 11, "HP_MAX": 18, "MP_MIN": 9, "MP_MAX": 16 },
	"Elf":   { "LV": 1, "STR": 6, "DEX": 12, "VIT": 6, "AGI": 14, "INT": 14, "MND": 14, "LUC": 6, "HP_MIN": 7, "HP_MAX": 14, "MP_MIN": 15, "MP_MAX": 22 },
	"Dwarf": { "LV": 1, "STR": 14, "DEX": 14, "VIT": 14, "AGI": 6, "INT": 6, "MND": 6, "LUC": 12, "HP_MIN": 15, "HP_MAX": 22, "MP_MIN": 7, "MP_MAX": 14 }
}

const ALLOCATABLE_STATS = ["STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]

var current_base_stats = {}
var allocated_bonus_stats = { "STR": 0, "DEX": 0, "VIT": 0, "AGI": 0, "INT": 0, "MND": 0, "LUC": 0 }
var initial_bonus_points: int = 0
var remaining_bonus_points: int = 0

var stat_labels_dictionary = {}
var current_focused_stat_button: Button = null

# --- ゲーム進行フラグ ---
var is_advanced_races_unlocked: bool = false 
var is_class_group_1_unlocked: bool = false 
var is_class_group_2_unlocked: bool = false 
var is_class_group_3_unlocked: bool = false 

# --- 見た目(外見)選択用の変数群 ---
var face_image_paths: Array[String] = [] 
var face_sprites: Array[TextureRect] = [] 
var current_image_index: int = 0 
var target_wheel_angle: float = 0.0 
var current_wheel_angle: float = 0.0 
var is_wheel_focused: bool = false 
var wheel_tween: Tween = null

func _ready() -> void:
	if backspace_button and not backspace_button.pressed.is_connected(_on_backspace_button_pressed):
		backspace_button.pressed.connect(_on_backspace_button_pressed)
	if confirm_button and not confirm_button.pressed.is_connected(_on_confirm_button_pressed):
		confirm_button.pressed.connect(_on_confirm_button_pressed)
		
	if status_back_button and not status_back_button.pressed.is_connected(_on_status_back_pressed):
		status_back_button.pressed.connect(_on_status_back_pressed)
	if status_next_button and not status_next_button.pressed.is_connected(_on_status_next_pressed):
		status_next_button.pressed.connect(_on_status_next_pressed)
		
	if appearance_random_button and not appearance_random_button.pressed.is_connected(_on_appearance_random_pressed):
		appearance_random_button.pressed.connect(_on_appearance_random_pressed)
	if appearance_back_button and not appearance_back_button.pressed.is_connected(_on_appearance_back_pressed):
		appearance_back_button.pressed.connect(_on_appearance_back_pressed)
	if appearance_next_button and not appearance_next_button.pressed.is_connected(_on_appearance_next_pressed):
		appearance_next_button.pressed.connect(_on_appearance_next_pressed)
		
	# ★追加:確認画面のボタン接続
	if confirm_back_button and not confirm_back_button.pressed.is_connected(_on_confirm_back_pressed):
		confirm_back_button.pressed.connect(_on_confirm_back_pressed)
	if confirm_create_button and not confirm_create_button.pressed.is_connected(_on_confirm_create_pressed):
		confirm_create_button.pressed.connect(_on_confirm_create_pressed)
		
	hide()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	if message_layout: message_layout.hide()

func _input(event: InputEvent) -> void:
	if not is_visible_in_tree():
		return
		
	if event.is_action_pressed("ui_cancel"):
		if $NameLayout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if name_input_field.text.length() > 0:
				_on_backspace_button_pressed()
			else:
				_cancel_and_return_to_guild()
				
		elif race_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_name_input()
			
		elif status_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if current_focused_stat_button and current_focused_stat_button.has_meta("stat_key"):
				var stat_key = current_focused_stat_button.get_meta("stat_key")
				if allocated_bonus_stats[stat_key] > 0:
					_modify_stat_point(stat_key, -1)
					return
			
			var total_spent = 0
			for val in allocated_bonus_stats.values():
				total_spent += val
			if total_spent == 0:
				_back_to_race_selection()
				
		elif class_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_status_allocation()
			
		elif appearance_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_class_selection()
			
		# 確認画面でのキャンセル挙動(見た目選択に戻る)
		elif confirmation_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_appearance_selection()

	# 見た目画面での操作制御
	if appearance_layout.is_visible_in_tree() and is_wheel_focused and face_image_paths.size() > 0:
		if event.is_action_pressed("ui_right", true) or event.is_action("ui_right"):
			if event.is_pressed():
				get_viewport().set_input_as_handled()
				_rotate_wheel(1)
		elif event.is_action_pressed("ui_left", true) or event.is_action("ui_left"):
			if event.is_pressed():
				get_viewport().set_input_as_handled()
				_rotate_wheel(-1)
		elif event.is_action_pressed("ui_accept"):
			get_viewport().set_input_as_handled()
			if appearance_next_button:
				appearance_next_button.grab_focus()
				
	# 登録メッセージ画面が表示されている時の挙動
	elif message_layout.is_visible_in_tree():
		if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"):
			get_viewport().set_input_as_handled()
			_return_to_guild_menu()

func activate_name_input() -> void:
	name_input_field.text = ""
	show()
	$NameLayout.show()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	if message_layout: message_layout.hide()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		current_max_length = 8
		_setup_software_keyboard(KEYS_JA)
		if title_label: title_label.text = "名前を入力してください"
		if backspace_button: backspace_button.text = "一字消す"
		if confirm_button: confirm_button.text = "決定"
	else:
		current_max_length = 16
		_setup_software_keyboard(KEYS_EN)
		if title_label: title_label.text = "Enter your name."
		if backspace_button: backspace_button.text = "BackSpace"
		if confirm_button: confirm_button.text = "Confirm"
		
	name_input_field.max_length = current_max_length
	keyboard_grid.columns = 10
	
	if first_button:
		first_button.grab_focus.call_deferred()

func _setup_software_keyboard(keys_array: Array) -> void:
	first_button = null
	for child in keyboard_grid.get_children():
		child.queue_free()
		
	for i in range(keys_array.size()):
		var key = keys_array[i]
		if key == " " and TranslationServer.get_locale().left(2) == "en":
			var spacer = Control.new()
			keyboard_grid.add_child(spacer)
			continue
			
		var btn = Button.new()
		btn.text = key
		btn.custom_minimum_size = Vector2(40, 40) 
		btn.focus_mode = Control.FOCUS_ALL 
		btn.pressed.connect(_on_key_pressed.bind(key))
		keyboard_grid.add_child(btn)
		
		if first_button == null:
			first_button = btn

func _on_key_pressed(char_text: String) -> void:
	if name_input_field.text.length() < current_max_length:
		name_input_field.text += char_text

func _on_backspace_button_pressed() -> void:
	if name_input_field.text.length() > 0:
		name_input_field.text = name_input_field.text.left(name_input_field.text.length() - 1)

func _on_confirm_button_pressed() -> void:
	var cleaned_name = name_input_field.text.strip_edges()
	if cleaned_name.is_empty():
		return
		
	final_character_name = cleaned_name
	_proceed_to_race_selection()

func _proceed_to_race_selection() -> void:
	$NameLayout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	if message_layout: message_layout.hide()
	race_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	if current_locale == "ja":
		race_title_label.text = "種族を選択してください"
	else:
		race_title_label.text = "Select a race."
		
	for child in race_button_container.get_children():
		child.queue_free()
		
	var available_races = ["Human"]
	if is_advanced_races_unlocked:
		available_races.append("Elf")
		available_races.append("Dwarf")
		
	var r_buttons = []
	for race_id in available_races:
		var btn = Button.new()
		if current_locale == "ja":
			match race_id:
				"Human": btn.text = "人間"
				"Elf": btn.text = "エルフ"
				"Dwarf": btn.text = "ドワーフ"
		else:
			btn.text = race_id
			
		btn.focus_mode = Control.FOCUS_ALL
		btn.pressed.connect(_on_race_selected.bind(race_id))
		race_button_container.add_child(btn)
		r_buttons.append(btn)
		
	for i in range(r_buttons.size()):
		var prev = r_buttons[(i - 1 + r_buttons.size()) % r_buttons.size()]
		var next = r_buttons[(i + 1) % r_buttons.size()]
		r_buttons[i].focus_neighbor_top = prev.get_path()
		r_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if r_buttons.size() > 0:
		r_buttons[0].grab_focus.call_deferred()

func _on_race_selected(race_id: String) -> void:
	final_character_race = race_id
	_proceed_to_status_allocation()

func _back_to_name_input() -> void:
	race_layout.hide()
	$NameLayout.show()
	if first_button:
		first_button.grab_focus.call_deferred()

func _cancel_and_return_to_guild() -> void:
	hide()
	creation_canceled.emit()

func _proceed_to_status_allocation() -> void:
	race_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	if message_layout: message_layout.hide()
	status_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		status_instruction_label.text = "ボーナスポイントを振ってください"
		status_back_button.text = "ひとつ前に戻る"
		status_next_button.text = "次に進む"
	else:
		status_instruction_label.text = "Please allocate your bonus points."
		status_back_button.text = "Back"
		status_next_button.text = "Next"

	var base = RACE_BASE_STATS[final_character_race]
	current_base_stats = {
		"LV": base["LV"],
		"STR": base["STR"],
		"DEX": base["DEX"],
		"VIT": base["VIT"],
		"AGI": base["AGI"],
		"INT": base["INT"],
		"MND": base["MND"],
		"LUC": base["LUC"],
		"HP": randi_range(base["HP_MIN"], base["HP_MAX"]),
		"MP": randi_range(base["MP_MIN"], base["MP_MAX"])
	}
	
	for stat in ALLOCATABLE_STATS:
		allocated_bonus_stats[stat] = 0
		
	initial_bonus_points = randi_range(5, 12)
	remaining_bonus_points = initial_bonus_points
	
	_build_status_grid()
	_update_summary_ui()

func _build_status_grid() -> void:
	for child in status_grid_container.get_children():
		child.queue_free()
		
	stat_labels_dictionary.clear()
	status_grid_container.columns = 4
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	_add_static_stat_row("Lv", current_base_stats["LV"])
	_add_static_stat_row("HP", current_base_stats["HP"])
	_add_static_stat_row("MP", current_base_stats["MP"])
	
	var focusable_buttons = []
	for stat_key in ALLOCATABLE_STATS:
		var display_name = stat_key
		if current_locale == "ja":
			match stat_key:
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
				
		var name_lbl = Label.new()
		name_lbl.text = display_name
		status_grid_container.add_child(name_lbl)
		
		var base_lbl = Label.new()
		base_lbl.text = str(current_base_stats[stat_key])
		status_grid_container.add_child(base_lbl)
		
		var bonus_lbl = Label.new()
		bonus_lbl.text = " +0"
		status_grid_container.add_child(bonus_lbl)
		stat_labels_dictionary[stat_key] = bonus_lbl
		
		var opt_btn = Button.new()
		opt_btn.text = " ◀ ▶ "
		opt_btn.focus_mode = Control.FOCUS_ALL
		opt_btn.set_meta("stat_key", stat_key)
		
		opt_btn.pressed.connect(_on_stat_button_pressed.bind(stat_key))
		opt_btn.focus_entered.connect(func(): current_focused_stat_button = opt_btn)
		
		status_grid_container.add_child(opt_btn)
		focusable_buttons.append(opt_btn)
		
	focusable_buttons.append(status_back_button)
	focusable_buttons.append(status_next_button)
	
	for i in range(focusable_buttons.size()):
		var prev = focusable_buttons[(i - 1 + focusable_buttons.size()) % focusable_buttons.size()]
		var next = focusable_buttons[(i + 1) % focusable_buttons.size()]
		focusable_buttons[i].focus_neighbor_top = prev.get_path()
		focusable_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if focusable_buttons.size() > 0:
		focusable_buttons[0].grab_focus.call_deferred()

func _add_static_stat_row(stat_title: String, val: int) -> void:
	var name_lbl = Label.new()
	name_lbl.text = stat_title
	var val_lbl = Label.new()
	val_lbl.text = str(val)
	status_grid_container.add_child(name_lbl)
	status_grid_container.add_child(val_lbl)
	status_grid_container.add_child(Label.new())
	status_grid_container.add_child(Label.new())

func _update_summary_ui() -> void:
	var current_locale = TranslationServer.get_locale().left(2)
	var display_race = final_character_race
	if current_locale == "ja":
		match final_character_race:
			"Human": display_race = "人間"
			"Elf": display_race = "エルフ"
			"Dwarf": display_race = "ドワーフ"
			
	if current_locale == "ja":
		character_summary_label.text = "名前: %s   種族: %s   残りボーナスポイント: %d" % [final_character_name, display_race, remaining_bonus_points]
	else:
		character_summary_label.text = "Name: %s   Race: %s   Bonus Points: %d" % [final_character_name, display_race, remaining_bonus_points]

func _on_stat_button_pressed(stat_key: String) -> void:
	if remaining_bonus_points > 0:
		_modify_stat_point(stat_key, 1)

func _modify_stat_point(stat_key: String, amount: int) -> void:
	allocated_bonus_stats[stat_key] += amount
	remaining_bonus_points -= amount
	
	var bonus_label = stat_labels_dictionary[stat_key] as Label
	if allocated_bonus_stats[stat_key] > 0:
		bonus_label.text = " +%d" % allocated_bonus_stats[stat_key]
	else:
		bonus_label.text = " +0"
		
	_update_summary_ui()

func _back_to_race_selection() -> void:
	status_layout.hide()
	_proceed_to_race_selection()

func _on_status_back_pressed() -> void:
	_back_to_race_selection()

func _on_status_next_pressed() -> void:
	_proceed_to_class_selection()

func _proceed_to_class_selection() -> void:
	status_layout.hide()
	if appearance_layout: appearance_layout.hide()
	class_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		class_title_label.text = "職業を選んでください"
	else:
		class_title_label.text = "Select a class."
		
	_build_class_status_preview()
	_build_class_columns()

func _build_class_status_preview() -> void:
	for child in class_status_preview.get_children():
		child.queue_free()
		
	class_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
		
		name_lbl.text = display_name
		
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
			
		total_lbl.text = " %d" % total_val
		
		class_status_preview.add_child(name_lbl)
		class_status_preview.add_child(total_lbl)

func _build_class_columns() -> void:
	for child in basic_class_column.get_children(): child.queue_free()
	for child in advanced_class_column.get_children(): child.queue_free()
		
	var current_locale = TranslationServer.get_locale().left(2)
	var basic_ids = ["Fighter", "Martial Artist", "Rogue", "Cleric", "Wizard", "Scholar"]
	var adv_ids = []
	if is_class_group_1_unlocked: adv_ids.append_array(["Ranger", "Knight", "Summoner"])
	if is_class_group_2_unlocked: adv_ids.append_array(["Reaper", "Samurai", "Warlock"])
	if is_class_group_3_unlocked: adv_ids.append_array(["Ninja", "Sage"])
	
	var basic_buttons = []
	var adv_buttons = []
	
	for cid in basic_ids:
		var btn = _create_class_button(cid, current_locale)
		basic_class_column.add_child(btn)
		basic_buttons.append(btn)
		
	for cid in adv_ids:
		var btn = _create_class_button(cid, current_locale)
		advanced_class_column.add_child(btn)
		adv_buttons.append(btn)
		
	for i in range(basic_buttons.size()):
		var prev = basic_buttons[(i - 1 + basic_buttons.size()) % basic_buttons.size()]
		var next = basic_buttons[(i + 1) % basic_buttons.size()]
		basic_buttons[i].focus_neighbor_top = prev.get_path()
		basic_buttons[i].focus_neighbor_bottom = next.get_path()
		if adv_buttons.size() > 0:
			var target_index = min(i, adv_buttons.size() - 1)
			basic_buttons[i].focus_neighbor_right = adv_buttons[target_index].get_path()
			
	for i in range(adv_buttons.size()):
		var prev = adv_buttons[(i - 1 + adv_buttons.size()) % adv_buttons.size()]
		var next = adv_buttons[(i + 1) % adv_buttons.size()]
		adv_buttons[i].focus_neighbor_top = prev.get_path()
		adv_buttons[i].focus_neighbor_bottom = next.get_path()
		var target_index = min(i, basic_buttons.size() - 1)
		adv_buttons[i].focus_neighbor_left = basic_buttons[target_index].get_path()
		
	if basic_buttons.size() > 0:
		basic_buttons[0].grab_focus.call_deferred()

func _create_class_button(class_id: String, locale: String) -> Button:
	var btn = Button.new()
	if locale == "ja":
		match class_id:
			"Fighter": btn.text = "戦士"
			"Rogue": btn.text = "盗賊"
			"Cleric": btn.text = "僧侶"
			"Wizard": btn.text = "魔法使い"
			"Ranger": btn.text = "狩人"
			"Knight": btn.text = "ナイト"
			"Samurai": btn.text = "侍"
			"Warlock": btn.text = "ワーロック"
			"Ninja": btn.text = "忍者"
			"Sage": btn.text = "賢者"
	else:
		btn.text = class_id
		
	btn.focus_mode = Control.FOCUS_ALL
	btn.pressed.connect(_on_class_selected.bind(class_id))
	return btn

func _on_class_selected(class_id: String) -> void:
	final_character_class = class_id
	_proceed_to_appearance_selection()

func _back_to_status_allocation() -> void:
	class_layout.hide()
	_proceed_to_status_allocation()

func _proceed_to_appearance_selection() -> void:
	class_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	appearance_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		appearance_title_label.text = "見た目を選んでください"
		appearance_random_button.text = "ランダムで決める"
		appearance_back_button.text = "職業選択に戻る"
		appearance_next_button.text = "確認画面に進む"
	else:
		appearance_title_label.text = "Select your appearance."
		appearance_random_button.text = "Choose Randomly"
		appearance_back_button.text = "Back to Class"
		appearance_next_button.text = "Proceed"

	_update_appearance_summary_text(current_locale)
	_build_appearance_status_preview()
	_scan_face_images_folder()
	_build_appearance_wheel()
	_setup_appearance_focus_neighbors()

func _update_appearance_summary_text(current_locale: String) -> void:
	var display_race = final_character_race
	var display_class = final_character_class
	if current_locale == "ja":
		match final_character_race:
			"Human": display_race = "人間"
			"Elf": display_race = "エルフ"
			"Dwarf": display_race = "ドワーフ"
		match final_character_class:
			"Fighter": display_class = "戦士"
			"Rogue": display_class = "盗賊"
			"Cleric": display_class = "僧侶"
			"Wizard": display_class = "魔法使い"
			"Ranger": display_class = "狩人"
			"Knight": display_class = "ナイト"
			"Samurai": display_class = "侍"
			"Warlock": display_class = "ワーロック"
			"Ninja": display_class = "忍者"
			"Sage": display_class = "賢者"
			
	if current_locale == "ja":
		appearance_summary_label.text = "名前: %s   種族: %s   職業: %s" % [final_character_name, display_race, display_class]
	else:
		appearance_summary_label.text = "Name: %s   Race: %s   Class: %s" % [final_character_name, display_race, display_class]

func _build_appearance_status_preview() -> void:
	for child in appearance_status_preview.get_children():
		child.queue_free()
		
	appearance_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
		
		name_lbl.text = display_name
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
		total_lbl.text = " %d" % total_val
		
		appearance_status_preview.add_child(name_lbl)
		appearance_status_preview.add_child(total_lbl)

func _scan_face_images_folder() -> void:
	face_image_paths.clear()
	var folder_path = "res://image/face/"
	if DirAccess.dir_exists_absolute(folder_path):
		var dir = DirAccess.open(folder_path)
		if dir:
			dir.list_dir_begin()
			var file_name = dir.get_next()
			while file_name != "":
				if not dir.current_is_dir() and (file_name.ends_with(".png") or file_name.ends_with(".PNG")):
					face_image_paths.append(folder_path + file_name)
				file_name = dir.get_next()
			dir.list_dir_end()
	face_image_paths.sort()

func _build_appearance_wheel() -> void:
	for child in appearance_wheel_control.get_children():
		child.queue_free()
	face_sprites.clear()
	
	if face_image_paths.is_empty():
		var error_label = Label.new()
		error_label.text = "No PNG images found in res://image/face/"
		appearance_wheel_control.add_child(error_label)
		return
		
	for i in range(face_image_paths.size()):
		var texture = load(face_image_paths[i])
		var tex_rect = TextureRect.new()
		tex_rect.texture = texture
		tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
		tex_rect.custom_minimum_size = Vector2(120, 120)
		tex_rect.size = Vector2(120, 120)
		tex_rect.pivot_offset = tex_rect.size / 2
		appearance_wheel_control.add_child(tex_rect)
		face_sprites.append(tex_rect)
		
	current_image_index = 0
	target_wheel_angle = 0.0
	current_wheel_angle = 0.0
	_update_wheel_positions()

func _process(_delta: float) -> void:
	if appearance_layout.is_visible_in_tree() and face_image_paths.size() > 0:
		_update_wheel_positions()

func _update_wheel_positions() -> void:
	var total_count = face_sprites.size()
	if total_count == 0: return
	var radius_x = 160.0
	var radius_y = 60.0 
	var center = appearance_wheel_control.size / 2
	
	for i in range(total_count):
		var angle_offset = (i * 2.0 * PI / total_count)
		var sprite_angle = current_wheel_angle + angle_offset
		var cos_val = cos(sprite_angle + PI/2)
		var sin_val = sin(sprite_angle + PI/2)
		
		var pos_x = center.x + cos_val * radius_x
		var pos_y = center.y + sin_val * radius_y
		var sprite = face_sprites[i]
		sprite.position = Vector2(pos_x - sprite.size.x / 2, pos_y - sprite.size.y / 2)
		
		var depth_ratio = (sin_val + 1.0) / 2.0
		var current_size = lerp(100.0, 180.0, depth_ratio)
		sprite.size = Vector2(current_size, current_size)
		sprite.pivot_offset = sprite.size / 2
		
		var brightness = lerp(0.4, 1.0, depth_ratio)
		sprite.modulate = Color(brightness, brightness, brightness, 1.0)
		sprite.z_index = int(depth_ratio * 100)

func _rotate_wheel(direction: int) -> void:
	var total_count = face_image_paths.size()
	if total_count == 0: return
	
	current_image_index = (current_image_index + direction + total_count) % total_count
	var step_angle = (2.0 * PI / total_count)
	var ideal_target_angle = -current_image_index * step_angle
	
	var angle_diff = ideal_target_angle - target_wheel_angle
	angle_diff = fposmod(angle_diff + PI, 2.0 * PI) - PI
	target_wheel_angle += angle_diff
	
	if wheel_tween: wheel_tween.kill()
	wheel_tween = create_tween()
	wheel_tween.set_ease(Tween.EASE_OUT)
	wheel_tween.set_trans(Tween.TRANS_QUAD)
	wheel_tween.tween_property(self, "current_wheel_angle", target_wheel_angle, 0.2)

func _rotate_to_specific_index(idx: int) -> void:
	if face_image_paths.is_empty(): return
	current_image_index = idx
	var step_angle = (2.0 * PI / face_image_paths.size())
	var ideal_target_angle = -current_image_index * step_angle
	var angle_diff = ideal_target_angle - target_wheel_angle
	angle_diff = fposmod(angle_diff + PI, 2.0 * PI) - PI
	target_wheel_angle += angle_diff
	
	if wheel_tween: wheel_tween.kill()
	wheel_tween = create_tween()
	wheel_tween.set_ease(Tween.EASE_OUT)
	wheel_tween.set_trans(Tween.TRANS_CUBIC)
	wheel_tween.tween_property(self, "current_wheel_angle", target_wheel_angle, 0.4)

func _setup_appearance_focus_neighbors() -> void:
	var wheel_focus_node = appearance_wheel_control
	if appearance_wheel_control.focus_mode == Control.FOCUS_NONE:
		appearance_wheel_control.focus_mode = Control.FOCUS_ALL
		
	if not wheel_focus_node.focus_entered.is_connected(_on_wheel_focus_entered):
		wheel_focus_node.focus_entered.connect(_on_wheel_focus_entered)
	if not wheel_focus_node.focus_exited.is_connected(_on_wheel_focus_exited):
		wheel_focus_node.focus_exited.connect(_on_wheel_focus_exited)
		
	wheel_focus_node.focus_neighbor_left = wheel_focus_node.get_path()
	wheel_focus_node.focus_neighbor_right = wheel_focus_node.get_path()
	wheel_focus_node.focus_neighbor_bottom = appearance_random_button.get_path()
	
	appearance_random_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_random_button.focus_neighbor_left = appearance_next_button.get_path()
	appearance_random_button.focus_neighbor_right = appearance_back_button.get_path()
	
	appearance_back_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_back_button.focus_neighbor_left = appearance_random_button.get_path()
	appearance_back_button.focus_neighbor_right = appearance_next_button.get_path()
	
	appearance_next_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_next_button.focus_neighbor_left = appearance_back_button.get_path()
	appearance_next_button.focus_neighbor_right = appearance_random_button.get_path()
	
	wheel_focus_node.grab_focus.call_deferred()

func _on_wheel_focus_entered() -> void: is_wheel_focused = true
func _on_wheel_focus_exited() -> void: is_wheel_focused = false

func _on_appearance_random_pressed() -> void:
	if face_image_paths.is_empty(): return
	_rotate_to_specific_index(randi() % face_image_paths.size())

func _on_appearance_back_pressed() -> void:
	_back_to_class_selection()

func _back_to_class_selection() -> void:
	if wheel_tween: wheel_tween.kill()
	appearance_layout.hide()
	_proceed_to_class_selection()

func _on_appearance_next_pressed() -> void:
	if face_image_paths.is_empty(): return
	final_character_image_path = face_image_paths[current_image_index]
	_proceed_to_confirmation_screen()


# ==============================================================================
# ★新設:8. 確認画面(Confirmation Screen)の実装ロジック
# ==============================================================================

func _proceed_to_confirmation_screen() -> void:
	appearance_layout.hide()
	confirmation_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	# 1. 見出し・ボタンのローカライズ設定
	if current_locale == "ja":
		confirm_title_label.text = "このキャラクターを作成しますか?"
		confirm_back_button.text = "見た目選択に戻る"
		confirm_create_button.text = "作成する"
	else:
		confirm_title_label.text = "Create this character?"
		confirm_back_button.text = "Back to Appearance"
		confirm_create_button.text = "Create"
		
	# 2. サマリー表示のコピー(名前・種族・職業)
	confirm_summary_label.text = appearance_summary_label.text
	
	# 3. 左側:ステータス合計値表示の構築
	_build_confirm_status_preview()
	
	# 4. 右側:選択した見た目画像を表示(★サイズを 256x256 に統一)
	if FileAccess.file_exists(final_character_image_path):
		confirm_face_preview.texture = load(final_character_image_path)
		confirm_face_preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
		confirm_face_preview.custom_minimum_size = Vector2(256, 256)
		confirm_face_preview.size = Vector2(256, 256)
	
	# 5. フォーカス移動ルート設定
	confirm_back_button.focus_neighbor_left = confirm_create_button.get_path()
	confirm_back_button.focus_neighbor_right = confirm_create_button.get_path()
	confirm_create_button.focus_neighbor_left = confirm_back_button.get_path()
	confirm_create_button.focus_neighbor_right = confirm_back_button.get_path()
	
	# 最初から「作成する」ボタンにフォーカスを当てる
	confirm_create_button.grab_focus.call_deferred()

# 左側:ステータスの表示(合計値)
func _build_confirm_status_preview() -> void:
	for child in confirm_status_preview.get_children():
		child.queue_free()
		
	confirm_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
				
		name_lbl.text = display_name
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
		total_lbl.text = " %d" % total_val
		
		confirm_status_preview.add_child(name_lbl)
		confirm_status_preview.add_child(total_lbl)

# 「見た目選択に戻る」ボタン処理
func _on_confirm_back_pressed() -> void:
	_back_to_appearance_selection()

func _back_to_appearance_selection() -> void:
	confirmation_layout.hide()
	_proceed_to_appearance_selection()

# 「作成する」ボタン処理
func _on_confirm_create_pressed() -> void:
	confirmation_layout.hide()
	message_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		message_text_label.text = "名前【%s】がギルドに登録されました。" % final_character_name
	else:
		message_text_label.text = "Name [%s] has been registered to the guild." % final_character_name
			
	# メッセージ画面自体はボタンを持たないのでコンテナにフォーカスを当てる
	message_layout.focus_mode = Control.FOCUS_ALL
	message_layout.grab_focus.call_deferred()

# メッセージ確認後、ギルドメニュー(元の画面)へ完全に戻る処理
func _return_to_guild_menu() -> void:
	message_layout.hide()
	hide() # キャラクターメイク画面全体を閉じる
	creation_canceled.emit() # 完了シグナル(または専用シグナル)をギルド側に送る

キャラクター作成用スクリプト (character_creation.gd) の上記のように変更してください。これでキャラクターを作成することができます。

キャラクタークラス作成

現状だと、作成したキャラを保持しておく器がありません。上記までだと「名前【〇〇〇〇】がギルドに登録されました」と表示されますが、どこにもそのキャラは存在していない状態です。ここからはキャラクターを保持するための最初の1歩として、キャラクタークラスを作成していきます。

キャラクタークラスとは?

キャラクタークラスとは、作成したキャラクター1名分のデータを保持するクラスです。これはキャラクターを作るごとに増えていきます。

キャラクター用データクラス新規作成

class_name CharaData
extends RefCounted

# ==============================================================================
# 1. ステータスデータ (内部管理はIDまたは英語、表示時に自動翻訳)
# ==============================================================================
# ---- プロフィール ----
var chara_name: String = ""
var chara_class: String = "" # 内部ID: "Fighter", "Wizard" など
var race: String = ""        # 内部ID: "Human", "Elf", "Dwarf"
var face_image_path: String = ""

# ---- 基本成長ステータス ----
var level: int = 1
var current_hp: int = 0
var max_hp: int = 0
var current_mp: int = 0
var max_mp: int = 0
var exp_points: int = 0

# ---- 能力値 (レベルアップ等で成長する値) ----
var str_val: int = 0 # 力
var dex_val: int = 0 # 器用さ
var vit_val: int = 0 # 体力
var agi_val: int = 0 # 素早さ
var int_val: int = 0 # 知恵
var mnd_val: int = 0 # 信仰心
var luc_val: int = 0 # 運の良さ

# ---- 戦闘パラメータ ----
var atk: int = 0 
var def: int = 0 
var condition_id: String = "Good" # 内部ID: "Good"(良好), "Poison"(毒), "Paralysis"(マヒ)

# ---- クラスポイント (CP) 保持用辞書 ----
var class_points: Dictionary = {
	"Fighter": 0, "Martial Artist": 0, "Rogue": 0, "Cleric": 0, "Wizard": 0,
	"Scholar": 0, "Ranger": 0, "Knight": 0, "Summoner": 0, "Reaper": 0,
	"Samurai": 0, "Warlock": 0, "Ninja": 0, "Sage": 0
}

# ---- スキル関連 ----
var class_skills: Array[String] = []       
var equipped_skill: String = ""            
var additional_skills: Array[String] = []  

# ---- 装備スロット ----
var equipment: Dictionary = {
	"main_hand": null, "off_hand": null, "head": null, "armor": null,
	"feet": null, "accessory_1": null, "accessory_2": null
}

# ==============================================================================
# 2. 初期化 (コンストラクタ)
# ==============================================================================
func _init(n: String, r: String, c: String, img: String, stats: Dictionary, hp: int, mp: int) -> void:
	chara_name = n
	race = r
	chara_class = c
	face_image_path = img
	
	level = 1
	exp_points = 0
	condition_id = "Good"
	
	str_val = stats.get("STR", 0)
	dex_val = stats.get("DEX", 0)
	vit_val = stats.get("VIT", 0)
	agi_val = stats.get("AGI", 0)
	int_val = stats.get("INT", 0)
	mnd_val = stats.get("MND", 0)
	luc_val = stats.get("LUC", 0)
	
	max_hp = hp
	current_hp = hp
	max_mp = mp
	current_mp = mp
	
	update_display_parameters()

func update_display_parameters() -> void:
	def = get_defense()
	atk = get_melee_attack()

# ==============================================================================
# 日英言語対応(ローカライズ)用表示メソッド群
# ==============================================================================

# 現在のゲーム言語("ja" かそれ以外か)を取得するヘルパー
func _is_japanese() -> bool:
	return TranslationServer.get_locale().left(2) == "ja"

# 職業の表示名を取得。ゲーム内言語が英語ならそのまま "Fighter" 等を返す
func get_display_class() -> String:
	if _is_japanese():
		match chara_class:
			"Fighter": return "戦士"
			"Rogue": return "盗賊"
			"Cleric": return "僧侶"
			"Wizard": return "魔法使い"
			"Ranger": return "狩人"
			"Knight": return "ナイト"
			"Samurai": return "侍"
			"Warlock": return "ワーロック"
			"Ninja": return "忍者"
			"Sage": return "賢者"
	return chara_class

# 種族の表示名を取得。ゲーム内言語が英語ならそのまま "Human" 等を返す
func get_display_race() -> String:
	if _is_japanese():
		match race:
			"Human": return "人間"
			"Elf": return "エルフ"
			"Dwarf": return "ドワーフ"
	return race

# 状態異常の表示名を取得
func get_display_condition() -> String:
	if _is_japanese():
		match condition_id:
			"Good": return "良好"
			"Poison": return "毒"
			"Paralysis": return "マヒ"
			"Dead": return "死亡"
	else:
		match condition_id:
			"Good": return "Good"
			"Poison": return "Poison"
			"Paralysis": return "Paralysis"
			"Dead": return "Dead"
	return condition_id

# ==============================================================================
# 3. 非表示ステータス 計算用メソッド群
# ==============================================================================
func get_melee_attack() -> int:
	var weapon_atk = 0
	return int(str_val / 2) + 1 + weapon_atk

func get_ranged_attack() -> int:
	var weapon_atk = 0
	return int(dex_val / 2) + 1 + weapon_atk

func is_back_row_attack_allowed() -> bool:
	return false

func is_wielding_two_handed() -> bool:
	return false

func get_accuracy() -> int:
	return 100

func get_defense() -> int:
	return 0

func get_evasion() -> int:
	var score = int(dex_val / 2) + (agi_val * 2) + int(luc_val / 2)
	if score <= 100: return 0
	return int(score / 100)

func get_status_resistance() -> int: return int(ceil(float(luc_val) / 10.0))
func get_critical_rate() -> int: return 0

# ==============================================================================
# 4. ユーティリティメソッド
# ==============================================================================
func get_current_class_points() -> int:
	if class_points.has(chara_class): return class_points[chara_class]
	return 0

func add_class_points(class_id: String, amount: int) -> void:
	if class_points.has(class_id):
		class_points[class_id] = clampi(class_points[class_id] + amount, 0, 999)

新規に chara_data.gd というスクリプトファイルを作成します。そのファイルに上記コードを入力してください。これはキャラクター1名分のデータを保持するクラスです。

この時点では、状態異常やダメージ計算の仕様が固まっていません。それらは戦闘関連の調整の際に再度行います。今は仮の計算式・値だとお考え下さい。

キャラクター作成用コード修正

extends Control

signal creation_canceled

# 子ノードへの参照
@onready var title_label: Label = $NameLayout/TitleLabel
@onready var name_input_field: LineEdit = $NameLayout/NameInputField
@onready var keyboard_grid: GridContainer = $NameLayout/KeyboardGrid
@onready var backspace_button: Button = $NameLayout/BottomMenu/BackspaceButton
@onready var confirm_button: Button = $NameLayout/BottomMenu/ConfirmButton

@onready var race_layout: VBoxContainer = $RaceLayout
@onready var race_title_label: Label = $RaceLayout/RaceTitleLabel
@onready var race_button_container: VBoxContainer = $RaceLayout/RaceButtonContainer

@onready var status_layout: VBoxContainer = $StatusLayout
@onready var status_instruction_label: Label = $StatusLayout/StatusInstructionLabel
@onready var character_summary_label: Label = $StatusLayout/CharacterSummaryLabel
@onready var status_grid_container: GridContainer = $StatusLayout/StatusGridContainer
@onready var status_back_button: Button = $StatusLayout/StatusBottomMenu/StatusBackButton
@onready var status_next_button: Button = $StatusLayout/StatusBottomMenu/StatusNextButton

@onready var class_layout: VBoxContainer = $ClassLayout
@onready var class_title_label: Label = $ClassLayout/ClassTitleLabel
@onready var class_status_preview: GridContainer = $ClassLayout/ClassMainContent/ClassStatusPreview
@onready var basic_class_column: VBoxContainer = $ClassLayout/ClassMainContent/ClassSelectionArea/BasicClassColumn
@onready var advanced_class_column: VBoxContainer = $ClassLayout/ClassMainContent/ClassSelectionArea/AdvancedClassColumn

@onready var appearance_layout: VBoxContainer = $AppearanceLayout
@onready var appearance_title_label: Label = $AppearanceLayout/AppearanceTitleLabel
@onready var appearance_summary_label: Label = $AppearanceLayout/AppearanceSummaryLabel
@onready var appearance_status_preview: GridContainer = $AppearanceLayout/AppearanceMainContent/AppearanceStatusPreview
@onready var appearance_wheel_control: Control = $AppearanceLayout/AppearanceMainContent/AppearanceWheelControl
@onready var appearance_random_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceRandomButton
@onready var appearance_back_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceBackButton
@onready var appearance_next_button: Button = $AppearanceLayout/AppearanceBottomMenu/AppearanceNextButton

# ★追加:「8. 確認画面・メッセージ画面」用の子ノード参照
@onready var confirmation_layout: VBoxContainer = $ConfirmationLayout
@onready var confirm_title_label: Label = $ConfirmationLayout/ConfirmTitleLabel
@onready var confirm_summary_label: Label = $ConfirmationLayout/ConfirmSummaryLabel
@onready var confirm_status_preview: GridContainer = $ConfirmationLayout/ConfirmMainContent/ConfirmStatusPreview
@onready var confirm_face_preview: TextureRect = $ConfirmationLayout/ConfirmMainContent/ConfirmFacePreview
@onready var confirm_back_button: Button = $ConfirmationLayout/ConfirmBottomMenu/ConfirmBackButton
@onready var confirm_create_button: Button = $ConfirmationLayout/ConfirmBottomMenu/ConfirmCreateButton

@onready var message_layout: VBoxContainer = $MessageLayout
@onready var message_text_label: Label = $MessageLayout/MessageTextLabel

# キーボードの文字データ定義
const KEYS_JA = [
	"あ", "い", "う", "え", "お", "か", "き", "く", "け", "こ",
	"さ", "し", "す", "せ", "そ", "た", "ち", "つ", "て", "と",
	"な", "に", "ぬ", "ね", "の", "は", "ひ", "ふ", "へ", "ほ",
	"ま", "み", "む", "め", "も", "や", "ゆ", "よ", "わ", "を",
	"ら", "り", "る", "れ", "ろ", "ん", "っ", "ゃ", "ゅ", "ょ",
	"が", "ぎ", "ぐ", "げ", "ご", "ざ", "じ", "ず", "ぜ", "ぞ",
	"だ", "ぢ", "づ", "で", "ど", "ば", "び", "ぶ", "べ", "ぼ",
	"ぱ", "ぴ", "ぷ", "ぺ", "ぽ", "ー", "〜", "、", "。", " "
]

const KEYS_EN = [
	"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
	"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
	"U", "V", "W", "X", "Y", "Z", " ", " ", " ", " ",
	"a", "b", "c", "d", "e", "f", "g", "h", "i", "j",
	"k", "l", "m", "n", "o", "p", "q", "r", "s", "t",
	"u", "v", "w", "x", "y", "z", "-", "_", "!", "?"
]

var current_max_length: int = 16
var first_button: Button = null 

# --- キャラクターの作成データ保持用 ---
var final_character_name: String = ""
var final_character_race: String = ""
var final_character_class: String = ""
var final_character_image_path: String = "" 

# --- 種族の初期ステータス定義 ---
const RACE_BASE_STATS = {
	"Human": { "LV": 1, "STR": 9, "DEX": 9, "VIT": 9, "AGI": 9, "INT": 9, "MND": 9, "LUC": 9, "HP_MIN": 11, "HP_MAX": 18, "MP_MIN": 9, "MP_MAX": 16 },
	"Elf":   { "LV": 1, "STR": 6, "DEX": 12, "VIT": 6, "AGI": 14, "INT": 14, "MND": 14, "LUC": 6, "HP_MIN": 7, "HP_MAX": 14, "MP_MIN": 15, "MP_MAX": 22 },
	"Dwarf": { "LV": 1, "STR": 14, "DEX": 14, "VIT": 14, "AGI": 6, "INT": 6, "MND": 6, "LUC": 12, "HP_MIN": 15, "HP_MAX": 22, "MP_MIN": 7, "MP_MAX": 14 }
}

const ALLOCATABLE_STATS = ["STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]

var current_base_stats = {}
var allocated_bonus_stats = { "STR": 0, "DEX": 0, "VIT": 0, "AGI": 0, "INT": 0, "MND": 0, "LUC": 0 }
var initial_bonus_points: int = 0
var remaining_bonus_points: int = 0

var stat_labels_dictionary = {}
var current_focused_stat_button: Button = null

# --- ゲーム進行フラグ ---
var is_advanced_races_unlocked: bool = false
var is_class_group_1_unlocked: bool = false 
var is_class_group_2_unlocked: bool = false 
var is_class_group_3_unlocked: bool = false 

# --- 見た目(外見)選択用の変数群 ---
var face_image_paths: Array[String] = [] 
var face_sprites: Array[TextureRect] = [] 
var current_image_index: int = 0 
var target_wheel_angle: float = 0.0 
var current_wheel_angle: float = 0.0 
var is_wheel_focused: bool = false 
var wheel_tween: Tween = null


func _ready() -> void:
	if backspace_button and not backspace_button.pressed.is_connected(_on_backspace_button_pressed):
		backspace_button.pressed.connect(_on_backspace_button_pressed)
	if confirm_button and not confirm_button.pressed.is_connected(_on_confirm_button_pressed):
		confirm_button.pressed.connect(_on_confirm_button_pressed)
		
	if status_back_button and not status_back_button.pressed.is_connected(_on_status_back_pressed):
		status_back_button.pressed.connect(_on_status_back_pressed)
	if status_next_button and not status_next_button.pressed.is_connected(_on_status_next_pressed):
		status_next_button.pressed.connect(_on_status_next_pressed)
		
	if appearance_random_button and not appearance_random_button.pressed.is_connected(_on_appearance_random_pressed):
		appearance_random_button.pressed.connect(_on_appearance_random_pressed)
	if appearance_back_button and not appearance_back_button.pressed.is_connected(_on_appearance_back_pressed):
		appearance_back_button.pressed.connect(_on_appearance_back_pressed)
	if appearance_next_button and not appearance_next_button.pressed.is_connected(_on_appearance_next_pressed):
		appearance_next_button.pressed.connect(_on_appearance_next_pressed)
		
	# ★追加:確認画面のボタン接続
	if confirm_back_button and not confirm_back_button.pressed.is_connected(_on_confirm_back_pressed):
		confirm_back_button.pressed.connect(_on_confirm_back_pressed)
	if confirm_create_button and not confirm_create_button.pressed.is_connected(_on_confirm_create_pressed):
		confirm_create_button.pressed.connect(_on_confirm_create_pressed)
		
	hide()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	if message_layout: message_layout.hide()

func _input(event: InputEvent) -> void:
	if not is_visible_in_tree():
		return
		
	if event.is_action_pressed("ui_cancel"):
		if $NameLayout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if name_input_field.text.length() > 0:
				_on_backspace_button_pressed()
			else:
				_cancel_and_return_to_guild()
				
		elif race_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_name_input()
			
		elif status_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			if current_focused_stat_button and current_focused_stat_button.has_meta("stat_key"):
				var stat_key = current_focused_stat_button.get_meta("stat_key")
				if allocated_bonus_stats[stat_key] > 0:
					_modify_stat_point(stat_key, -1)
					return
			
			var total_spent = 0
			for val in allocated_bonus_stats.values():
				total_spent += val
			if total_spent == 0:
				_back_to_race_selection()
				
		elif class_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_status_allocation()
			
		elif appearance_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_class_selection()
			
		# 確認画面でのキャンセル挙動(見た目選択に戻る)
		elif confirmation_layout.is_visible_in_tree():
			get_viewport().set_input_as_handled()
			_back_to_appearance_selection()

	# 見た目画面での操作制御
	if appearance_layout.is_visible_in_tree() and is_wheel_focused and face_image_paths.size() > 0:
		if event.is_action_pressed("ui_right", true) or event.is_action("ui_right"):
			if event.is_pressed():
				get_viewport().set_input_as_handled()
				_rotate_wheel(1)
		elif event.is_action_pressed("ui_left", true) or event.is_action("ui_left"):
			if event.is_pressed():
				get_viewport().set_input_as_handled()
				_rotate_wheel(-1)
		elif event.is_action_pressed("ui_accept"):
			get_viewport().set_input_as_handled()
			if appearance_next_button:
				appearance_next_button.grab_focus()
				
	# 登録メッセージ画面が表示されている時の挙動
	elif message_layout.is_visible_in_tree():
		if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"):
			get_viewport().set_input_as_handled()
			_return_to_guild_menu()

func activate_name_input() -> void:
	name_input_field.text = ""
	show()
	$NameLayout.show()
	if race_layout: race_layout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	if message_layout: message_layout.hide()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		current_max_length = 8
		_setup_software_keyboard(KEYS_JA)
		if title_label: title_label.text = "名前を入力してください"
		if backspace_button: backspace_button.text = "一字消す"
		if confirm_button: confirm_button.text = "決定"
	else:
		current_max_length = 16
		_setup_software_keyboard(KEYS_EN)
		if title_label: title_label.text = "Enter your name."
		if backspace_button: backspace_button.text = "BackSpace"
		if confirm_button: confirm_button.text = "Confirm"
		
	name_input_field.max_length = current_max_length
	keyboard_grid.columns = 10
	
	if first_button:
		first_button.grab_focus.call_deferred()

func _setup_software_keyboard(keys_array: Array) -> void:
	first_button = null
	for child in keyboard_grid.get_children():
		child.queue_free()
		
	for i in range(keys_array.size()):
		var key = keys_array[i]
		if key == " " and TranslationServer.get_locale().left(2) == "en":
			var spacer = Control.new()
			keyboard_grid.add_child(spacer)
			continue
			
		var btn = Button.new()
		btn.text = key
		btn.custom_minimum_size = Vector2(40, 40) 
		btn.focus_mode = Control.FOCUS_ALL 
		btn.pressed.connect(_on_key_pressed.bind(key))
		keyboard_grid.add_child(btn)
		
		if first_button == null:
			first_button = btn

func _on_key_pressed(char_text: String) -> void:
	if name_input_field.text.length() < current_max_length:
		name_input_field.text += char_text

func _on_backspace_button_pressed() -> void:
	if name_input_field.text.length() > 0:
		name_input_field.text = name_input_field.text.left(name_input_field.text.length() - 1)

func _on_confirm_button_pressed() -> void:
	var cleaned_name = name_input_field.text.strip_edges()
	if cleaned_name.is_empty():
		return
		
	final_character_name = cleaned_name
	_proceed_to_race_selection()

func _proceed_to_race_selection() -> void:
	$NameLayout.hide()
	if status_layout: status_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	if message_layout: message_layout.hide()
	race_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	if current_locale == "ja":
		race_title_label.text = "種族を選択してください"
	else:
		race_title_label.text = "Select a race."
		
	for child in race_button_container.get_children():
		child.queue_free()
		
	var available_races = ["Human"]
	if is_advanced_races_unlocked:
		available_races.append("Elf")
		available_races.append("Dwarf")
		
	var r_buttons = []
	for race_id in available_races:
		var btn = Button.new()
		if current_locale == "ja":
			match race_id:
				"Human": btn.text = "人間"
				"Elf": btn.text = "エルフ"
				"Dwarf": btn.text = "ドワーフ"
		else:
			btn.text = race_id
			
		btn.focus_mode = Control.FOCUS_ALL
		btn.pressed.connect(_on_race_selected.bind(race_id))
		race_button_container.add_child(btn)
		r_buttons.append(btn)
		
	for i in range(r_buttons.size()):
		var prev = r_buttons[(i - 1 + r_buttons.size()) % r_buttons.size()]
		var next = r_buttons[(i + 1) % r_buttons.size()]
		r_buttons[i].focus_neighbor_top = prev.get_path()
		r_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if r_buttons.size() > 0:
		r_buttons[0].grab_focus.call_deferred()

func _on_race_selected(race_id: String) -> void:
	final_character_race = race_id
	_proceed_to_status_allocation()

func _back_to_name_input() -> void:
	race_layout.hide()
	$NameLayout.show()
	if first_button:
		first_button.grab_focus.call_deferred()

func _cancel_and_return_to_guild() -> void:
	hide()
	creation_canceled.emit()

func _proceed_to_status_allocation() -> void:
	race_layout.hide()
	if class_layout: class_layout.hide()
	if appearance_layout: appearance_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	if message_layout: message_layout.hide()
	status_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		status_instruction_label.text = "ボーナスポイントを振ってください"
		status_back_button.text = "ひとつ前に戻る"
		status_next_button.text = "次に進む"
	else:
		status_instruction_label.text = "Please allocate your bonus points."
		status_back_button.text = "Back"
		status_next_button.text = "Next"

	var base = RACE_BASE_STATS[final_character_race]
	current_base_stats = {
		"LV": base["LV"],
		"STR": base["STR"],
		"DEX": base["DEX"],
		"VIT": base["VIT"],
		"AGI": base["AGI"],
		"INT": base["INT"],
		"MND": base["MND"],
		"LUC": base["LUC"],
		"HP": randi_range(base["HP_MIN"], base["HP_MAX"]),
		"MP": randi_range(base["MP_MIN"], base["MP_MAX"])
	}
	
	for stat in ALLOCATABLE_STATS:
		allocated_bonus_stats[stat] = 0
		
	initial_bonus_points = randi_range(5, 12)
	remaining_bonus_points = initial_bonus_points
	
	_build_status_grid()
	_update_summary_ui()

func _build_status_grid() -> void:
	for child in status_grid_container.get_children():
		child.queue_free()
		
	stat_labels_dictionary.clear()
	status_grid_container.columns = 4
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	_add_static_stat_row("Lv", current_base_stats["LV"])
	_add_static_stat_row("HP", current_base_stats["HP"])
	_add_static_stat_row("MP", current_base_stats["MP"])
	
	var focusable_buttons = []
	for stat_key in ALLOCATABLE_STATS:
		var display_name = stat_key
		if current_locale == "ja":
			match stat_key:
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
				
		var name_lbl = Label.new()
		name_lbl.text = display_name
		status_grid_container.add_child(name_lbl)
		
		var base_lbl = Label.new()
		base_lbl.text = str(current_base_stats[stat_key])
		status_grid_container.add_child(base_lbl)
		
		var bonus_lbl = Label.new()
		bonus_lbl.text = " +0"
		status_grid_container.add_child(bonus_lbl)
		stat_labels_dictionary[stat_key] = bonus_lbl
		
		var opt_btn = Button.new()
		opt_btn.text = " ◀ ▶ "
		opt_btn.focus_mode = Control.FOCUS_ALL
		opt_btn.set_meta("stat_key", stat_key)
		
		opt_btn.pressed.connect(_on_stat_button_pressed.bind(stat_key))
		opt_btn.focus_entered.connect(func(): current_focused_stat_button = opt_btn)
		
		status_grid_container.add_child(opt_btn)
		focusable_buttons.append(opt_btn)
		
	focusable_buttons.append(status_back_button)
	focusable_buttons.append(status_next_button)
	
	for i in range(focusable_buttons.size()):
		var prev = focusable_buttons[(i - 1 + focusable_buttons.size()) % focusable_buttons.size()]
		var next = focusable_buttons[(i + 1) % focusable_buttons.size()]
		focusable_buttons[i].focus_neighbor_top = prev.get_path()
		focusable_buttons[i].focus_neighbor_bottom = next.get_path()
		
	if focusable_buttons.size() > 0:
		focusable_buttons[0].grab_focus.call_deferred()

func _add_static_stat_row(stat_title: String, val: int) -> void:
	var name_lbl = Label.new()
	name_lbl.text = stat_title
	var val_lbl = Label.new()
	val_lbl.text = str(val)
	status_grid_container.add_child(name_lbl)
	status_grid_container.add_child(val_lbl)
	status_grid_container.add_child(Label.new())
	status_grid_container.add_child(Label.new())

func _update_summary_ui() -> void:
	var current_locale = TranslationServer.get_locale().left(2)
	var display_race = final_character_race
	if current_locale == "ja":
		match final_character_race:
			"Human": display_race = "人間"
			"Elf": display_race = "エルフ"
			"Dwarf": display_race = "ドワーフ"
			
	if current_locale == "ja":
		character_summary_label.text = "名前: %s   種族: %s   残りボーナスポイント: %d" % [final_character_name, display_race, remaining_bonus_points]
	else:
		character_summary_label.text = "Name: %s   Race: %s   Bonus Points: %d" % [final_character_name, display_race, remaining_bonus_points]

func _on_stat_button_pressed(stat_key: String) -> void:
	if remaining_bonus_points > 0:
		_modify_stat_point(stat_key, 1)

func _modify_stat_point(stat_key: String, amount: int) -> void:
	allocated_bonus_stats[stat_key] += amount
	remaining_bonus_points -= amount
	
	var bonus_label = stat_labels_dictionary[stat_key] as Label
	if allocated_bonus_stats[stat_key] > 0:
		bonus_label.text = " +%d" % allocated_bonus_stats[stat_key]
	else:
		bonus_label.text = " +0"
		
	_update_summary_ui()

func _back_to_race_selection() -> void:
	status_layout.hide()
	_proceed_to_race_selection()

func _on_status_back_pressed() -> void:
	_back_to_race_selection()

func _on_status_next_pressed() -> void:
	_proceed_to_class_selection()

func _proceed_to_class_selection() -> void:
	status_layout.hide()
	if appearance_layout: appearance_layout.hide()
	class_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		class_title_label.text = "職業を選んでください"
	else:
		class_title_label.text = "Select a class."
		
	_build_class_status_preview()
	_build_class_columns()

func _build_class_status_preview() -> void:
	for child in class_status_preview.get_children():
		child.queue_free()
		
	class_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
		
		name_lbl.text = display_name
		
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
			
		total_lbl.text = " %d" % total_val
		
		class_status_preview.add_child(name_lbl)
		class_status_preview.add_child(total_lbl)

func _build_class_columns() -> void:
	for child in basic_class_column.get_children(): child.queue_free()
	for child in advanced_class_column.get_children(): child.queue_free()
		
	var current_locale = TranslationServer.get_locale().left(2)
	var basic_ids = ["Fighter", "Martial Artist", "Rogue", "Cleric", "Wizard", "Scholar"]
	var adv_ids = []
	if is_class_group_1_unlocked: adv_ids.append_array(["Ranger", "Knight", "Summoner"])
	if is_class_group_2_unlocked: adv_ids.append_array(["Reaper", "Samurai", "Warlock"])
	if is_class_group_3_unlocked: adv_ids.append_array(["Ninja", "Sage"])
	
	var basic_buttons = []
	var adv_buttons = []
	
	for cid in basic_ids:
		var btn = _create_class_button(cid, current_locale)
		basic_class_column.add_child(btn)
		basic_buttons.append(btn)
		
	for cid in adv_ids:
		var btn = _create_class_button(cid, current_locale)
		advanced_class_column.add_child(btn)
		adv_buttons.append(btn)
		
	for i in range(basic_buttons.size()):
		var prev = basic_buttons[(i - 1 + basic_buttons.size()) % basic_buttons.size()]
		var next = basic_buttons[(i + 1) % basic_buttons.size()]
		basic_buttons[i].focus_neighbor_top = prev.get_path()
		basic_buttons[i].focus_neighbor_bottom = next.get_path()
		if adv_buttons.size() > 0:
			var target_index = min(i, adv_buttons.size() - 1)
			basic_buttons[i].focus_neighbor_right = adv_buttons[target_index].get_path()
			
	for i in range(adv_buttons.size()):
		var prev = adv_buttons[(i - 1 + adv_buttons.size()) % adv_buttons.size()]
		var next = adv_buttons[(i + 1) % adv_buttons.size()]
		adv_buttons[i].focus_neighbor_top = prev.get_path()
		adv_buttons[i].focus_neighbor_bottom = next.get_path()
		var target_index = min(i, basic_buttons.size() - 1)
		adv_buttons[i].focus_neighbor_left = basic_buttons[target_index].get_path()
		
	if basic_buttons.size() > 0:
		basic_buttons[0].grab_focus.call_deferred()

func _create_class_button(class_id: String, locale: String) -> Button:
	var btn = Button.new()
	if locale == "ja":
		match class_id:
			"Fighter": btn.text = "戦士"
			"Rogue": btn.text = "盗賊"
			"Cleric": btn.text = "僧侶"
			"Wizard": btn.text = "魔法使い"
			"Ranger": btn.text = "狩人"
			"Knight": btn.text = "ナイト"
			"Samurai": btn.text = "侍"
			"Warlock": btn.text = "ワーロック"
			"Ninja": btn.text = "忍者"
			"Sage": btn.text = "賢者"
	else:
		btn.text = class_id
		
	btn.focus_mode = Control.FOCUS_ALL
	btn.pressed.connect(_on_class_selected.bind(class_id))
	return btn

func _on_class_selected(class_id: String) -> void:
	final_character_class = class_id
	_proceed_to_appearance_selection()

func _back_to_status_allocation() -> void:
	class_layout.hide()
	_proceed_to_status_allocation()

func _proceed_to_appearance_selection() -> void:
	class_layout.hide()
	if confirmation_layout: confirmation_layout.hide()
	appearance_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	if current_locale == "ja":
		appearance_title_label.text = "見た目を選んでください"
		appearance_random_button.text = "ランダムで決める"
		appearance_back_button.text = "職業選択に戻る"
		appearance_next_button.text = "確認画面に進む"
	else:
		appearance_title_label.text = "Select your appearance."
		appearance_random_button.text = "Choose Randomly"
		appearance_back_button.text = "Back to Class"
		appearance_next_button.text = "Proceed"

	_update_appearance_summary_text(current_locale)
	_build_appearance_status_preview()
	_scan_face_images_folder()
	_build_appearance_wheel()
	_setup_appearance_focus_neighbors()

func _update_appearance_summary_text(current_locale: String) -> void:
	var display_race = final_character_race
	var display_class = final_character_class
	if current_locale == "ja":
		match final_character_race:
			"Human": display_race = "人間"
			"Elf": display_race = "エルフ"
			"Dwarf": display_race = "ドワーフ"
		match final_character_class:
			"Fighter": display_class = "戦士"
			"Rogue": display_class = "盗賊"
			"Cleric": display_class = "僧侶"
			"Wizard": display_class = "魔法使い"
			"Ranger": display_class = "狩人"
			"Knight": display_class = "ナイト"
			"Samurai": display_class = "侍"
			"Warlock": display_class = "ワーロック"
			"Ninja": display_class = "忍者"
			"Sage": display_class = "賢者"
			
	if current_locale == "ja":
		appearance_summary_label.text = "名前: %s   種族: %s   職業: %s" % [final_character_name, display_race, display_class]
	else:
		appearance_summary_label.text = "Name: %s   Race: %s   Class: %s" % [final_character_name, display_race, display_class]

func _build_appearance_status_preview() -> void:
	for child in appearance_status_preview.get_children():
		child.queue_free()
		
	appearance_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
		
		name_lbl.text = display_name
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
		total_lbl.text = " %d" % total_val
		
		appearance_status_preview.add_child(name_lbl)
		appearance_status_preview.add_child(total_lbl)

func _scan_face_images_folder() -> void:
	face_image_paths.clear()
	var folder_path = "res://image/face/"
	if DirAccess.dir_exists_absolute(folder_path):
		var dir = DirAccess.open(folder_path)
		if dir:
			dir.list_dir_begin()
			var file_name = dir.get_next()
			while file_name != "":
				if not dir.current_is_dir() and (file_name.ends_with(".png") or file_name.ends_with(".PNG")):
					face_image_paths.append(folder_path + file_name)
				file_name = dir.get_next()
			dir.list_dir_end()
	face_image_paths.sort()

func _build_appearance_wheel() -> void:
	for child in appearance_wheel_control.get_children():
		child.queue_free()
	face_sprites.clear()
	
	if face_image_paths.is_empty():
		var error_label = Label.new()
		error_label.text = "No PNG images found in res://image/face/"
		appearance_wheel_control.add_child(error_label)
		return
		
	for i in range(face_image_paths.size()):
		var texture = load(face_image_paths[i])
		var tex_rect = TextureRect.new()
		tex_rect.texture = texture
		tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
		tex_rect.custom_minimum_size = Vector2(120, 120)
		tex_rect.size = Vector2(120, 120)
		tex_rect.pivot_offset = tex_rect.size / 2
		appearance_wheel_control.add_child(tex_rect)
		face_sprites.append(tex_rect)
		
	current_image_index = 0
	target_wheel_angle = 0.0
	current_wheel_angle = 0.0
	_update_wheel_positions()

func _process(_delta: float) -> void:
	if appearance_layout.is_visible_in_tree() and face_image_paths.size() > 0:
		_update_wheel_positions()

func _update_wheel_positions() -> void:
	var total_count = face_sprites.size()
	if total_count == 0: return
	var radius_x = 160.0
	var radius_y = 60.0 
	var center = appearance_wheel_control.size / 2
	
	for i in range(total_count):
		var angle_offset = (i * 2.0 * PI / total_count)
		var sprite_angle = current_wheel_angle + angle_offset
		var cos_val = cos(sprite_angle + PI/2)
		var sin_val = sin(sprite_angle + PI/2)
		
		var pos_x = center.x + cos_val * radius_x
		var pos_y = center.y + sin_val * radius_y
		var sprite = face_sprites[i]
		sprite.position = Vector2(pos_x - sprite.size.x / 2, pos_y - sprite.size.y / 2)
		
		var depth_ratio = (sin_val + 1.0) / 2.0
		var current_size = lerp(100.0, 180.0, depth_ratio)
		sprite.size = Vector2(current_size, current_size)
		sprite.pivot_offset = sprite.size / 2
		
		var brightness = lerp(0.4, 1.0, depth_ratio)
		sprite.modulate = Color(brightness, brightness, brightness, 1.0)
		sprite.z_index = int(depth_ratio * 100)

func _rotate_wheel(direction: int) -> void:
	var total_count = face_image_paths.size()
	if total_count == 0: return
	
	current_image_index = (current_image_index + direction + total_count) % total_count
	var step_angle = (2.0 * PI / total_count)
	var ideal_target_angle = -current_image_index * step_angle
	
	var angle_diff = ideal_target_angle - target_wheel_angle
	angle_diff = fposmod(angle_diff + PI, 2.0 * PI) - PI
	target_wheel_angle += angle_diff
	
	if wheel_tween: wheel_tween.kill()
	wheel_tween = create_tween()
	wheel_tween.set_ease(Tween.EASE_OUT)
	wheel_tween.set_trans(Tween.TRANS_QUAD)
	wheel_tween.tween_property(self, "current_wheel_angle", target_wheel_angle, 0.2)

func _rotate_to_specific_index(idx: int) -> void:
	if face_image_paths.is_empty(): return
	current_image_index = idx
	var step_angle = (2.0 * PI / face_image_paths.size())
	var ideal_target_angle = -current_image_index * step_angle
	var angle_diff = ideal_target_angle - target_wheel_angle
	angle_diff = fposmod(angle_diff + PI, 2.0 * PI) - PI
	target_wheel_angle += angle_diff
	
	if wheel_tween: wheel_tween.kill()
	wheel_tween = create_tween()
	wheel_tween.set_ease(Tween.EASE_OUT)
	wheel_tween.set_trans(Tween.TRANS_CUBIC)
	wheel_tween.tween_property(self, "current_wheel_angle", target_wheel_angle, 0.4)

func _setup_appearance_focus_neighbors() -> void:
	var wheel_focus_node = appearance_wheel_control
	if appearance_wheel_control.focus_mode == Control.FOCUS_NONE:
		appearance_wheel_control.focus_mode = Control.FOCUS_ALL
		
	if not wheel_focus_node.focus_entered.is_connected(_on_wheel_focus_entered):
		wheel_focus_node.focus_entered.connect(_on_wheel_focus_entered)
	if not wheel_focus_node.focus_exited.is_connected(_on_wheel_focus_exited):
		wheel_focus_node.focus_exited.connect(_on_wheel_focus_exited)
		
	wheel_focus_node.focus_neighbor_left = wheel_focus_node.get_path()
	wheel_focus_node.focus_neighbor_right = wheel_focus_node.get_path()
	wheel_focus_node.focus_neighbor_bottom = appearance_random_button.get_path()
	
	appearance_random_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_random_button.focus_neighbor_left = appearance_next_button.get_path()
	appearance_random_button.focus_neighbor_right = appearance_back_button.get_path()
	
	appearance_back_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_back_button.focus_neighbor_left = appearance_random_button.get_path()
	appearance_back_button.focus_neighbor_right = appearance_next_button.get_path()
	
	appearance_next_button.focus_neighbor_top = wheel_focus_node.get_path()
	appearance_next_button.focus_neighbor_left = appearance_back_button.get_path()
	appearance_next_button.focus_neighbor_right = appearance_random_button.get_path()
	
	wheel_focus_node.grab_focus.call_deferred()

func _on_wheel_focus_entered() -> void: is_wheel_focused = true
func _on_wheel_focus_exited() -> void: is_wheel_focused = false

func _on_appearance_random_pressed() -> void:
	if face_image_paths.is_empty(): return
	_rotate_to_specific_index(randi() % face_image_paths.size())

func _on_appearance_back_pressed() -> void:
	_back_to_class_selection()

func _back_to_class_selection() -> void:
	if wheel_tween: wheel_tween.kill()
	appearance_layout.hide()
	_proceed_to_class_selection()

func _on_appearance_next_pressed() -> void:
	if face_image_paths.is_empty(): return
	final_character_image_path = face_image_paths[current_image_index]
	_proceed_to_confirmation_screen()


# ==============================================================================
# ★新設:8. 確認画面(Confirmation Screen)の実装ロジック
# ==============================================================================

func _proceed_to_confirmation_screen() -> void:
	appearance_layout.hide()
	confirmation_layout.show()
	
	var current_locale = TranslationServer.get_locale().left(2)
	
	# 1. 見出し・ボタンのローカライズ設定
	if current_locale == "ja":
		confirm_title_label.text = "このキャラクターを作成しますか?"
		confirm_back_button.text = "見た目選択に戻る"
		confirm_create_button.text = "作成する"
	else:
		confirm_title_label.text = "Create this character?"
		confirm_back_button.text = "Back to Appearance"
		confirm_create_button.text = "Create"
		
	# 2. サマリー表示のコピー(名前・種族・職業)
	confirm_summary_label.text = appearance_summary_label.text
	
	# 3. 左側:ステータス合計値表示の構築
	_build_confirm_status_preview()
	
	# 4. 右側:選択した見た目画像を表示(★サイズを 256x256 に統一)
	if FileAccess.file_exists(final_character_image_path):
		confirm_face_preview.texture = load(final_character_image_path)
		confirm_face_preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
		confirm_face_preview.custom_minimum_size = Vector2(256, 256)
		confirm_face_preview.size = Vector2(256, 256)
	
	# 5. フォーカス移動ルート設定
	confirm_back_button.focus_neighbor_left = confirm_create_button.get_path()
	confirm_back_button.focus_neighbor_right = confirm_create_button.get_path()
	confirm_create_button.focus_neighbor_left = confirm_back_button.get_path()
	confirm_create_button.focus_neighbor_right = confirm_back_button.get_path()
	
	# 最初から「作成する」ボタンにフォーカスを当てる
	confirm_create_button.grab_focus.call_deferred()

# 左側:ステータスの表示(合計値)
func _build_confirm_status_preview() -> void:
	for child in confirm_status_preview.get_children():
		child.queue_free()
		
	confirm_status_preview.columns = 2
	var current_locale = TranslationServer.get_locale().left(2)
	var preview_keys = ["LV", "HP", "MP", "STR", "DEX", "VIT", "AGI", "INT", "MND", "LUC"]
	
	for key in preview_keys:
		var name_lbl = Label.new()
		var total_lbl = Label.new()
		var display_name = key
		if current_locale == "ja":
			match key:
				"LV": display_name = "レベル"
				"HP": display_name = "HP"
				"MP": display_name = "MP"
				"STR": display_name = "力"
				"DEX": display_name = "器用さ"
				"VIT": display_name = "体力"
				"AGI": display_name = "素早さ"
				"INT": display_name = "知恵"
				"MND": display_name = "精神"
				"LUC": display_name = "運"
				
		name_lbl.text = display_name
		var total_val = current_base_stats[key]
		if key in ALLOCATABLE_STATS:
			total_val += allocated_bonus_stats[key]
		total_lbl.text = " %d" % total_val
		
		confirm_status_preview.add_child(name_lbl)
		confirm_status_preview.add_child(total_lbl)

# 「見た目選択に戻る」ボタン処理
func _on_confirm_back_pressed() -> void:
	_back_to_appearance_selection()

func _back_to_appearance_selection() -> void:
	confirmation_layout.hide()
	_proceed_to_appearance_selection()

# 「作成する」ボタン処理
func _on_confirm_create_pressed() -> void:
	confirmation_layout.hide()
	message_layout.show()
	
	# 1. 最終ステータス(ベース値 + ボーナス値)の合算辞書を作成
	var final_stats: Dictionary = {}
	for key in ALLOCATABLE_STATS:
		final_stats[key] = current_base_stats[key] + allocated_bonus_stats[key]
	
	# 2. 職業名と種族名をCharaData用の内部ID(英語)にマッピング
	var class_id: String = _get_class_internal_id(final_character_class)
	var race_id: String = _get_race_internal_id(final_character_race)
	
	# 3. ★キャラクタークラス(器)のインスタンスを生成してデータを流し込む
	var new_adventurer = CharaData.new(
		final_character_name,
		race_id,
		class_id,
		final_character_image_path,
		final_stats,
		current_base_stats["HP"], 
		current_base_stats["MP"]  
	)
	
	# 4. デバッグ確認用:正しくデータが器に収まったかをGodotの出力ウインドウに表示
	print("--- 新しい冒険者がデータクラスに格納されました ---")
	print("名前: ", new_adventurer.chara_name)
	print("種族(内部ID): ", new_adventurer.race, " (表示名: ", new_adventurer.get_display_race(), ")")
	print("職業(内部ID): ", new_adventurer.chara_class, " (表示名: ", new_adventurer.get_display_class(), ")")
	print("画像パス: ", new_adventurer.face_image_path)
	print("STR: ", new_adventurer.str_val, " / AGI: ", new_adventurer.agi_val, " / LUC: ", new_adventurer.luc_val)
	print("暫定近接攻撃力: ", new_adventurer.get_melee_attack())
	print("--------------------------------------------------")
	
	# 5. 登録完了メッセージの表示
	var current_locale = TranslationServer.get_locale().left(2)
	if current_locale == "ja":
		message_text_label.text = "名前【%s】がギルドに登録されました。" % final_character_name
	else:
		message_text_label.text = "Name [%s] has been registered to the guild." % final_character_name
			
	message_layout.focus_mode = Control.FOCUS_ALL
	message_layout.grab_focus.call_deferred()

# --- 表示名から内部ID(英語)に変換するヘルパー関数 ---
func _get_class_internal_id(display_name: String) -> String:
	match display_name:
		"戦士", "Fighter": return "Fighter"
		"盗賊", "Rogue": return "Rogue"
		"僧侶", "Cleric": return "Cleric"
		"魔法使い", "Wizard": return "Wizard"
		"狩人", "Ranger": return "Ranger"
		"ナイト", "Knight": return "Knight"
		"侍", "Samurai": return "Samurai"
		"ワーロック", "Warlock": return "Warlock"
		"忍者", "Ninja": return "Ninja"
		"鍵者", "Sage": return "Sage"
	return "Fighter"

func _get_race_internal_id(display_name: String) -> String:
	match display_name:
		"人間", "Human": return "Human"
		"エルフ", "Elf": return "Elf"
		"ドワーフ", "Dwarf": return "Dwarf"
	return "Human"

# メッセージ確認後、ギルドメニュー(元の画面)へ完全に戻る処理
func _return_to_guild_menu() -> void:
	message_layout.hide()
	hide() # キャラクターメイク画面全体を閉じる
	creation_canceled.emit() # 完了シグナル(または専用シグナル)をギルド側に送る

キャラクター作成用スクリプト (character_creation.gd) の上記のように変更してください。これでキャラクター作成時にデータクラスに値を保持することができます。

「冒険者をよびだす」「冒険者をあずける」などの冒険者ギルドの機能については、次の工程で実装します。(別記事)

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

CAPTCHA


目次