diff --git a/app_entry.gd b/app_entry.gd index b86feb4..1ff06a8 100644 --- a/app_entry.gd +++ b/app_entry.gd @@ -106,6 +106,7 @@ func _on_save_slot_selected(slot_index: int) -> void: _on_normal_requested() return if character_select != null: + _configure_character_select_for_slot(slot) character_select.visible = true func _on_new_slot_requested(slot_index: int) -> void: @@ -118,12 +119,24 @@ func _on_new_slot_requested(slot_index: int) -> void: opening_prologue.open() return if character_select != null: + _configure_character_select_for_slot(SaveManager.get_active_slot() if SaveManager != null else {}) character_select.visible = true func _on_opening_prologue_finished() -> void: if character_select != null: + _configure_character_select_for_slot(SaveManager.get_active_slot() if SaveManager != null else {}) character_select.visible = true +func _configure_character_select_for_slot(slot: Dictionary) -> void: + if character_select == null or not character_select.has_method("set_disabled_family_ids"): + return + var disabled: Array[String] = [] + for raw_id in String(slot.get("crowned_families", "")).split(",", false): + var family_id := raw_id.strip_edges() + if not family_id.is_empty() and not disabled.has(family_id): + disabled.append(family_id) + character_select.set_disabled_family_ids(disabled) + func _input(event: InputEvent) -> void: if not (event is InputEventKey) or not event.pressed or event.echo: return @@ -158,6 +171,9 @@ func _on_character_selected(character_id: StringName) -> void: selected_character_id = character_id if character_select != null: character_select.visible = false + if play_mode_select != null and play_mode_select.has_method("open"): + play_mode_select.open(character_id) + return _on_normal_requested() diff --git a/export_presets.cfg b/export_presets.cfg index 47b584b..8b0dd2f 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,8 +9,15 @@ export_filter="all_resources" include_filter="" exclude_filter="" export_path="build/windows/InfinityKingdom.exe" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" encryption_include_filters="" encryption_exclude_filters="" +seed=0 encrypt_pck=false encrypt_directory=false script_export_mode=2 @@ -21,9 +28,10 @@ custom_template/debug="" custom_template/release="" debug/export_console_wrapper=0 binary_format/embed_pck=true -binary_format/architecture="x86_64" texture_format/s3tc_bptc=true texture_format/etc2_astc=false +shader_baker/enabled=false +binary_format/architecture="x86_64" codesign/enable=false codesign/timestamp=true codesign/timestamp_server_url="" @@ -39,15 +47,23 @@ application/product_version="1.0.0" application/company_name="Es777777" application/product_name="Infinity Kingdom" application/file_description="Infinity Kingdom" -application/copyright="Copyright (c) 2026 Es777777" +application/copyright="Copyright (c) 2026 xinxiangchen" application/trademarks="" +application/export_angle=0 +application/export_d3d12=0 +application/d3d12_agility_sdk_multiarch=true ssh_remote_deploy/enabled=false ssh_remote_deploy/host="user@host_ip" ssh_remote_deploy/port="22" ssh_remote_deploy/extra_args_ssh="" ssh_remote_deploy/extra_args_scp="" -ssh_remote_deploy/run_script="#!/usr/bin/env bash\nexport DISPLAY=:0\nunzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"\n\"{temp_dir}/{exe_name}\" {cmd_args}" -ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash\nkill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\")\nrm -rf \"{temp_dir}\"" +ssh_remote_deploy/run_script="#!/usr/bin/env bash +export DISPLAY=:0 +unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\" +\"{temp_dir}/{exe_name}\" {cmd_args}" +ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash +kill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\") +rm -rf \"{temp_dir}\"" [preset.1] @@ -60,8 +76,15 @@ export_filter="all_resources" include_filter="" exclude_filter="" export_path="build/web/index.html" +patches=PackedStringArray() +patch_delta_encoding=false +patch_delta_compression_level_zstd=19 +patch_delta_min_reduction=0.1 +patch_delta_include_filters="*" +patch_delta_exclude_filters="" encryption_include_filters="" encryption_exclude_filters="" +seed=0 encrypt_pck=false encrypt_directory=false script_export_mode=2 @@ -71,6 +94,7 @@ script_export_mode=2 custom_template/debug="" custom_template/release="" variant/extensions_support=false +variant/thread_support=false vram_texture_compression/for_desktop=true vram_texture_compression/for_mobile=false html/export_icon=true @@ -80,6 +104,7 @@ html/canvas_resize_policy=2 html/focus_canvas_on_start=true html/experimental_virtual_keyboard=false progressive_web_app/enabled=false +progressive_web_app/ensure_cross_origin_isolation_headers=true progressive_web_app/offline_page="" progressive_web_app/display=1 progressive_web_app/orientation=0 @@ -87,3 +112,5 @@ progressive_web_app/icon_144x144="" progressive_web_app/icon_180x180="" progressive_web_app/icon_512x512="" progressive_web_app/background_color=Color(0, 0, 0, 1) +threads/emscripten_pool_size=8 +threads/godot_pool_size=4 diff --git a/project.godot b/project.godot index e68d8f9..22e1720 100644 --- a/project.godot +++ b/project.godot @@ -18,6 +18,7 @@ config/icon="res://icon.svg" [autoload] AccessoryManager="*res://systems/accessories/accessory_manager.gd" +ConsumableManager="*res://systems/consumables/consumable_manager.gd" RunDirector="*res://systems/run/run_director.gd" SaveManager="*res://systems/run/save_manager.gd" LineageDirector="*res://systems/run/lineage_director.gd" diff --git a/systems/consumables/consumable_manager.gd b/systems/consumables/consumable_manager.gd new file mode 100644 index 0000000..79450af --- /dev/null +++ b/systems/consumables/consumable_manager.gd @@ -0,0 +1,162 @@ +extends Node + +signal inventory_changed(slots: Array) +signal consumable_used(consumable_id: String, slot_index: int) + +const MAX_SLOTS := 6 +const CATALOG := { + "bandage_pack": { + "name": "Bandage Pack", + "short_name": "BDG", + "summary": "Restore 22% max health.", + "icon": "res://assets/ui/consumable/bandage_pack.png", + "tint": Color(0.84, 0.74, 0.64, 1.0) + }, + "healing_vial": { + "name": "Healing Vial", + "short_name": "HP", + "summary": "Restore 35% max health.", + "icon": "res://assets/ui/consumable/first_aid_bottle.png", + "tint": Color(0.62, 1.0, 0.72, 1.0) + }, + "medkit": { + "name": "Medkit", + "short_name": "MED", + "summary": "Restore 55% max health.", + "icon": "res://assets/ui/consumable/medkit.png", + "tint": Color(0.95, 0.86, 0.74, 1.0) + }, + "armor_patch": { + "name": "Light Armor Pack", + "short_name": "DEF", + "summary": "Restore 45% max defense.", + "icon": "res://assets/ui/consumable/light_armor_pack.png", + "tint": Color(0.52, 0.96, 0.92, 1.0) + }, + "inspiration_ampoule": { + "name": "Protective Candle", + "short_name": "IN", + "summary": "Restore 45% max inspiration.", + "icon": "res://assets/ui/consumable/protective_candle.png", + "tint": Color(0.46, 0.72, 1.0, 1.0) + }, + "edge_oil": { + "name": "Sharpening Oil", + "short_name": "ATK", + "summary": "Next fight: +10% attack damage.", + "icon": "res://assets/ui/consumable/sharpening_oil.png", + "tint": Color(1.0, 0.72, 0.46, 1.0) + } +} + +var slots: Array[Dictionary] = [] +var rng := RandomNumberGenerator.new() + +func _ready() -> void: + rng.randomize() + reset_run() + +func reset_run() -> void: + slots.clear() + for _index in range(MAX_SLOTS): + slots.append({}) + inventory_changed.emit(get_slots()) + +func get_slots() -> Array[Dictionary]: + return slots.duplicate(true) + +func get_catalog() -> Dictionary: + return CATALOG.duplicate(true) + +func random_consumable_id() -> String: + var keys := CATALOG.keys() + if keys.is_empty(): + return "" + return String(keys[rng.randi_range(0, keys.size() - 1)]) + +func add_consumable(consumable_id: String, amount: int = 1) -> bool: + if not CATALOG.has(consumable_id) or amount <= 0: + return false + for slot in slots: + if String(slot.get("id", "")) == consumable_id: + slot["amount"] = int(slot.get("amount", 0)) + amount + inventory_changed.emit(get_slots()) + return true + for slot in slots: + if slot.is_empty(): + slot["id"] = consumable_id + slot["amount"] = amount + inventory_changed.emit(get_slots()) + return true + return false + +func use_slot(slot_index: int, actor: Node) -> bool: + if slot_index < 0 or slot_index >= slots.size(): + return false + var slot := slots[slot_index] + var consumable_id := String(slot.get("id", "")) + if consumable_id.is_empty() or not CATALOG.has(consumable_id): + return false + if not _apply_consumable(consumable_id, actor): + return false + var next_amount := int(slot.get("amount", 1)) - 1 + if next_amount <= 0: + slots[slot_index] = {} + else: + slot["amount"] = next_amount + consumable_used.emit(consumable_id, slot_index) + inventory_changed.emit(get_slots()) + return true + +func describe(consumable_id: String) -> Dictionary: + var data: Dictionary = (CATALOG.get(consumable_id, {}) as Dictionary).duplicate(true) + data["id"] = consumable_id + return data + +func _apply_consumable(consumable_id: String, actor: Node) -> bool: + if actor == null or not is_instance_valid(actor): + return false + match consumable_id: + "bandage_pack": + return _restore_ratio(actor, "hp", "max_hp", 0.22) + "healing_vial": + return _restore_ratio(actor, "hp", "max_hp", 0.35) + "medkit": + return _restore_ratio(actor, "hp", "max_hp", 0.55) + "armor_patch": + return _restore_ratio(actor, "defense", "max_defense", 0.45) + "inspiration_ampoule": + return _restore_ratio(actor, "inspiration", "max_inspiration", 0.45) + "edge_oil": + return _queue_next_fight_prep("edge_oil", "Edge Oil", {"attack_damage_pct": 0.10}) + return false + +func _restore_ratio(actor: Node, current_field: String, max_field: String, ratio: float) -> bool: + if not _has_property(actor, current_field) or not _has_property(actor, max_field): + return false + var max_value := float(actor.get(max_field)) + if max_value <= 0.0: + return false + actor.set(current_field, clampf(float(actor.get(current_field)) + max_value * ratio, 0.0, max_value)) + if actor.has_method("emit_stat_signals"): + actor.emit_stat_signals() + return true + +func _queue_next_fight_prep(consumable_id: String, title: String, effects: Dictionary) -> bool: + if RunDirector == null: + return false + RunDirector.set_pending_encounter_prep({ + "choice_id": consumable_id, + "title": title, + "summary": "Consumable prep for the next map.", + "temporary_effects": effects + }) + return true + +func _has_property(actor: Node, field: String) -> bool: + if actor == null: + return false + for property in actor.get_property_list(): + if String(property.get("name", "")) == field: + return true + return false diff --git a/systems/consumables/consumable_manager.gd.uid b/systems/consumables/consumable_manager.gd.uid new file mode 100644 index 0000000..b451003 --- /dev/null +++ b/systems/consumables/consumable_manager.gd.uid @@ -0,0 +1 @@ +uid://b48vhargq8s1o diff --git a/systems/pickups/run_pickup.gd b/systems/pickups/run_pickup.gd index 5df60d2..d7e8076 100644 --- a/systems/pickups/run_pickup.gd +++ b/systems/pickups/run_pickup.gd @@ -10,12 +10,15 @@ var age: float = 0.0 var lifetime: float = 8.0 var drift_phase: float = 0.0 var collected_flag: bool = false +var icon_texture: Texture2D = null func setup(pickup_kind: String, pickup_amount: float, options: Dictionary = {}) -> void: name = "RunPickup" kind = pickup_kind amount = maxf(pickup_amount, 0.0) tint = options.get("tint", _default_tint(kind)) + var icon_path := String(options.get("icon", "")) + icon_texture = load(icon_path) as Texture2D if not icon_path.is_empty() else null var launch_speed := float(options.get("launch_speed", randf_range(34.0, 62.0))) var launch_angle := float(options.get("launch_angle", randf() * TAU)) velocity = Vector2.RIGHT.rotated(launch_angle) * launch_speed @@ -59,6 +62,15 @@ func _draw() -> void: var outer_radius := 7.0 * scale_pulse var inner_radius := 4.0 * scale_pulse draw_circle(center, outer_radius + 2.0, Color(0.02, 0.03, 0.05, 0.52)) + if icon_texture != null: + var icon_size := Vector2(28.0, 28.0) + if kind == "accessory": + icon_size = Vector2(26.0, 26.0) + elif kind == "consumable": + icon_size = Vector2(30.0, 30.0) + draw_texture_rect(icon_texture, Rect2(center - icon_size * 0.5, icon_size), false, Color.WHITE) + draw_arc(center, maxf(icon_size.x, icon_size.y) * 0.5 + 1.0, 0.0, TAU, 20, Color(1.0, 1.0, 1.0, 0.44), 1.2) + return match kind: "gold": var diamond := PackedVector2Array([ @@ -84,6 +96,14 @@ func _draw() -> void: draw_colored_polygon(shield, tint) draw_rect(Rect2(center + Vector2(-1.2, -4.0), Vector2(2.4, 8.0)), Color.WHITE, true) draw_rect(Rect2(center + Vector2(-4.0, -1.2), Vector2(8.0, 2.4)), Color.WHITE, true) + "consumable": + draw_rect(Rect2(center + Vector2(-outer_radius * 0.55, -outer_radius), Vector2(outer_radius * 1.1, outer_radius * 1.8)), tint, true) + draw_rect(Rect2(center + Vector2(-outer_radius * 0.32, -outer_radius * 1.24), Vector2(outer_radius * 0.64, outer_radius * 0.34)), Color(0.92, 0.96, 1.0, 1.0), true) + draw_rect(Rect2(center + Vector2(-1.1, -4.0), Vector2(2.2, 8.0)), Color.WHITE, true) + draw_rect(Rect2(center + Vector2(-4.0, -1.1), Vector2(8.0, 2.2)), Color.WHITE, true) + "accessory": + draw_arc(center, outer_radius * 0.75, 0.0, TAU, 24, tint, 2.8) + draw_circle(center + Vector2(0.0, -outer_radius * 0.82), inner_radius * 0.62, Color(1.0, 0.96, 0.72, 1.0)) _: draw_circle(center, outer_radius, tint) draw_circle(center + Vector2(0.0, -1.0), inner_radius, Color(0.94, 1.0, 0.94, 0.72)) @@ -105,5 +125,9 @@ func _default_tint(pickup_kind: String) -> Color: return Color(0.52, 0.96, 0.92, 1.0) "heal": return Color(0.58, 1.0, 0.72, 1.0) + "consumable": + return Color(0.80, 0.90, 1.0, 1.0) + "accessory": + return Color(1.0, 0.88, 0.52, 1.0) _: return Color(1.0, 0.88, 0.42, 1.0) diff --git a/systems/run/lineage_director.gd b/systems/run/lineage_director.gd index 7ebef2e..ca79efd 100644 --- a/systems/run/lineage_director.gd +++ b/systems/run/lineage_director.gd @@ -6,8 +6,14 @@ signal archive_dead(payload: Dictionary) const MAX_SEEDS := 5 const BASE_APTITUDE_TOTAL := 9 +const MIN_APTITUDE_TOTAL := 7 +const MAX_APTITUDE_TOTAL := 15 +const MIN_ATTRIBUTE_VALUE := 1 +const FAMILY_IDS := ["knight", "ranger", "mage"] var family_id: String = "" +var current_emperor_family: String = "" +var crowned_families: Array[String] = [] var generation_index: int = 1 var reincarnation_index: int = 1 var seeds_left: int = MAX_SEEDS @@ -25,6 +31,8 @@ func start_or_resume_from_slot(slot: Dictionary, selected_family_id: String = "" family_id = String(slot.get("family_id", "")) if family_id.is_empty(): family_id = selected_family_id + current_emperor_family = String(slot.get("current_emperor_family", "")) + crowned_families = _parse_family_list(String(slot.get("crowned_families", ""))) reincarnation_index = int(slot.get("reincarnation_index", 1)) generation_index = int(slot.get("generation_index", 1)) seeds_left = int(slot.get("seeds_left", MAX_SEEDS)) @@ -34,21 +42,28 @@ func start_or_resume_from_slot(slot: Dictionary, selected_family_id: String = "" "agility": int(slot.get("agility", 3)), "focus": int(slot.get("focus", 3)) } + current_aptitude = _clamped_aptitude(current_aptitude) last_score = { "score": int(slot.get("last_score", 0)), "grade": String(slot.get("last_score_grade", "")) } if SaveManager != null and SaveManager.active_slot_index >= 0 and not family_id.is_empty(): - SaveManager.update_active_slot_patch({"family_id": family_id}) + SaveManager.update_active_slot_patch({ + "family_id": family_id, + "current_emperor_family": current_emperor_family, + "crowned_families": _family_list_to_string(crowned_families) + }) _emit_state() func begin_new_lineage(selected_family_id: String) -> void: family_id = selected_family_id + current_emperor_family = "" + crowned_families.clear() reincarnation_index = 1 generation_index = 1 seeds_left = MAX_SEEDS current_encounter_index = 0 - current_aptitude = {"strength": 3, "agility": 3, "focus": 3} + current_aptitude = generate_aptitude_from_total(BASE_APTITUDE_TOTAL, family_id) last_score.clear() if SaveManager != null and SaveManager.active_slot_index >= 0: SaveManager.update_active_slot_patch({ @@ -60,6 +75,33 @@ func begin_new_lineage(selected_family_id: String) -> void: "strength": current_aptitude["strength"], "agility": current_aptitude["agility"], "focus": current_aptitude["focus"], + "crowned_families": _family_list_to_string(crowned_families), + "current_emperor_family": current_emperor_family, + "dead_archive": false + }) + _emit_state() + +func begin_reincarnation_family(selected_family_id: String) -> void: + if selected_family_id.is_empty(): + return + if not can_select_family(selected_family_id): + return + family_id = selected_family_id + generation_index = 1 + seeds_left = MAX_SEEDS + current_encounter_index = 0 + current_aptitude = generate_aptitude_from_total(BASE_APTITUDE_TOTAL, family_id) + last_score.clear() + if SaveManager != null and SaveManager.active_slot_index >= 0: + SaveManager.update_active_slot_patch({ + "family_id": family_id, + "current_emperor_family": current_emperor_family, + "generation_index": generation_index, + "seeds_left": seeds_left, + "current_encounter_index": current_encounter_index, + "strength": current_aptitude["strength"], + "agility": current_aptitude["agility"], + "focus": current_aptitude["focus"], "dead_archive": false }) _emit_state() @@ -82,6 +124,7 @@ func consume_death(death_summary: Dictionary) -> Dictionary: } if seeds_left <= 0: patch["dead_archive"] = true + patch["ending_type"] = "ember_extinguished" if SaveManager != null and SaveManager.active_slot_index >= 0: SaveManager.update_active_slot_patch(patch) var dead_payload := { @@ -140,10 +183,156 @@ func score_generation(summary: Dictionary) -> Dictionary: } func generate_next_aptitude(score_data: Dictionary) -> Dictionary: + var current_total := get_aptitude_total() + var delta := grade_to_point_delta(String(score_data.get("grade", "D"))) + var next_total := clampi(current_total + delta, MIN_APTITUDE_TOTAL, MAX_APTITUDE_TOTAL) + return generate_aptitude_from_total(next_total, family_id) + +func grade_to_point_delta(grade: String) -> int: + match grade: + "S": + return 3 + "A": + return 2 + "B": + return 1 + "C": + return 0 + _: + return -1 + +func get_aptitude_total() -> int: + return ( + int(current_aptitude.get("strength", 3)) + + int(current_aptitude.get("agility", 3)) + + int(current_aptitude.get("focus", 3)) + ) + +func generate_aptitude_from_total(total: int, source_family_id: String = "") -> Dictionary: + var clamped_total := clampi(total, MIN_APTITUDE_TOTAL, MAX_APTITUDE_TOTAL) + var next := _base_aptitude_for_family(source_family_id) + var remaining := maxi(clamped_total - _aptitude_total(next), 0) + var weights := _family_weights_for(source_family_id) + while remaining > 0: + var pick := _weighted_pick(weights) + next[pick] = int(next[pick]) + 1 + remaining -= 1 + while _aptitude_total(next) > clamped_total: + var reduced := _reduce_highest_weighted_attribute(next, weights) + if reduced.is_empty(): + break + next[reduced] = int(next[reduced]) - 1 + return _clamped_aptitude(next) + +func apply_manual_aptitude_shift(from_attribute: String, to_attribute: String) -> Dictionary: + if not current_aptitude.has(from_attribute) or not current_aptitude.has(to_attribute): + return current_aptitude.duplicate(true) + if from_attribute == to_attribute: + return current_aptitude.duplicate(true) + if int(current_aptitude.get(from_attribute, 0)) <= MIN_ATTRIBUTE_VALUE: + return current_aptitude.duplicate(true) + current_aptitude[from_attribute] = int(current_aptitude[from_attribute]) - 1 + current_aptitude[to_attribute] = int(current_aptitude[to_attribute]) + 1 + _persist_aptitude() + _emit_state() + return current_aptitude.duplicate(true) + +func apply_manual_aptitude_bonus(attribute_id: String) -> Dictionary: + if not current_aptitude.has(attribute_id): + return current_aptitude.duplicate(true) + var from_attribute := _highest_attribute_except(attribute_id) + if from_attribute.is_empty(): + return current_aptitude.duplicate(true) + return apply_manual_aptitude_shift(from_attribute, attribute_id) + +func _base_aptitude_for_family(source_family_id: String) -> Dictionary: + match source_family_id: + "knight": + return {"strength": 4, "agility": 3, "focus": 2} + "ranger": + return {"strength": 2, "agility": 4, "focus": 3} + "mage": + return {"strength": 2, "agility": 3, "focus": 4} + _: + return {"strength": 3, "agility": 3, "focus": 3} + +func _family_weights_for(source_family_id: String) -> Dictionary: + match source_family_id: + "knight": + return {"strength": 1.55, "agility": 0.8, "focus": 0.95} + "ranger": + return {"strength": 0.85, "agility": 1.55, "focus": 0.9} + "mage": + return {"strength": 0.85, "agility": 0.9, "focus": 1.55} + _: + return {"strength": 1.0, "agility": 1.0, "focus": 1.0} + +func _aptitude_total(aptitude: Dictionary) -> int: + return int(aptitude.get("strength", 0)) + int(aptitude.get("agility", 0)) + int(aptitude.get("focus", 0)) + +func _clamped_aptitude(aptitude: Dictionary) -> Dictionary: + var result := { + "strength": maxi(int(aptitude.get("strength", 3)), MIN_ATTRIBUTE_VALUE), + "agility": maxi(int(aptitude.get("agility", 3)), MIN_ATTRIBUTE_VALUE), + "focus": maxi(int(aptitude.get("focus", 3)), MIN_ATTRIBUTE_VALUE) + } + var total := _aptitude_total(result) + while total > MAX_APTITUDE_TOTAL: + var key := _highest_attribute_in(result) + if key.is_empty() or int(result.get(key, 0)) <= MIN_ATTRIBUTE_VALUE: + break + result[key] = int(result[key]) - 1 + total -= 1 + return result + +func _highest_attribute_except(attribute_id: String) -> String: + var best_key := "" + var best_value := MIN_ATTRIBUTE_VALUE + for key in ["strength", "agility", "focus"]: + if key == attribute_id: + continue + var value := int(current_aptitude.get(key, 0)) + if value > best_value: + best_key = key + best_value = value + return best_key + +func _highest_attribute_in(aptitude: Dictionary) -> String: + var best_key := "" + var best_value := MIN_ATTRIBUTE_VALUE + for key in ["strength", "agility", "focus"]: + var value := int(aptitude.get(key, 0)) + if value > best_value: + best_key = key + best_value = value + return best_key + +func _reduce_highest_weighted_attribute(aptitude: Dictionary, weights: Dictionary) -> String: + var best_key := "" + var best_score := -INF + for key in ["strength", "agility", "focus"]: + var value := int(aptitude.get(key, 0)) + if value <= MIN_ATTRIBUTE_VALUE: + continue + var score := float(value) - float(weights.get(key, 1.0)) * 0.2 + if score > best_score: + best_score = score + best_key = key + return best_key + +func _persist_aptitude() -> void: + if SaveManager != null and SaveManager.active_slot_index >= 0: + SaveManager.update_active_slot_patch({ + "strength": int(current_aptitude.get("strength", 3)), + "agility": int(current_aptitude.get("agility", 3)), + "focus": int(current_aptitude.get("focus", 3)) + }) + +func _legacy_generate_next_aptitude(score_data: Dictionary) -> Dictionary: var score := int(score_data.get("score", 0)) - var total := 7 + var total := MIN_APTITUDE_TOTAL if score >= 88: - total = 15 + total = MAX_APTITUDE_TOTAL elif score >= 72: total = 13 elif score >= 52: @@ -185,53 +374,99 @@ func apply_aptitude_to_actor(actor: Node) -> void: if actor.has_method("emit_stat_signals"): actor.emit_stat_signals() -func apply_manual_aptitude_bonus(attribute_id: String) -> Dictionary: - if not current_aptitude.has(attribute_id): - return current_aptitude.duplicate(true) - current_aptitude[attribute_id] = int(current_aptitude[attribute_id]) + 1 - if SaveManager != null and SaveManager.active_slot_index >= 0: - SaveManager.update_active_slot_patch({ - "strength": int(current_aptitude.get("strength", 3)), - "agility": int(current_aptitude.get("agility", 3)), - "focus": int(current_aptitude.get("focus", 3)) - }) - _emit_state() - return current_aptitude.duplicate(true) - func complete_reincarnation() -> Dictionary: + var crowned_family := family_id + if not family_id.is_empty() and not crowned_families.has(family_id): + crowned_families.append(family_id) reincarnation_index += 1 + family_id = "" + current_emperor_family = crowned_family generation_index = 1 seeds_left = MAX_SEEDS current_encounter_index = 0 + current_aptitude = generate_aptitude_from_total(BASE_APTITUDE_TOTAL, "") last_score.clear() if SaveManager != null and SaveManager.active_slot_index >= 0: var active := SaveManager.get_active_slot() SaveManager.update_active_slot_patch({ "cleared": true, + "family_id": family_id, + "current_emperor_family": current_emperor_family, + "crowned_families": _family_list_to_string(crowned_families), "reincarnation_index": reincarnation_index, "generation_index": generation_index, "seeds_left": seeds_left, "current_encounter_index": current_encounter_index, + "strength": current_aptitude["strength"], + "agility": current_aptitude["agility"], + "focus": current_aptitude["focus"], "total_runs": int(active.get("total_runs", 1)) + 1 }) _emit_state() return get_state() +func get_available_families() -> Array[String]: + var available: Array[String] = [] + for id in FAMILY_IDS: + if not crowned_families.has(id): + available.append(id) + return available + +func get_disabled_families() -> Array[String]: + var disabled: Array[String] = [] + for id in FAMILY_IDS: + if crowned_families.has(id): + disabled.append(id) + return disabled + +func can_select_family(candidate_family_id: String) -> bool: + return FAMILY_IDS.has(candidate_family_id) and not crowned_families.has(candidate_family_id) + +func throne_cycle_complete() -> bool: + return get_available_families().is_empty() + +func would_complete_throne_cycle_after_current() -> bool: + var seen := crowned_families.duplicate() + if not family_id.is_empty() and FAMILY_IDS.has(family_id) and not seen.has(family_id): + seen.append(family_id) + return seen.size() >= FAMILY_IDS.size() + func get_state() -> Dictionary: return { "family_id": family_id, + "current_emperor_family": current_emperor_family, + "crowned_families": crowned_families.duplicate(true), + "available_families": get_available_families(), + "disabled_families": get_disabled_families(), + "throne_cycle_complete": throne_cycle_complete(), "reincarnation_index": reincarnation_index, "generation_index": generation_index, "seeds_left": seeds_left, "max_seeds": MAX_SEEDS, "current_encounter_index": current_encounter_index, "aptitude": current_aptitude.duplicate(true), + "aptitude_total": get_aptitude_total(), "last_score": last_score.duplicate(true) } func _emit_state() -> void: state_changed.emit(get_state()) +func _parse_family_list(value: String) -> Array[String]: + var result: Array[String] = [] + for raw in value.split(",", false): + var id := raw.strip_edges() + if FAMILY_IDS.has(id) and not result.has(id): + result.append(id) + return result + +func _family_list_to_string(value: Array[String]) -> String: + var parts: Array[String] = [] + for id in value: + if FAMILY_IDS.has(id) and not parts.has(id): + parts.append(id) + return ",".join(parts) + func _family_weights() -> Dictionary: match family_id: "knight": diff --git a/systems/run/save_manager.gd b/systems/run/save_manager.gd index c1ed5df..e5cd2d9 100644 --- a/systems/run/save_manager.gd +++ b/systems/run/save_manager.gd @@ -33,6 +33,12 @@ const SCORE_GRADE_SIZE := 8 const OFF_TOTAL_RUNS := 136 const OFF_TOTAL_DEATHS := 140 const OFF_UPDATED_UNIX_TIME := 144 +const OFF_CROWNED_FAMILIES := 152 +const CROWNED_FAMILIES_SIZE := 48 +const OFF_CURRENT_EMPEROR_FAMILY := 200 +const CURRENT_EMPEROR_FAMILY_SIZE := 16 +const OFF_ENDING_TYPE := 216 +const ENDING_TYPE_SIZE := 32 var active_slot_index: int = -1 var active_slot: Dictionary = {} @@ -168,6 +174,15 @@ func update_slot_patch(slot_index: int, patch: Dictionary) -> Dictionary: "total_deaths": file.seek(base + OFF_TOTAL_DEATHS) file.store_32(int(patch[key])) + "crowned_families": + file.seek(base + OFF_CROWNED_FAMILIES) + file.store_buffer(_fixed_string_bytes(String(patch[key]), CROWNED_FAMILIES_SIZE)) + "current_emperor_family": + file.seek(base + OFF_CURRENT_EMPEROR_FAMILY) + file.store_buffer(_fixed_string_bytes(String(patch[key]), CURRENT_EMPEROR_FAMILY_SIZE)) + "ending_type": + file.seek(base + OFF_ENDING_TYPE) + file.store_buffer(_fixed_string_bytes(String(patch[key]), ENDING_TYPE_SIZE)) file.seek(base + OFF_UPDATED_UNIX_TIME) file.store_64(int(Time.get_unix_time_from_system())) if slot_index == active_slot_index: @@ -182,6 +197,18 @@ func mark_active_dead_archive() -> void: "seeds_left": 0 }) +func seal_active_ending(ending_type: String) -> void: + if ending_type.is_empty(): + return + var current := get_active_slot() + if not String(current.get("ending_type", "")).is_empty(): + return + update_active_slot_patch({ + "dead_archive": true, + "cleared": true, + "ending_type": ending_type + }) + func _ensure_save_file() -> void: if FileAccess.file_exists(save_path): var file := FileAccess.open(save_path, FileAccess.READ) @@ -234,6 +261,12 @@ func _store_record(file: FileAccess, record: Dictionary) -> void: file.store_32(int(record.get("total_runs", 1))) file.store_32(int(record.get("total_deaths", 0))) file.store_64(int(record.get("updated_unix_time", Time.get_unix_time_from_system()))) + file.seek(start + OFF_CROWNED_FAMILIES) + file.store_buffer(_fixed_string_bytes(String(record.get("crowned_families", "")), CROWNED_FAMILIES_SIZE)) + file.seek(start + OFF_CURRENT_EMPEROR_FAMILY) + file.store_buffer(_fixed_string_bytes(String(record.get("current_emperor_family", "")), CURRENT_EMPEROR_FAMILY_SIZE)) + file.seek(start + OFF_ENDING_TYPE) + file.store_buffer(_fixed_string_bytes(String(record.get("ending_type", "")), ENDING_TYPE_SIZE)) while file.get_position() < start + RECORD_SIZE: file.store_8(0) @@ -266,8 +299,17 @@ func _read_record_at_current_position(file: FileAccess, fallback_slot_id: int) - "last_score_grade": _read_fixed_string(file, SCORE_GRADE_SIZE), "total_runs": int(file.get_32()), "total_deaths": int(file.get_32()), - "updated_unix_time": int(file.get_64()) + "updated_unix_time": int(file.get_64()), + "crowned_families": "", + "current_emperor_family": "", + "ending_type": "" } + file.seek(start + OFF_CROWNED_FAMILIES) + record["crowned_families"] = _read_fixed_string(file, CROWNED_FAMILIES_SIZE) + file.seek(start + OFF_CURRENT_EMPEROR_FAMILY) + record["current_emperor_family"] = _read_fixed_string(file, CURRENT_EMPEROR_FAMILY_SIZE) + file.seek(start + OFF_ENDING_TYPE) + record["ending_type"] = _read_fixed_string(file, ENDING_TYPE_SIZE) file.seek(start + RECORD_SIZE) if not occupied: record["slot_id"] = fallback_slot_id @@ -294,6 +336,9 @@ func _default_record(slot_index: int) -> Dictionary: "last_score_grade": "", "total_runs": 1, "total_deaths": 0, + "crowned_families": "", + "current_emperor_family": "", + "ending_type": "", "updated_unix_time": 0 } diff --git a/tests/smoke_accessory_flow.gd b/tests/smoke_accessory_flow.gd index 4373c44..26a40a4 100644 --- a/tests/smoke_accessory_flow.gd +++ b/tests/smoke_accessory_flow.gd @@ -23,8 +23,8 @@ func _run() -> void: push_error("Player was not spawned") quit(1) return - if world.accessory_choice == null or not world.accessory_choice.visible: - push_error("Accessory choice did not open after character select") + if world.current_encounter == null: + push_error("Encounter did not start directly after character select") quit(1) return var accessory_manager := root.get_node_or_null("/root/AccessoryManager") @@ -32,6 +32,12 @@ func _run() -> void: push_error("AccessoryManager autoload missing") quit(1) return + world._offer_accessory("Smoke Relic", "test") + await process_frame + if world.accessory_choice == null or not world.accessory_choice.visible: + push_error("Accessory choice did not open when explicitly requested") + quit(1) + return var choices: Array = accessory_manager.current_choices if choices.is_empty(): push_error("Accessory choices were not generated") @@ -42,7 +48,7 @@ func _run() -> void: world._on_accessory_choice_made(String(first_choice.get("id", "")), false) await process_frame if world.current_encounter == null: - push_error("Encounter did not start after accessory choice") + push_error("Encounter was lost after accessory choice") quit(1) return if String(accessory_manager.get_equipped_accessory().get("id", "none")) == "none": diff --git a/tests/smoke_lineage_save.gd b/tests/smoke_lineage_save.gd index 51cca27..1596b39 100644 --- a/tests/smoke_lineage_save.gd +++ b/tests/smoke_lineage_save.gd @@ -49,6 +49,7 @@ func _run() -> void: push_error("SaveManager did not delete a single fixed record slot") quit(1) return + save_manager.select_slot(2) var lineage := root.get_node_or_null("/root/LineageDirector") if lineage == null: @@ -68,9 +69,67 @@ func _run() -> void: push_error("LineageDirector did not consume death into the next generation") quit(1) return - var aptitude: Dictionary = lineage.apply_manual_aptitude_bonus("focus") - if int(aptitude.get("focus", 0)) < 3: - push_error("LineageDirector did not apply manual aptitude point") + var next_aptitude := state.get("aptitude", {}) as Dictionary + var next_total := int(state.get("aptitude_total", 0)) + if next_total != 15: + push_error("LineageDirector did not clamp next generation aptitude total") + quit(1) + return + var source_attribute := "strength" + if int(next_aptitude.get(source_attribute, 0)) <= 1: + source_attribute = "agility" + var shifted: Dictionary = lineage.apply_manual_aptitude_shift(source_attribute, "focus") + var shifted_total := int(shifted.get("strength", 0)) + int(shifted.get("agility", 0)) + int(shifted.get("focus", 0)) + if shifted_total != next_total: + push_error("LineageDirector manual aptitude shift changed total points") + quit(1) + return + if int(shifted.get("focus", 0)) != int(next_aptitude.get("focus", 0)) + 1: + push_error("LineageDirector did not move a manual aptitude point") + quit(1) + return + lineage.complete_reincarnation() + var completed_state: Dictionary = lineage.get_state() + if not (completed_state.get("crowned_families", []) as Array).has("knight"): + push_error("LineageDirector did not record the crowned family after reincarnation completion") + quit(1) + return + if String(completed_state.get("family_id", "")) != "": + push_error("LineageDirector did not clear family selection for the next reincarnation") + quit(1) + return + if String(completed_state.get("current_emperor_family", "")) != "knight": + push_error("LineageDirector did not convert the cleared family into the next emperor boss") + quit(1) + return + if not (completed_state.get("disabled_families", []) as Array).has("knight"): + push_error("LineageDirector did not expose the crowned family as disabled") + quit(1) + return + if bool(lineage.can_select_family("knight")) or not bool(lineage.can_select_family("ranger")): + push_error("LineageDirector family availability rules are wrong after first clear") + quit(1) + return + lineage.begin_reincarnation_family("ranger") + var second_state: Dictionary = lineage.get_state() + if String(second_state.get("family_id", "")) != "ranger" or int(second_state.get("reincarnation_index", 0)) != 2: + push_error("LineageDirector did not start the second reincarnation with the chosen remaining family") + quit(1) + return + if String(second_state.get("current_emperor_family", "")) != "knight": + push_error("LineageDirector changed the emperor boss when selecting the next player family") + quit(1) + return + var saved_after_cycle: Dictionary = save_manager.read_slot(2) + if ( + String(saved_after_cycle.get("crowned_families", "")) != "knight" + or String(saved_after_cycle.get("family_id", "")) != "ranger" + or String(saved_after_cycle.get("current_emperor_family", "")) != "knight" + ): + push_error("SaveManager did not persist throne-cycle family fields: crowned=%s family=%s" % [ + String(saved_after_cycle.get("crowned_families", "")), + String(saved_after_cycle.get("family_id", "")) + ]) quit(1) return quit(0) diff --git a/tests/smoke_locale_zh_hans.gd b/tests/smoke_locale_zh_hans.gd index 09f8d04..685c991 100644 --- a/tests/smoke_locale_zh_hans.gd +++ b/tests/smoke_locale_zh_hans.gd @@ -83,10 +83,14 @@ func _run() -> void: await process_frame var battle_objective := world.battle_status.get("objective_value_label") as Label - if battle_objective == null or battle_objective.text.find("选择") == -1: + if battle_objective == null or battle_objective.text.find("场") == -1 or battle_objective.text.find("清空战场") == -1: push_error("Battle status objective did not localize to Simplified Chinese") quit(1) return + if battle_objective.text.find("Encounter") != -1 or battle_objective.text.find("clear the arena") != -1: + push_error("Battle status objective still contains English in Simplified Chinese") + quit(1) + return var accessory_manager := root.get_node_or_null("/root/AccessoryManager") if accessory_manager == null: diff --git a/tests/smoke_run_flow.gd b/tests/smoke_run_flow.gd index 4319dd1..11edc92 100644 --- a/tests/smoke_run_flow.gd +++ b/tests/smoke_run_flow.gd @@ -165,12 +165,18 @@ func _run() -> void: quit(1) return var objective_value := world.battle_status.get("objective_value_label") as Label - if objective_value == null or objective_value.text.find("relic") == -1: - push_error("Battle status did not switch to relic objective after character select") + if objective_value == null or objective_value.text.is_empty(): + push_error("Battle status did not populate after character select") quit(1) return + if world.current_encounter == null: + push_error("Encounter did not start directly after character select") + quit(1) + return + world._offer_accessory("Smoke Relic", "test") + await process_frame if world.accessory_choice == null or not world.accessory_choice.visible: - push_error("Accessory choice did not open after character select") + push_error("Accessory choice did not open when explicitly requested") quit(1) return var accessory_preview := world.accessory_choice.get_node("Backdrop/CenterContainer/PanelContainer/MarginContainer/VBoxContainer/PreviewPanel/MarginContainer/VBoxContainer/PreviewDetail") as Label @@ -178,6 +184,7 @@ func _run() -> void: push_error("Accessory choice preview did not initialize") quit(1) return + world.accessory_choice.close() world.active_run_event_kind = "shop" world.run_event_panel.open("shop", 0) @@ -188,29 +195,14 @@ func _run() -> void: push_error("Unaffordable shop choice did not keep the event panel open") quit(1) return - if world.encounter_index != -1: - push_error("Unaffordable shop choice incorrectly advanced the encounter index") + if world.encounter_index != 0: + push_error("Unaffordable shop choice incorrectly changed the encounter index") quit(1) return world.run_event_panel.close() - var choices: Array = accessory_manager.current_choices - if choices.is_empty(): - push_error("No accessory choices were generated") - quit(1) - return - - var keep_event := InputEventKey.new() - keep_event.keycode = KEY_K - keep_event.pressed = true - world.accessory_choice._unhandled_input(keep_event) - await process_frame - if world.accessory_choice.visible: - push_error("Accessory choice did not close after keep shortcut") - quit(1) - return if world.current_encounter == null: - push_error("Encounter did not begin after keep shortcut") + push_error("Encounter was lost after explicit accessory preview") quit(1) return await process_frame @@ -239,16 +231,20 @@ func _run() -> void: world._on_encounter_defeated() await create_timer(0.9).timeout await process_frame - if world.run_event_panel == null or not world.run_event_panel.visible: - push_error("Run event panel did not open after the first map encounter") + if world.stage_reward_panel == null or not world.stage_reward_panel.visible: + push_error("Stage reward panel did not open after the first map encounter") quit(1) return - world.run_event_panel.close() - world._on_run_event_choice_made("skip") - await create_timer(1.4).timeout + var reward_choices: Array = world.stage_reward_panel.get("choices") + if reward_choices.is_empty(): + push_error("Stage reward panel did not receive choices") + quit(1) + return + world._on_stage_reward_chosen(reward_choices[0]) + await create_timer(5.2).timeout await process_frame if world.encounter_index != 1: - push_error("World did not advance to the second map encounter") + push_error("World did not advance to the second map encounter after stage reward") quit(1) return if world.current_encounter == null or not is_instance_valid(world.current_encounter): @@ -262,7 +258,7 @@ func _run() -> void: world.add_child(stub_encounter) world.current_encounter = stub_encounter world._on_encounter_defeated() - await create_timer(1.3).timeout + await create_timer(5.2).timeout await process_frame if world.run_event_panel != null and world.run_event_panel.visible: diff --git "a/todolist/Cpp\351\207\215\346\236\204\345\217\257\350\241\214\346\200\247\344\270\216Codex\344\272\244\346\216\245\346\200\273\347\273\223.md" "b/todolist/Cpp\351\207\215\346\236\204\345\217\257\350\241\214\346\200\247\344\270\216Codex\344\272\244\346\216\245\346\200\273\347\273\223.md" new file mode 100644 index 0000000..714653e --- /dev/null +++ "b/todolist/Cpp\351\207\215\346\236\204\345\217\257\350\241\214\346\200\247\344\270\216Codex\344\272\244\346\216\245\346\200\273\347\273\223.md" @@ -0,0 +1,298 @@ +# C++ 重构可行性与 Codex 交接总结 + +更新日期:2026-06-09 +仓库位置:`F:\infinity_kingdom` +当前分支:`master` +当前已同步远端:`origin/master`,最新提交为 `d6b7b2f Fix startup errors and polish combat UI` + +## 1. 先说结论 + +后续把大部分游戏逻辑逐步改成 C++ / GDExtension 是可行的,但不建议直接把整个项目一次性重写。 + +更推荐的做法是: + +1. Godot 场景、UI、流程编排继续用 GDScript。 +2. 高频、纯计算、规则稳定的玩法逻辑逐步迁移到 C++。 +3. 每迁移一个模块,都先保留 GDScript 对外 API 不变。 +4. 主程序仍然通过统一接口调用,不关心背后是 GDScript 还是 C++。 + +也就是说,不是“把游戏推倒重写成 C++”,而是“把核心玩法系统做成 C++ 模块,Godot/GDScript 负责把模块串起来”。 + +## 2. 为什么可行 + +当前项目已经具备 C++/GDExtension 的基础条件: + +- 仓库中有 `SConstruct`。 +- 仓库中有 `thirdparty/godot-cpp/`。 +- 项目结构已经是 Godot 4 项目。 +- 后续可以通过 GDExtension 暴露 C++ 类给 GDScript 调用。 + +从架构上看,项目当前已经有一些天然适合迁移的模块: + +- 战斗伤害结算。 +- 角色属性、构筑修正、buff/debuff 合并。 +- 敌人 AI 的目标选择和移动决策。 +- 投射物批量更新。 +- 地图物件碰撞框生成。 +- 血脉火种、资质继承、皇帝血量持久化等纯规则系统。 + +这些模块大多可以做成“输入 Dictionary/结构体,输出结果”的形式,适合 C++。 + +## 3. 为什么不能直接全部 C++ 化 + +不建议直接全部改 C++,原因有四个: + +1. 当前玩法规则还没完全定稿。血脉火种、资质继承、皇帝血量跨代保留、技能分支、武器强化、消耗品槽都还在待实现阶段,太早 C++ 化会增加修改成本。 +2. Godot 的 UI、场景树、信号连接、资源加载用 GDScript 更快更直观,课程项目时间有限,不值得全部 C++ 化。 +3. 当前 `world.gd` 还承担大量整合职责,需要先拆模块再迁移,否则会把一个大脚本变成一个更难维护的大 C++ 类。 +4. GDExtension 调试成本高于 GDScript。每次改 C++ 都要编译,组员协作时容易因为环境不同出问题。 + +因此最稳的策略是:先解耦,再迁移。 + +## 4. 推荐迁移顺序 + +### 第一阶段:不改变玩法,只搭 C++ 最小闭环 + +目标:证明仓库能稳定加载一个 C++ 扩展。 + +要做: + +- 确认 `.gdextension` 文件位置。 +- 确认 Windows debug/release 动态库输出路径。 +- 写一个最小 C++ 类,例如 `CppMathTest`。 +- 在 Godot 中用 GDScript 调用这个类。 +- 写清楚构建命令。 + +验收: + +- Godot 打开项目不报扩展加载错误。 +- GDScript 能调用 C++ 方法并得到正确返回。 +- 其他同学按文档能重新编译成功。 + +### 第二阶段:迁移纯计算模块 + +优先迁移: + +- 伤害结算。 +- 属性修正合并。 +- 暴击、护甲、防御、伤害类型计算。 +- 构筑效果合并。 + +原因: + +- 不依赖场景树。 +- 容易写测试。 +- 出错范围小。 +- 后续角色、敌人、Boss 都会用到。 + +建议 C++ 模块名: + +- `CombatCalculator` +- `ModifierStack` +- `DamagePayload` + +GDScript 外观接口保持类似: + +```gdscript +var result := CombatCalculator.calculate_damage(payload, attacker_state, defender_state) +``` + +### 第三阶段:迁移 Run 规则 + +适合迁移: + +- 血脉火种。 +- 代际编号。 +- 资质生成。 +- 皇帝血量跨代保留。 +- 大周目胜负判断。 + +建议 C++ 模块名: + +- `LineageState` +- `AptitudeGenerator` +- `EmperorProgress` + +注意: + +- UI 仍然用 GDScript。 +- 存档/读档暂时可以先不做复杂二进制格式。 +- C++ 只返回状态,主流程决定显示什么面板。 + +### 第四阶段:迁移敌人和投射物高频逻辑 + +适合迁移: + +- 敌人目标选择。 +- 追击、绕障、保持距离。 +- 投射物运动更新。 +- 大量弹幕碰撞前筛选。 + +不建议一开始迁移完整 Boss: + +- Boss 规则变化多。 +- 动画、音频、阶段演出经常需要调。 +- 用 GDScript 更适合快速打磨。 + +### 第五阶段:地图工具类迁移 + +适合迁移: + +- 根据图片 alpha 计算最高、最低、最左、最右非透明像素。 +- 生成小物件碰撞外框。 +- 物件摆放的空间检测。 + +注意: + +- 地图场景拼接仍建议 GDScript 做。 +- C++ 只做计算,不直接创建大量 Godot 节点。 + +## 5. 哪些内容建议保留 GDScript + +这些内容不要急着 C++ 化: + +- `world.gd` 这种主流程编排。 +- UI 面板。 +- 选人流程。 +- 暂停、设置、结果界面。 +- 音乐切换。 +- 地图节点创建和场景拼接。 +- Boss 演出和阶段调参。 +- 调试入口。 + +这些模块变化频繁,用 GDScript 更适合快速协作。 + +## 6. 下一个 Codex 接手时的仓库状态 + +本轮已经做过: + +1. 从 GitHub 拉取并更新本地仓库。 +2. 本地 `master` 已和 `origin/master` 对齐到 `d6b7b2f`。 +3. 在 `todolist/` 下新增了两份文档: + - `代码架构指南.md` + - `策划差距与待办指南.md` +4. 本文档用于说明 C++ 重构可行性和后续交接。 + +注意: + +- 当前工作区还有一个原本就存在的 `export_presets.cfg` 本地修改,本轮没有改它。 +- 当前新增的 `todolist/` 文档还没有提交到 Git。 +- 如果要提交文档,请只提交 `todolist/`,不要把 `export_presets.cfg` 混进去,除非确认它也应该进入版本。 + +## 7. 当前项目核心结构速记 + +主入口: + +- `app_entry.gd` +- `app_entry.tscn` +- `world.gd` +- `world.tscn` + +角色: + +- `characters/knight/` +- `characters/ranger/` +- `characters/mage/` + +敌人和 Boss: + +- `actors/enemy/town_enemy.gd` +- `actors/encounters/` +- `actors/bosses/town/` + +地图: + +- `systems/map/map_runtime.gd` +- `tools/map_browser_demo.gd` + +运行规则: + +- `systems/run/run_director.gd` +- `systems/run/run_effects.gd` +- `systems/run/audio_route.gd` + +饰品和拾取: + +- `systems/accessories/accessory_manager.gd` +- `systems/pickups/` + +UI: + +- `ui/character_select.*` +- `ui/play_mode_select.*` +- `ui/battle_status.*` +- `ui/character_debug_status.*` +- `ui/debug_enemy_select.*` +- `ui/accessory_choice.*` +- `ui/run_event_panel.*` +- `ui/pause_menu.*` +- `ui/result_screen.*` + +C++ 相关: + +- `SConstruct` +- `src/` +- `thirdparty/godot-cpp/` + +## 8. 当前玩法完成度速记 + +已经有: + +- 三职业选择。 +- 正常模式和角色调试模式。 +- 地图房间拼接。 +- 每张地图一轮怪物刷新。 +- 六类小兵素材和逻辑接入。 +- 守关 Boss、双子/王子 Boss、最终 Boss 场景。 +- 地图入口出口、房间转场、摄像机锁定。 +- 小物件随机生成和碰撞。 +- HUD、事件、金币、经验、饰品选择。 +- BGM 路由。 + +还缺: + +- 血脉火种。 +- 父死子继。 +- 资质继承。 +- 皇帝血量跨代保留。 +- 技能分支选择和两次强化。 +- 武器强化词条系统。 +- 主动消耗品槽。 +- 空间化教堂、军需库、商人、皇帝门前准备点。 +- 统一交互系统。 +- 更完整的 C++/GDExtension 接入。 + +## 9. 给下一个 Codex 的建议任务顺序 + +如果下一个 Codex 要继续推进 C++ 和架构,请按这个顺序: + +1. 先读 `todolist/代码架构指南.md`。 +2. 再读 `todolist/策划差距与待办指南.md`。 +3. 确认 `git status`,不要误提交 `export_presets.cfg`。 +4. 先做 GDExtension 最小可运行样例。 +5. 写 `docs/cpp-gdextension-quickstart.md` 或补充已有 C++ 文档。 +6. 迁移一个最小纯计算模块,例如伤害结算。 +7. 给 GDScript 保持同名包装接口。 +8. 运行 Godot 项目确认正常模式和角色调试模式都不坏。 +9. 再考虑迁移血脉火种、资质继承、皇帝持久血量。 + +## 10. 最重要的原则 + +后续不要为了“用了 C++”而破坏协作效率。 + +这门大作业最需要的是: + +- 主流程能跑。 +- 模块边界清楚。 +- 同学之间不互相改爆对方代码。 +- 策划核心卖点能落地。 +- C++ 用在真正适合的地方。 + +所以推荐最终架构是: + +```text +GDScript:场景、UI、流程、调试、资源装配 +C++:战斗计算、构筑规则、继承规则、状态推进、高频更新 +``` + +这样既能满足“后续大部分核心逻辑用 C++ 重写”的课程目标,也不会把 Godot 项目变成难以调试和难以协作的全 C++ 工程。 diff --git "a/todolist/\344\273\243\347\240\201\346\236\266\346\236\204\346\214\207\345\215\227.md" "b/todolist/\344\273\243\347\240\201\346\236\266\346\236\204\346\214\207\345\215\227.md" new file mode 100644 index 0000000..4f2892e --- /dev/null +++ "b/todolist/\344\273\243\347\240\201\346\236\266\346\236\204\346\214\207\345\215\227.md" @@ -0,0 +1,393 @@ +# 代码架构指南 + +更新日期:2026-06-09 +当前依据:本地 `master` 已从 `origin/master` 更新到 `d6b7b2f Fix startup errors and polish combat UI`。 + +这份文档给整合组、角色组、地图组、UI 组和后续 C++/GDExtension 组共用。核心目标是:各模块封装自己的状态和实现细节,只暴露清晰 API、信号和数据结构,主流程只负责调度,不深入改每个模块内部。 + +## 1. 当前总体结构 + +当前项目是一个 Godot 4 的 2D 动作 Roguelite 原型。运行入口在 `app_entry.tscn` / `app_entry.gd`,主流程在 `world.tscn` / `world.gd`。`world.gd` 目前承担了较多职责:选人、开局、地图房间切换、战斗生成、奖励、事件、HUD、音频、结果界面都由它串起来。 + +现阶段能跑通的主循环是: + +1. 进入标题/选人界面。 +2. 选择正常模式或角色调试模式。 +3. 正常模式中选择骑士、游侠或法师。 +4. 进入饰品选择或开局准备。 +5. `MapRuntime` 根据地图素材生成横向房间、碰撞、入口出口、随机物件和房间锁定摄像机。 +6. `world.gd` 按遭遇顺序生成普通小怪、守关 Boss、空房间、双子 Boss 和最终 Boss。 +7. 每场战斗结束后进入金币、经验、饰品、事件或服务节点。 +8. 玩家死亡或胜利后进入结果界面,并可重开。 + +重要判断:项目已经有“可试玩整合形态”,但架构仍处在“主流程集中调度”的阶段。下一步应把 `world.gd` 中的长期规则拆到独立系统里,让主流程只连接系统之间的信号。 + +## 2. 目录职责 + +| 目录/文件 | 当前职责 | 后续边界建议 | +|---|---|---| +| `app_entry.gd` / `app_entry.tscn` | Godot 启动入口,进入主程序 | 只负责启动场景和启动参数,不承载玩法 | +| `world.gd` / `world.tscn` | 主流程整合、选人、地图、战斗、UI、奖励、音频 | 保留为“流程编排器”,逐步把具体规则下沉到系统模块 | +| `characters/` | 三个玩家角色及状态机 | 每个角色只暴露统一角色 API,不直接依赖地图、UI、奖励系统 | +| `actors/enemy/` | 普通敌人与六类小兵逻辑 | 小兵数据表化,敌人只管行为与战斗,不管刷怪流程 | +| `actors/bosses/` | Boss 场景和脚本 | Boss 暴露统一 Boss API,阶段和特殊机制内部封装 | +| `actors/encounters/` | 遭遇场景,负责生成本场敌人并发出清场信号 | 遭遇只管本房间敌人波次,不管下一张图和奖励 | +| `combat/` | 伤害、生命、运行期贴图、通用战斗工具 | 后续优先迁移到 C++ 的候选层 | +| `systems/map/` | 地图运行期拼接、房间碰撞、摄像机锁定、随机物件 | 地图组主要负责这里和地图工具 | +| `systems/run/` | 周目状态、金币、事件、奖励修正、音频路线 | 后续拆出血脉/代际/章节进度系统 | +| `systems/accessories/` | 饰品数据、选择、装备效果 | 保持数据驱动,不让角色直接写饰品规则 | +| `systems/pickups/` | 拾取物、掉落、运行期拾取逻辑 | 后续可扩展消耗品槽 | +| `ui/` | 选人、模式选择、战斗状态、事件面板、设置、调试 UI | UI 只读状态、发请求,不直接改玩法数据 | +| `audio/` | 音乐管理和音量设置 | 通过 profile/route 播放,不让 Boss 或地图直接切 BGM | +| `tools/` | 地图预览、角色调试、Boss 预览、导出脚本 | 只作为开发工具,不进入正式主流程规则 | +| `src/`、`thirdparty/godot-cpp/` | C++/GDExtension 预留和依赖 | 后续放高频逻辑,GDScript 保留编排 | +| `docs/` | 已有协作文档和代码规范 | 长期文档保留这里;临时待办和差距分析放 `todolist/` | + +## 3. 主流程模块 + +### 3.1 `world.gd` 的当前职责 + +`world.gd` 是当前整合中心,主要做这些事: + +- 预加载玩家角色、遭遇、Boss、地图运行期、拾取物和 UI 脚本。 +- 连接选人、饰品选择、事件面板、暂停、结果、设置等 UI 信号。 +- 创建并激活 `MapRuntime`。 +- 维护当前 `encounter_index` 和 `current_encounter`。 +- 根据 `ENCOUNTER_SCENES` 与 `FINAL_BOSS_SCENES` 生成敌人。 +- 控制房间入口出口、自动走门、黑幕转场和摄像机更新。 +- 调用 `RunDirector` 奖励金币、经验、事件路线。 +- 监听角色受击、敌人死亡、战斗结束,刷新 HUD 和世界血条。 +- 调用 `Music` / `AudioRoute` 播放对应阶段 BGM。 + +这部分当前可用,但耦合偏重。后续建议拆成: + +| 目标系统 | 从 `world.gd` 拆走的内容 | +|---|---| +| `EncounterFlow` | 遭遇索引、生成遭遇、清场判断、进入下一房间 | +| `RoomTransitionService` | 自动走门、黑幕、入口出口、转场锁输入 | +| `RunProgression` | 胜利/失败、章节推进、代际重开、皇帝血量持久化 | +| `RewardFlow` | 金币、经验、饰品、事件、服务节点触发顺序 | +| `WorldHudBinder` | HUD 绑定、血条创建、UI 状态刷新 | +| `AudioRouteBinder` | 根据房间、Boss、结果切 BGM | + +### 3.2 主流程对外 API 建议 + +主流程只应该提供少量高层入口: + +```gdscript +func start_normal_run(hero_id: String) -> void +func start_debug_run(hero_id: String, enemy_id: String) -> void +func request_next_room() -> void +func report_encounter_cleared(summary: Dictionary) -> void +func report_player_dead(summary: Dictionary) -> void +func restart_to_title() -> void +``` + +其他模块不要直接改 `encounter_index`、不要直接 `queue_free()` 主流程节点、不要直接切 `Music`。需要影响主流程时,用信号或上述 API。 + +## 4. 角色模块 + +### 4.1 当前角色结构 + +`characters/knight/`、`characters/ranger/`、`characters/mage/` 各自有场景、主脚本和状态机。它们已经比早期“贴图直接移动”更接近模块化:角色内部处理移动、攻击、技能、资源、受击、动画/贴图姿态。 + +角色调试入口在 `tools/character_debug_world.tscn`,UI 在 `ui/character_debug_status.tscn` 与 `ui/debug_enemy_select.tscn`,可以脱离地图直接测试角色和敌人。 + +### 4.2 角色组必须统一暴露的 API + +每个玩家角色建议实现同一组方法。主程序只认这些方法,不关心角色内部是状态机、动画树还是未来 C++ 组件。 + +```gdscript +func setup_run_context(context: Dictionary) -> void +func apply_run_modifiers(modifiers: Dictionary) -> void +func apply_accessory_effect(effect: Dictionary) -> void +func clear_run_effects() -> void + +func get_display_name() -> String +func get_hud_state() -> Dictionary +func get_combat_state() -> Dictionary +func get_facing_direction() -> int + +func set_control_enabled(enabled: bool) -> void +func set_auto_walk_direction(direction: Vector2) -> void +func clear_control_effects(clear_movement: bool, clear_attack: bool, clear_skill: bool) -> void + +func receive_damage(payload: Dictionary) -> Dictionary +func heal(amount: float) -> void +func restore_resource(kind: String, amount: float) -> void +func is_alive() -> bool +``` + +建议统一信号: + +```gdscript +signal died(summary: Dictionary) +signal health_changed(current: float, maximum: float) +signal resource_changed(kind: String, current: float, maximum: float) +signal attack_started(payload: Dictionary) +signal attack_resolved(payload: Dictionary) +signal skill_used(payload: Dictionary) +signal state_changed(state: Dictionary) +``` + +### 4.3 角色组不要做的事 + +- 不要在角色脚本里直接引用 `world.gd`。 +- 不要直接打开 UI 面板。 +- 不要自己决定地图进度、掉落、金币、BGM。 +- 不要把敌人或 Boss 的特殊逻辑写进玩家角色。 +- 不要为了某个角色单独新增主流程分支,除非先抽象成通用能力。 + +角色可以读取 `context`,例如当前房间、构筑效果、调试模式,但只能把它当配置,不应把整合流程写进自己内部。 + +## 5. 敌人与 Boss 模块 + +### 5.1 普通敌人 + +当前普通小怪集中在 `actors/enemy/town_enemy.gd`,可表现六类基础兵种。后续建议把“兵种参数”数据表化: + +```gdscript +{ + "id": "knight_swordsman", + "display_name": "持剑卫兵", + "family": "knight", + "scene": "res://actors/enemy/town_enemy.tscn", + "sprite": "...", + "weapon": "...", + "hp": 90, + "move_speed": 120, + "attack_profile": "melee_slash" +} +``` + +遭遇场景只按 `enemy_id` 生成,不直接关心贴图和行为参数。 + +### 5.2 Boss + +Boss 当前分布在 `actors/bosses/town/`。有守关 Boss、双子 Boss、多个最终 Boss。第七章废案列阵机制已从流程中移除,代码保留在仓库中,主流程使用 `empty_encounter.tscn`。 + +Boss 建议统一暴露: + +```gdscript +func setup_boss(context: Dictionary) -> void +func start_battle(player: Node) -> void +func set_battle_enabled(enabled: bool) -> void +func get_boss_state() -> Dictionary +func get_music_profile() -> StringName +``` + +统一信号: + +```gdscript +signal boss_defeated(summary: Dictionary) +signal phase_changed(phase_id: String, payload: Dictionary) +signal boss_health_changed(current: float, maximum: float) +``` + +Boss 自己可以处理阶段、武器、朝向、动画节奏和弹幕,但不要自己进入下一关、发奖励或切场景。 + +## 6. 地图模块 + +### 6.1 当前地图运行期 + +`systems/map/map_runtime.gd` 会从 `tools/map_browser_demo.gd` 的地图配置读取房间素材,横向拼成一串房间。它当前负责: + +- 加载地图大图。 +- 计算房间矩形和可行走矩形。 +- 给每张图添加围墙碰撞。 +- 在左右边缘添加入口/出口视觉标记。 +- 随机放置每张图对应的小物件。 +- 给物件生成基于透明像素外框/多边形的碰撞。 +- 添加房间锁定摄像机,让镜头只在当前房间范围内移动。 + +### 6.2 地图组 API 建议 + +地图模块对主流程暴露: + +```gdscript +func setup(world_root: Node2D, spawn_marker: Marker2D, encounter_marker: Marker2D, rng: RandomNumberGenerator) -> void +func build() -> void +func activate_room(room_index: int, player_character: Node) -> void +func update_camera(room_index: int, player_character: Node, force: bool = false) -> void +func player_spawn_for_room(room_index: int) -> Vector2 +func encounter_spawn_for_room(room_index: int) -> Vector2 +func room_entrance_target(room_index: int) -> Vector2 +func room_exit_target(room_index: int) -> Vector2 +``` + +地图组不要直接生成敌人,也不要决定这一房间打什么怪。地图只告诉主流程:玩家站哪、敌人生成点在哪、镜头如何锁定、碰撞在哪里。 + +### 6.3 地图碰撞规范 + +- 地图本体碰撞只围住不可通过的墙、边界、建筑边缘,不要围住草地、道路或主可行走区域。 +- 每张图的碰撞应按视觉检查逐图调整,不能只用一个固定比例套所有图。 +- 小物件碰撞用素材非透明像素的最高、最低、最左、最右外框作为基础框,再按体验缩小 5%-15%,避免玩家被视觉外透明边卡住。 +- 可挡子弹/攻击的小物件需要加入 `projectile_blocker` 组,并设置合理 `collision_layer` / `collision_mask`。 +- 随机物件必须避开入口、出口、玩家出生点、敌人出生点和已有物件。 + +## 7. UI 模块 + +UI 当前包括: + +- `ui/character_select.*`:选人。 +- `ui/play_mode_select.*`:正常/调试模式选择。 +- `ui/battle_status.*`:战斗状态和流程提示。 +- `ui/knight_hud.*`、`ui/hud_meter_bar.gd`、`ui/cooldown_skill_icon.gd`:战斗 HUD。 +- `ui/accessory_choice.*`:饰品选择。 +- `ui/run_event_panel.*`:事件/商店/服务选择。 +- `ui/debug_enemy_select.*`、`ui/character_debug_status.*`:角色调试。 +- `ui/pause_menu.*`、`ui/result_screen.*`、`ui/settings_panel.*`、`ui/audio_settings_panel.*`:系统 UI。 + +UI 的边界: + +- UI 发出 `xxx_requested` 或 `xxx_selected` 信号。 +- UI 可以展示 `Dictionary` 状态。 +- UI 不直接改角色数值、金币、房间、BGM。 +- UI 文案应支持简体中文、繁体中文、英文三套,至少新增中文时不要破坏已有英文键。 + +## 8. 运行进度、奖励和饰品 + +### 8.1 `RunDirector` + +`systems/run/run_director.gd` 当前维护: + +- 金币。 +- 已清遭遇数。 +- 事件路线。 +- 本局修正。 +- 下一场临时准备效果。 +- 事件历史、奖励历史。 +- 英雄等级、经验、击杀数。 +- 城镇服务节点是否已使用。 + +这是后续“血脉火种、代际继承、皇帝持久血量”的自然扩展点,但不建议继续把所有规则塞进一个单例。建议拆成: + +| 系统 | 职责 | +|---|---| +| `RunDirector` | 单局进度、金币、事件路线、房间奖励 | +| `LineageDirector` | 血脉火种、代际编号、资质继承、死亡重生 | +| `EmperorProgress` | 皇帝跨代剩余血量、本大周目胜负 | +| `BuildDirector` | 技能分支、武器强化、饰品、消耗品槽 | + +### 8.2 饰品与构筑 + +`systems/accessories/accessory_manager.gd` 当前适合作为饰品数据和效果入口。建议继续保持: + +- 饰品数据集中注册。 +- 选择逻辑由管理器生成候选。 +- 角色只接收最终效果,不关心候选来源。 +- UI 只显示候选,不自己计算效果。 + +## 9. 音频模块 + +音频应统一通过 `audio/music_manager.gd` 和 `systems/run/audio_route.gd` 管理。推荐规则: + +- 地图、Boss、事件只返回 `music_profile` 或阶段标签。 +- `world.gd` 或后续 `AudioRouteBinder` 决定何时切换。 +- 不要在 Boss 脚本里直接 `Music.play_profile()`,否则转场、暂停和结果界面会打架。 +- 守关 Boss、王子/双子 Boss、最终 Boss 应使用不同 profile,避免同一首音乐误用。 + +## 10. C++/GDExtension 迁移边界 + +当前仓库有 `thirdparty/godot-cpp/` 和 `SConstruct`,说明已经具备后续 GDExtension 的基础依赖。但当前主流程仍以 GDScript 为主,且 C++ 扩展不应一次性吞掉整套玩法。 + +建议迁移顺序: + +1. 先迁移纯计算、无场景依赖、可单元测试的逻辑。 +2. 保持 GDScript API 不变,让主流程调用同名包装方法。 +3. 每迁移一个模块都保留 GDScript fallback 或测试场景。 + +优先候选: + +| 候选 | 原因 | +|---|---| +| 伤害结算 payload 解析 | 高频、纯计算、容易测试 | +| 碰撞/像素外框生成 | 地图物件多时可能偏重 | +| 敌人目标选择与转向 | 高频、规则固定 | +| 弹幕/投射物批量更新 | 数量多时收益明显 | +| 构筑修正合并 | 纯数据合并,便于保证一致性 | + +不建议首批迁移: + +- UI。 +- 选人和菜单流程。 +- 地图场景拼装。 +- 仍在频繁改规则的 Boss 技能。 + +## 11. 模块协作规则 + +### 11.1 新功能提交前要回答的问题 + +1. 这个功能属于哪个模块? +2. 它需要暴露给谁调用? +3. 暴露的是方法、信号还是数据表? +4. 有没有直接引用不该引用的模块? +5. 调试入口能不能单独测? +6. 改动后正常模式和调试模式是否都还能进? + +### 11.2 推荐依赖方向 + +推荐: + +```text +world/flow -> systems -> actors/characters -> combat +world/flow -> ui +world/flow -> audio +world/flow -> map +ui -> signals -> world/flow +``` + +避免: + +```text +characters -> world +boss -> ui +map -> encounter +ui -> RunDirector direct mutation +enemy -> Music +``` + +### 11.3 数据结构约定 + +跨模块传值尽量用 `Dictionary`,但必须固定键名。示例: + +```gdscript +var damage_payload := { + "amount": 24.0, + "source": self, + "source_id": "knight_slash", + "damage_type": "physical", + "can_crit": true, + "knockback": Vector2.ZERO, + "tags": ["melee", "weapon"] +} +``` + +不要让每个角色、敌人、Boss 都发不同格式的 payload。字段不确定时,先在文档里补,再写代码。 + +## 12. 测试与验收 + +推荐每次合入前至少做: + +1. Godot 打开项目无红色解析错误。 +2. 正常模式能从选人进入第一张图。 +3. 第一张图清完一轮怪后能进入下一张图。 +4. 守关 Boss 有形象、血条、武器、BGM。 +5. 双子 Boss 有形象、血条、武器、BGM。 +6. 角色调试模式能选择角色和测试敌人。 +7. 暂停、结果、音量设置能打开关闭。 +8. 地图碰撞不会挡住主路,墙体能挡住玩家。 + +若改到地图或 Boss 动画,必须实际打开试玩,不能只看代码。 + +## 13. 下一步解耦建议 + +优先级从高到低: + +1. 把 `world.gd` 的遭遇生成、清场和下一房间逻辑拆到 `systems/run/encounter_flow.gd`。 +2. 新建 `systems/run/lineage_director.gd`,承接血脉火种和代际重生。 +3. 新建 `systems/run/emperor_progress.gd`,承接皇帝跨代血量。 +4. 新建 `systems/interaction/interaction_service.gd`,统一拾取、商店、教堂、军需库、准备点优先级。 +5. 把角色统一 API 写成文档和测试脚本,角色组后续按 API 改。 +6. 把敌人/Boss 参数逐步表格化,减少主流程硬编码。 +7. C++ 先做伤害结算或地图物件碰撞生成,不要先碰 UI 和流程。 diff --git "a/todolist/\345\212\240\347\202\271\344\270\216\346\255\246\345\231\250\345\215\207\347\272\247\350\247\204\345\210\231\345\276\205\345\212\236.md" "b/todolist/\345\212\240\347\202\271\344\270\216\346\255\246\345\231\250\345\215\207\347\272\247\350\247\204\345\210\231\345\276\205\345\212\236.md" new file mode 100644 index 0000000..7e59dae --- /dev/null +++ "b/todolist/\345\212\240\347\202\271\344\270\216\346\255\246\345\231\250\345\215\207\347\272\247\350\247\204\345\210\231\345\276\205\345\212\236.md" @@ -0,0 +1,410 @@ +# 加点与武器升级规则待办 + +更新日期:2026-06-11 + +本文档用于固定后续要改的两个核心系统:代际加点规则、武器升级规则。当前代码中这两块都只是占位或半成品,后续实现时以本文档为准。 + +## 1. 当前问题 + +### 1.1 加点系统问题 + +当前代码里第一代开局没有加点,第一代固定为: + +```text +强壮 3 / 迅捷 3 / 专注 3 +``` + +死亡后,系统根据上一代评分生成下一代资质总点数,然后玩家再额外选择一项 `+1`。这会导致几个问题: + +- 玩家开局没有构筑参与感。 +- 加点只出现在死亡后,像死亡惩罚结算,不像角色构筑。 +- 当前手动加点是额外白送 `+1`,可能突破总点数上限。 +- 评分、随机分配、手动加点之间的关系不清楚。 + +### 1.2 武器升级系统问题 + +当前代码里有 `forge_edge`、`forge_focus`、`forge_guard` 等锻造奖励,但它们本质上是状态奖励或本局 modifier,不是真正的武器升级系统。 + +后续需要单独实现“武器升级系统”,不要再用普通关间事件替代。 + +## 2. 加点系统新规则 + +### 2.1 基础规则 + +每一代都有三项资质: + +```text +强壮 strength +迅捷 agility +专注 focus +``` + +第一代开局应有加点/微调入口,而不是直接 3/3/3 进游戏。 + +建议第一代基础总点数: + +```text +第一代:9 点 +``` + +第一代默认分配可以按职业偏向: + +```text +骑士:强壮 4 / 迅捷 3 / 专注 2 +游侠:强壮 2 / 迅捷 4 / 专注 3 +法师:强壮 2 / 迅捷 3 / 专注 4 +``` + +玩家开局可以微调 1 点: + +```text +从一个属性 -1,给另一个属性 +1 +``` + +注意:微调不是额外加点,不改变总点数。 + +### 2.2 死亡后的评分修正规则 + +每次死亡后,根据上一代评分修正下一代总点数。 + +评分修正: + +```text +S:下一代总点数 +3 +A:下一代总点数 +2 +B:下一代总点数 +1 +C:下一代总点数 +0 +D:下一代总点数 -1 +``` + +总点数上下限: + +```text +最低:7 点 +最高:15 点 +``` + +示例: + +```text +第一代 9 点,S 评分死亡 -> 第二代 12 点 +第二代 12 点,S 评分死亡 -> 第三代 15 点 +第三代 15 点,S 评分死亡 -> 第四代仍然 15 点 +``` + +```text +第一代 9 点,A 评分死亡 -> 第二代 11 点 +第二代 11 点,B 评分死亡 -> 第三代 12 点 +第三代 12 点,C 评分死亡 -> 第四代 12 点 +第四代 12 点,D 评分死亡 -> 第五代 11 点 +``` + +### 2.3 四次轮回与火种 + +如果初始火种为 5,则玩家最多经历: + +```text +第一代 + 4 次死亡后的继承者 +``` + +也就是最多能通过四次死亡继承来调整资质。 + +如果每次死亡评分都是 S: + +```text +第一代:9 +第二代:12 +第三代:15 +第四代:15 +第五代:15 +``` + +不会突破 15 点。 + +### 2.4 下一代点数分配方式 + +下一代总点数确定后,可以按职业权重自动分配初稿: + +```text +骑士偏强壮 +游侠偏迅捷 +法师偏专注 +``` + +然后玩家进行一次微调: + +```text +从一个属性挪 1 点到另一个属性 +``` + +不再使用“直接 +1”的方式。 + +### 2.5 评分和加点的耦合关系 + +新的耦合关系应改成: + +```text +上一代死亡表现 -> 评分等级 -> 下一代总点数变化 -> 自动分配 -> 玩家微调 +``` + +不要再让评分直接随机生成一个完全独立的总点数。 + +### 2.6 代码改动目标 + +需要改的核心位置: + +```text +systems/run/lineage_director.gd +ui/heir_select_panel.gd +world.gd +ui/lineage_hud.gd +``` + +建议新增或调整字段: + +```gdscript +var aptitude_total: int = 9 +var current_aptitude := { + "strength": 4, + "agility": 3, + "focus": 2 +} +``` + +建议新增方法: + +```gdscript +func grade_to_point_delta(grade: String) -> int +func generate_aptitude_from_total(total: int, family_id: String) -> Dictionary +func apply_manual_aptitude_shift(from_attribute: String, to_attribute: String) -> Dictionary +func get_aptitude_total() -> int +``` + +需要替换当前逻辑: + +```gdscript +apply_manual_aptitude_bonus(attribute_id) +``` + +这个方法现在是直接 `+1`,后续应替换成“从一项挪到另一项”的微调逻辑。 + +## 3. 武器升级系统新规则 + +### 3.1 触发时机 + +武器升级不作为每关奖励随机出现,而是固定关键节点奖励。 + +当前确定两个固定来源: + +```text +1. 打过收官 Boss 后固定获得一次武器升级 +2. 商店中可花钱购买一次武器升级机会 +3. 军需库中可选择武器升级或免费装备 +``` + +说明: + +- “收官 Boss”指阶段性关键 Boss,例如城镇守关 Boss 或当前章节的守关 Boss。 +- 商店中的武器升级应该是明确商品,不是随机普通状态奖励。 +- 军需库是空间交互节点,提供免费装备或武器升级类军备选项。 +- 武器升级不应混在 `bounty / pact / attunement / scout` 这种普通关间事件池里。 + +### 3.2 轮回后清零 + +武器升级只属于当前这一代。 + +规则: + +```text +角色死亡并进入下一代后,武器升级全部清零。 +``` + +原因: + +- 轮回继承的核心应该是资质和皇帝血量推进,不是武器词条永久继承。 +- 防止多代叠加后数值爆炸。 +- 让每一代都有重新构筑武器路线的空间。 + +### 3.3 升级类型 + +先做最小可实现版本,只包含攻速和伤害两类数值升级。 + +武器升级选项: + +```text +攻速 +10% +攻速 +15% +伤害 +15% +伤害 +30% +``` + +建议命名: + +```text +轻巧磨锋:攻速 +10% +疾速铭刻:攻速 +15% +锋刃强化:伤害 +15% +王印重铸:伤害 +30% +``` + +### 3.4 数值含义 + +攻速提升: + +```text +攻速 +10% = 普攻间隔 * 0.90 +攻速 +15% = 普攻间隔 * 0.85 +``` + +伤害提升: + +```text +伤害 +15% = 武器/普攻伤害 * 1.15 +伤害 +30% = 武器/普攻伤害 * 1.30 +``` + +如果当前角色有技能也使用武器伤害,可以先只作用于普攻,后续再决定是否影响技能。 + +### 3.5 升级选择方式 + +最小实现建议: + +```text +每次触发武器升级时,从 4 个升级中展示 3 个,玩家选择 1 个。 +``` + +也可以商店固定展示 2 个: + +```text +商店:展示 2 个武器升级,需要花金币购买 +收官 Boss:展示 3 个武器升级,免费选择 1 个 +``` + +具体 UI 可以复用现有 `run_event_panel` 的三选一形式,但需要在代码层面单独区分“武器升级”与“普通状态奖励”。 + +### 3.6 叠加规则 + +当前最小规则: + +```text +同一代最多获得 2 次武器升级 +升级可以叠加 +死亡后清零 +``` + +叠加示例: + +```text +选择伤害 +15%,再选择伤害 +30% +最终普攻伤害倍率 = 1.15 * 1.30 = 1.495 +``` + +```text +选择攻速 +10%,再选择攻速 +15% +最终普攻间隔倍率 = 0.90 * 0.85 = 0.765 +``` + +如后续觉得数值过强,可以改成同类取最高或做递减收益,但第一版先保持简单。 + +### 3.7 建议代码结构 + +建议新增: + +```text +systems/weapons/weapon_upgrade_manager.gd +ui/weapon_upgrade_panel.gd +``` + +核心状态: + +```gdscript +var current_upgrades: Array[String] = [] +var attack_damage_multiplier: float = 1.0 +var attack_interval_multiplier: float = 1.0 +``` + +建议 API: + +```gdscript +func reset_generation() -> void +func generate_upgrade_choices(count: int = 3, source: String = "boss") -> Array[Dictionary] +func apply_upgrade(upgrade_id: String, actor: Node) -> void +func apply_to_actor(actor: Node) -> void +func get_state() -> Dictionary +``` + +升级数据示例: + +```gdscript +{ + "id": "weapon_speed_10", + "name": "轻巧磨锋", + "summary": "攻速 +10%。", + "effects": { + "attack_interval_multiplier": 0.90 + } +} +``` + +```gdscript +{ + "id": "weapon_damage_30", + "name": "王印重铸", + "summary": "伤害 +30%。", + "effects": { + "attack_damage_multiplier": 1.30 + } +} +``` + +### 3.8 和现有系统的关系 + +需要注意: + +- 武器升级不是饰品。 +- 武器升级不是 `RunEffects` 中的普通事件奖励。 +- 武器升级不应该被 `AccessoryManager.reset_run()` 管理。 +- 轮回后武器升级清零,应在下一代重生时调用 `WeaponUpgradeManager.reset_generation()`。 +- 如果普通重新开始整个大周目,也要清零。 + +可以临时复用 UI,但系统层要独立。 + +## 4. 关间奖励节奏调整建议 + +当前每过一关都给饰品或状态奖励过于频繁。后续建议改成: + +```text +普通小怪关:金币/经验/少量掉落 +商店:固定可买武器升级,另有少量补给或饰品 +收官 Boss:固定武器升级 +关键剧情/皇帝门前:可给饰品、恢复、最终准备 +死亡轮回:资质变化,武器升级清零 +``` + +这样能让奖励职责更清楚: + +```text +资质:跨代成长 +武器:本代战斗成长 +饰品:本代构筑偏向 +消耗品:短期战术资源 +金币:商店购买资源 +``` + +## 5. 验收标准 + +### 5.1 加点系统验收 + +- 第一代开局能看到资质分配或微调界面。 +- 每代资质总点数始终在 7-15。 +- S/A/B/C/D 评分分别对应 +3/+2/+1/+0/-1。 +- 玩家微调不会改变总点数。 +- HUD 能显示当前代数、火种、三项资质和总点数。 + +### 5.2 武器升级系统验收 + +- 打过收官 Boss 后固定出现武器升级选择。 +- 商店固定提供武器升级购买入口。 +- 攻速 +10%、攻速 +15%、伤害 +15%、伤害 +30% 能正确作用于角色。 +- 死亡进入下一代后武器升级清零。 +- 武器升级不再混入普通每关事件奖励。 diff --git "a/todolist/\345\244\232\347\273\223\345\261\200\344\270\216\347\232\207\345\270\235\350\275\256\346\233\277\350\247\204\345\210\231\345\276\205\345\212\236.md" "b/todolist/\345\244\232\347\273\223\345\261\200\344\270\216\347\232\207\345\270\235\350\275\256\346\233\277\350\247\204\345\210\231\345\276\205\345\212\236.md" new file mode 100644 index 0000000..1eb8253 --- /dev/null +++ "b/todolist/\345\244\232\347\273\223\345\261\200\344\270\216\347\232\207\345\270\235\350\275\256\346\233\277\350\247\204\345\210\231\345\276\205\345\212\236.md" @@ -0,0 +1,364 @@ +# 多结局与皇帝轮替规则待办 + +更新日期:2026-06-11 + +本文档用于固定后续主线循环、结局和皇帝轮替规则。当前代码中,最终遭遇打完会直接进入胜利/结局结果页;后续设计应改成:打败王子后不立刻结束游戏,而是进入下一轮皇帝循环。 + +## 1. 当前代码状态 + +当前 `world.gd` 中,最终遭遇胜利后会调用: + +```gdscript +_complete_run_victory() +``` + +这个方法会: + +```text +记录最终 Boss 被击败 +判断结局类型 +显示胜利/结局结果页 +返回选择角色或结束当前流程 +``` + +当前已有的结局判断雏形: + +```text +普通胜利 victory +打碎王冠 true_ending +开发者房间 developer_room +火种耗尽 defeat/archive burned out +``` + +当前已有的最终 Boss 选择雏形: + +```text +第一轮 reincarnation <= 1:双子/王子 Boss +后续轮回:根据当前 family_id 选择骑士/游侠/法师对应最终 Boss +``` + +但这还不是完整设计。当前问题是: + +- 打过王子后流程会进入胜利页,像是游戏结束。 +- 没有把王子作为“下一轮皇帝/下一轮 Boss 循环”的起点。 +- 没有记录三个家族是否都当过皇帝。 +- 没有根据已登基家族限制玩家后续可选角色。 +- 没有根据当前皇帝家族改变小兵刷新比例。 + +## 2. 新主线循环目标 + +后续主线应改成: + +```text +第一轮:三家族可选 -> 打王子/双子 +打败王子后:不结束游戏,进入下一轮 +下一轮:上一轮选择/结局导致某个家族登基成为皇帝 +玩家只能从剩余家族中选角色 +继续挑战新皇帝 +再打败后继续轮替 +直到触发某个结局 +``` + +也就是说,王子不是最终结局,而是第一轮的关键 Boss。 + +## 3. 多结局设计 + +### 3.1 打碎王冠 + +结局定位: + +```text +真结局 / 终止轮回 +``` + +触发方向: + +- 玩家满足特定条件后,最终选择打碎王冠。 +- 打碎王冠后,王位循环结束。 +- 这是最理想结局。 + +当前代码已有 `true_ending / Crown Breaker` 的雏形,后续应保留并重写触发条件。 + +### 3.2 逃向远方 + +结局定位: + +```text +中性结局 / 放弃王冠 +``` + +触发方向: + +- 玩家在某次击败皇帝后选择不继承、不打碎王冠,而是逃离王国。 +- 可以作为一种提前结束路线。 +- 不一定是失败,但也没有解决王冠循环。 + +需要新增: + +```text +ending_kind = "escape" +``` + +### 3.3 火种熄灭 + +结局定位: + +```text +失败结局 +``` + +触发方向: + +- 血脉火种耗尽。 +- 皇帝仍未被彻底解决。 +- 当前档案结束。 + +当前代码已有火种耗尽失败结果页雏形,后续应把文案和结局类型统一到结局系统中。 + +建议: + +```text +ending_kind = "ember_extinguished" +``` + +### 3.4 戴上王冠 + +结局定位: + +```text +坏结局 +``` + +触发方向: + +- 经历四次轮回后,三个家族都曾经当过皇帝。 +- 玩家仍然选择戴上王冠。 +- 王冠循环没有被打破,只是换了新的统治者。 + +这是 bad ending。 + +建议: + +```text +ending_kind = "wear_crown_bad" +``` + +文案方向: + +```text +三个家族都证明过自己无法摆脱王冠。 +继承者终于坐上王座,却只是成为下一任囚徒。 +``` + +## 4. 皇帝轮替规则 + +### 4.1 家族登基记录 + +需要记录哪些家族已经当过皇帝: + +```gdscript +var crowned_families: Array[String] = [] +``` + +可能值: + +```text +knight +ranger +mage +``` + +每打完一轮并进入下一轮时,把对应家族加入 `crowned_families`。 + +### 4.2 可选角色限制 + +第一轮: + +```text +骑士 / 游侠 / 法师 都可选 +``` + +打过王子后进入下一轮: + +```text +只能从剩余两个家族中选择 +``` + +再打过下一任皇帝后: + +```text +只能从最后一个未登基家族中选择 +``` + +如果三个家族都当过皇帝: + +```text +进入最终结局分歧 +``` + +### 4.3 当前皇帝家族 + +需要维护: + +```gdscript +var current_emperor_family: String = "" +``` + +它决定: + +- 当前最终 Boss 使用哪个家族形态。 +- 普通小兵刷新权重。 +- 关卡文案和音乐倾向。 +- 后续结局条件。 + +## 5. 小兵刷新权重 + +当前想法: + +```text +当前皇帝对应家族的小兵,占比 50% +其余两大家族的小兵,共占 50% +``` + +建议第一版: + +```text +皇帝家族小兵:50% +其他家族 A:25% +其他家族 B:25% +``` + +示例: + +如果当前皇帝是法师: + +```text +法师系小兵:50% +骑士系小兵:25% +游侠系小兵:25% +``` + +如果当前皇帝是骑士: + +```text +骑士系小兵:50% +游侠系小兵:25% +法师系小兵:25% +``` + +如果当前皇帝是游侠: + +```text +游侠系小兵:50% +骑士系小兵:25% +法师系小兵:25% +``` + +## 6. Boss 轮替建议 + +第一轮: + +```text +王子/双子 Boss +``` + +后续轮次: + +```text +骑士家族登基 -> 骑士皇帝 Boss +游侠家族登基 -> 游侠皇帝 Boss +法师家族登基 -> 法师皇帝 Boss +``` + +注意:打败王子之后不应该显示最终结局,而应进入下一轮选择。 + +## 7. 需要新增或改造的系统 + +建议新增: + +```text +systems/run/throne_cycle_director.gd +``` + +职责: + +- 记录当前王冠循环进度。 +- 记录已经登基的家族。 +- 记录当前皇帝家族。 +- 给角色选择 UI 提供可选家族列表。 +- 给敌人生成系统提供小兵权重。 +- 给最终 Boss 选择逻辑提供 Boss 类型。 +- 判断是否进入结局分歧。 + +建议 API: + +```gdscript +func start_cycle() -> void +func get_available_families() -> Array[String] +func choose_challenger_family(family_id: String) -> void +func record_prince_defeated(winning_family: String) -> void +func record_emperor_defeated(winning_family: String) -> void +func get_current_emperor_family() -> String +func get_enemy_family_weights() -> Dictionary +func should_enter_final_choice() -> bool +func choose_ending(choice_id: String) -> Dictionary +func get_state() -> Dictionary +``` + +## 8. 需要改的现有位置 + +### 8.1 `world.gd` + +需要调整: + +- `_complete_run_victory()` 不应总是进入结果页。 +- 打败王子后应调用王冠循环推进,而不是结束游戏。 +- 最终 Boss 选择应由 `ThroneCycleDirector` 决定。 + +### 8.2 `ui/character_select.gd` + +需要调整: + +- 根据 `get_available_families()` 禁用已登基家族。 +- UI 明确说明为什么当前只能选两个或一个角色。 + +### 8.3 敌人生成系统 + +需要调整: + +- 普通敌人按当前皇帝家族权重生成。 +- 当前皇帝家族对应小兵占 50%。 + +### 8.4 `EndingDirector` + +需要调整: + +- 结局不只由无伤/教堂洗礼决定。 +- 新增打碎王冠、逃向远方、火种熄灭、戴上王冠 bad ending。 +- 将结局条件和王冠循环状态关联。 + +## 9. 结局选择点 + +当满足条件时,应该出现结局选择 UI,而不是自动结算。 + +可能选项: + +```text +打碎王冠 +逃向远方 +戴上王冠 +``` + +如果火种耗尽,则不选择,直接进入火种熄灭结局。 + +## 10. 验收标准 + +- 打败王子后不结束游戏,而是进入下一轮。 +- 下一轮只能选择剩余两个家族。 +- 再打败一任皇帝后,只能选择最后一个未登基家族。 +- 三个家族都当过皇帝后,进入最终结局分歧。 +- 当前皇帝家族对应小兵刷新比例为 50%。 +- 火种耗尽触发火种熄灭结局。 +- 可以触发打碎王冠结局。 +- 可以触发逃向远方结局。 +- 可以触发戴上王冠 bad ending。 diff --git "a/todolist/\346\225\231\345\240\202\345\225\206\345\272\227\345\206\233\351\234\200\345\272\223\344\272\244\344\272\222\350\212\202\347\202\271\345\276\205\345\212\236.md" "b/todolist/\346\225\231\345\240\202\345\225\206\345\272\227\345\206\233\351\234\200\345\272\223\344\272\244\344\272\222\350\212\202\347\202\271\345\276\205\345\212\236.md" new file mode 100644 index 0000000..b6c194f --- /dev/null +++ "b/todolist/\346\225\231\345\240\202\345\225\206\345\272\227\345\206\233\351\234\200\345\272\223\344\272\244\344\272\222\350\212\202\347\202\271\345\276\205\345\212\236.md" @@ -0,0 +1,281 @@ +# 教堂、商店、军需库交互节点待办 + +更新日期:2026-06-11 + +本文档用于固定后续场景交互节点设计。当前项目里很多服务被塞进了关间三选一卡牌系统,例如 `services`、`shop`、`rest`、`forge`。后续要把这些内容从“每关后随机/半随机事件”迁移成地图上的固定交互节点。 + +## 1. 总体目标 + +后续地图中应接入三个明确的服务节点: + +```text +教堂 +商店 +军需库 +``` + +它们应作为地图上的可交互对象出现,而不是每过一关都自动弹三选一奖励。 + +玩家靠近节点后按交互键打开对应 UI。UI 可以复用现在的卡牌样式,但系统逻辑要拆清楚。 + +## 2. 三个节点的定位 + +| 节点 | 核心定位 | 资源消耗 | 是否应随机 | +|---|---|---|---| +| 教堂 | 恢复、净化、稳定状态 | 免费或少量金币 | 不随机,固定交互 | +| 商店 | 花钱购买饰品、武器升级、状态 | 消耗金币 | 商品可随机,节点固定 | +| 军需库 | 武器升级或免费装备补给 | 免费或有限次数 | 选项可随机,节点固定 | + +## 3. 教堂 + +### 3.1 功能定位 + +教堂主要负责恢复和保命,不负责武器成长。 + +可提供: + +```text +恢复生命 +恢复护甲 +恢复灵感 +清除负面状态 +少量临时防护 +``` + +### 3.2 第一版建议选项 + +教堂打开后可以三选一: + +```text +医疗包:恢复 45% 生命 +守护蜡烛:恢复护甲与灵感 +野战修整:恢复护甲,并提升少量最大生命 +``` + +这些可以沿用当前 `RunEffects` 里的: + +```text +rest_heal +rest_focus +rest_repair +``` + +但后续触发来源要从关间事件改成地图交互节点。 + +### 3.3 使用次数 + +建议每个教堂节点每代只能使用一次。 + +```text +同一代用过后,该节点变成已使用状态。 +死亡轮回后,节点使用状态重置。 +``` + +## 4. 商店 + +### 4.1 功能定位 + +商店负责金币消费。后续商店应提供: + +```text +饰品 +花钱升级武器 +购买状态 +``` + +### 4.2 第一版商品类型 + +建议商品分三类: + +```text +饰品商品:购买后打开饰品选择或直接获得一件饰品 +武器升级商品:花金币获得一次武器升级选择 +状态商品:购买一项本代/本局状态强化 +``` + +### 4.3 当前已有可迁移内容 + +当前代码里已有这些商店选项: + +```text +shop_attack:花 28 金币,本局攻击伤害 +10% +shop_defense:花 26 金币,恢复护甲并提升最大护甲 +shop_relic:花 36 金币,获得一次额外饰品选择 +``` + +后续应调整成: + +```text +shop_relic -> 商店饰品 +shop_weapon_upgrade -> 花钱升级武器 +shop_status_attack / shop_status_defense -> 买状态 +``` + +### 4.4 商店商品数量 + +建议每次商店展示 3-4 个商品: + +```text +至少 1 个饰品相关 +至少 1 个武器升级相关 +至少 1 个状态相关 +第 4 格可随机补给/折扣/高价值商品 +``` + +玩家可以买多个,还是只能买一个,需要后续决定。第一版建议: + +```text +每个商店节点每代最多购买 1 次 +``` + +这样方便控制强度,也减少 UI 和经济复杂度。 + +## 5. 军需库 + +### 5.1 功能定位 + +军需库负责免费或低成本军备补给。它和商店区别是: + +```text +商店 = 花钱买 +军需库 = 免费拿一次,但选择更受限制 +``` + +军需库可以提供: + +```text +武器升级 +免费装备 +护甲/补给 +短期战斗准备 +``` + +### 5.2 第一版建议选项 + +军需库打开后三选一: + +```text +武器维护:获得一次武器升级 +轻甲补给:恢复护甲并提升少量最大护甲 +战备装备:获得一件免费装备或临时状态 +``` + +其中“武器维护”应接入独立武器升级系统,不再使用当前 `forge_*` 状态奖励冒充武器升级。 + +### 5.3 免费装备的定义 + +“免费装备”第一版可以先做成轻量规则,不做完整背包: + +```text +临时护甲 +一次性护盾 +短期攻击/移速状态 +免费消耗品 +``` + +如果后续装备系统来不及做完整,可以把免费装备先做成“装备卡/状态卡”,但 UI 文案要明确它不是饰品,也不是武器升级。 + +## 6. 和现有三选一系统的迁移关系 + +当前已有: + +```text +services:一次性三选一,里面包含教堂、军需库、商店推荐卡 +shop:完整商店事件,但不在普通事件池中 +rest:教堂恢复事件 +forge:锻造状态奖励 +``` + +后续迁移目标: + +```text +services -> 删除或只作为旧流程兼容 +rest_* -> 迁移到教堂节点 +shop_* -> 迁移到商店节点 +forge_* -> 部分废弃,部分迁移为军需库状态补给 +武器升级 -> 新建独立 WeaponUpgradeManager +``` + +不要继续把所有服务塞进过关后的随机事件池。 + +## 7. 推荐交互流程 + +### 7.1 地图上 + +```text +玩家进入服务房间 +看到教堂 / 商店 / 军需库节点 +靠近节点 +出现交互提示 +按交互键打开对应 UI +选择后节点标记为已使用 +继续前进 +``` + +### 7.2 UI 层 + +可以复用现有卡牌 UI 风格,但建议拆成独立入口: + +```text +ui/church_panel.gd +ui/shop_panel.gd +ui/armory_panel.gd +ui/weapon_upgrade_panel.gd +``` + +如果时间不够,也可以先复用 `run_event_panel.gd`,但调用时要传入明确的节点类型,不要继续走随机事件池。 + +## 8. 数据结构建议 + +服务节点统一数据: + +```gdscript +{ + "id": "shop_01", + "type": "shop", + "used": false, + "room_index": 5, + "choices": [] +} +``` + +商品数据: + +```gdscript +{ + "id": "shop_weapon_upgrade", + "type": "weapon_upgrade", + "name": "武器升级", + "cost": 45, + "summary": "花费金币,选择一项武器强化。", + "payload": { + "open_panel": "weapon_upgrade" + } +} +``` + +军需库选项: + +```gdscript +{ + "id": "armory_weapon_maintenance", + "type": "weapon_upgrade", + "name": "武器维护", + "cost": 0, + "summary": "免费获得一次武器升级。", + "payload": { + "open_panel": "weapon_upgrade" + } +} +``` + +## 9. 验收标准 + +- 地图上能看到教堂、商店、军需库节点。 +- 玩家靠近后能交互打开对应 UI。 +- 教堂能恢复状态。 +- 商店能购买饰品、花钱升级武器、购买状态。 +- 军需库能选择武器升级或免费装备。 +- 节点使用后不会反复无限领取。 +- 轮回后节点状态按设计重置。 +- 普通过关不再强制弹出大量状态/饰品奖励。 diff --git "a/todolist/\346\226\207\346\241\243\347\264\242\345\274\225.md" "b/todolist/\346\226\207\346\241\243\347\264\242\345\274\225.md" new file mode 100644 index 0000000..25f1b77 --- /dev/null +++ "b/todolist/\346\226\207\346\241\243\347\264\242\345\274\225.md" @@ -0,0 +1,89 @@ +# todolist 文档索引 + +更新日期:2026-06-12 + +这个文件夹用于集中放置后续交接、待办、架构和解释性文档。后面的同学或 Codex 接手时,可以优先从这里读,不需要在仓库里到处找说明。 + +## 1. 本轮新增的交接文档 + +| 文档 | 用途 | +|---|---| +| `代码架构指南.md` | 说明当前代码架构、模块职责、角色/地图/UI/敌人/Boss/音频边界,以及后续如何解耦 | +| `策划差距与待办指南.md` | 对照策划案说明当前完成度、未完成系统、P0/P1/P2/P3 待办 | +| `Cpp重构可行性与Codex交接总结.md` | 说明 C++/GDExtension 重构是否可行、推荐迁移顺序、给下一个 Codex 的交接上下文 | +| `加点与武器升级规则待办.md` | 固定后续加点系统和武器升级系统的新规则,包括评分修正、总点数上限、武器升级触发点和数值 | +| `角色组武器升级接口约定.md` | 给角色组的武器升级实现边界和 API 约定,说明整合组只调用接口,具体表现由角色组封装 | +| `教堂商店军需库交互节点待办.md` | 固定后续教堂、商店、军需库三个地图交互节点的定位、选项和迁移方式 | +| `多结局与皇帝轮替规则待办.md` | 固定后续多结局、王子后继续轮回、家族登基、可选角色限制和小兵刷新权重规则 | +| `消耗品槽与存档扩展待办.md` | 固定后续主动消耗品槽、复杂存档结构,以及技能成长第一版暂不进入的口径 | + +## 2. 旧文档副本位置 + +旧的解释性文档已经复制到: + +`todolist/现有文档副本/` + +这是副本,原文件仍保留在原位置。后续如果要长期维护,建议以原位置为准;如果只是交接和快速阅读,可以直接看这里。 + +## 3. 已复制的根目录文档 + +| 副本路径 | 原路径 | 用途 | +|---|---|---| +| `现有文档副本/README.md` | `README.md` | 项目基础说明 | + +## 4. 已复制的 docs 文档 + +| 副本路径 | 原路径 | 用途 | +|---|---|---| +| `现有文档副本/docs/PROJECT_STRUCTURE.md` | `docs/PROJECT_STRUCTURE.md` | 项目目录结构说明 | +| `现有文档副本/docs/CHARACTER_MODULE_API.md` | `docs/CHARACTER_MODULE_API.md` | 角色模块 API 说明 | +| `现有文档副本/docs/GDSCRIPT_CODE_STANDARDS.md` | `docs/GDSCRIPT_CODE_STANDARDS.md` | GDScript 中文代码规范 | +| `现有文档副本/docs/MAP_INTEGRATION_NOTES.md` | `docs/MAP_INTEGRATION_NOTES.md` | 地图整合说明 | +| `现有文档副本/docs/cpp-workflow.md` | `docs/cpp-workflow.md` | C++/GDExtension 工作流说明 | + +## 5. 已复制的分工与更新记录 + +| 副本路径 | 原路径 | 用途 | +|---|---|---| +| `现有文档副本/docs/分工/模块负责人协作与GDExtension接入说明.md` | `docs/分工/模块负责人协作与GDExtension接入说明.md` | 各模块负责人协作、GDExtension 接入说明 | +| `现有文档副本/docs/update/本轮整合改动记录.md` | `docs/update/本轮整合改动记录.md` | 本轮整合改动中文记录 | +| `现有文档副本/docs/update/地图修复总结.md` | `docs/update/地图修复总结.md` | 地图修复和碰撞相关总结 | + +## 6. 推荐阅读顺序 + +如果是整合组或下一个 Codex 接手,建议按这个顺序读: + +1. `文档索引.md` +2. `Cpp重构可行性与Codex交接总结.md` +3. `代码架构指南.md` +4. `策划差距与待办指南.md` +5. `加点与武器升级规则待办.md` +6. `角色组武器升级接口约定.md` +7. `教堂商店军需库交互节点待办.md` +8. `多结局与皇帝轮替规则待办.md` +9. `消耗品槽与存档扩展待办.md` +10. `现有文档副本/docs/PROJECT_STRUCTURE.md` +11. `现有文档副本/docs/CHARACTER_MODULE_API.md` +12. `现有文档副本/docs/GDSCRIPT_CODE_STANDARDS.md` +13. `现有文档副本/docs/分工/模块负责人协作与GDExtension接入说明.md` + +如果是地图组,优先读: + +1. `代码架构指南.md` 的地图模块部分 +2. `策划差距与待办指南.md` 的地图组任务 +3. `现有文档副本/docs/MAP_INTEGRATION_NOTES.md` +4. `现有文档副本/docs/update/地图修复总结.md` + +如果是角色组,优先读: + +1. `代码架构指南.md` 的角色模块部分 +2. `加点与武器升级规则待办.md` +3. `角色组武器升级接口约定.md` +4. `现有文档副本/docs/CHARACTER_MODULE_API.md` +5. `现有文档副本/docs/GDSCRIPT_CODE_STANDARDS.md` + +如果是 C++/GDExtension 组,优先读: + +1. `Cpp重构可行性与Codex交接总结.md` +2. `现有文档副本/docs/cpp-workflow.md` +3. `现有文档副本/docs/分工/模块负责人协作与GDExtension接入说明.md` diff --git "a/todolist/\346\226\260\347\211\210\347\263\273\347\273\237\346\225\264\345\220\210\346\224\271\345\212\250\350\257\246\347\273\206\350\257\264\346\230\216.md" "b/todolist/\346\226\260\347\211\210\347\263\273\347\273\237\346\225\264\345\220\210\346\224\271\345\212\250\350\257\246\347\273\206\350\257\264\346\230\216.md" new file mode 100644 index 0000000..07ed8fc --- /dev/null +++ "b/todolist/\346\226\260\347\211\210\347\263\273\347\273\237\346\225\264\345\220\210\346\224\271\345\212\250\350\257\246\347\273\206\350\257\264\346\230\216.md" @@ -0,0 +1,246 @@ +# 新版系统整合改动详细说明 + +本文档记录本次新版整合中已经落地的代码改动,方便负责地图、角色、UI、系统逻辑、后续 C++ 重构的同学理解当前实现边界。 + +## 一、版本融合与分支基础 + +本次开发前先从 GitHub 主仓库拉取了最新 `origin/master`,并将本地已有改动与远端合并后的版本融合。 + +本次改动基于当前主流程继续开发,没有重新推翻原有架构。主要目标是把轮回、结局、关间奖励、消耗品、饰品掉落等玩法逻辑接入主程序,同时尽量保持模块职责清晰。 + +## 二、轮回与家族选择逻辑 + +相关文件: + +- `systems/run/lineage_director.gd` +- `ui/character_select.gd` +- `ui/heir_select_panel.gd` +- `app_entry.gd` +- `world.gd` + +已实现内容: + +1. 轮回档案中记录当前家族、已登基家族、当前皇帝家族。 +2. 第一轮通关后进入下一轮时,会禁用已经登基过的家族。 +3. 选人界面支持接收禁用家族列表,并在 UI 上禁用对应角色。 +4. 三个家族都登基后,再次完成最终 Boss 且未触发真结局时,会进入“戴上王冠”的坏结局。 +5. 后继者加点逻辑由“直接加点”改成“属性转移”:从一个属性扣 1 点,给另一个属性加 1 点。 +6. 评分到点数变化规则已经接入:S/A/B/C/D 分别影响下一代基础点数。 + +当前设计边界: + +- 杀死阶段 Boss 不是大结局,只是轮回推进节点。 +- 大结局必须由明确结局条件触发。 +- 每一次选人/继承仍有 5 个血脉火种。 + +## 三、结局与存档封存 + +相关文件: + +- `systems/run/save_manager.gd` +- `systems/run/lineage_director.gd` +- `ui/save_slot_select.gd` +- `ui/result_screen.gd` +- `world.gd` + +已实现内容: + +1. 存档二进制记录新增 `ending_type` 字段。 +2. 最终结局触发后调用 `SaveManager.seal_active_ending()` 封存档案。 +3. 已封存档案不能继续进入游戏。 +4. 创档/选档界面会显示结局类型。 +5. 封存保护不会覆盖已有结局,避免真结局被后续开放结局覆盖。 + +当前支持的结局类型: + +- `break_crown`:打碎王冠,真结局。 +- `ember_extinguished`:五个火种耗尽,火种熄灭结局。 +- `escape`:奔向远方,开放结局。 +- `crown_bad`:三个家族都成为皇帝后仍戴上王冠,坏结局。 + +当前开放结局实现: + +- 普通阶段通关结果界面允许长时间停留触发“奔向远方”。 +- 真结局界面不再允许被开放结局覆盖。 + +## 四、关间奖励三选一 + +相关文件: + +- `world.gd` +- `ui/stage_reward_panel.gd` + +已实现内容: + +1. 普通关卡清场后,不再直接自动发奖励。 +2. 普通关卡清场后会打开独立的“关间奖励三选一”面板。 +3. 三张卡中混合出现“下一张小地图临时状态”和“消耗品”。 +4. 每次三选一至少包含 1 个状态奖励和 1 个消耗品奖励。 +5. 选择状态奖励后,会写入 `RunDirector` 的 pending encounter prep,只在下一张小地图生效。 +6. 选择消耗品后,会加入消耗品栏。 +7. 选择完成后才进入下一张地图。 + +状态稀有度: + +- D:50% +- C:30% +- B:15% +- A:5% + +当前状态池: + +- 战斗专注:下一张图提高攻击伤害。 +- 迅步:下一张图提高移动速度。 +- 守势呼吸:下一张图提高护甲和生命上限。 +- 澄心:下一张图缩短技能冷却。 + +注意: + +- 旧的 `run_event_panel` 仍保留,后续商店、教堂、军需库可以继续使用或重构。 +- 新的关间奖励面板 `stage_reward_panel.gd` 与商店事件解耦,不直接处理金币交易。 + +## 五、消耗品系统 + +相关文件: + +- `systems/consumables/consumable_manager.gd` +- `ui/consumable_bar.gd` +- `world.gd` +- `project.godot` + +已实现内容: + +1. 新增 `ConsumableManager` 自动加载单例。 +2. 新增 1-6 数字键消耗品栏。 +3. 消耗品栏显示图标、快捷键编号、数量。 +4. 消耗品可以由关间奖励获得,也可以由怪物掉落获得。 +5. 消耗品使用逻辑封装在 `ConsumableManager` 中,世界主流程只调用公开 API。 + +当前消耗品: + +- `bandage_pack`:绷带包,恢复 22% 最大生命。 +- `healing_vial`:急救瓶,恢复 35% 最大生命。 +- `medkit`:医疗箱,恢复 55% 最大生命。 +- `armor_patch`:轻甲包,恢复 45% 最大护甲。 +- `inspiration_ampoule`:守护蜡烛,恢复 45% 最大灵感。 +- `edge_oil`:磨刀油,使下一场战斗攻击伤害提升 10%。 + +已接入的消耗品素材: + +- `assets/ui/consumable/bandage_pack.png` +- `assets/ui/consumable/first_aid_bottle.png` +- `assets/ui/consumable/medkit.png` +- `assets/ui/consumable/light_armor_pack.png` +- `assets/ui/consumable/protective_candle.png` +- `assets/ui/consumable/sharpening_oil.png` + +## 六、怪物掉落与地图拾取物 + +相关文件: + +- `world.gd` +- `systems/pickups/run_pickup.gd` + +已实现内容: + +1. `RunPickup` 支持外部传入 `icon`,可以显示真实 PNG 图标。 +2. 消耗品掉落会显示对应消耗品素材。 +3. 饰品掉落会显示对应饰品素材。 +4. 没有图标时仍保留旧的代码绘制图形作为兜底。 + +掉落规则: + +- 小怪死亡:3% 概率掉落消耗品。 +- 小怪死亡:0.1% 概率掉落饰品。 +- Boss 死亡:随机掉落 1-2 个消耗品。 +- Boss 死亡:100% 掉落饰品。 +- 饰品稀有度按 D/C/B/A = 50/30/15/5 抽取后映射到现有饰品稀有度。 + +## 七、饰品展示位置 + +相关文件: + +- `ui/inventory_panel.gd` +- `world.gd` +- `systems/accessories/accessory_manager.gd` + +当前建议与实现方向: + +1. 战斗主 UI 只保留“当前装备饰品摘要”,避免战斗界面信息过载。 +2. 背包系统继续负责展示: + - 当前饰品。 + - 饰品描述。 + - 饰品历史。 + - 饰品图鉴/目录。 +3. 地图掉落中拾取饰品后会立即装备,并记录进背包历史。 + +后续如果要做“饰品背包多件持有”,建议不要直接塞进战斗 UI,而是在背包里做独立页签: + +- 当前装备 +- 已持有饰品 +- 消耗品 +- 图鉴 + +## 八、主流程变化 + +相关文件: + +- `world.gd` + +旧流程: + +1. 选人。 +2. 自动弹初始饰品。 +3. 战斗。 +4. 关间事件/饰品选择。 + +新流程: + +1. 选人。 +2. 直接进入第一张地图战斗。 +3. 普通关清场。 +4. 弹出关间奖励三选一。 +5. 选择奖励。 +6. 进入下一张地图。 +7. Boss / 最终 Boss 清场后进入阶段结算或最终结局判断。 + +## 九、测试更新 + +相关文件: + +- `tests/smoke_run_flow.gd` +- `tests/smoke_accessory_flow.gd` +- `tests/smoke_lineage_save.gd` + +已更新内容: + +1. 主流程测试不再要求“选人后必须弹初始饰品”。 +2. 主流程测试改为检查选人后直接进入战斗。 +3. 主流程测试新增关间奖励面板检查。 +4. 饰品测试改为手动打开饰品面板,验证饰品系统仍可生成选择并装备。 +5. 轮回/存档测试覆盖新增字段和继承逻辑。 + +已通过的 smoke 测试: + +- `tests/smoke_run_flow.gd` +- `tests/smoke_accessory_flow.gd` +- `tests/smoke_lineage_save.gd` +- `tests/smoke_story_endings.gd` +- `tests/smoke_ui_screens.gd` + +说明: + +Godot headless 测试结束时仍会打印 RID/resource 泄漏警告,这是当前项目中已有的 headless 退出警告;本次验证以进程退出码为准,以上测试退出码均为 0。 + +## 十、后续建议 + +1. 背包系统后续应增加“消耗品页签”和“已持有饰品页签”,现在只是消耗品栏和饰品历史。 +2. 商店、教堂、军需库应该复用 `ConsumableManager` 和后续武器升级 API,不要直接改角色字段。 +3. 关间奖励池应继续配置化,后续可从 JSON 或资源表读取。 +4. 饰品掉落目前是立即装备,未来如果要多件持有,需要引入饰品库存模块。 +5. C++ 重构时优先抽离: + - 存档记录读写。 + - 轮回评分与家族状态。 + - 奖励抽取与掉落表。 + - 消耗品栏数据结构。 + diff --git "a/todolist/\346\266\210\350\200\227\345\223\201\346\247\275\344\270\216\345\255\230\346\241\243\346\211\251\345\261\225\345\276\205\345\212\236.md" "b/todolist/\346\266\210\350\200\227\345\223\201\346\247\275\344\270\216\345\255\230\346\241\243\346\211\251\345\261\225\345\276\205\345\212\236.md" new file mode 100644 index 0000000..832019b --- /dev/null +++ "b/todolist/\346\266\210\350\200\227\345\223\201\346\247\275\344\270\216\345\255\230\346\241\243\346\211\251\345\261\225\345\276\205\345\212\236.md" @@ -0,0 +1,309 @@ +# 消耗品槽与存档扩展待办 + +更新日期:2026-06-11 + +本文档用于固定两个后续必须补的系统:主动消耗品槽、复杂存档。技能分支成长第一版暂不进入待办,不作为近期目标。 + +## 1. 第一版不做技能成长 + +当前角色已有基础技能,但策划稿中的: + +```text +每个职业 3 个技能分支 +每代只选 1 个主动技能 +后续两次强化该技能 +``` + +第一版暂时不做。 + +原因: + +- 当前角色和 Boss 已经有不少待打磨内容。 +- 加点、武器升级、服务节点、多结局、存档已经足够构成第一版主循环。 +- 技能成长需要大量 UI、数值和角色脚本配合,容易拖慢整合。 + +第一版技能目标改为: + +```text +保留现有角色技能 +保证技能能正常释放、伤害正确、UI 冷却正确 +不做技能分支选择 +不做技能强化树 +``` + +后续有余力再作为第二版扩展。 + +## 2. 消耗品槽 + +### 2.1 系统目标 + +玩家应拥有 1 个主动消耗品槽。 + +规则: + +```text +同一时间只能装备 1 种消耗品 +同类消耗品可堆叠数量 +异类消耗品需要弹出替换/放弃面板 +死亡或进入下一代后不继承 +``` + +### 2.2 消耗品来源 + +消耗品可以来自: + +```text +军需库免费装备/补给 +商店购买 +地图小物件/宝箱掉落 +教堂或特殊节点奖励 +Boss 后补给 +``` + +注意:消耗品不应该每关强制给,应该作为有限资源。 + +### 2.3 第一版消耗品类型 + +建议第一版只做 4-6 个。 + +| 消耗品 | 效果 | 类型 | +|---|---|---| +| 医疗瓶 | 恢复 25% 最大生命 | 瞬发 | +| 护甲包 | 恢复护甲,或获得少量护盾 | 瞬发 | +| 磨锋油 | 8 秒内攻击伤害 +15% | 短时状态 | +| 疾步药剂 | 8 秒内移动速度 +18% | 短时状态 | +| 聚灵药剂 | 立即恢复灵感,短时间技能冷却略降 | 瞬发/短时状态 | +| 烟幕瓶 | 短时间降低敌人追踪或创造逃生窗口 | 功能型,可选 | + +### 2.4 使用规则 + +建议: + +```text +瞬发型:按键后立即生效并扣数量 +短施放型:生效瞬间扣数量,被打断则不扣 +短时状态型:生效后显示持续时间 +``` + +第一版可以先全部做成瞬发或短时状态,不做复杂打断。 + +### 2.5 UI 需求 + +HUD 需要显示: + +```text +当前消耗品图标 +数量 +是否可用 +冷却/持续状态 +空槽提示 +``` + +拾取异类时需要显示替换面板: + +```text +保留当前 +替换为新消耗品 +放弃新消耗品 +``` + +### 2.6 建议代码结构 + +建议新增: + +```text +systems/consumables/consumable_manager.gd +systems/consumables/consumables.json +ui/consumable_slot.gd +ui/consumable_replace_panel.gd +``` + +核心 API: + +```gdscript +func reset_generation() -> void +func get_state() -> Dictionary +func can_pickup(consumable_id: String) -> bool +func pickup(consumable_id: String, amount: int = 1) -> Dictionary +func replace_with(consumable_id: String, amount: int = 1) -> void +func use_current(actor: Node) -> bool +func apply_effect(consumable_id: String, actor: Node) -> void +``` + +### 2.7 验收标准 + +- HUD 能显示当前消耗品槽。 +- 玩家能拾取消耗品。 +- 同类会堆叠。 +- 异类会弹替换面板。 +- 使用后数量减少。 +- 数量为 0 后槽位清空。 +- 死亡进入下一代后消耗品清空。 + +## 3. 存档复杂度扩展 + +### 3.1 为什么需要扩展 + +后续加入王冠循环、多结局、皇帝轮替、血脉火种、资质、武器升级、服务节点后,存档不能只保存一个简单 run 状态。 + +存档需要能恢复: + +```text +当前大周目/王冠循环 +当前第几代 +火种数量 +当前皇帝是谁 +哪些家族已经当过皇帝 +哪些家族还可选 +当前皇帝血量 +当前章节/房间进度 +当前资质 +当前金币和经济状态 +当前饰品 +当前武器升级 +当前消耗品 +服务节点使用状态 +已触发结局条件 +``` + +### 3.2 建议存档分层 + +建议把存档拆成几块,而不是所有字段平铺。 + +```gdscript +{ + "version": 2, + "slot_meta": {}, + "lineage": {}, + "throne_cycle": {}, + "run": {}, + "build": {}, + "map": {}, + "ending_flags": {} +} +``` + +### 3.3 `lineage` + +保存血脉和代际: + +```gdscript +{ + "family_id": "knight", + "generation_index": 2, + "seeds_left": 4, + "max_seeds": 5, + "aptitude_total": 12, + "aptitude": { + "strength": 5, + "agility": 4, + "focus": 3 + }, + "last_score": { + "grade": "A", + "score": 78 + } +} +``` + +### 3.4 `throne_cycle` + +保存王冠循环: + +```gdscript +{ + "cycle_index": 2, + "current_emperor_family": "mage", + "crowned_families": ["knight"], + "available_families": ["ranger", "mage"], + "prince_defeated": true +} +``` + +### 3.5 `run` + +保存当前局内状态: + +```gdscript +{ + "current_encounter_index": 5, + "cleared_encounters": 5, + "gold": 72, + "hero_level": 3, + "hero_xp": 18, + "total_kills": 24, + "event_history": [] +} +``` + +### 3.6 `build` + +保存构筑: + +```gdscript +{ + "accessory_id": "wolf_pendant", + "weapon_upgrades": ["weapon_damage_15", "weapon_speed_10"], + "consumable": { + "id": "healing_potion", + "count": 2 + }, + "run_modifiers": {} +} +``` + +注意: + +- 武器升级和消耗品是本代内容。 +- 死亡进入下一代后应清空。 +- 如果设计为断线恢复当前代,则存档内仍要保存当前代状态。 + +### 3.7 `map` + +保存地图和服务节点: + +```gdscript +{ + "chapter_id": "town", + "room_index": 5, + "used_service_nodes": { + "church_01": true, + "shop_01": false, + "armory_01": true + } +} +``` + +### 3.8 `ending_flags` + +保存结局条件: + +```gdscript +{ + "can_break_crown": false, + "can_escape": true, + "ember_extinguished": false, + "wear_crown_bad_ready": false +} +``` + +### 3.9 版本迁移 + +存档需要 `version` 字段。 + +```text +version = 1:当前旧存档 +version = 2:加入王冠循环、消耗品、武器升级后的新存档 +``` + +读取旧存档时要补默认值,不能直接报错。 + +### 3.10 验收标准 + +- 退出重进后,火种、代数、资质能恢复。 +- 退出重进后,当前皇帝家族、已登基家族、可选家族能恢复。 +- 退出重进后,皇帝剩余血量能恢复。 +- 退出重进后,武器升级、饰品、消耗品能恢复当前代状态。 +- 死亡进入下一代后,武器升级和消耗品清零。 +- 服务节点使用状态能保存。 +- 旧存档不会导致游戏崩溃。 diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/README.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/README.md" new file mode 100644 index 0000000..3507a02 --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/README.md" @@ -0,0 +1,99 @@ +# Infinity Kingdom + +Godot 4 action prototype with three playable families, a town boss rush, custom UI art, audio mix controls, and an accessory relic system. + +![Infinity Kingdom title art](assets/ui/background/title_screen_bg.png) + +## Features + +- Three playable families: Knight, Ranger, and Mage. +- Boss-rush loop with town mobs, elite bosses, cleaner victory/defeat flow, and no dead-end post-boss event. +- Accessory relic system with generated choices, current relic display, and stat modifiers. +- Mid-run event deck with shop, relic resonance, rest, training, and high-risk pact branches. +- Variable town enemy sweep waves instead of a single fixed opener every run. +- Custom UI art library for buttons, panels, icons, bars, portraits, and relic cards. +- Layered music, ambience, SFX, and an in-game audio mix panel. +- Headless smoke test for project boot and accessory selection flow. + +## Run + +1. Open `project.godot` with Godot 4.6 or newer. +2. Launch the project and enter through the title menu in `app_entry.tscn`. +3. Pick Knight, Ranger, or Mage. +4. Choose an accessory at the start and after each cleared encounter. + +On Windows, double-click `start_game.bat` to launch the game with the bundled local Godot copy if present. The script refreshes Godot imports first, so a freshly synced archive can start without opening the editor once by hand. + +Useful script options: + +```powershell +.\start_game.bat +.\start_game.bat -Editor +.\start_game.bat -Test +``` + +## Controls + +- `WASD`: move +- `J` or left mouse: attack +- `K`, `L`, `I`: skills +- `F10`: audio mix panel +- `Esc`: close audio panel +- `` ` ``: debug panel +- Gamepad: left stick move, south button attack, west/north/east buttons skills + +## Test + +```powershell +.\start_game.bat -Test +``` + +GitHub Actions runs the same smoke checks on every push and pull request. + +## Optional C++ Extension Scaffold + +The `src/`, `SConstruct`, and `demo/coursework_extension.gdextension.example` files are a scaffold for the later Godot C++ coursework extension. Normal gameplay, UI work, and smoke tests do not require compiling it. + +If you want to work on the C++ side: + +```powershell +git submodule update --init --recursive +Copy-Item demo\coursework_extension.gdextension.example demo\coursework_extension.gdextension +``` + +Then build the library with SCons as described in `docs/cpp-workflow.md`. + +## Export + +Export presets are committed for: + +- Windows Desktop: `build/windows/InfinityKingdom.exe` +- Web: `build/web/index.html` + +Install the matching Godot export templates, then export from the Godot editor or CLI. + +On Windows: + +```powershell +.\export_game.bat +.\export_game.bat -Preset Web +``` + +## Structure + +- `characters/`: playable character scenes and state machines +- `actors/`: enemies, encounters, and town bosses +- `combat/`: shared health and defense component +- `effects/`: damage numbers and projectiles +- `systems/accessories/`: accessory data, equip logic, and stat application +- `systems/run/`: run rewards, event sequencing, and run event effects +- `systems/feedback/`: combat feedback helpers such as hitstop +- `ui/`: HUD, character select, accessory choice, and UI skin helpers +- `assets/`: committed gameplay and UI art assets +- `audio/`: music, ambience, SFX managers, generated placeholder audio + +See `docs/PROJECT_STRUCTURE.md` for the runtime flow and accessory system notes. + +## License + +Public source repository. All rights reserved; see `LICENSE`. diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/CHARACTER_MODULE_API.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/CHARACTER_MODULE_API.md" new file mode 100644 index 0000000..e44e6ec --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/CHARACTER_MODULE_API.md" @@ -0,0 +1,173 @@ +# Character Module API + +This document is the contract between the character team and the integration team. + +The short version: character code may change internally, but external systems should only call the public methods and listen to the public signals listed here. This keeps the GDScript prototype ready for a later C++/GDExtension rewrite. + +## Module Boundary + +The character module owns: + +- Movement input interpretation for the active player actor. +- Character state machine and state transitions. +- Normal attack and skill execution. +- Character-local cooldowns, resource consumption, invulnerability, control effects, and death. +- Character visual presentation: body sprite, weapon sprite, attack arcs, local VFX, damage numbers. + +The character module must not own: + +- Run progression, rewards, relic choice, or level flow. +- Encounter spawning or wave sequencing. +- HUD layout or menu behavior. +- Enemy AI decisions. +- Global audio routing. + +## Public Signals + +Every playable character must expose these signals: + +```gdscript +signal attack_started(attack_name: StringName) +signal attack_hit(attack_name: StringName, target: Node) +signal attack_finished(attack_name: StringName) +signal skill_used(skill_name: StringName) +signal hp_changed(current_hp: float, max_hp_value: float) +signal inspiration_changed(current_inspiration: float, max_inspiration_value: float) +signal took_damage(amount: float, remaining_hp: float) +signal shield_changed(current_shield: float) +signal defense_changed(current_defense: float, max_defense_value: float) +signal control_status_changed(summary: String) +signal died +``` + +Optional, if the character supports build-time upgrades: + +```gdscript +signal upgrades_changed +``` + +## Public Methods + +Every playable character must expose these methods: + +```gdscript +func get_character_name() -> String +func emit_stat_signals() -> void + +func receive_hit(payload: Dictionary) -> void +func heal(amount: float) -> void + +func gain_inspiration(amount: float) -> void +func can_cast_skill(skill_name: StringName) -> bool + +func apply_control_effects(payload: Dictionary) -> void +func clear_control_effects(clear_silence: bool = false, clear_root: bool = true, clear_slow: bool = true) -> void +func get_control_status_text() -> String +func get_effective_move_speed() -> float +func is_silenced() -> bool +func is_rooted() -> bool +func is_slowed() -> bool +``` + +Optional upgrade API: + +```gdscript +func get_upgrade_sections() -> Array +func is_upgrade_enabled(upgrade_id: String) -> bool +func set_upgrade_enabled(upgrade_id: String, enabled: bool) -> void +func clear_all_upgrades() -> void +``` + +## Required Public Properties + +Integration code may read these properties for HUD and run systems: + +```gdscript +var hp: float +var max_hp: float +var inspiration: float +var max_inspiration: float +var defense: float +var max_defense: float +var shield: float +var move_speed: float +var attack_damage: float +var attack_interval: float +var crit_rate: float +var cooldowns: Dictionary +``` + +If C++/GDExtension replaces the GDScript character, these properties should remain exposed through Godot property binding. + +## Hit Payload Contract + +Characters receive damage through: + +```gdscript +receive_hit(payload: Dictionary) +``` + +The common payload keys are: + +```gdscript +{ + "source": Node, + "damage": float, + "crit_rate": float, + "damage_multiplier": float, + "damage_multiplier_duration": float, + "silence_duration": float, + "root_duration": float, + "slow_duration": float, + "slow_multiplier": float +} +``` + +Unknown keys must be ignored by the receiver. This lets relics, enemies, and later C++ systems add effects without breaking older characters. + +## Character Team Rules + +- Keep each character scene root as `CharacterBody2D`. +- Keep the root node in group `player`. +- Keep `HealthComponent` as the authority for HP, defense, shield, and death unless a replacement component exposes the same methods and signals. +- Do not make UI nodes children of a character. +- Do not directly control run flow from character code. +- Do not assume a specific HUD exists. +- Use signals for events that other modules need. +- Prefer helper components/scripts for visuals, skills, and payload construction instead of growing one huge character file. + +Recommended future split: + +```text +characters// + .tscn + .gd # public facade and high-level orchestration + state_machine.gd + states/*.gd + components/ + character_combat.gd # hit payloads, target selection, damage dispatch + character_controls.gd # silence/root/slow + character_visuals.gd # body, weapon, death texture, local VFX + skill_controller.gd # skill cooldowns/costs/requests +``` + +## Integration Team Rules + +- Spawn characters only from their `.tscn`. +- Bind HUD by signals and public properties. +- Apply relic/stat effects through `AccessoryManager` or a future combat-effect service. +- Do not reach into a character state node directly. +- Do not edit character internals from world/encounter scripts. +- Check for optional APIs with `has_method()` when possible. + +## C++/GDExtension Migration Target + +When moving to C++/GDExtension, keep the Godot-facing facade stable: + +- Same root scene path. +- Same signal names. +- Same method names. +- Same property names. +- Same payload dictionary keys. + +The implementation behind those APIs can move from GDScript to C++ without requiring the world, HUD, encounter, and relic modules to change. diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/GDSCRIPT_CODE_STANDARDS.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/GDSCRIPT_CODE_STANDARDS.md" new file mode 100644 index 0000000..a4560ee --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/GDSCRIPT_CODE_STANDARDS.md" @@ -0,0 +1,282 @@ +# GDScript 模块代码规范 + +本文先约束当前 GDScript 协作方式。C++/GDExtension 后续单独出规范;在 C++ 接入前,各板块都按这里写 facade、信号和数据结构。 + +## 总原则 + +1. 一个模块只暴露一个清晰入口,内部节点和临时变量不让其他模块直接访问。 +2. 模块之间用方法、信号、`Dictionary` snapshot 交互,不跨模块 `$SomeChild/DeepNode`。 +3. `world.gd` 只做流程编排,不继续承载地图、UI、人物、交互、数值细节。 +4. 新功能先写模块 API,再接主流程;不要先把逻辑塞进按钮回调或角色脚本里。 +5. 资源路径、平衡数值、掉落表、地图候选点优先集中到数据文件或模块常量,不散落在多个脚本。 + +## 命名规范 + +- 文件名:小写蛇形,如 `map_runtime.gd`、`interaction_service.gd`。 +- 场景名:模块名 + 类型,如 `KnightCharacter.tscn`、`AccessoryChoicePanel.tscn`。 +- 普通变量/函数:小写蛇形,如 `current_room_index`、`activate_room()`。 +- 信号:过去式或事件式,如 `character_selected`、`interaction_committed`。 +- 常量:全大写蛇形,如 `MAX_FIRE_SEEDS`。 +- `StringName` 状态值使用 `&"idle"`、`&"recover"` 这种形式。 + +## 文件结构顺序 + +推荐脚本按这个顺序写: + +```gdscript +extends Node + +signal changed(snapshot: Dictionary) + +const CONFIG := {} + +@export var enabled := true + +@onready var child: Node = $Child + +var runtime_state := {} + +func _ready() -> void: + pass + +func public_api() -> void: + pass + +func _private_helper() -> void: + pass +``` + +对外 API 放在私有 helper 前面,方便整合组快速看入口。 + +## 类型与返回值 + +- 公共方法必须标注参数和返回类型。 +- 能用 `Node2D`、`CanvasLayer`、`Texture2D` 等明确类型时不要写成裸 `Node`。 +- 模块 snapshot 用 `Dictionary`,字段名稳定,不随 UI 文案变化。 +- 数值统一用 `float` 或 `int`,不要同一字段有时字符串有时数字。 + +示例: + +```gdscript +func get_run_snapshot() -> Dictionary: + return { + "family_id": family_id, + "fire_seed": fire_seed, + "emperor_hp": emperor_hp + } +``` + +## 信号规范 + +信号只传必要数据,不传整个模块内部节点。 + +推荐: + +```gdscript +signal fire_seed_changed(current: int, max_value: int) +signal accessory_selected(payload: Dictionary) +``` + +不推荐: + +```gdscript +signal ui_clicked(button_node) +``` + +原因是接收方会开始依赖 UI 内部结构,后面重构很痛。 + +## UI 负责人规范 + +UI 只展示状态和发出玩家选择,不直接改玩法数据。 + +每个 UI 面板建议提供: + +```gdscript +signal confirmed(payload: Dictionary) +signal cancelled +signal closed + +func open(payload: Dictionary = {}) -> void +func close() -> void +func refresh(snapshot: Dictionary) -> void +``` + +UI 不允许: + +- 直接扣玩家金币。 +- 直接给角色加血。 +- 直接切换地图房间。 +- 直接播放战斗阶段 BGM。 + +UI 可以: + +- 显示金币、血量、火种、饰品、消耗品。 +- 把玩家选择通过 signal 发给整合组。 +- 做按钮、动画、提示和布局适配。 + +## 人物负责人规范 + +每个角色封装自己的素材、动画、碰撞、攻击、技能和冷却。整合组只调用角色 API。 + +人物至少提供: + +```gdscript +signal defeated +signal health_changed(current: float, max_value: float) +signal defense_changed(current: float, max_value: float) +signal skill_cast(skill_id: StringName) + +func setup_player_control(enabled: bool) -> void +func apply_run_effects(effects: Dictionary) -> void +func receive_hit(payload: Dictionary) -> void +func get_combat_snapshot() -> Dictionary +``` + +人物不允许: + +- 直接访问 HUD。 +- 直接创建地图物件。 +- 直接推进关卡。 +- 直接决定奖励。 + +## 敌人与 Boss 规范 + +敌人/Boss 对外统一是 damageable actor。 + +必须满足: + +- 加入 `damageable` group。 +- 能接收 `receive_hit(payload: Dictionary)`。 +- 死亡时发 `defeated`。 +- 子弹/近战效果自己管理,但不能直接改玩家 UI。 + +小怪资源要求: + +- 身体贴图在 `actors/enemy/textures`。 +- 武器贴图在 `art/final_materials/weapons`。 +- 场景里可以有临时 `Weapon` 节点,但最终显示由脚本或 Sprite 资源统一维护。 + +## 地图负责人规范 + +地图模块只负责房间几何和相机,不负责战斗胜负。 + +当前入口是: + +```gdscript +systems/map/map_runtime.gd +``` + +公开方法: + +```gdscript +func setup(world_root: Node2D, spawn_marker: Marker2D, encounter_marker: Marker2D, rng: RandomNumberGenerator) -> void +func build() -> void +func activate_room(room_index: int, player_character: Node) -> void +func update_camera(room_index: int, player_character: Node, force: bool = false) -> void +func player_spawn_for_room(room_index: int) -> Vector2 +func encounter_spawn_for_room(room_index: int) -> Vector2 +``` + +地图不允许: + +- 直接生成奖励。 +- 直接改角色血量。 +- 直接决定是否战斗结束。 + +地图必须保证: + +- 房间左右入口/出口位置清楚。 +- 相机只锁当前房间。 +- 墙体碰撞只围不可走区域。 +- 随机物件碰撞包住可见像素,能挡子弹/攻击。 + +## 交互负责人规范 + +交互模块负责“玩家按交互键时优先触发谁”。 + +推荐优先级: + +1. 关键场景节点。 +2. 饰品或特殊掉落。 +3. 商店。 +4. 消耗品拾取。 +5. 环境提示。 + +推荐 API: + +```gdscript +signal interaction_available(target: Dictionary) +signal interaction_committed(result: Dictionary) +signal interaction_cancelled + +func register_target(target: Node, data: Dictionary) -> void +func unregister_target(target: Node) -> void +func get_best_target(actor: Node2D) -> Dictionary +func request_interact(actor: Node2D) -> void +func confirm(payload: Dictionary = {}) -> void +func cancel() -> void +``` + +## 轮回/火种负责人规范 + +血缘火种和皇帝持久 HP 是 run state,不属于 UI,也不属于角色。 + +推荐 API: + +```gdscript +signal fire_seed_changed(current: int, max_value: int) +signal emperor_hp_changed(current: float, max_value: float) +signal generation_started(snapshot: Dictionary) +signal grand_run_failed(reason: StringName) + +func start_grand_run(family_id: StringName) -> void +func start_generation() -> Dictionary +func record_generation_death(snapshot: Dictionary) -> bool +func record_emperor_damage(amount: float) -> void +func get_run_snapshot() -> Dictionary +``` + +逻辑要求: + +- 初始火种为 `5`。 +- 每次死亡消耗 `1`。 +- 火种耗尽且皇帝未死,大轮回失败。 +- 同一大轮回内皇帝 HP 不回复。 + +## 整合负责人规范 + +整合组负责连线,不负责替其他组实现内部细节。 + +整合组可以改: + +- `world.gd` +- 模块 facade 的连接逻辑 +- 流程顺序 +- smoke 测试 + +整合组不应该改: + +- 人物内部动画节点。 +- UI 内部按钮布局。 +- 地图碰撞细节。 +- C++ 内部算法。 + +如果主流程需要新能力,先写成明确需求: + +```text +人物模块需要新增 get_combat_snapshot() 字段 "active_skill_cooldowns"。 +地图模块需要新增 get_room_exit_position(room_index)。 +UI 模块需要新增 refresh_bloodline(snapshot)。 +``` + +## 提交前检查 + +每次提交至少跑: + +```powershell +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --quit +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --script res://tests/smoke_run_flow.gd +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --script res://tests/smoke_player_control.gd +``` + +如果没跑,提交说明里必须写清楚原因。 diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/MAP_INTEGRATION_NOTES.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/MAP_INTEGRATION_NOTES.md" new file mode 100644 index 0000000..dec9528 --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/MAP_INTEGRATION_NOTES.md" @@ -0,0 +1,78 @@ +# Map Integration Notes + +Map assets found: + +```text +F:\无尽王国\infinity_kingdom\infinity_kingdom\game +F:\无尽王国\infinity_kingdom\infinity_kingdom\game\godot_project +``` + +The `game` folder contains large PNG room illustrations. Several filenames are mojibake, so the first integration step copied the usable room images into stable English paths: + +```text +res://assets/maps/stitched_demo/room_01_outer_entrance.png +res://assets/maps/stitched_demo/room_02_street_battle_1.png +res://assets/maps/stitched_demo/room_03_street_battle_2.png +res://assets/maps/stitched_demo/room_04_central_plaza.png +res://assets/maps/stitched_demo/room_09_elite_zone.png +res://assets/maps/stitched_demo/room_10_palace_hall.png +res://assets/maps/stitched_demo/room_11_palace_corridor.png +res://assets/maps/stitched_demo/room_12_king_gate.png +``` + +The `game/godot_project` folder also contains a greybox map prototype with room and corridor scenes. Its useful structure is: + +```text +scenes/rooms/*.tscn +scenes/corridors/*.tscn +scripts/room_base.gd +scripts/room_loader.gd +``` + +Recommended final direction: + +- Use room scenes as gameplay units. +- Keep room metadata on a room facade script. +- Let encounter systems query entrances, exits, and spawn points. +- Keep high-resolution room PNGs as visual backdrops. +- Add collisions/walk masks later, either by TileMap or by authored CollisionPolygon2D. + +For now, `res://tools/map_browser_demo.tscn` is a browsing prototype. It stitches room PNGs into a long route and gives a placeholder player plus camera. Adjacent room images are placed edge-to-edge: each room's left edge is treated as an entrance and its right edge is treated as an exit. + +The demo now uses the matching transparent prop layers under `res://assets/maps/stitched_demo/props/`. Each room has a set of visually checked prop candidates, and every run randomly chooses a few cover props per room. Generated cover props are `StaticBody2D` nodes with their own `CollisionShape2D`, so they can be used as temporary cover against bullets or melee attack traces. + +Camera and room flow: + +- The camera is locked to the active room and clamps inside that room's bounds. +- The camera zoom is intentionally closer than the old browser view, so a room is not shown all at once. +- The player cannot leave through the right exit until the active room is cleared. +- In this prototype, pressing `C` marks the active room as cleared. In the real integration, replace that with an enemy-death counter from the encounter system. +- After clearing, walking into the right exit transfers the player to the next room's left entrance. + +Main flow mapping in `res://world.gd`: + +```text +room_01_outer_entrance -> town mob encounter +room_02_street_battle_1 -> town mob encounter +room_03_street_battle_2 -> town mob encounter +room_04_central_plaza -> town mob encounter +room_09_elite_zone -> town mob encounter +room_10_palace_hall -> Judicator boss +room_11_palace_corridor -> Royal Guard Formation +room_12_king_gate -> Twin Princes boss +``` + +The first five rooms are treated as outside/approach rooms and use the six soldier enemy types. The first palace room uses the first boss. The king gate / throne-front room uses the final twin boss. + +The same demo also places six enemy material previews on top of the stitched route: + +```text +res://actors/enemy/textures/swordsman.png +res://actors/enemy/textures/shield.png +res://actors/enemy/textures/hunter.png +res://actors/enemy/textures/archer.png +res://actors/enemy/textures/arcanist.png +res://actors/enemy/textures/apprentice_mage.png +``` + +These are visual previews only. They do not run enemy AI or combat logic yet. diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/PROJECT_STRUCTURE.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/PROJECT_STRUCTURE.md" new file mode 100644 index 0000000..1535165 --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/PROJECT_STRUCTURE.md" @@ -0,0 +1,41 @@ +# Project Structure + +Infinity Kingdom is a Godot 4 action prototype centered on a short town boss-rush loop. + +## Runtime Flow + +1. `world.tscn` loads the title UI, audio managers, HUD, character select, and accessory choice UI. +2. The player selects Knight, Ranger, or Mage. +3. `AccessoryManager` resets the run and opens the first relic choice. +4. Encounters advance in order: a variable town enemy sweep, Judicator, Royal Guard Formation, Twin Princes. +5. `RunDirector` awards gold, then deals a short event deck of shop plus shuffled rest/training/pact/attunement branches. + +## Main Folders + +- `characters/`: playable character scenes, controllers, and state machines. +- `actors/`: enemy units, encounter controllers with variable waves, and town bosses. +- `combat/`: shared health and defense logic. +- `effects/`: damage numbers and projectile scenes. +- `systems/accessories/`: relic data, choice generation, stat application, and equipped state. +- `systems/run/`: run state, gold rewards, event sequencing, and event stat effects. +- `systems/feedback/`: combat feedback helpers such as hitstop. +- `ui/`: HUD, character selection, accessory choice, audio panel, and skin helpers. +- `assets/`: committed UI art, portraits, icons, bars, frames, and backgrounds. +- `audio/`: music/SFX managers, generated placeholder audio, and generation prompts. +- `tests/`: headless smoke tests for project boot and accessory flow. + +## Accessory System + +`systems/accessories/accessory_manager.gd` owns the accessory catalogue and current run state. +Accessories apply additive or percentage-based modifiers to actor stats, then resync health, +defense, inspiration, and HUD signals. `ui/accessory_choice.tscn` displays the generated choices +and emits the selected relic back to `world.gd`. + +Accessory data lives in `systems/accessories/accessories.json`. The manager validates the JSON at +load time and falls back to its built-in catalog if the file is missing or malformed. + +## Run Events + +`systems/run/run_director.gd` tracks gold, cleared encounters, and the remaining event deck. +`ui/run_event_panel.tscn` offers shop, rest, training, relic resonance, and pact choices; +`systems/run/run_effects.gd` applies the selected reward or tradeoff to the current character. diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/cpp-workflow.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/cpp-workflow.md" new file mode 100644 index 0000000..d4a42e9 --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/cpp-workflow.md" @@ -0,0 +1,150 @@ +# C++ 开发手册 + +这份文档说明你们应该如何在这个项目里编写 C++,并让 Godot 能直接调用这些代码。 + +## 1. 项目工作原理 + +你们写的 C++ 代码不会直接被 Godot 当脚本运行。 + +正确流程是: + +1. 在 `src/` 中编写 C++ 类 +2. 用 `godot-cpp` 方式绑定方法 +3. 在 `src/register_types.cpp` 里注册类 +4. 编译成动态库 +5. 编译后把 `demo/coursework_extension.gdextension.example` 复制成 `demo/coursework_extension.gdextension`,再由 Godot 加载 + +完成后,这些类就能在 Godot 编辑器和运行时中被使用。 + +## 2. 代码写在哪 + +你们的主要 C++ 代码都写在: + +- `src/你的类名.h` +- `src/你的类名.cpp` + +建议一类一对文件,结构清晰,方便 6 个人协作。 + +## 3. 新建一个可被 Godot 调用的类 + +头文件示例: + +```cpp +#pragma once + +#include +#include + +namespace godot { + +class EnemyAI : public Node { + GDCLASS(EnemyAI, Node) + +protected: + static void _bind_methods(); + +public: + void think(float delta); +}; + +} +``` + +源文件示例: + +```cpp +#include "enemy_ai.h" + +using namespace godot; + +void EnemyAI::_bind_methods() { + ClassDB::bind_method(D_METHOD("think", "delta"), &EnemyAI::think); +} + +void EnemyAI::think(float delta) { + // 在这里写你的玩法逻辑 +} +``` + +## 4. 一定要注册类 + +打开 `src/register_types.cpp`,把你的类注册进去: + +```cpp +GDREGISTER_CLASS(EnemyAI); +``` + +如果不注册,Godot 就看不到这个类。 + +## 5. 每次改完都要重新编译 + +第一次启用扩展前,先准备 Godot 的扩展清单: + +```powershell +Copy-Item demo\coursework_extension.gdextension.example demo\coursework_extension.gdextension +``` + +修改 C++ 后,执行: + +```bat +scripts\build_debug.bat +``` + +## 6. 在 Godot 中调用 C++ 类 + +重新编译成功后,可以在 GDScript 里直接使用: + +```gdscript +var enemy := EnemyAI.new() +enemy.think(0.016) +``` + +也可以在编辑器里把注册后的类直接作为节点添加到场景里。 + +## 7. 仓库里已经给好的示例 + +### `GameManager` + +作用: + +- 保存简单分数 +- 提供 `team_name` +- 提供 `add_score()` +- 提供 `get_status_text()` + +### `PlayerController` + +作用: + +- 继承 `CharacterBody2D` +- 提供 `speed` +- 接收 `Vector2` 输入进行移动 + +## 8. 推荐开发规范 + +- 每个人尽量负责不同系统,减少同时改同一文件 +- 头文件放声明,`.cpp` 放实现 +- 新系统尽量新建文件,不要全塞进一个类 +- 方法命名清晰稳定,方便场景和脚本调用 +- 提交前至少自己编译一次 +- 新功能尽量先在 `demo/` 里验证 + +## 9. 课程项目 6 人分工建议 + +1. 核心玩法逻辑 +2. 玩家控制、攻击、交互 +3. 敌人、AI、行为树或状态机 +4. 数据系统、分数、背包、存档 +5. Godot 场景、UI、演出与资源整合 +6. 构建、联调、合并和最终测试 + +## 10. 最重要的一点 + +这个项目是“用 C++ 写 Godot 扩展”,不是“把普通 C++ 文件扔进去就会自动运行”。 + +要让 Godot 调用到你的代码,必须完成这四步: + +- 写类 +- 绑定方法 +- 注册类 +- 重新编译 diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/update/\345\234\260\345\233\276\344\277\256\345\244\215\346\200\273\347\273\223.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/update/\345\234\260\345\233\276\344\277\256\345\244\215\346\200\273\347\273\223.md" new file mode 100644 index 0000000..b44e634 --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/update/\345\234\260\345\233\276\344\277\256\345\244\215\346\200\273\347\273\223.md" @@ -0,0 +1,141 @@ +# 地图修复总结 + +日期:2026-06-08 + +## 修复目标 + +本次地图修复主要解决游戏地图资源不完整、关卡路线不连续、后半段地图缺失、物品层和碰撞箱不匹配的问题。修复后,地图浏览器和正式游戏流程都可以使用 1-13 号房间资源,其中正式战斗流程仍保持原来的 9 场遭遇节奏。 + +## 资源补全 + +已补齐 `assets/maps/stitched_demo/` 下的后续地图底图: + +- `room_05_church.png` +- `room_06_armory.png` +- `room_07_shop.png` +- `room_08_inner_gate.png` +- `room_13_throne_room.png` + +同时补齐对应物品/前景层: + +- `props/room_05_props.png` +- `props/room_06_props.png` +- `props/room_07_props.png` +- `props/room_08_props.png` +- `props/room_13_props.png` + +Godot 资源导入文件 `.import` 也已生成并更新,确保项目打开或运行时可以直接加载这些图片资源。 + +## 地图路线修复 + +修改文件:`tools/map_browser_demo.gd` + +主要改动: + +- 将 `ROOM_PATHS` 扩展到完整 13 个房间。 +- 将 `ROOM_TITLES` 扩展到完整 13 个房间名称。 +- 将 `ROOM_PROP_LAYER_PATHS` 扩展到完整 13 个物品层。 +- 修正出口判断逻辑,让最后一个房间才显示最终出口。 +- 禁用了旧的随机物品摆放逻辑,避免旧物品随机覆盖新地图设计。 + +现在地图顺序为: + +1. Outer Entrance +2. Street Battle 1 +3. Street Battle 2 +4. Central Plaza +5. Church +6. Armory +7. Shop +8. Inner Gate +9. Elite Zone +10. Palace Hall +11. Palace Corridor +12. King Gate +13. Throne Room + +## 碰撞箱修复 + +修改文件:`tools/map_browser_demo.gd` + +主要改动: + +- 为 13 个房间重新配置 `WALKABLE_AREAS`,控制玩家可活动区域。 +- 为 13 个房间配置 `ROOM_WALL_COLLISIONS`,让墙体、边界、柱子、王座、装饰台阶等不可穿过。 +- 针对最后的王座房间单独缩小可行走范围,避免玩家走进王座、侧墙和装饰区。 +- 物品碰撞改为根据物品图片透明度计算基础碰撞框,再按需要微调尺寸。 +- 开启了物品碰撞调试轮廓,便于之后继续检查碰撞范围。 + +## 正式游戏流程适配 + +修改文件:`world.gd` + +原问题:地图现在有 13 个房间,但正式战斗流程不是 13 场战斗,如果直接一一对应,会打乱游戏节奏。 + +解决方式: + +- 新增 `ENCOUNTER_ROOM_INDICES` 映射表。 +- 正式战斗仍按 9 个遭遇推进。 +- 地图显示时将 9 个遭遇映射到 13 个地图房间中的关键节点。 + +当前映射为: + +- 遭遇 1-4:使用房间 1-4 +- 遭遇 5-9:跳到房间 9-13 +- 房间 5-8 作为完整地图浏览和路线资源保留,不强制插入战斗节奏 + +## 最终房间修复 + +修改文件:`systems/map/map_runtime.gd` + +主要改动: + +- 对最终王座房间的遭遇点做了特殊处理。 +- 最终 Boss/遭遇位置放在王座前方偏右的位置,而不是默认房间中央。 +- 这样可以避免角色、敌人和王座贴得太近,也更符合最后一关视觉构图。 + +## 合并上游后的兼容处理 + +合并 `xinxiangchen/infinity_kingdom` 后续分支改动时,地图相关逻辑做了兼容: + +- 保留了我们新增的 13 房间地图资源。 +- 保留了上游的一波一图、Boss 视觉、空遭遇等新逻辑。 +- 解决了 `tools/map_browser_demo.gd` 的合并冲突。 +- 正式流程和地图房间不再强制一一对应,避免上游战斗流程被地图数量破坏。 + +## 测试结果 + +已使用 Godot 4.6.3 跑完整项目测试: + +```powershell +.\start_game.bat -Test +``` + +结果: + +- 测试退出码为 `0`。 +- 没有 `ERROR`。 +- 没有 `SCRIPT ERROR`。 +- 没有 `FAILED`。 +- 额外补跑并加入了 `tests/smoke_boss_visuals.gd`,Boss 视觉测试通过。 + +## 相关提交 + +地图修复主提交: + +```text +e50a4ac Fix map route resources and collision setup +``` + +合并上游新版本提交: + +```text +5894d4e Merge remote-tracking branch 'fork/one-wave-per-map' into integrate-morenew-character +``` + +## 后续注意 + +- 如果之后继续替换地图图片,需要同步检查对应 `.import` 文件。 +- 如果新增战斗关卡,需要同时调整 `ENCOUNTER_ROOM_INDICES`。 +- 如果要让房间 5-8 也进入正式战斗流程,需要重新设计遭遇数量和 Boss 节奏。 +- 如果碰撞范围视觉上不舒服,可以打开碰撞调试后继续微调 `ROOM_WALL_COLLISIONS`。 diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/update/\346\234\254\350\275\256\346\225\264\345\220\210\346\224\271\345\212\250\350\256\260\345\275\225.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/update/\346\234\254\350\275\256\346\225\264\345\220\210\346\224\271\345\212\250\350\256\260\345\275\225.md" new file mode 100644 index 0000000..1ffb09b --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/update/\346\234\254\350\275\256\346\225\264\345\220\210\346\224\271\345\212\250\350\256\260\345\275\225.md" @@ -0,0 +1,290 @@ +# 本轮整合改动记录 + +本文记录本轮我在项目里完成的主要工作,方便后续在 PR、答辩、组内交接时说明“这一轮到底改了什么”。内容按模块整理,全部面向当前 Godot/GDScript 项目。 + +## 总体目标 + +这轮改动的核心目标是把项目从“能跑的单体大文件”推进到“适合多人协作的大作业工程”: + +- 接入新版 morenew 角色素材和角色代码。 +- 把地图、人物、UI、交互逻辑逐步解耦。 +- 给角色组、地图组、UI 组、整合组留下清晰 API 和规范。 +- 做出可以单独调试角色和敌人的入口。 +- 把地图、敌人、Boss、音频和 C++ GDExtension 脚手架都纳入主仓库。 + +## 角色接入 + +本轮把 morenew 的角色资源接进主项目,并保留原来的选人流程。 + +已经完成: + +- 接入骑士、游侠、法师三类角色场景。 +- 主流程选人后能实例化对应角色。 +- 角色素材、武器、死亡图、技能图标等资源进入项目结构。 +- 角色可以和主流程 HUD、战斗、受击、死亡逻辑联动。 +- 写了角色模块 API 文档,要求角色组后续封装自己,对外只暴露方法和信号。 + +相关文档: + +- `docs/CHARACTER_MODULE_API.md` +- `docs/GDSCRIPT_CODE_STANDARDS.md` + +## 地图与关卡整合 + +地图部分从单独 demo 开始,逐步接入到主流程。 + +已经完成: + +- 把多张地图房间横向拼接。 +- 每个房间左右有入口/出口概念。 +- 相机锁定当前房间,不会跨房间乱移动。 +- 玩家清完当前房间敌人后,才进入下一张图。 +- 城外阶段使用小兵波次。 +- 皇宫阶段放 Boss: + - 第一个皇宫 Boss:审判官。 + - 王座前最终 Boss:双子王子。 + +地图碰撞也做了多轮修正: + +- 地图本身的碰撞改为围住墙体和不可走边界。 +- 不再用整片草地/地面当碰撞。 +- 逐图维护墙体碰撞框。 +- 地图上的小物件使用素材图 alpha 像素包围盒算法生成碰撞框。 +- 小物件可以作为掩体,阻挡子弹或攻击。 +- 主流程地图会随机生成部分小物件,增加躲避空间。 + +相关文件: + +- `tools/map_browser_demo.gd` +- `systems/map/map_runtime.gd` +- `assets/maps/stitched_demo/` + +相关文档: + +- `docs/MAP_INTEGRATION_NOTES.md` + +## 小兵与 Boss + +本轮把 6 种小兵和两个主要 Boss 接进主流程和调试流程。 + +小兵包括: + +- 剑兵 +- 盾卫 +- 弓手 +- 猎手 +- 学徒法师 +- 秘术师 + +Boss 包括: + +- 审判官 +- 双子王子 + +已完成: + +- 城外阶段全部使用小兵波次。 +- 皇宫阶段进入 Boss。 +- 角色调试入口中可以选择 6 种小兵和 2 个 Boss 作为测试目标。 +- 小兵武器显示修复:之前场景里 `Weapon` 节点默认隐藏,现在会根据敌人类型自动挂真实武器贴图。 +- 小怪支持普通/精英武器图。 + +相关文件: + +- `actors/enemy/` +- `actors/bosses/town/` +- `ui/debug_enemy_select.gd` +- `tools/character_debug_world.gd` + +## 音频接入 + +本轮把音频 zip 包里的 BGM 放入对应阶段,并接入主流程播放逻辑。 + +已完成: + +- 标题界面 BGM。 +- 城外小兵战 BGM。 +- 皇宫探索/过渡 BGM。 +- 王宫守卫阶段 BGM。 +- 皇帝/最终 Boss 阶段 BGM。 +- 教堂/间歇阶段 BGM。 +- 胜利和失败 BGM。 + +同时把阶段到 BGM profile 的映射从 `world.gd` 中拆出来: + +- `systems/run/audio_route.gd` + +这样后续音频组只需要维护 profile 和资源,不需要改主流程。 + +## 架构解耦 + +本轮重点拆了 `world.gd` 中过重的职责。 + +已完成: + +- 地图运行时逻辑拆到 `systems/map/map_runtime.gd`。 +- 音频阶段路由拆到 `systems/run/audio_route.gd`。 +- 新增模块负责人协作文档,约定 UI、人物、地图、交互、轮回、C++ 的边界。 +- 新增 GDScript 代码规范,要求模块之间通过 API、信号、snapshot 交互。 + +当前设计原则: + +- `world.gd` 只做主流程编排。 +- 人物模块只管自己移动、攻击、技能、受击、动画。 +- 地图模块只管房间、碰撞、相机、出生点、战斗点。 +- UI 模块只展示状态和发出玩家选择,不直接修改玩法数据。 +- 交互逻辑后续独立成 service,不塞进角色或 UI。 +- 血缘火种和皇帝持久 HP 后续属于 run state,不属于 UI 或角色。 + +相关文档: + +- `docs/MODULE_OWNERSHIP_AND_GDEXTENSION.md` +- `docs/GDSCRIPT_CODE_STANDARDS.md` +- `docs/PROJECT_STRUCTURE.md` + +## 角色调试入口 + +本轮新增了一个独立角色调试入口,专门给角色组和整合组测试角色。 + +当前启动流程: + +1. 进入主入口。 +2. 先使用原来的选人 UI 选择角色。 +3. 选完后出现新的模式选择 UI: + - 正常流程 + - 角色调试 +4. 选择正常流程会进入完整主游戏。 +5. 选择角色调试会进入无地图测试场。 + +角色调试场特点: + +- 不加载地图。 +- 不走饰品奖励。 +- 不走关卡结算。 +- 有简单方块测试场。 +- 有独立角色状态 HUD。 +- 可以选择测试敌人。 +- 可以选择训练假人、小兵、Boss。 +- 可以按 `R` 重置目标。 +- 可以按 `Esc` 返回选人。 + +新增 UI: + +- `ui/play_mode_select.gd` +- `ui/debug_enemy_select.gd` +- `ui/character_debug_status.gd` + +新增入口: + +- `app_entry.tscn` +- `app_entry.gd` + +新增启动上下文: + +- `systems/run/startup_context.gd` + +## C++ GDExtension 脚手架 + +本轮把已有 C++ GDExtension 插件脚手架接入项目,保证后续 C++ 大作业重构可以继续推进。 + +已完成: + +- 添加 `SConstruct`。 +- 添加 `demo/coursework_extension.gdextension.example`,默认不自动加载未编译的动态库。 +- 添加 `src/` 下的 C++ 示例类。 +- 添加 `thirdparty/godot-cpp` submodule。 +- 修正 `.gdextension` 示例动态库路径为当前项目结构下的 `res://demo/bin/...`。 +- 本地已使用 `scons platform=windows target=template_debug -j8` 编译通过。 + +注意: + +- GitHub 下载 ZIP 不会自动带完整 submodule。 +- 要编译 C++,推荐使用: + +```bash +git clone --recursive https://github.com/xinxiangchen/infinity_kingdom.git +``` + +如果已经 clone 但 submodule 没拉: + +```bash +git submodule update --init --recursive +``` + +相关文档: + +- `docs/cpp-workflow.md` +- `docs/MODULE_OWNERSHIP_AND_GDEXTENSION.md` + +## Git 与仓库状态 + +本轮已经把代码推送到你的主仓库: + +- 主仓库:`xinxiangchen/infinity_kingdom` +- 主分支:`master` +- 整合分支:`integrate-morenew-character` + +当前推荐协作方式: + +- 你的仓库作为主仓库。 +- 其他同学从你的仓库 fork 或 clone。 +- 每个人从 `master` 拉自己的功能分支。 +- 完成后向 `xinxiangchen/infinity_kingdom:master` 提 PR。 + +推荐分支命名: + +- `feature/character-refactor` +- `feature/map-collision` +- `feature/ui-polish` +- `feature/interaction-system` +- `feature/bloodline-run-state` + +## 本轮验证 + +本轮多次执行了 Godot smoke 和 C++ 构建验证。 + +常用验证命令: + +```powershell +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --quit + +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --script res://tests/smoke_run_flow.gd + +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --script res://tests/smoke_player_control.gd + +scons platform=windows target=template_debug -j8 +``` + +本轮主要提交都通过了基础加载和 smoke 检查。 + +## 后续建议 + +角色组: + +- 按 `docs/CHARACTER_MODULE_API.md` 和 `docs/GDSCRIPT_CODE_STANDARDS.md` 重构角色。 +- 不要让角色脚本直接访问地图或 UI。 +- 角色只暴露方法和信号。 + +地图组: + +- 继续逐图修墙体碰撞。 +- 维护物件候选和掩体碰撞。 +- 新地图先在地图 demo 中验证,再进主流程。 + +UI 组: + +- UI 只接收 snapshot 和发出 signal。 +- 不直接改角色血量、金币、地图状态。 +- 后续血缘火种和皇帝 HP 需要进 HUD。 + +整合组: + +- 保持 `world.gd` 只做编排。 +- 新逻辑优先抽 service/facade。 +- 每次合并前跑 smoke。 + +C++ 组: + +- 优先迁移纯逻辑。 +- 不要先迁 UI 或 Godot 场景节点。 +- 第一批适合迁移:血缘火种、皇帝持久 HP、战斗公式、奖励随机。 diff --git "a/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/\345\210\206\345\267\245/\346\250\241\345\235\227\350\264\237\350\264\243\344\272\272\345\215\217\344\275\234\344\270\216GDExtension\346\216\245\345\205\245\350\257\264\346\230\216.md" "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/\345\210\206\345\267\245/\346\250\241\345\235\227\350\264\237\350\264\243\344\272\272\345\215\217\344\275\234\344\270\216GDExtension\346\216\245\345\205\245\350\257\264\346\230\216.md" new file mode 100644 index 0000000..cc6e04f --- /dev/null +++ "b/todolist/\347\216\260\346\234\211\346\226\207\346\241\243\345\211\257\346\234\254/docs/\345\210\206\345\267\245/\346\250\241\345\235\227\350\264\237\350\264\243\344\272\272\345\215\217\344\275\234\344\270\216GDExtension\346\216\245\345\205\245\350\257\264\346\230\216.md" @@ -0,0 +1,352 @@ +# 模块负责人协作与 C++ GDExtension 接入说明 + +本文给 UI、人物、地图、交互/玩法、整合、C++ GDExtension 几个负责人对齐使用。目标是让每个模块“自己封装自己”,主程序只通过公开 API 和信号交互,后续加入血缘火种与 C++ 重构时不需要重写整条主流程。 + +参考策划案:`C:\Users\15312\Desktop\infinity_kingdom\specdocs\轮回王国_融合精简策划案.md` + +## 一句话目标 + +`world.gd` 只做流程编排:选人、进房间、开战、结算、进下一房间。人物、地图、UI、交互、音频、数值/轮回系统都通过 facade 脚本暴露接口,不能互相直接钻进内部节点改状态。 + +## 当前拆分现状 + +| 模块 | 当前入口 | 负责人应该维护 | 主程序允许调用 | +| --- | --- | --- | --- | +| 主流程/整合 | `world.gd` | 路线推进、战斗开始/结束、奖励/事件入口、模块连接 | 各模块公开 API 和信号 | +| 人物 | `characters/*/*.tscn`、`characters/*/*.gd` | 移动、攻击、技能、动画、生命/防御组件、人物素材 | `setup_player_control()`、`apply_run_effects()`、生命/攻击信号 | +| 敌人与 Boss | `actors/enemy`、`actors/bosses`、`actors/encounters` | 小兵/Boss 行为、刷怪组合、子弹与受击 | encounter 的 `started/completed/failed` 信号 | +| 地图 | `systems/map/map_runtime.gd`、`tools/map_browser_demo.gd`、`assets/maps` | 房间拼接、墙体碰撞、物件碰撞、相机锁房间、出生点/战斗点 | `activate_room()`、`player_spawn_for_room()`、`encounter_spawn_for_room()`、`update_camera()` | +| UI | `ui/*`、各 CanvasLayer 场景 | HUD、选择界面、暂停、设置、奖励、事件、结算 | `show/open/close/bind/update` 类方法,外加信号 | +| 交互/玩法系统 | `systems/run`、`systems/pickups` | 饰品、事件、拾取、商店/教堂/军械库、未来血缘火种 | 统一请求/确认/取消 API | +| 音频 | `audio/music_manager.gd`、`systems/run/audio_route.gd` | BGM 资源、阶段 profile、混音设置 | `play_profile(profile_id)` 或 `AudioRoute` | + +## 模块边界规则 + +1. 人物脚本不能直接访问 HUD、地图、`world.gd` 的子节点。人物只发信号,或暴露查询/设置方法。 +2. UI 不能直接修改人物、地图、Boss 血量和奖励数据。UI 只展示数据,并通过信号发出玩家选择。 +3. 地图模块不能知道角色职业、技能、奖励逻辑。它只负责房间几何、碰撞、相机、出生点和战斗点。 +4. 交互模块不能控制 UI 布局。它只产出“当前可交互对象、交互结果、奖励/消耗数据”。 +5. 整合组不能直接改人物内部节点名来完成需求,必须要求人物组补公开 API。 +6. 后续 C++ 只迁纯逻辑和数据计算,不迁 UI 场景、不迁素材节点树。 + +## 人物模块 API + +详细人物规范见 `docs/CHARACTER_MODULE_API.md`。角色组后续重构时至少保证这些能力: + +```gdscript +signal defeated +signal health_changed(current: float, max_value: float) +signal defense_changed(current: float, max_value: float) +signal attack_performed +signal skill_cast(skill_id: StringName) + +func setup_player_control(enabled: bool) -> void +func apply_run_effects(effects: Dictionary) -> void +func receive_damage(amount: float, source: Node = null) -> void +func heal(amount: float) -> void +func get_combat_snapshot() -> Dictionary +``` + +人物组需要把“素材图、动画、碰撞体、攻击判定、技能冷却”封装在角色场景里。整合组只关心角色能不能移动、能不能攻击、当前血量是多少、角色死亡信号有没有发出。 + +角色资产建议结构: + +```text +characters/ + knight/ + knight.tscn + knight.gd + textures/ + animations/ + ranger/ + mage/ +``` + +## 地图模块 API + +当前主程序已接入 `systems/map/map_runtime.gd`。地图负责人维护地图数据和碰撞,不需要去改 `world.gd` 的主流程。 + +```gdscript +func setup(world_root: Node2D, spawn_marker: Marker2D, encounter_marker: Marker2D, rng: RandomNumberGenerator) -> void +func build() -> void +func activate_room(room_index: int, player_character: Node) -> void +func update_camera(room_index: int, player_character: Node, force: bool = false) -> void +func player_spawn_for_room(room_index: int) -> Vector2 +func encounter_spawn_for_room(room_index: int) -> Vector2 +``` + +地图数据暂时集中在 `tools/map_browser_demo.gd`: + +- `ROOM_PATHS`:每张房间图。 +- `ROOM_WALL_COLLISIONS`:只围墙体/不可走边界的碰撞框。 +- `ROOM_PROP_LAYER_PATHS`:每张房间对应的小物件图层。 +- `PROP_CANDIDATES`:可随机生成的掩体物件候选。 +- `calculate_prop_collision_rect()`:按素材 alpha 的最高、最低、最左、最右有效像素生成物件碰撞框。 + +后续建议把这些常量迁到 `data/maps/*.tres` 或 JSON,避免工具脚本和主程序共享同一个大常量文件。 + +## UI 模块 API + +UI 负责人维护界面,不直接改玩法状态。推荐每个 UI 场景只保留以下形式: + +```gdscript +signal confirmed(payload: Dictionary) +signal cancelled +signal closed + +func bind_context(context: Dictionary) -> void +func open(payload: Dictionary = {}) -> void +func close() -> void +func refresh(snapshot: Dictionary) -> void +``` + +HUD 需要覆盖策划案里的 MVP 信息: + +- 玩家生命、防御/护盾。 +- 当前家族/职业/角色名。 +- 血缘火种剩余数量。 +- 主动技能冷却。 +- 消耗品槽位和数量。 +- 饰品图标/简短状态。 +- 金币。 +- 皇帝战显示皇帝剩余 HP 和本轮已累积伤害。 + +## 交互模块 API + +交互系统负责“当前按交互键应该触发什么”。策划案要求交互优先级为: + +1. 固定关键节点:教堂、军械库、祭坛、皇帝战前准备。 +2. 饰品或特殊掉落。 +3. 商店。 +4. 消耗品拾取。 +5. 环境提示。 + +推荐新增 facade:`systems/interaction/interaction_service.gd` + +```gdscript +signal interaction_available(target: Dictionary) +signal interaction_cleared +signal interaction_committed(result: Dictionary) +signal interaction_cancelled + +func register_target(target: Node, data: Dictionary) -> void +func unregister_target(target: Node) -> void +func get_best_target(actor: Node2D) -> Dictionary +func request_interact(actor: Node2D) -> void +func confirm(payload: Dictionary = {}) -> void +func cancel() -> void +``` + +交互目标数据建议: + +```gdscript +{ + "id": "church_heal_01", + "type": "church|armory|altar|shop|pickup|environment", + "priority": 100, + "prompt": "Pray", + "cost": {"gold": 20}, + "effect": "scene_interaction_effect", + "payload": {} +} +``` + +## 血缘火种与轮回 API + +策划案核心约束: + +- 大轮回开始时血缘火种为 `5`。 +- 每次角色死亡消耗 `1` 个火种。 +- 火种耗尽且皇帝未死,则本次大轮回失败。 +- 同一个大轮回内,皇帝 HP 持久,不随玩家死亡回复。 +- MVP 只持久化皇帝 HP,不持久化阶段临时状态。 +- 家族等于职业:Knight、Ranger、Mage;同一大轮回不跨家族继承。 + +推荐新增 facade:`systems/run/bloodline_run_state.gd` + +```gdscript +signal fire_seed_changed(current: int, max_value: int) +signal emperor_hp_changed(current: float, max_value: float) +signal generation_started(snapshot: Dictionary) +signal grand_run_failed(reason: StringName) +signal grand_run_completed + +func start_grand_run(family_id: StringName) -> void +func start_generation() -> Dictionary +func record_generation_death(snapshot: Dictionary) -> bool +func record_emperor_damage(amount: float) -> void +func get_fire_seed_count() -> int +func get_emperor_remaining_hp() -> float +func get_run_snapshot() -> Dictionary +``` + +继承资质建议由纯逻辑函数生成,便于未来迁 C++: + +```gdscript +func generate_child_aptitude(parent: Dictionary, run_progress: Dictionary, rng_seed: int) -> Dictionary +``` + +输出字段: + +```gdscript +{ + "family_id": &"knight", + "generation": 3, + "fire_seed": 3, + "strength": 5, + "agility": 4, + "focus": 3, + "adjust_points": 1, + "emperor_hp_remaining": 720.0 +} +``` + +## 数据表建议 + +饰品、消耗品、场景节点不要散在脚本分支里。建议放到 `data/`: + +```text +data/ + accessories/ + consumables/ + scene_nodes/ + balance/ +``` + +效果字段统一命名: + +- `passive_effect` +- `active_use_effect` +- `scene_interaction_effect` + +触发器统一命名: + +- `on_pick` +- `on_use` +- `on_hit` +- `on_crit` +- `on_kill` +- `on_low_hp` +- `on_enter_room` +- `on_first_hit` + +## C++ GDExtension 接入操作 + +先不要把整个项目迁 C++。第一阶段只迁纯逻辑模块,GDScript 保留 Godot 场景 facade。 + +推荐目录: + +```text +cpp/ + SConstruct + src/ + register_types.cpp + infinity_run_state.cpp + infinity_run_state.h + bloodline_system.cpp + bloodline_system.h + combat_formula.cpp + combat_formula.h + godot-cpp/ +addons/ + infinity_core/ + infinity_core.gdextension + bin/ +``` + +一次性准备: + +```powershell +cd F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom +git submodule add https://github.com/godotengine/godot-cpp.git cpp/godot-cpp +cd cpp\godot-cpp +scons platform=windows target=template_debug -j8 +cd .. +scons platform=windows target=template_debug -j8 +``` + +`.gdextension` 示例: + +```ini +[configuration] +entry_symbol = "infinity_core_library_init" +compatibility_minimum = "4.4" +reloadable = true + +[libraries] +windows.debug.x86_64 = "res://addons/infinity_core/bin/infinity_core.windows.template_debug.x86_64.dll" +windows.release.x86_64 = "res://addons/infinity_core/bin/infinity_core.windows.template_release.x86_64.dll" +``` + +优先迁移顺序: + +1. `BloodlineSystem`:火种消耗、世代生成、皇帝 HP 持久化。 +2. `CombatFormula`:属性到伤害/防御/暴击/冷却的公式。 +3. `WeightedPicker`:奖励、事件、物件随机候选抽取。 +4. 可选:AI 决策或地图碰撞候选生成。 + +暂时不要迁: + +- UI 控件和 CanvasLayer。 +- 角色素材节点树、AnimationPlayer、Sprite2D 结构。 +- 地图图片拼接显示。 +- 音频播放节点。 + +GDScript facade 调 C++ 的方式: + +```gdscript +var core := InfinityRunState.new() +core.start_grand_run(&"knight") +core.record_emperor_damage(120.0) +var snapshot := core.get_run_snapshot() +``` + +## 协作流程 + +整合组: + +- 只在 `world.gd` 和 facade 间连线。 +- 发现需要人物/地图/UI 新能力时,提 API 需求,不直接改别人内部节点。 +- 每次接入后跑 smoke 测试。 + +人物组: + +- 每个角色一个独立场景,素材、动画、碰撞、攻击、技能都在场景内部。 +- 对外只暴露人物 API 和信号。 +- 不引用 HUD、地图、奖励、BGM。 + +地图组: + +- 每张图维护墙体碰撞和物件候选。 +- 房间左右入口/出口、相机锁房间、出生点/战斗点由地图模块给出。 +- 新图先在地图 demo 里可视化验证,再接主流程。 + +UI 组: + +- UI 只接 snapshot,不持有玩法真状态。 +- 玩家选择通过 signal 发回整合组。 +- 血缘火种、皇帝 HP、消耗品、饰品状态按 snapshot 刷新。 + +C++ 组: + +- 先写纯逻辑类,保持可从 GDScript 创建和调用。 +- 每迁一个逻辑模块,保留 GDScript facade 名称,避免主流程大改。 +- C++ 类输出 Dictionary/Array/StringName 等 Godot 友好类型,方便 UI 和脚本消费。 + +## 最低测试要求 + +每次整合提交至少跑: + +```powershell +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --quit +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --script res://tests/smoke_run_flow.gd +F:\无尽王国\infinity_kingdom\infinity_kingdom\_tools\godot_4_6_3\Godot_v4.6.3-stable_win64_console.exe --path F:\无尽王国\infinity_kingdom\infinity_kingdom\infinity_kingdom --headless --script res://tests/smoke_player_control.gd +``` + +模块负责人自测重点: + +- 人物:能选中、能移动、能攻击、会死亡、信号正确。 +- 地图:每房间碰撞只围不可走墙体;随机物件碰撞包住可见像素;相机只锁当前房间。 +- UI:打开/关闭不阻塞主流程;选择信号只发一次;文字不溢出。 +- 交互:同一位置多个对象时按优先级触发。 +- 轮回:死亡消耗火种;火种为 0 且皇帝未死时失败;皇帝 HP 在同一大轮回内不回复。 diff --git "a/todolist/\347\255\226\345\210\222\345\267\256\350\267\235\344\270\216\345\276\205\345\212\236\346\214\207\345\215\227.md" "b/todolist/\347\255\226\345\210\222\345\267\256\350\267\235\344\270\216\345\276\205\345\212\236\346\214\207\345\215\227.md" new file mode 100644 index 0000000..dcada99 --- /dev/null +++ "b/todolist/\347\255\226\345\210\222\345\267\256\350\267\235\344\270\216\345\276\205\345\212\236\346\214\207\345\215\227.md" @@ -0,0 +1,454 @@ +# 策划差距与待办指南 + +更新日期:2026-06-09 +对照策划案:`C:\Users\15312\Desktop\infinity_kingdom\specdocs\轮回王国_融合精简策划案.md` +当前代码依据:本地 `master` 已更新到 `origin/master` 的 `d6b7b2f Fix startup errors and polish combat UI`。 + +这份文档用于说明:当前代码已经完成了哪些可试玩内容,相比策划案还缺哪些系统,以及后续各模块应该按什么顺序补。 + +## 1. 总体结论 + +当前项目已经不是纯 Demo,而是一个能跑主流程的可试玩原型:有三职业、地图房间、普通小怪、多个 Boss、房间转场、随机地图物件、HUD、事件/饰品/金币/经验、音频和角色调试入口。 + +但它还没有完全达到策划案的核心卖点。策划案最核心的“父死子继”“血脉火种”“皇帝血量跨代保留”“资质继承”还没有形成完整系统。目前更像“一次性通关的房间制动作 Roguelite 原型”,而不是完整的“轮回王国”。 + +后续要优先补的是大周目/小轮回结构,而不是继续堆更多 Boss 或更多饰品。 + +## 2. 当前已经具备的内容 + +### 2.1 可试玩主流程 + +- 可以从入口进入选人。 +- 有正常模式和角色调试模式。 +- 正常模式可以选择骑士、游侠、法师。 +- 可以进入地图房间并开始战斗。 +- 每张地图目前按“一轮怪物刷新”推进。 +- 打完当前遭遇后通过入口/出口和转场进入下一张图。 +- 有结果界面、暂停菜单和设置界面。 + +### 2.2 玩家角色 + +- 三个家族/职业已经存在:骑士、游侠、法师。 +- 角色有基础移动、攻击、技能、资源和 HUD。 +- 角色调试入口可以脱离地图测试角色。 +- 部分角色和 Boss 贴图、武器、朝向、动态表现已经接入。 + +### 2.3 敌人与 Boss + +- 六类普通小兵素材和逻辑已接入主流程。 +- 城外/城镇阶段主要使用小兵遭遇。 +- 守关 Boss 已接入。 +- 双子/王子 Boss 已接入后段。 +- 第七章废案列阵机制已从主流程移除,代码保留。 +- 多个最终 Boss 场景存在。 + +### 2.4 地图 + +- 地图素材已在运行时拼接为横向房间序列。 +- 每张图有入口和出口。 +- 摄像机锁定在当前房间,不直接看到全部地图。 +- 地图本体有墙体/边界碰撞。 +- 小物件可以随机生成到地图上。 +- 小物件有基于素材像素外框/透明区域的碰撞,用于挡路、挡弹或制造掩体。 + +### 2.5 奖励、事件、饰品 + +- `RunDirector` 已管理金币、事件路线、经验、击杀数和本局修正。 +- 饰品选择和饰品效果已经有基础结构。 +- 事件面板支持商店、服务、教堂、军需库、悬赏、训练、锻造、契约、共鸣、侦查等抽象节点。 +- 战斗结束后可以触发奖励和事件。 + +### 2.6 UI 与音频 + +- 选人 UI、模式选择 UI、战斗状态 UI、角色调试 UI、事件选择 UI、饰品选择 UI、暂停/设置/结果 UI 已存在。 +- BGM 已按标题、普通战斗、Boss、结果等阶段接入。 +- 守关 Boss 和王子/双子 Boss 应保持不同 Boss 音乐。 + +### 2.7 工具和工程 + +- 有地图预览、角色调试、Boss 预览、导出脚本。 +- 有 `thirdparty/godot-cpp/` 和 `SConstruct`,后续可继续做 C++/GDExtension。 +- 已有 `docs/` 下的模块 API、GDScript 规范、C++ 工作流等文档。 + +## 3. 和策划案的主要差距 + +| 策划内容 | 当前状态 | 差距说明 | 优先级 | +|---|---|---|---| +| 父死子继小轮回 | 未完成 | 死亡后还没有消耗火种、生成下一代、从当前章节入口重开的完整流程 | P0 | +| 血脉火种 | 未完成 | 没有 5 枚火种、死亡扣除、耗尽失败的系统 UI 和状态 | P0 | +| 皇帝血量跨代保留 | 未完成 | 当前 Boss 血量更接近单次战斗,没有大周目内皇帝剩余血量持久化 | P0 | +| 家族固定职业 | 部分完成 | 选人有三职业,但“大周目内后代固定同家族”的规则没接上 | P0 | +| 资质继承 | 未完成 | 没有强壮/迅捷/专注三维资质生成、父代表现加权、1 点微调 | P0 | +| 技能分支成长 | 首版暂不做 | 角色保留现有技能;策划中的 3 分支、每代 1 主动技能、2 次强化流程第一版不进入近期待办 | Backlog | +| 武器强化 | 部分完成 | 有战斗修正/锻造事件,但没有独立武器升级系统;后续应固定为收官 Boss 与商店奖励,轮回后清零 | P1 | +| 首饰系统 | 部分完成 | 有饰品和选择,但来源、稀有度、替换、单代平均机会还需按策划收束 | P1 | +| 消耗品槽 | 未完成/很弱 | 策划要求 1 个主动消耗品槽、堆叠、替换面板、使用键,目前没有完整系统 | P1 | +| 城镇伪开放 | 部分完成 | 当前用横向房间和事件面板表达,缺少空间化教堂/军需库/商人侧区 | P1 | +| 皇宫房间制 | 部分完成 | 房间推进存在,但还没形成完整“前厅 -> 回廊/庭院 -> 皇帝门前 -> 王座厅”规则 | P1 | +| 教堂/军需库/商店节点 | 部分完成 | 逻辑在事件面板里,缺少地图上的固定交互节点和交互优先级 | P1 | +| 皇帝门前准备点 | 未完成 | 没有空间化安全房间、第二次强化和最终三选一准备点 | P1 | +| 交互机制规范 | 部分完成 | 拾取、饰品、事件存在,但没有统一 `InteractionService` 和优先级锁定 | P1 | +| 六类普通兵权重 | 部分完成 | 六类兵存在,但“统治家族 60% + 另外两家各 20%”未明确数据化 | P2 | +| 皇宫精英兵 | 部分完成 | 有精英/放大敌人的思路,但没有作为皇宫独立模板体系完成 | P2 | +| 三种皇帝主形态 | 部分完成 | 有多个最终 Boss 场景,但和“骑士/游侠/法师皇帝三主形态 + 持久血量”的策划还没统一 | P2 | +| 王冠循环与多结局 | 未完成 | 当前打败最终 Boss 后直接进入胜利/结局页;后续应支持王子后继续轮回、三家族登基、可选角色减少、打碎王冠/逃离/火种熄灭/戴冠坏结局 | P0 | +| UI:火种/代际/资质 | 未完成 | 目前 HUD 没有火种数量、当前第几代、资质微调、皇帝持久血量 | P0 | +| UI:消耗品和交互提示 | 部分完成 | 有状态 UI,但缺少策划要求的消耗品槽、替换面板、固定交互提示 | P1 | +| 存档复杂度 | 不足 | 后续王冠循环、已登基家族、当前皇帝、皇帝血量、武器升级、消耗品、服务节点状态都需要进入存档 | P0 | +| 新手引导 | 未完成 | 没有第一局引导、按键说明和系统解释节奏 | P3 | +| C++/GDExtension 实装 | 未完成 | 依赖存在,但当前核心玩法仍是 GDScript,扩展尚未稳定接入主流程 | P2 | + +## 4. P0 待办:先补核心卖点 + +P0 是决定项目是不是“轮回王国”的系统,应先做。 + +### 4.0 王冠循环与多结局 + +目标: + +- 打败王子/双子后不直接结束游戏,而是进入下一轮皇帝循环。 +- 下一轮只能从剩余两个家族中选择角色。 +- 再击败一任皇帝后,只能从最后一个未登基家族中选择角色。 +- 三个家族都曾当过皇帝后,进入最终结局分歧。 +- 当前皇帝家族决定普通小兵刷新权重:皇帝家族小兵 50%,另外两家各 25%。 +- 结局至少包括:打碎王冠、逃向远方、火种熄灭、戴上王冠 bad ending。 + +建议新增: + +- `systems/run/throne_cycle_director.gd` +- `ui/ending_choice_panel.gd` + +验收标准: + +- 王子战胜利后不会直接显示最终结局。 +- 可选角色会随着已登基家族减少。 +- 小兵刷新会受当前皇帝家族影响。 +- 四类结局都能被明确触发。 + +### 4.1 血脉火种系统 + +目标: + +- 大周目开始时火种为 5。 +- 玩家死亡消耗 1 枚火种。 +- 火种还剩时进入下一代生成流程。 +- 火种耗尽且皇帝未死则大周目失败。 +- UI 显示当前火种、当前代数、死亡后的扣除结果。 + +建议新增: + +- `systems/run/lineage_director.gd` +- `ui/lineage_panel.gd` +- `ui/lineage_panel.tscn` + +核心 API: + +```gdscript +func start_lineage(family_id: String) -> void +func can_respawn() -> bool +func consume_seed(death_summary: Dictionary) -> Dictionary +func generate_next_heir(parent_summary: Dictionary) -> Dictionary +func get_lineage_state() -> Dictionary +``` + +验收标准: + +- 进入新大周目显示 5 枚火种。 +- 死亡后显示剩余 4 枚,并重新进入当前章节入口。 +- 火种归零后进入失败结果。 +- 重开大周目后火种恢复 5。 + +### 4.2 资质与继承 + +目标: + +- 每一代有强壮、迅捷、专注三维资质。 +- 总点数 7-15。 +- 第一代开局应有资质分配或微调入口,不应固定 3/3/3 后直接进游戏。 +- 下一代总点数由上一代评分修正:S +3,A +2,B +1,C +0,D -1。 +- 玩家可微调 1 点:从一个属性挪到另一个属性,不额外增加总点数。 +- 资质转换为生命、防御、移速、攻速、冷却、攻击/灵感、暴击等属性。 + +建议新增: + +- `systems/run/aptitude_generator.gd` +- `ui/heir_select_panel.gd` + +验收标准: + +- 开局出现第一代资质分配或微调面板。 +- 死亡后出现下一代资质面板。 +- 玩家能看到父代表现如何影响下一代。 +- 微调 1 点后进入当前章节入口,且总点数仍保持 7-15。 +- 三职业应用同一资质规则,但映射到职业核心属性不同。 + +### 4.3 皇帝血量跨代保留 + +目标: + +- 皇帝有大周目级最大血量和剩余血量。 +- 同一大周目内,玩家对皇帝造成的伤害会保存。 +- 玩家死亡后,下一代再次进入皇帝战时从剩余血量开始。 +- 首版只保存血量,不保存阶段、仇恨、临时状态。 + +建议新增: + +- `systems/run/emperor_progress.gd` + +核心 API: + +```gdscript +func start_emperor(max_hp: float) -> void +func apply_emperor_damage(amount: float) -> void +func get_remaining_hp() -> float +func get_progress_state() -> Dictionary +func is_emperor_defeated() -> bool +``` + +验收标准: + +- 第一代把皇帝打到 70% 后死亡。 +- 第二代进入皇帝战,皇帝从 70% 开始。 +- 皇帝死亡后本大周目胜利。 +- 重开大周目后皇帝血量重置。 + +## 5. P1 待办:补完整单代构筑 + +### 5.1 技能分支成长 + +第一版暂不做技能分支成长。当前只要求保留现有角色技能,并保证释放、伤害、冷却 UI 正常。 + +暂不进入近期待办: + +- 职业 3 分支。 +- 每代 1 主动技能选择。 +- 技能两次强化。 +- 技能强化树 UI。 + +第一版验收标准: + +- 三个角色现有技能能正常释放。 +- 技能伤害、冷却、资源消耗正常。 +- 技能 UI 正常显示。 + +### 5.2 武器强化 + +待办: + +- 建立独立武器升级系统,不再把 `forge_*` 状态奖励当作武器升级。 +- 武器升级作为固定关键节点奖励:打过收官 Boss 后固定一次,商店固定提供一次购买机会。 +- 每代最多 2 次武器升级。 +- 轮回进入下一代后,武器升级全部清零。 +- 第一版先做数值升级:攻速 +10%、攻速 +15%、伤害 +15%、伤害 +30%。 +- 强化结果进入角色攻击 payload。 + +建议数据字段: + +```gdscript +{ + "id": "weapon_speed_10", + "display_name": "轻巧磨锋", + "type": "weapon_upgrade", + "tags": ["weapon", "speed"], + "effect": {"attack_interval_multiplier": 0.90} +} +``` + +```gdscript +{ + "id": "weapon_damage_30", + "display_name": "王印重铸", + "type": "weapon_upgrade", + "tags": ["weapon", "damage"], + "effect": {"attack_damage_multiplier": 1.30} +} +``` + +### 5.3 消耗品槽 + +待办: + +- 增加 1 个主动消耗品槽。 +- 同类未满则堆叠。 +- 异类弹出替换面板。 +- 支持瞬发型和短施放型。 +- UI 显示物品图标、数量、冷却/可用状态。 +- 消耗品来源包括军需库、商店、地图小物件/宝箱、教堂或 Boss 后补给。 +- 第一版建议只做 4-6 个消耗品,例如医疗瓶、护甲包、磨锋油、疾步药剂、聚灵药剂。 + +验收标准: + +- 玩家只能同时持有一种消耗品。 +- 拾取同类数量增加,拾取异类需要确认。 +- 使用成功后才扣数量。 +- 死亡/通关后不继承。 + +### 5.4 空间化交互节点 + +待办: + +- 新增 `systems/interaction/interaction_service.gd`。 +- 地图上放教堂、军需库、商人、皇帝门前准备点。 +- 统一交互优先级:关键节点 > 饰品/特殊掉落 > 商店 > 普通消耗品 > 环境物件。 +- UI 只显示当前锁定目标和按键提示。 + +验收标准: + +- 多个交互物重叠时只锁定一个。 +- 教堂、军需库、商人都能通过地图节点触发。 +- 打开面板时玩家输入被合理锁定。 + +## 6. P2 待办:补内容规则和工程化 + +### 6.0 存档复杂度扩展 + +后续需要把存档扩展到能保存王冠循环和当前代构筑。 + +待办: + +- 存档加入 `version` 字段,支持旧存档默认值补齐。 +- 保存血脉火种、代数、资质总点数和三项资质。 +- 保存当前王冠循环、当前皇帝家族、已登基家族、可选家族。 +- 保存当前皇帝剩余血量。 +- 保存当前房间/章节进度、金币、等级、击杀、事件历史。 +- 保存当前饰品、武器升级、消耗品。 +- 保存教堂、商店、军需库等服务节点使用状态。 +- 死亡进入下一代后清空武器升级和消耗品,但保留应继承的轮回/王冠状态。 + +验收标准: + +- 退出重进后,火种、代数、资质、当前皇帝、已登基家族能恢复。 +- 退出重进后,当前代的饰品、武器升级、消耗品能恢复。 +- 旧存档不会导致游戏崩溃。 + +### 6.1 敌人权重和皇宫精英 + +待办: + +- 建立普通兵数据表。 +- 统治家族兵种权重 60%,另外两家各 20%。 +- 皇宫阶段使用放大版精英兵,提升体型、血量、伤害和压迫参数。 +- 遭遇场景按数据表生成,不写死敌人组合。 + +### 6.2 Boss 结构统一 + +待办: + +- 明确守关 Boss、双子/王子 Boss、最终皇帝 Boss 的章节位置。 +- 三种皇帝主形态与当前 `FINAL_BOSS_SCENES` 对齐。 +- Boss 血条、BGM、阶段名、死亡 summary 使用统一格式。 +- Boss 武器比例、挥砍轨迹、朝向、行走动态继续打磨。 + +### 6.3 C++/GDExtension + +待办: + +- 确认 `.gdextension` 文件、二进制输出目录和导出设置。 +- 先迁移纯计算模块,不迁移 UI。 +- 为 C++ 模块提供 GDScript 包装和测试场景。 +- 生成库文件不要误提交中间 `.obj`。 + +建议优先迁移: + +- 伤害结算。 +- buff/modifier 合并。 +- 大量 projectile 更新。 +- 地图物件像素外框检测。 + +## 7. P3 待办:体验和展示 + +待办: + +- 第一局新手引导。 +- 关键机制提示:火种、继承、皇帝不回血。 +- 角色选择页展示职业定位。 +- 构筑选择页展示当前已选分支、武器词条、饰品、消耗品。 +- 结算页展示本代贡献:推进到哪、击杀、对皇帝伤害、下一代资质影响。 +- 音频和视觉反馈继续统一。 + +## 8. 各模块负责人任务清单 + +### 8.1 整合组 + +- 拆分 `world.gd`,优先做 `EncounterFlow`、`RoomTransitionService`、`RewardFlow`。 +- 接入 `LineageDirector`、`EmperorProgress`。 +- 保证正常模式和调试模式都能进。 +- 合并 PR 时检查模块边界,不接受角色脚本直接改主流程。 + +### 8.2 角色组 + +- 按统一角色 API 改骑士、游侠、法师。 +- 补齐朝向、受击、攻击、技能、死亡信号。 +- 第一版不做技能分支成长,只保证现有技能稳定可用。 +- 角色调试入口必须能单独测试每个角色。 + +### 8.3 敌人与 Boss 组 + +- 小兵按数据表组织六类基础兵。 +- Boss 使用统一 API 和信号。 +- 调整 Boss 武器比例、挥砍轨迹、行走动态、左右朝向。 +- 废案机制代码保留但不接主流程。 + +### 8.4 地图组 + +- 逐图校准地图本体碰撞,只围住墙体和不可通行边缘。 +- 小物件碰撞按素材非透明像素外框生成。 +- 随机物件避开入口、出口、出生点和敌人点。 +- 每张图入口出口保持左右方向清晰。 +- 摄像机只能锁定当前房间。 + +### 8.5 UI 组 + +- 补火种/代际/资质 UI。 +- 补消耗品槽 UI。 +- 补交互提示 UI。 +- 分支选择、武器强化、皇帝门前准备点使用统一三选一面板风格。 +- UI 只发信号,不直接改玩法状态。 + +### 8.6 数值与策划组 + +- 定义三维资质到属性的映射。 +- 定义武器词条和消耗品池;技能分支第一版暂不进入。 +- 定义消耗品池和价格。 +- 定义普通兵权重和皇宫精英参数。 +- 定义每代平均资源投放目标。 + +### 8.7 C++/GDExtension 组 + +- 先保证 Godot 能加载最小扩展。 +- 从纯计算模块开始迁移。 +- 每个 C++ 类提供 GDScript 可调用包装。 +- 写清楚构建命令、输出路径和不要提交的中间文件。 + +## 9. 推荐开发顺序 + +建议后续两轮先按这个顺序做: + +1. 整合组新增 `LineageDirector` 和 `EmperorProgress` 的 GDScript 版本。 +2. UI 组补火种、代际、皇帝剩余血量显示。 +3. 角色组统一三职业 API,保证死亡 summary 和战斗 summary 能返回。 +4. 整合组把死亡后重生接到当前章节入口。 +5. 数值组补资质生成和属性映射。 +6. UI 组补资质微调界面。 +7. 地图组完成逐图碰撞复核。 +8. 敌人组完成普通兵数据表和权重。 +9. 构筑组补武器强化、消耗品槽;技能分支第一版暂不做。 +10. C++ 组开始迁移伤害结算或碰撞外框检测。 + +## 10. 最小验收版本定义 + +达到下面条件,才算更接近策划案里的 MVP: + +- 选择家族后,整个大周目固定该职业。 +- 玩家有 5 枚血脉火种。 +- 死亡扣 1 枚火种并生成下一代。 +- 下一代有继承资质,能微调 1 点。 +- 皇帝血量在同一大周目内不会回满。 +- 城镇和皇宫至少各有一组明确房间流程。 +- 第一版不要求技能分支成长,但现有技能必须稳定可用。 +- 每代有固定武器强化、一个首饰槽、一个消耗品槽。 +- 存档能保存王冠循环、皇帝血量、已登基家族、当前代构筑和服务节点状态。 +- 教堂、军需库、商人、皇帝门前准备点至少以可交互节点形式存在。 +- 角色调试入口和正常模式都能运行。 + +当前项目已经有骨架和不少可玩内容,下一步要把“轮回”从文档里的设定变成代码里的主循环。 diff --git "a/todolist/\350\247\222\350\211\262\347\273\204\346\255\246\345\231\250\345\215\207\347\272\247\346\216\245\345\217\243\347\272\246\345\256\232.md" "b/todolist/\350\247\222\350\211\262\347\273\204\346\255\246\345\231\250\345\215\207\347\272\247\346\216\245\345\217\243\347\272\246\345\256\232.md" new file mode 100644 index 0000000..28f0e63 --- /dev/null +++ "b/todolist/\350\247\222\350\211\262\347\273\204\346\255\246\345\231\250\345\215\207\347\272\247\346\216\245\345\217\243\347\272\246\345\256\232.md" @@ -0,0 +1,317 @@ +# 角色组武器升级接口约定 + +更新日期:2026-06-12 + +本文档给角色组使用。武器升级的具体表现、动画、判定、数值落点由角色组负责;整合组只负责在 Boss 奖励、商店、军需库等系统中触发升级,并通过统一 API 通知当前角色。 + +目标是让角色模块保持封装:外部系统不直接修改角色的攻击力、攻速、武器节点、动画播放器或碰撞箱。 + +## 1. 分工边界 + +### 1.1 角色组负责 + +角色组需要负责: + +- 角色武器升级状态的保存与清空。 +- 武器升级对普攻伤害、攻击间隔、挥砍判定、投射物、武器动画的实际影响。 +- 不同角色或 Boss 的武器升级表现差异。 +- 武器升级后 UI 需要读取的展示状态。 +- 轮回清空时,角色能恢复到未升级状态。 + +角色组可以自行决定内部实现方式,例如: + +- 直接在角色基类里维护升级状态。 +- 做一个 `WeaponUpgradeState` 子组件。 +- 每个角色独立实现,但对外 API 保持一致。 + +### 1.2 整合组负责 + +整合组只负责: + +- 在打过收官 Boss 后,弹出或触发武器升级选择。 +- 在商店中提供花钱升级武器的入口。 +- 在军需库中提供升级武器或领取装备的入口。 +- 调用角色暴露的武器升级 API。 +- 在角色死亡、轮回、重新开局时调用清空 API。 +- 存档时读取角色暴露的升级状态,读档时把状态交还给角色恢复。 + +整合组不应该: + +- 直接改 `player.damage`。 +- 直接改 `player.attack_interval`。 +- 直接改角色内部武器节点的 scale、rotation、collision shape。 +- 直接操作角色内部动画树或动画播放器。 +- 根据角色类型写大量 `if knight / ranger / mage` 的武器逻辑。 + +## 2. 必须暴露的角色 API + +每个可操作角色至少需要提供以下方法。 + +```gdscript +func can_accept_weapon_upgrade(upgrade_id: String, upgrade_data: Dictionary) -> bool +``` + +用途:整合组在展示升级选项前,询问当前角色是否能接受该升级。 + +返回: + +- `true`:可以选择。 +- `false`:不应该展示,或展示为不可选。 + +```gdscript +func apply_weapon_upgrade(upgrade_id: String, upgrade_data: Dictionary) -> void +``` + +用途:玩家确认选择后,整合组调用这个方法,把升级交给角色内部处理。 + +要求: + +- 角色内部记录该升级已经获得。 +- 角色内部更新伤害、攻速、动画或判定需要用到的倍率。 +- 重复升级是否允许,由角色内部决定,但需要和策划规则一致。 + +```gdscript +func clear_weapon_upgrades() -> void +``` + +用途:角色死亡进入下一代、重新开始大周目、或调试模式重置角色时调用。 + +要求: + +- 清空本代获得的所有武器升级。 +- 恢复基础伤害、基础攻击间隔、基础武器表现。 +- 不影响角色的资质、等级、饰品、消耗品等其他系统。 + +```gdscript +func get_weapon_upgrade_state() -> Dictionary +``` + +用途:UI、存档、调试面板读取当前武器升级状态。 + +建议返回格式: + +```gdscript +{ + "applied_upgrades": ["weapon_damage_15", "weapon_speed_10"], + "damage_multiplier": 1.15, + "attack_interval_multiplier": 0.90 +} +``` + +```gdscript +func restore_weapon_upgrade_state(state: Dictionary) -> void +``` + +用途:读档或调试恢复状态时调用。 + +要求: + +- 不要只恢复最终倍率。 +- 推荐根据 `applied_upgrades` 重新应用升级数据。 +- 这样后续数值表改动时,不容易出现旧倍率残留。 + +## 3. 武器升级数据约定 + +整合组传给角色的 `upgrade_data` 建议保持以下结构。 + +```gdscript +{ + "id": "weapon_damage_15", + "name": "锋刃强化 I", + "summary": "武器伤害 +15%", + "type": "weapon_upgrade", + "effects": { + "attack_damage_multiplier": 1.15 + } +} +``` + +```gdscript +{ + "id": "weapon_speed_10", + "name": "轻巧磨锋", + "summary": "攻击速度 +10%", + "type": "weapon_upgrade", + "effects": { + "attack_interval_multiplier": 0.90 + } +} +``` + +第一版只需要支持四种升级: + +| id | 效果 | 角色内部建议解释 | +|---|---|---| +| `weapon_speed_10` | 攻速 +10% | 普攻间隔乘以 `0.90` | +| `weapon_speed_15` | 攻速 +15% | 普攻间隔乘以 `0.85` | +| `weapon_damage_15` | 伤害 +15% | 武器/普攻伤害乘以 `1.15` | +| `weapon_damage_30` | 伤害 +30% | 武器/普攻伤害乘以 `1.30` | + +注意:攻速提升不要写成冷却时间加法,建议统一使用攻击间隔倍率。 + +## 4. 角色内部实现要求 + +### 4.1 不要只改显示数值 + +武器升级必须真的影响战斗逻辑,而不只是 HUD 文本。 + +至少需要作用到: + +- 普攻实际伤害。 +- 普攻实际冷却或攻击间隔。 +- 近战武器的判定生成时机。 +- 远程武器的投射物发射频率或投射物伤害。 + +### 4.2 挥砍判定和动画要一起调整 + +如果角色是持剑、长枪、斧头等近战角色,武器升级后不能只改数值。 + +角色组需要检查: + +- 武器挥砍动画轨迹是否和伤害判定一致。 +- 武器旋转中心是否稳定。 +- 武器是否出现异常自转。 +- 左右移动时,角色贴图和武器贴图是否一起转向。 +- 攻速提高后,判定帧是否仍然对齐动画。 + +### 4.3 不同角色可以有不同表现 + +同一个升级在不同角色上可以表现不同,但最终数值语义要一致。 + +例如: + +- 骑士:武器变重,挥砍范围略明显,剑的视觉尺寸可以更突出。 +- 猎鹰者:攻击频率提升可以体现在发射节奏或武器前摇缩短。 +- 法师:如果普攻不是剑,可以作用到法器/弹体伤害或发射间隔。 + +但整合组不需要知道这些差异,只调用统一 API。 + +## 5. 和其他系统的关系 + +### 5.1 和饰品不同 + +武器升级不是饰品。 + +它不应该放进饰品管理器,也不应该占用饰品栏。 + +### 5.2 和状态奖励不同 + +武器升级不是普通状态奖励。 + +它不应该混进每关随机三选一的状态池,例如 `forge_edge`、`forge_focus`、`forge_guard` 这类旧奖励后续需要区分处理。 + +### 5.3 和轮回的关系 + +武器升级只属于当前这一代。 + +角色死亡进入下一代时: + +```gdscript +player.clear_weapon_upgrades() +``` + +必须清空。 + +资质、火种、皇帝血量推进可以跨代;武器升级不跨代。 + +### 5.4 和存档的关系 + +存档系统只保存角色暴露的状态: + +```gdscript +var state := player.get_weapon_upgrade_state() +``` + +读档时: + +```gdscript +player.restore_weapon_upgrade_state(state) +``` + +存档系统不直接计算倍率。 + +## 6. 整合组调用流程 + +### 6.1 Boss 奖励 + +```text +打过收官 Boss +-> 生成 3 个武器升级选项 +-> 根据 player.can_accept_weapon_upgrade 过滤 +-> 玩家选择 1 个 +-> player.apply_weapon_upgrade(upgrade_id, upgrade_data) +``` + +### 6.2 商店 + +```text +进入商店 +-> 展示 1 到 2 个武器升级商品 +-> 玩家支付金币 +-> player.apply_weapon_upgrade(upgrade_id, upgrade_data) +``` + +### 6.3 军需库 + +```text +进入军需库 +-> 玩家在免费装备、补给、武器升级之间选择 +-> 如果选择武器升级 +-> player.apply_weapon_upgrade(upgrade_id, upgrade_data) +``` + +### 6.4 轮回 + +```text +玩家死亡 +-> 结算评分 +-> 进入下一代 +-> player.clear_weapon_upgrades() +-> 生成下一代资质 +``` + +## 7. 验收标准 + +角色组提交时至少需要满足: + +- 当前三个玩家角色都实现统一武器升级 API。 +- 四种第一版升级都能被角色接受并产生真实战斗效果。 +- 攻速升级会改变实际攻击间隔,不只是改变动画速度。 +- 伤害升级会改变实际命中的伤害。 +- 死亡轮回后升级清空。 +- 左右移动时角色和武器贴图能正确翻转。 +- 近战挥砍动画和碰撞判定轨迹基本一致。 +- 调试入口能直接给角色施加武器升级,并看见效果。 + +## 8. 给角色组的实现建议 + +推荐角色组内部维护以下状态: + +```gdscript +var weapon_upgrade_ids: Array[String] = [] +var weapon_damage_multiplier: float = 1.0 +var weapon_attack_interval_multiplier: float = 1.0 +``` + +应用升级时: + +```gdscript +func apply_weapon_upgrade(upgrade_id: String, upgrade_data: Dictionary) -> void: + if not can_accept_weapon_upgrade(upgrade_id, upgrade_data): + return + weapon_upgrade_ids.append(upgrade_id) + var effects := upgrade_data.get("effects", {}) + weapon_damage_multiplier *= float(effects.get("attack_damage_multiplier", 1.0)) + weapon_attack_interval_multiplier *= float(effects.get("attack_interval_multiplier", 1.0)) + _refresh_weapon_stats() +``` + +攻击计算时: + +```gdscript +var final_damage := base_weapon_damage * weapon_damage_multiplier +var final_interval := base_attack_interval * weapon_attack_interval_multiplier +``` + +这只是建议写法,不强制。强制要求是:对外 API 一致,外部系统不需要知道角色内部怎么实现。 + diff --git a/ui/character_select.gd b/ui/character_select.gd index e9bb04c..b8b0879 100644 --- a/ui/character_select.gd +++ b/ui/character_select.gd @@ -230,6 +230,7 @@ var active_about_entry_id: String = "about_overview" var detail_mode: String = "hero" var screen_mode: String = "menu" var menu_preview_key: StringName = &"" +var disabled_family_ids: Array[String] = [] func _ready() -> void: _build_ui() @@ -703,6 +704,17 @@ func _focus_selected_card() -> void: if selected_hero_index >= 0 and selected_hero_index < hero_buttons.size(): hero_buttons[selected_hero_index].grab_focus() +func set_disabled_family_ids(next_disabled_family_ids: Array) -> void: + disabled_family_ids.clear() + for raw_id in next_disabled_family_ids: + var family_id := String(raw_id) + if not disabled_family_ids.has(family_id): + disabled_family_ids.append(family_id) + if _is_selected_hero_disabled(): + selected_hero_index = _first_enabled_hero_index() + _refresh_hero_button_enabled_states() + _set_selected_hero(selected_hero_index) + func _sync_title_banner_visibility() -> void: if title_banner_frame != null: title_banner_frame.visible = screen_mode == "menu" @@ -867,6 +879,7 @@ func _set_selected_hero(hero_index: int) -> void: detail_mode = "hero" selected_hero_index = clampi(hero_index, 0, HEROES.size() - 1) var hero: Dictionary = HEROES[selected_hero_index] + var disabled := _is_hero_disabled(hero) hero_portrait.texture = load(String(hero.get("portrait", ""))) as Texture2D hero_detail_title.text = _hero_name(hero) hero_detail_role.text = "%s | %s" % [ @@ -878,6 +891,8 @@ func _set_selected_hero(hero_index: int) -> void: "若已进入选角,按 Enter 直接开始;否则先打开选角再锁定这名角色。", "若已進入選角,按 Enter 直接開始;否則先打開選角再鎖定這名角色。" ) + if disabled: + action_text = _disabled_hero_text() hero_detail_desc.text = "%s\n%s\n%s\n%s" % [ _hero_summary(hero), " / ".join(_hero_stats(hero)), @@ -891,9 +906,42 @@ func _activate_selected_hero() -> void: func _activate_hero(hero_index: int) -> void: var safe_index := clampi(hero_index, 0, HEROES.size() - 1) + if _is_hero_disabled(HEROES[safe_index]): + _set_selected_hero(_first_enabled_hero_index()) + return visible = false character_selected.emit(HEROES[safe_index]["id"]) +func _refresh_hero_button_enabled_states() -> void: + for hero_index in range(hero_buttons.size()): + var button := hero_buttons[hero_index] + if button == null: + continue + var disabled := _is_hero_disabled(HEROES[hero_index]) + button.disabled = disabled + button.modulate = Color(0.55, 0.55, 0.58, 0.72) if disabled else Color.WHITE + +func _is_selected_hero_disabled() -> bool: + if selected_hero_index < 0 or selected_hero_index >= HEROES.size(): + return false + return _is_hero_disabled(HEROES[selected_hero_index]) + +func _is_hero_disabled(hero: Dictionary) -> bool: + return disabled_family_ids.has(String(hero.get("id", ""))) + +func _first_enabled_hero_index() -> int: + for hero_index in range(HEROES.size()): + if not _is_hero_disabled(HEROES[hero_index]): + return hero_index + return 0 + +func _disabled_hero_text() -> String: + return _locale_text( + "This family has already worn the crown in this archive. Choose one of the remaining bloodlines.", + "这个家族已经在本档案里戴过王冠。请选择剩余血脉。", + "這個家族已經在本檔案裡戴過王冠。請選擇剩餘血脈。" + ) + func _set_gallery_entry(entry_id: String) -> void: detail_mode = "gallery" var entries := _gallery_entries() diff --git a/ui/consumable_bar.gd b/ui/consumable_bar.gd new file mode 100644 index 0000000..48fb975 --- /dev/null +++ b/ui/consumable_bar.gd @@ -0,0 +1,72 @@ +extends CanvasLayer + +const UISkin := preload("res://ui/ui_skin.gd") + +var row: HBoxContainer +var slot_icons: Array[TextureRect] = [] +var slot_labels: Array[Label] = [] + +func _ready() -> void: + layer = 9 + _build_ui() + if ConsumableManager != null: + if not ConsumableManager.inventory_changed.is_connected(_on_inventory_changed): + ConsumableManager.inventory_changed.connect(_on_inventory_changed) + _on_inventory_changed(ConsumableManager.get_slots()) + +func _build_ui() -> void: + var margin := MarginContainer.new() + margin.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) + margin.offset_left = -608.0 + margin.offset_top = -96.0 + margin.offset_right = -26.0 + margin.offset_bottom = -24.0 + margin.add_theme_constant_override("margin_left", 0) + margin.add_theme_constant_override("margin_top", 0) + margin.add_theme_constant_override("margin_right", 0) + margin.add_theme_constant_override("margin_bottom", 0) + add_child(margin) + + row = HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + margin.add_child(row) + for index in range(ConsumableManager.MAX_SLOTS if ConsumableManager != null else 4): + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(88, 64) + panel.add_theme_stylebox_override("panel", UISkin.content_panel_style()) + row.add_child(panel) + var stack := VBoxContainer.new() + stack.alignment = BoxContainer.ALIGNMENT_CENTER + stack.add_theme_constant_override("separation", 0) + panel.add_child(stack) + var icon := TextureRect.new() + icon.custom_minimum_size = Vector2(42, 40) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + stack.add_child(icon) + slot_icons.append(icon) + var label := Label.new() + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + UISkin.label(label, 12, UISkin.COLOR_TEXT) + stack.add_child(label) + slot_labels.append(label) + +func _on_inventory_changed(slots: Array) -> void: + for index in range(slot_labels.size()): + var slot: Dictionary = slots[index] if index < slots.size() else {} + var label := slot_labels[index] + var icon := slot_icons[index] + var consumable_id := String(slot.get("id", "")) + if consumable_id.is_empty() or ConsumableManager == null: + icon.texture = null + label.text = "%d -" % [index + 1] + continue + var data := ConsumableManager.describe(consumable_id) + icon.texture = load(String(data.get("icon", "res://assets/ui/icon/ui_unknown.png"))) as Texture2D + label.text = "%d %s x%d" % [ + index + 1, + String(data.get("short_name", consumable_id)), + int(slot.get("amount", 1)) + ] diff --git a/ui/consumable_bar.gd.uid b/ui/consumable_bar.gd.uid new file mode 100644 index 0000000..4757224 --- /dev/null +++ b/ui/consumable_bar.gd.uid @@ -0,0 +1 @@ +uid://bs7yel2cclf55 diff --git a/ui/heir_select_panel.gd b/ui/heir_select_panel.gd index f4017fe..435da2c 100644 --- a/ui/heir_select_panel.gd +++ b/ui/heir_select_panel.gd @@ -1,13 +1,25 @@ extends CanvasLayer -signal confirmed(attribute_id: String) +signal confirmed(from_attribute: String, to_attribute: String) const UISkin := preload("res://ui/ui_skin.gd") -var selected_attribute := "strength" +const ATTRIBUTES := ["strength", "agility", "focus"] +const ATTRIBUTE_KEYS := { + KEY_1: "strength", + KEY_2: "agility", + KEY_3: "focus" +} + +var selected_from := "strength" +var selected_to := "agility" +var current_aptitude: Dictionary = {} +var lineage_state: Dictionary = {} +var score_data: Dictionary = {} var title_label: Label var score_label: Label -var aptitude_labels: Dictionary = {} +var from_buttons: Dictionary = {} +var to_buttons: Dictionary = {} var confirm_button: Button func _ready() -> void: @@ -17,27 +29,12 @@ func _ready() -> void: _build_ui() func open(payload: Dictionary) -> void: - var lineage := payload.get("lineage", {}) as Dictionary - var score := payload.get("score", {}) as Dictionary - var aptitude := lineage.get("aptitude", {}) as Dictionary - title_label.text = "%s %d" % [ - _locale_text("Heir Generation", "继承者 第", "繼承者 第"), - int(lineage.get("generation_index", 1)) - ] - score_label.text = "%s %s / %d | %s" % [ - _locale_text("Grade", "评分", "評分"), - String(score.get("grade", "D")), - int(score.get("score", 0)), - _locale_text("Add 1 aptitude point before respawn.", "重生前选择一项资质 +1。", "重生前選擇一項資質 +1。") - ] - for key in aptitude_labels.keys(): - var button := aptitude_labels[key] as Button - button.text = "%s\n%d%s" % [ - _attribute_name(String(key)), - int(aptitude.get(key, 3)), - " +1" if String(key) == selected_attribute else "" - ] - _refresh_selection() + lineage_state = payload.get("lineage", {}) as Dictionary + score_data = payload.get("score", {}) as Dictionary + current_aptitude = lineage_state.get("aptitude", {}) as Dictionary + selected_from = _default_from_attribute() + selected_to = _default_to_attribute(selected_from) + _refresh_copy() visible = true get_tree().paused = true confirm_button.grab_focus() @@ -46,15 +43,16 @@ func _unhandled_input(event: InputEvent) -> void: if not visible: return if event is InputEventKey and event.pressed and not event.echo: - match event.keycode: - KEY_1: - _select("strength") - KEY_2: - _select("agility") - KEY_3: - _select("focus") - KEY_ENTER, KEY_KP_ENTER, KEY_SPACE: - _confirm() + if ATTRIBUTE_KEYS.has(event.keycode): + _select_to(String(ATTRIBUTE_KEYS[event.keycode])) + elif event.keycode == KEY_Q: + _select_from("strength") + elif event.keycode == KEY_W: + _select_from("agility") + elif event.keycode == KEY_E: + _select_from("focus") + elif event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER or event.keycode == KEY_SPACE: + _confirm() get_viewport().set_input_as_handled() func _build_ui() -> void: @@ -68,7 +66,7 @@ func _build_ui() -> void: add_child(center) var panel := PanelContainer.new() - panel.custom_minimum_size = Vector2(620, 420) + panel.custom_minimum_size = Vector2(700, 520) panel.add_theme_stylebox_override("panel", UISkin.menu_panel_style()) center.add_child(panel) @@ -92,23 +90,39 @@ func _build_ui() -> void: UISkin.label(score_label, 15, UISkin.COLOR_TEXT) column.add_child(score_label) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) - column.add_child(row) - for key in ["strength", "agility", "focus"]: - var button := Button.new() - button.custom_minimum_size = Vector2(0, 92) - button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - UISkin.button_styles(button, "large") - button.pressed.connect(_select.bind(key)) - row.add_child(button) - aptitude_labels[key] = button + var from_label := Label.new() + from_label.text = _locale_text("Move 1 point from", "从这里挪出 1 点", "從這裡挪出 1 點") + UISkin.label(from_label, 14, UISkin.COLOR_MUTED) + column.add_child(from_label) + + var from_row := HBoxContainer.new() + from_row.add_theme_constant_override("separation", 10) + column.add_child(from_row) + for key in ATTRIBUTES: + var button := _attribute_button() + button.pressed.connect(_select_from.bind(key)) + from_row.add_child(button) + from_buttons[key] = button + + var to_label := Label.new() + to_label.text = _locale_text("Add 1 point to", "把 1 点加到这里", "把 1 點加到這裡") + UISkin.label(to_label, 14, UISkin.COLOR_MUTED) + column.add_child(to_label) + + var to_row := HBoxContainer.new() + to_row.add_theme_constant_override("separation", 10) + column.add_child(to_row) + for key in ATTRIBUTES: + var button := _attribute_button() + button.pressed.connect(_select_to.bind(key)) + to_row.add_child(button) + to_buttons[key] = button var hint := Label.new() hint.text = _locale_text( - "1 Strength: HP/armor | 2 Agility: speed/crit | 3 Focus: inspiration/skills", - "1 强壮:生命/护甲 | 2 迅捷:速度/暴击 | 3 专注:灵感/技能", - "1 強壯:生命/護甲 | 2 迅捷:速度/暴擊 | 3 專注:靈感/技能" + "Q/W/E choose the source. 1/2/3 choose the target. Total aptitude does not change.", + "Q/W/E 选择扣点项,1/2/3 选择加点项。总资质点数不会改变。", + "Q/W/E 選擇扣點項,1/2/3 選擇加點項。總資質點數不會改變。" ) hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART UISkin.label(hint, 13, UISkin.COLOR_MUTED) @@ -120,15 +134,65 @@ func _build_ui() -> void: confirm_button.pressed.connect(_confirm) column.add_child(confirm_button) -func _select(attribute_id: String) -> void: - selected_attribute = attribute_id - _refresh_selection() +func _attribute_button() -> Button: + var button := Button.new() + button.custom_minimum_size = Vector2(0, 86) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + UISkin.button_styles(button, "large") + return button -func _refresh_selection() -> void: - for key in aptitude_labels.keys(): - var button := aptitude_labels[key] as Button - if button == null: - continue +func _select_from(attribute_id: String) -> void: + if not current_aptitude.has(attribute_id): + return + if int(current_aptitude.get(attribute_id, 0)) <= 1: + return + selected_from = attribute_id + if selected_to == selected_from: + selected_to = _default_to_attribute(selected_from) + _refresh_copy() + +func _select_to(attribute_id: String) -> void: + if not current_aptitude.has(attribute_id): + return + if attribute_id == selected_from: + return + selected_to = attribute_id + _refresh_copy() + +func _refresh_copy() -> void: + var aptitude_total := ( + int(current_aptitude.get("strength", 3)) + + int(current_aptitude.get("agility", 3)) + + int(current_aptitude.get("focus", 3)) + ) + title_label.text = "%s %d" % [ + _locale_text("Heir Generation", "继承者 第", "繼承者 第"), + int(lineage_state.get("generation_index", 1)) + ] + score_label.text = "%s %s / %d | %s %+d | %s %d" % [ + _locale_text("Grade", "评分", "評分"), + String(score_data.get("grade", "D")), + int(score_data.get("score", 0)), + _locale_text("Point delta", "点数修正", "點數修正"), + _grade_delta(String(score_data.get("grade", "D"))), + _locale_text("Total", "总点数", "總點數"), + aptitude_total + ] + _refresh_button_row(from_buttons, selected_from, true) + _refresh_button_row(to_buttons, selected_to, false) + confirm_button.disabled = selected_from == selected_to + +func _refresh_button_row(buttons: Dictionary, selected_attribute: String, source_row: bool) -> void: + for key in buttons.keys(): + var button := buttons[key] as Button + var value := int(current_aptitude.get(key, 3)) + var suffix := "" + if source_row and String(key) == selected_attribute: + suffix = " -1" + elif not source_row and String(key) == selected_attribute: + suffix = " +1" + button.text = "%s\n%d%s" % [_attribute_name(String(key)), value, suffix] + button.disabled = (source_row and value <= 1) or (not source_row and String(key) == selected_from) button.add_theme_stylebox_override( "normal", UISkin.flat_style( @@ -140,9 +204,46 @@ func _refresh_selection() -> void: ) func _confirm() -> void: + if selected_from == selected_to: + return visible = false get_tree().paused = false - confirmed.emit(selected_attribute) + confirmed.emit(selected_from, selected_to) + +func _default_from_attribute() -> String: + var best_key := "strength" + var best_value := -1 + for key in ATTRIBUTES: + var value := int(current_aptitude.get(key, 0)) + if value > best_value: + best_key = key + best_value = value + return best_key + +func _default_to_attribute(from_attribute: String) -> String: + var best_key := "" + var best_value := 999 + for key in ATTRIBUTES: + if key == from_attribute: + continue + var value := int(current_aptitude.get(key, 0)) + if value < best_value: + best_key = key + best_value = value + return best_key if not best_key.is_empty() else "agility" + +func _grade_delta(grade: String) -> int: + match grade: + "S": + return 3 + "A": + return 2 + "B": + return 1 + "C": + return 0 + _: + return -1 func _attribute_name(attribute_id: String) -> String: match attribute_id: @@ -161,3 +262,4 @@ func _locale_text(en_text: String, zh_hans_text: String, zh_hant_text: String) - "zh_Hans": return zh_hans_text return en_text + diff --git a/ui/result_screen.gd b/ui/result_screen.gd index 95c3285..fc19f1e 100644 --- a/ui/result_screen.gd +++ b/ui/result_screen.gd @@ -2,6 +2,7 @@ extends CanvasLayer signal closed signal quit_requested +signal open_ending_reached const UISkin := preload("res://ui/ui_skin.gd") const PANEL_MIN_SIZE := Vector2(320, 320) @@ -71,6 +72,9 @@ func show_result(kind: String, title: String, subtitle: String, detail: String, elif kind == "true_ending": accent = Color(1.0, 0.90, 0.50) badge_text = "CROWN BROKEN" + elif kind == "crown_bad": + accent = Color(0.92, 0.62, 0.48) + badge_text = "CROWN TAKEN" elif kind == "developer_room": accent = Color(0.56, 0.94, 1.0) badge_text = "DEBUG DOOR" @@ -90,7 +94,7 @@ func show_result(kind: String, title: String, subtitle: String, detail: String, _refresh_copy() idle_seconds = 0.0 open_ending_triggered = false - open_ending_allowed = kind == "victory" or kind == "true_ending" + open_ending_allowed = kind == "victory" visible = true get_tree().paused = true continue_button.grab_focus() @@ -128,6 +132,7 @@ func _close_result() -> void: func _show_open_ending() -> void: open_ending_triggered = true open_ending_allowed = false + open_ending_reached.emit() title_label.text = "No Crown" subtitle_label.text = "The heir waits, then turns away from the throne." detail_label.text = "After thirty quiet minutes, no one claims the crown. The loop remains behind as the archive walks out into an unwritten road." diff --git a/ui/save_slot_select.gd b/ui/save_slot_select.gd index d285009..291abd4 100644 --- a/ui/save_slot_select.gd +++ b/ui/save_slot_select.gd @@ -1,4 +1,4 @@ -extends CanvasLayer +extends CanvasLayer signal slot_selected(slot_index: int) signal new_slot_requested(slot_index: int) @@ -133,7 +133,11 @@ func _refresh_slots() -> void: delete_button.text = _locale_text("Delete", "删除", "刪除") continue var state_text := _locale_text("Active", "可继续", "可繼續") - if bool(slot.get("dead_archive", false)): + var ending_type := String(slot.get("ending_type", "")) + if not ending_type.is_empty(): + state_text = _ending_label(ending_type) + button.disabled = true + elif bool(slot.get("dead_archive", false)): state_text = _locale_text("Dead Archive", "死档", "死檔") button.disabled = true elif bool(slot.get("cleared", false)): @@ -162,6 +166,8 @@ func _refresh_slots() -> void: func _activate_slot(slot_index: int) -> void: _cancel_delete() var slot := SaveManager.read_slot(slot_index) if SaveManager != null else {} + if not String(slot.get("ending_type", "")).is_empty(): + return if bool(slot.get("dead_archive", false)): return if bool(slot.get("occupied", false)): @@ -221,6 +227,19 @@ func _family_label(family_id: String) -> String: return _locale_text("Unset", "未定", "未定") +func _ending_label(ending_type: String) -> String: + match ending_type: + "break_crown": + return _locale_text("Ending: Broken Crown", "结局:打碎王冠", "結局:打碎王冠") + "ember_extinguished": + return _locale_text("Ending: Embers Out", "结局:火种熄灭", "結局:火種熄滅") + "escape": + return _locale_text("Ending: Far Road", "结局:奔向远方", "結局:奔向遠方") + "crown_bad": + return _locale_text("Ending: Crown Taken", "结局:戴上王冠", "結局:戴上王冠") + _: + return _locale_text("Ending: Sealed", "结局:档案封存", "結局:檔案封存") + func _locale_text(en_text: String, zh_hans_text: String, zh_hant_text: String) -> String: if UISettings != null and UISettings.has_method("get_locale"): match String(UISettings.get_locale()): @@ -229,3 +248,4 @@ func _locale_text(en_text: String, zh_hans_text: String, zh_hant_text: String) - "zh_Hans": return zh_hans_text return en_text + diff --git a/ui/stage_reward_panel.gd b/ui/stage_reward_panel.gd new file mode 100644 index 0000000..830f776 --- /dev/null +++ b/ui/stage_reward_panel.gd @@ -0,0 +1,110 @@ +extends CanvasLayer + +signal reward_chosen(choice: Dictionary) + +const UISkin := preload("res://ui/ui_skin.gd") + +var backdrop: ColorRect +var panel: PanelContainer +var title_label: Label +var detail_label: Label +var choice_row: HBoxContainer +var choices: Array[Dictionary] = [] + +func _ready() -> void: + layer = 18 + process_mode = Node.PROCESS_MODE_ALWAYS + visible = false + _build_ui() + +func open(reward_choices: Array[Dictionary]) -> void: + choices = reward_choices.duplicate(true) + _rebuild_choices() + visible = true + get_tree().paused = true + +func close() -> void: + visible = false + get_tree().paused = false + +func _build_ui() -> void: + backdrop = ColorRect.new() + backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) + backdrop.color = Color(0.012, 0.014, 0.020, 0.72) + add_child(backdrop) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + backdrop.add_child(center) + + panel = PanelContainer.new() + panel.custom_minimum_size = Vector2(820, 420) + panel.add_theme_stylebox_override("panel", UISkin.menu_panel_style()) + center.add_child(panel) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 28) + margin.add_theme_constant_override("margin_top", 24) + margin.add_theme_constant_override("margin_right", 28) + margin.add_theme_constant_override("margin_bottom", 24) + panel.add_child(margin) + + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 14) + margin.add_child(column) + + title_label = Label.new() + title_label.text = _locale_text("Choose A Field Reward", "选择关间奖励", "選擇關間獎勵") + UISkin.label(title_label, 28, Color(0.98, 0.90, 0.66)) + column.add_child(title_label) + + detail_label = Label.new() + detail_label.text = _locale_text("Pick one. Status rewards last only for the next map.", "三选一。状态奖励只在下一张小地图生效。", "三選一。狀態獎勵只在下一張小地圖生效。") + detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + UISkin.label(detail_label, 14, Color(0.78, 0.84, 0.92)) + column.add_child(detail_label) + + choice_row = HBoxContainer.new() + choice_row.add_theme_constant_override("separation", 12) + choice_row.size_flags_vertical = Control.SIZE_EXPAND_FILL + column.add_child(choice_row) + +func _rebuild_choices() -> void: + for child in choice_row.get_children(): + child.queue_free() + for index in range(choices.size()): + var choice := choices[index] + var button := Button.new() + button.custom_minimum_size = Vector2(238, 240) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.alignment = HORIZONTAL_ALIGNMENT_CENTER + button.text = _choice_button_text(choice) + button.icon = load(String(choice.get("icon", "res://assets/ui/icon/ui_unknown.png"))) as Texture2D + button.expand_icon = true + button.tooltip_text = String(choice.get("summary", "")) + UISkin.button_styles(button, "large") + button.pressed.connect(_choose.bind(index)) + choice_row.add_child(button) + +func _choice_button_text(choice: Dictionary) -> String: + var header := String(choice.get("title", "Reward")) + var grade := String(choice.get("grade", "")) + if not grade.is_empty(): + header = "%s [%s]" % [header, grade] + return "%s\n\n%s" % [header, String(choice.get("summary", ""))] + +func _choose(index: int) -> void: + if index < 0 or index >= choices.size(): + return + var choice := choices[index].duplicate(true) + close() + reward_chosen.emit(choice) + +func _locale_text(en_text: String, zh_hans_text: String, zh_hant_text: String) -> String: + if UISettings != null and UISettings.has_method("get_locale"): + match String(UISettings.get_locale()): + "zh_Hant": + return zh_hant_text + "zh_Hans": + return zh_hans_text + return en_text diff --git a/ui/stage_reward_panel.gd.uid b/ui/stage_reward_panel.gd.uid new file mode 100644 index 0000000..bdc5fc2 --- /dev/null +++ b/ui/stage_reward_panel.gd.uid @@ -0,0 +1 @@ +uid://cohdfuj2xwr3p diff --git a/world.gd b/world.gd index 45052cc..44e654a 100644 --- a/world.gd +++ b/world.gd @@ -1,4 +1,4 @@ -extends Node2D +extends Node2D const KNIGHT_SCENE := preload("res://characters/knight/knight.tscn") const RANGER_SCENE := preload("res://characters/ranger/ranger.tscn") @@ -10,6 +10,8 @@ const DAMAGE_NUMBER_SCENE := preload("res://effects/damage_number.tscn") const RUN_PICKUP_SCRIPT := preload("res://systems/pickups/run_pickup.gd") const WORLD_HEALTH_BAR_SCRIPT := preload("res://ui/world_health_bar.gd") const INVENTORY_PANEL_SCRIPT := preload("res://ui/inventory_panel.gd") +const CONSUMABLE_BAR_SCRIPT := preload("res://ui/consumable_bar.gd") +const STAGE_REWARD_PANEL_SCRIPT := preload("res://ui/stage_reward_panel.gd") const LINEAGE_HUD_SCRIPT := preload("res://ui/lineage_hud.gd") const HEIR_SELECT_PANEL_SCRIPT := preload("res://ui/heir_select_panel.gd") const RANGER_BOSS_SCENE := preload("res://actors/bosses/town/ranger_boss.tscn") @@ -82,6 +84,8 @@ var gate_walk_last_distance: float = INF var door_transition_layer: CanvasLayer = null var door_transition_backdrop: ColorRect = null var inventory_panel: CanvasLayer = null +var consumable_bar: CanvasLayer = null +var stage_reward_panel: CanvasLayer = null var lineage_hud: CanvasLayer = null var heir_select_panel: CanvasLayer = null var pending_lineage_respawn: Dictionary = {} @@ -92,6 +96,8 @@ func _ready() -> void: _prepare_map_runtime() _build_door_transition_overlay() _build_inventory_panel() + _build_consumable_bar() + _build_stage_reward_panel() _build_lineage_hud() _build_heir_select_panel() if get_tree() != null and not get_tree().node_added.is_connected(_on_tree_node_added): @@ -111,6 +117,8 @@ func _ready() -> void: accessory_choice.reroll_requested.connect(_on_accessory_reroll_requested) if run_event_panel != null and run_event_panel.has_signal("event_choice_made"): run_event_panel.event_choice_made.connect(_on_run_event_choice_made) + if stage_reward_panel != null and stage_reward_panel.has_signal("reward_chosen"): + stage_reward_panel.reward_chosen.connect(_on_stage_reward_chosen) if pause_menu != null: if pause_menu.has_method("bind_world"): pause_menu.bind_world(self) @@ -123,6 +131,8 @@ func _ready() -> void: result_screen.closed.connect(_on_result_closed) if result_screen.has_signal("quit_requested"): result_screen.quit_requested.connect(_on_quit_requested) + if result_screen.has_signal("open_ending_reached"): + result_screen.open_ending_reached.connect(_on_open_ending_reached) if audio_settings_panel != null and audio_settings_panel.has_signal("closed"): audio_settings_panel.closed.connect(_on_audio_settings_panel_closed) if settings_panel != null and settings_panel.has_signal("closed"): @@ -254,6 +264,16 @@ func _build_inventory_panel() -> void: if inventory_panel.has_method("reset_run"): inventory_panel.reset_run() +func _build_consumable_bar() -> void: + consumable_bar = CONSUMABLE_BAR_SCRIPT.new() + consumable_bar.name = "ConsumableBar" + add_child(consumable_bar) + +func _build_stage_reward_panel() -> void: + stage_reward_panel = STAGE_REWARD_PANEL_SCRIPT.new() + stage_reward_panel.name = "StageRewardPanel" + add_child(stage_reward_panel) + func _build_lineage_hud() -> void: lineage_hud = LINEAGE_HUD_SCRIPT.new() lineage_hud.name = "LineageHud" @@ -369,6 +389,13 @@ func _unhandled_input(event: InputEvent) -> void: _toggle_inventory_panel() get_viewport().set_input_as_handled() return + if event.keycode >= KEY_1 and event.keycode < KEY_1 + (ConsumableManager.MAX_SLOTS if ConsumableManager != null else 4): + var used := ConsumableManager.use_slot(event.keycode - KEY_1, player_character) if ConsumableManager != null else false + if used: + _push_hud_feed(_ui_text("Consumable used", "已使用消耗品", "已使用消耗品"), XP_FLASH_COLOR, 1.02) + _refresh_battle_status() + get_viewport().set_input_as_handled() + return func _on_character_selected(character_id: StringName) -> void: _cancel_scheduled_title_music() @@ -384,6 +411,8 @@ func _on_character_selected(character_id: StringName) -> void: if Sfx != null: _play_ui_feedback(true) AccessoryManager.reset_run() + if ConsumableManager != null: + ConsumableManager.reset_run() RunDirector.reset_run() if EndingDirector != null: EndingDirector.reset_run() @@ -391,7 +420,13 @@ func _on_character_selected(character_id: StringName) -> void: if slot.is_empty() or not bool(slot.get("occupied", false)): LineageDirector.begin_new_lineage(_character_id_to_family_id(character_id)) elif String(slot.get("family_id", "")).is_empty(): - LineageDirector.begin_new_lineage(_character_id_to_family_id(character_id)) + var crowned_families := String(slot.get("crowned_families", "")) + var saved_reincarnation := int(slot.get("reincarnation_index", 1)) + if saved_reincarnation > 1 or not crowned_families.is_empty(): + LineageDirector.start_or_resume_from_slot(slot) + LineageDirector.begin_reincarnation_family(_character_id_to_family_id(character_id)) + else: + LineageDirector.begin_new_lineage(_character_id_to_family_id(character_id)) else: LineageDirector.start_or_resume_from_slot(slot, _character_id_to_family_id(character_id)) if inventory_panel != null: @@ -423,7 +458,7 @@ func _on_character_selected(character_id: StringName) -> void: player_character.died.connect(_on_player_died) _apply_cheat_safety() encounter_index = -1 - _offer_accessory(_ui_text("First Relic", "初始饰品", "初始飾品"), "opening") + _start_next_encounter() func _consume_startup_context() -> void: var startup := get_node_or_null("/root/StartupContext") @@ -572,7 +607,7 @@ func _on_encounter_defeated() -> void: if defeated_final_encounter: _complete_run_victory() else: - _offer_next_run_event() + _offer_stage_clear_reward() ) ) @@ -675,6 +710,136 @@ func _offer_next_run_event() -> void: _localized_detail_text("%s: %d" % [_ui_text("Gold", "金币", "金幣"), int(RunDirector.gold)]) ) +func _offer_stage_clear_reward() -> void: + var choices := _build_stage_reward_choices() + if stage_reward_panel != null and stage_reward_panel.has_method("open"): + _play_intermission_audio() + stage_reward_panel.open(choices) + _refresh_battle_status( + _ui_text("Field Reward", "关间奖励", "關間獎勵"), + _ui_text("Choose one reward before entering the next map.", "进入下一张图前选择一项奖励。", "進入下一張圖前選擇一項獎勵。"), + _localized_detail_text(_ui_text("Status lasts for one map; consumables go into number slots.", "状态只持续一张图;消耗品进入数字栏。", "狀態只持續一張圖;消耗品進入數字欄。")) + ) + return + if not choices.is_empty(): + _apply_stage_reward_choice(choices[0]) + _start_next_encounter() + +func _build_stage_reward_choices() -> Array[Dictionary]: + var choices: Array[Dictionary] = [] + var status_count := 0 + var consumable_count := 0 + while choices.size() < 3: + var force_consumable := choices.size() == 2 and consumable_count == 0 + var force_status := choices.size() == 2 and status_count == 0 + var make_consumable := force_consumable or (not force_status and reward_rng.randf() < 0.42) + if make_consumable: + var consumable_choice := _random_consumable_reward_choice() + if not consumable_choice.is_empty(): + choices.append(consumable_choice) + consumable_count += 1 + else: + choices.append(_stage_status_choice(_roll_dcba_grade())) + status_count += 1 + return choices + +func _random_consumable_reward_choice() -> Dictionary: + if ConsumableManager == null: + return {} + var consumable_id := ConsumableManager.random_consumable_id() + if consumable_id.is_empty(): + return {} + var data := ConsumableManager.describe(consumable_id) + return { + "type": "consumable", + "consumable_id": consumable_id, + "title": String(data.get("name", consumable_id)), + "summary": String(data.get("summary", "")), + "icon": String(data.get("icon", "res://assets/ui/icon/ui_unknown.png")) + } + +func _on_stage_reward_chosen(choice: Dictionary) -> void: + if stage_reward_panel != null and stage_reward_panel.has_method("close") and stage_reward_panel.visible: + stage_reward_panel.close() + _apply_stage_reward_choice(choice) + _start_next_encounter() + +func _apply_stage_reward_choice(choice: Dictionary) -> void: + match String(choice.get("type", "")): + "consumable": + var consumable_id := String(choice.get("consumable_id", "")) + if ConsumableManager != null: + ConsumableManager.add_consumable(consumable_id) + _refresh_battle_status( + _ui_text("Supply Packed", "补给入袋", "補給入袋"), + String(choice.get("title", consumable_id)), + _localized_detail_text(_ui_text("Use number keys to consume it.", "按数字键使用。", "按數字鍵使用。")) + ) + "status": + var prep := choice.get("prep", {}) as Dictionary + RunDirector.set_pending_encounter_prep(prep) + _refresh_battle_status( + _ui_text("Status Prepared", "状态已准备", "狀態已準備"), + String(choice.get("title", "Status")), + _localized_detail_text(String(choice.get("summary", ""))) + ) + +func _roll_dcba_grade() -> String: + var roll := reward_rng.randf() + if roll < 0.50: + return "D" + if roll < 0.80: + return "C" + if roll < 0.95: + return "B" + return "A" + +func _stage_status_choice(grade: String) -> Dictionary: + var grade_scale := { + "D": 0.08, + "C": 0.12, + "B": 0.17, + "A": 0.24 + } + var value := float(grade_scale.get(grade, 0.08)) + var options := [ + { + "title": _ui_text("Battle Focus", "战斗专注", "戰鬥專注"), + "summary": _ui_text("Next map: attack damage is temporarily increased.", "下一张小图:临时提升攻击伤害。", "下一張小圖:臨時提升攻擊傷害。"), + "icon": "res://assets/ui/buff/buff_attack_up.png", + "temporary_effects": {"attack_damage_pct": value} + }, + { + "title": _ui_text("Quick Step", "迅步", "迅步"), + "summary": _ui_text("Next map: move speed is temporarily increased.", "下一张小图:临时提升移动速度。", "下一張小圖:臨時提升移動速度。"), + "icon": "res://assets/ui/buff/buff_speed_up.png", + "temporary_effects": {"move_speed_pct": value} + }, + { + "title": _ui_text("Guarded Breath", "守势呼吸", "守勢呼吸"), + "summary": _ui_text("Next map: defense and max health are temporarily increased.", "下一张小图:临时提升护甲和生命上限。", "下一張小圖:臨時提升護甲和生命上限。"), + "icon": "res://assets/ui/buff/buff_defense_up.png", + "temporary_effects": {"max_defense": 10.0 + value * 80.0, "max_hp": 8.0 + value * 60.0} + }, + { + "title": _ui_text("Clear Mind", "澄心", "澄心"), + "summary": _ui_text("Next map: skill cooldowns are temporarily reduced.", "下一张小图:临时缩短技能冷却。", "下一張小圖:臨時縮短技能冷卻。"), + "icon": "res://assets/ui/buff/buff_mana_regen_up.png", + "temporary_effects": {"skill_cooldown_pct": -value} + } + ] + var prep: Dictionary = (options[reward_rng.randi_range(0, options.size() - 1)] as Dictionary).duplicate(true) + prep["choice_id"] = "stage_status_%s" % grade.to_lower() + prep["grade"] = grade + return { + "type": "status", + "title": String(prep.get("title", "Status")), + "summary": String(prep.get("summary", "")), + "grade": grade, + "icon": String(prep.get("icon", "res://assets/ui/icon/ui_unknown.png")), + "prep": prep + } + func _on_accessory_choice_made(_accessory_id: String, kept_current: bool) -> void: waiting_for_accessory_choice = false active_accessory_reason = "" @@ -857,34 +1022,27 @@ func _complete_run_victory() -> void: var ending_kind := _victory_result_kind() if ending_kind == "victory" and LineageDirector != null: LineageDirector.complete_reincarnation() + elif _is_final_ending_kind(ending_kind): + _seal_final_ending(ending_kind) if Music != null: Music.play_profile(&"victory") _schedule_title_music(2.8) _refresh_battle_status( - _ui_text("Town Cleared", "城镇试炼完成", "城鎮試煉完成"), - _ui_text("All enemy waves and town boss encounters are defeated.", "所有敌军波次与城镇 Boss 都已击破。", "所有敵軍波次與城鎮 Boss 都已擊破。"), - _localized_detail_text(_ui_text("Pick another champion to restart the sequence.", "重新选择角色即可再次开始这一轮试炼。", "重新選擇角色即可再次開始這一輪試煉。")) + _ui_text("Stage Cleared", "阶段通关", "階段通關"), + _ui_text("The boss is defeated. Final endings seal the archive; normal clears enter the next cycle.", "Boss 已击败。大结局会封存档案,普通通关会进入下一轮。", "Boss 已擊敗。大結局會封存檔案,普通通關會進入下一輪。"), + _localized_detail_text(_ui_text("Continue to return to archive flow.", "继续后回到档案流程。", "繼續後回到檔案流程。")) ) if character_select != null: character_select.visible = false _update_screen_layers() if result_screen != null and result_screen.has_method("show_result"): result_screen.show_result( - "victory", - _ui_text("Town Cleared", "城镇试炼完成", "城鎮試煉完成"), - _ui_text("All enemy waves and bosses are defeated.", "所有敌军波次与 Boss 都已击破。", "所有敵軍波次與 Boss 都已擊破。"), - _ui_text("Your relic build survived the trial. Continue to select a new champion.", "你的饰品构筑撑过了整场试炼。继续后可重新选择角色。", "你的飾品構築撐過了整場試煉。繼續後可重新選擇角色。"), + ending_kind, + _victory_result_title(ending_kind), + _victory_result_subtitle(ending_kind), + _victory_result_detail(ending_kind), _build_result_summary() ) - if result_screen != null and result_screen.has_method("show_result"): - if ending_kind != "victory": - result_screen.show_result( - ending_kind, - _victory_result_title(ending_kind), - _victory_result_subtitle(ending_kind), - _victory_result_detail(ending_kind), - _build_result_summary() - ) _refresh_battle_status() func _bind_actor_audio(actor: Node) -> void: @@ -1098,6 +1256,9 @@ func _build_defeat_rewards(actor: Node) -> Dictionary: drops.append(_drop_entry("repair", maxf(22.0, _player_stat_value("max_defense") * 0.28))) drops.append(_drop_entry("inspiration", maxf(8.0, _player_stat_value("max_inspiration") * 0.40))) drops.append(_drop_entry("heal", maxf(12.0, _player_stat_value("max_hp") * 0.14))) + for _drop_index in range(reward_rng.randi_range(1, 2)): + drops.append(_consumable_drop_entry(ConsumableManager.random_consumable_id() if ConsumableManager != null else "")) + drops.append(_accessory_drop_entry(_random_accessory_drop_id(_roll_dcba_grade()))) else: if is_shield or max_defense_value >= 90.0 or reward_rng.randf() < (0.18 + (0.16 if elite else 0.0)): drops.append(_drop_entry("repair", clampf(10.0 + max_defense_value * 0.09, 10.0, 28.0))) @@ -1107,6 +1268,10 @@ func _build_defeat_rewards(actor: Node) -> Dictionary: drops.append(_drop_entry("heal", clampf(6.0 + max_hp_value * 0.025, 6.0, 18.0))) if elite and drops.size() < 3: drops.append(_drop_entry("inspiration", 8.0)) + if ConsumableManager != null and reward_rng.randf() < 0.03: + drops.append(_consumable_drop_entry(ConsumableManager.random_consumable_id())) + if reward_rng.randf() < 0.001: + drops.append(_accessory_drop_entry(_random_accessory_drop_id(_roll_dcba_grade()))) return { "xp": xp_amount, "drops": drops @@ -1125,14 +1290,15 @@ func _spawn_reward_pickups(world_position: Vector2, drops: Array) -> void: float(drop.get("amount", 0.0)), { "tint": _pickup_tint(String(drop.get("kind", "gold"))), + "icon": String(drop.get("icon", "")), "launch_speed": 34.0 + float(index) * 8.0, "launch_angle": angle } ) - pickup.collected.connect(_on_run_pickup_collected) + pickup.collected.connect(_on_run_pickup_collected.bind(drop)) add_child(pickup) -func _on_run_pickup_collected(kind: String, amount: float, world_position: Vector2) -> void: +func _on_run_pickup_collected(kind: String, amount: float, world_position: Vector2, drop_data: Dictionary = {}) -> void: if player_character == null or not is_instance_valid(player_character): return if inventory_panel != null and inventory_panel.has_method("record_pickup"): @@ -1155,6 +1321,20 @@ func _on_run_pickup_collected(kind: String, amount: float, world_position: Vecto _heal_actor(player_character, amount) _spawn_world_text(world_position, _ui_text("+%d HP", "+%d 生命", "+%d 生命") % int(round(amount)), HEAL_FLASH_COLOR, 0.76) _push_hud_feed(_ui_text("Recovered +%d HP", "恢复 +%d 生命", "恢復 +%d 生命") % int(round(amount)), HEAL_FLASH_COLOR, 1.02) + "consumable": + var consumable_id := String(drop_data.get("consumable_id", "")) + if ConsumableManager != null and ConsumableManager.add_consumable(consumable_id): + var data := ConsumableManager.describe(consumable_id) + _spawn_world_text(world_position, String(data.get("short_name", "ITEM")), XP_FLASH_COLOR, 0.78) + _push_hud_feed(_ui_text("Consumable picked up", "拾取消耗品", "拾取消耗品"), XP_FLASH_COLOR, 1.02) + "accessory": + var accessory_id := String(drop_data.get("accessory_id", "")) + if not accessory_id.is_empty(): + var equipped := AccessoryManager.equip(accessory_id, player_character) + if inventory_panel != null and inventory_panel.has_method("record_relic_equipped"): + inventory_panel.record_relic_equipped(equipped) + _spawn_world_text(world_position, _ui_text("Relic", "饰品", "飾品"), LEVEL_UP_FLASH_COLOR, 0.82) + _push_hud_feed(String(equipped.get("name", _ui_text("Relic equipped", "已装备饰品", "已裝備飾品"))), LEVEL_UP_FLASH_COLOR, 1.06) func _apply_level_up_rewards(actor: Node, levels_gained: int) -> void: if actor == null or not is_instance_valid(actor): @@ -1258,11 +1438,29 @@ func _push_hud_feed(text: String, color_value: Color, scale_value: float = 1.0) if character_hud != null and character_hud.has_method("push_feed_message"): character_hud.push_feed_message(text, color_value, scale_value) -func _drop_entry(kind: String, amount: float) -> Dictionary: - return { +func _drop_entry(kind: String, amount: float, meta: Dictionary = {}) -> Dictionary: + var entry := { "kind": kind, "amount": amount } + for key in meta.keys(): + entry[key] = meta[key] + return entry + +func _consumable_drop_entry(consumable_id: String) -> Dictionary: + if ConsumableManager == null or consumable_id.is_empty(): + return _drop_entry("consumable", 1.0, {"consumable_id": consumable_id}) + var data := ConsumableManager.describe(consumable_id) + return _drop_entry("consumable", 1.0, { + "consumable_id": consumable_id, + "icon": String(data.get("icon", "res://assets/ui/icon/ui_unknown.png")) + }) + +func _accessory_drop_entry(accessory_id: String) -> Dictionary: + return _drop_entry("accessory", 1.0, { + "accessory_id": accessory_id, + "icon": _accessory_icon_path(accessory_id) + }) func _split_drop_amount(total_amount: int, parts: int) -> Array[int]: var result: Array[int] = [] @@ -1285,9 +1483,54 @@ func _pickup_tint(kind: String) -> Color: return REPAIR_FLASH_COLOR "heal": return HEAL_FLASH_COLOR + "consumable": + return Color(0.80, 0.90, 1.0, 1.0) + "accessory": + return LEVEL_UP_FLASH_COLOR _: return GOLD_FLASH_COLOR +func _random_accessory_drop_id(grade: String) -> String: + var target_rarity := _rarity_for_drop_grade(grade) + var catalog := AccessoryManager.get_catalog() if AccessoryManager != null else [] + var candidates: Array[Dictionary] = [] + for accessory in catalog: + var rarity := String(accessory.get("rarity_key", accessory.get("rarity", ""))) + var accessory_id := String(accessory.get("id", "")) + if accessory_id.is_empty() or accessory_id == "none": + continue + if rarity == target_rarity: + candidates.append(accessory) + if candidates.is_empty(): + for accessory in catalog: + var accessory_id := String(accessory.get("id", "")) + if not accessory_id.is_empty() and accessory_id != "none": + candidates.append(accessory) + if candidates.is_empty(): + return "" + return String(candidates[reward_rng.randi_range(0, candidates.size() - 1)].get("id", "")) + +func _accessory_icon_path(accessory_id: String) -> String: + if AccessoryManager == null or accessory_id.is_empty(): + return "" + for accessory in AccessoryManager.get_catalog(): + if String(accessory.get("id", "")) == accessory_id: + return String(accessory.get("icon", "")) + return "" + +func _rarity_for_drop_grade(grade: String) -> String: + match grade: + "D": + return "Common" + "C": + return "Uncommon" + "B": + return "Rare" + "A": + return "Epic" + _: + return "Common" + func _player_stat_value(field: String) -> float: if player_character == null or not is_instance_valid(player_character) or not _has_property(player_character, field): return 0.0 @@ -1372,7 +1615,7 @@ func _final_boss_scene_for_lineage() -> PackedScene: if LineageDirector != null: var lineage_state := LineageDirector.get_state() reincarnation = int(lineage_state.get("reincarnation_index", 1)) - family_id = String(lineage_state.get("family_id", family_id)) + family_id = String(lineage_state.get("current_emperor_family", lineage_state.get("family_id", family_id))) if reincarnation <= 1: return TWIN_PRINCES_BOSS_SCENE match family_id: @@ -1755,8 +1998,33 @@ func _victory_result_kind() -> String: return "developer_room" if _current_reincarnation_index() > 1 and EndingDirector != null and EndingDirector.can_break_crown(): return "true_ending" + if LineageDirector != null and LineageDirector.has_method("would_complete_throne_cycle_after_current") and bool(LineageDirector.would_complete_throne_cycle_after_current()): + return "crown_bad" return "victory" +func _is_final_ending_kind(kind: String) -> bool: + return kind == "true_ending" or kind == "crown_bad" + +func _save_ending_type_for_kind(kind: String) -> String: + match kind: + "true_ending": + return "break_crown" + "crown_bad": + return "crown_bad" + _: + return "" + +func _seal_final_ending(kind: String) -> void: + if SaveManager == null: + return + var ending_type := _save_ending_type_for_kind(kind) + if not ending_type.is_empty() and SaveManager.has_method("seal_active_ending"): + SaveManager.seal_active_ending(ending_type) + +func _on_open_ending_reached() -> void: + if SaveManager != null and SaveManager.has_method("seal_active_ending"): + SaveManager.seal_active_ending("escape") + func _current_reincarnation_index() -> int: if LineageDirector == null: return 1 @@ -1767,21 +2035,27 @@ func _victory_result_title(kind: String) -> String: return _ui_text("Developer Room", "开发者房间", "開發者房間") if kind == "true_ending": return _ui_text("Crown Breaker", "王冠坠地", "王冠墜地") - return _ui_text("Town Cleared", "Town Cleared", "Town Cleared") + if kind == "crown_bad": + return _ui_text("Crowned Again", "再戴王冠", "再戴王冠") + return _ui_text("Stage Cleared", "阶段通关", "階段通關") func _victory_result_subtitle(kind: String) -> String: if kind == "developer_room": return _ui_text("Three forbidden arts answer beneath the throne.", "三道禁术在王座下回应。", "三道禁術在王座下回應。") if kind == "true_ending": return _ui_text("The crown can be broken because the bloodline remained unscarred.", "血脉未被玷污,王冠终于可以被击碎。", "血脈未被玷污,王冠終於可以被擊碎。") - return _ui_text("All enemy waves and bosses are defeated.", "All enemy waves and bosses are defeated.", "All enemy waves and bosses are defeated.") + if kind == "crown_bad": + return _ui_text("Every family has worn the throne, but the loop still chooses a ruler.", "三个家族都已称帝,轮回却仍把王冠交回血脉。", "三個家族都已稱帝,輪迴卻仍把王冠交回血脈。") + return _ui_text("The boss is defeated, but this is only a stage ending.", "Boss 已击败,但这只是阶段性结局。", "Boss 已擊敗,但這只是階段性結局。") func _victory_result_detail(kind: String) -> String: if kind == "developer_room": return _ui_text("Cheat mode rewrites the throne-room exit into a hidden development chamber.", "作弊模式已把王座出口改写为隐藏开发房间。", "作弊模式已把王座出口改寫為隱藏開發房間。") if kind == "true_ending": return _ui_text("The heir raises a weapon, not to inherit, but to shatter the crown and end the loop.", "继承者举起武器,不为继位,只为打碎王冠并终止轮回。", "繼承者舉起武器,不為繼位,只為打碎王冠並終止輪迴。") - return _ui_text("Your relic build survived the trial. Continue to select a new champion.", "Your relic build survived the trial. Continue to select a new champion.", "Your relic build survived the trial. Continue to select a new champion.") + if kind == "crown_bad": + return _ui_text("After the fourth crown, the archive is sealed by the same choice it tried to escape.", "第四顶王冠落下,档案被它试图逃离的选择封存。", "第四頂王冠落下,檔案被它試圖逃離的選擇封存。") + return _ui_text("Continue to select the next champion and enter the next cycle.", "继续后选择下一名角色,进入下一轮轮回。", "繼續後選擇下一名角色,進入下一輪輪迴。") func _sync_audio_hint_state() -> void: if audio_shortcut_hint == null or not audio_shortcut_hint.has_method("set_panel_open"): @@ -1881,9 +2155,9 @@ func _open_heir_select_panel() -> void: return _respawn_lineage_heir() -func _on_heir_aptitude_confirmed(attribute_id: String) -> void: +func _on_heir_aptitude_confirmed(from_attribute: String, to_attribute: String) -> void: if LineageDirector != null: - LineageDirector.apply_manual_aptitude_bonus(attribute_id) + LineageDirector.apply_manual_aptitude_shift(from_attribute, to_attribute) _respawn_lineage_heir() func _respawn_lineage_heir() -> void: @@ -1903,6 +2177,8 @@ func _respawn_lineage_heir() -> void: if character_hud != null and character_hud.has_method("bind_character"): character_hud.bind_character(player_character) AccessoryManager.reset_run() + if ConsumableManager != null: + ConsumableManager.reset_run() RunDirector.reset_run() if EndingDirector != null: EndingDirector.reset_run() @@ -1915,7 +2191,7 @@ func _respawn_lineage_heir() -> void: _apply_cheat_safety() var restart_index := maxi(int(LineageDirector.get_state().get("current_encounter_index", 0)), 0) encounter_index = restart_index - 1 - _offer_accessory(_ui_text("Inherited Relic", "继承开局饰品", "繼承開局飾品"), "lineage") + _start_next_encounter() func _reset_to_character_select() -> void: _cancel_scheduled_title_music() @@ -1940,6 +2216,8 @@ func _reset_to_character_select() -> void: return_pause_after_audio_panel = false return_pause_after_settings_panel = false AccessoryManager.reset_run() + if ConsumableManager != null: + ConsumableManager.reset_run() RunDirector.reset_run() if SaveManager != null: SaveManager.active_slot_index = -1 @@ -2060,3 +2338,4 @@ func _is_target_defeated(target: Node) -> bool: if String(property.get("name", "")) == "hp": return float(target.get("hp")) <= 0.0 return false +