diff --git a/.github/workflows/godot-tests.yml b/.github/workflows/godot-tests.yml new file mode 100644 index 0000000..b59902e --- /dev/null +++ b/.github/workflows/godot-tests.yml @@ -0,0 +1,71 @@ +name: godot-tests + +# Separate from `repository-validation`, which checks repository hygiene and +# must keep passing on its own. This workflow only answers one question: does +# the game still build its resources and pass its automated tests? + +on: + pull_request: + branches: [develop, main] + workflow_dispatch: + +permissions: + contents: read + +env: + GODOT_VERSION: 4.3-stable + +jobs: + godot-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Cache Godot binary + id: godot-cache + uses: actions/cache@v4 + with: + path: ~/godot-bin + key: godot-${{ env.GODOT_VERSION }}-linux-x86_64 + + - name: Download Godot + if: steps.godot-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + mkdir -p ~/godot-bin + archive="Godot_v${GODOT_VERSION}_linux.x86_64" + url="https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/${archive}.zip" + curl -fsSL -o /tmp/godot.zip "$url" + unzip -q -o /tmp/godot.zip -d /tmp/godot + mv "/tmp/godot/${archive}" ~/godot-bin/godot + chmod +x ~/godot-bin/godot + + - name: Report Godot version + shell: bash + run: | + echo "$HOME/godot-bin" >> "$GITHUB_PATH" + ~/godot-bin/godot --version + + # Import must run first and on its own: it generates the .godot/ cache and + # the global class-name registry that the test scripts resolve against. + - name: Import project resources + shell: bash + run: godot --headless --import 2>&1 | tee /tmp/import.log + + - name: Fail on import errors + shell: bash + run: | + if grep -qE '^(SCRIPT )?ERROR:' /tmp/import.log; then + echo "Import reported errors:" + grep -nE '^(SCRIPT )?ERROR:' /tmp/import.log + exit 1 + fi + + # Runs as a scene, not via --script: a MainLoop script is compiled before + # the autoload singletons exist, so classes naming EventBus or RNGService + # would fail to compile and every service would silently be null. + - name: Run tests + shell: bash + run: godot --headless --path . res://tests/test_runner.tscn diff --git a/README.md b/README.md index db0a2cf..62b8df0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,63 @@ # Nine Nether -Nine Nether is a private Godot 4.x greybox prototype exploring a core trade-off: sacrifice structural survival and action freedom for offensive power. +Nine Nether is a Godot 4 greybox prototype exploring a core trade-off: sacrifice structural survival and action freedom for offensive power. Every power has a price. -- **Phase:** repository and framework setup -- **Playable status:** no playable build yet -- **Engine/language:** Godot 4.x / GDScript +- **Phase:** M1 — playable vertical slice foundation +- **Playable status:** playable end to end (wave → sacrifice → boss → victory or death → restart) +- **Engine/language:** Godot 4.3 stable / GDScript - **Branches:** `main` contains stable playable releases; `develop` is the integration branch; work occurs on one `claude/*` or `codex/*` branch per task. -Start with [the PRD](docs/PRD.md), [Prototype Contract](docs/PROTOTYPE_CONTRACT.md), and [contributor rules](AGENTS.md). Follow [the GitHub workflow](docs/GITHUB_WORKFLOW.md), test and update [the handoff](docs/AI_HANDOFF.md), then open a pull request to `develop`. +## Running it -The next milestone is Claude's `claude/core-framework` branch. This repository is a private greybox prototype, not a finished or publicly supported game. +Open the project folder in Godot 4.3 and press F5, or from the command line: + +```bash +godot --path . +``` + +The main scene is `res://scenes/main.tscn`. There are no external dependencies — clone and run. + +### Controls + +| Action | Keys | +| --- | --- | +| Move | `A` / `D` or `←` / `→` | +| Jump | `Space` or `W` | +| Light attack | `J` or left mouse | +| Confirm sacrifice | `Enter` | +| Restart run | `R` | + +### Debug (development builds) + +| Key | Command | +| --- | --- | +| `F1` | Toggle the RunState readout (seed, phase, player state, every stat, snapshot hash) | +| `F2` / `F3` | Heal / damage the player | +| `F4` | Spawn a reference enemy next to the player | +| `F5` | Start the boss encounter | +| `F6` / `F7` | Preview / apply the sacrifice | +| `F8` | Toggle hit, hurt and body boxes | + +## Tests + +```bash +godot --headless --import +godot --headless --path . res://tests/test_runner.tscn +``` + +See [docs/TESTING.md](docs/TESTING.md). CI runs `repository-validation` and `godot-tests` on every pull request into `develop`. + +## Where to start reading + +Product first, then implementation: + +1. [PRD](docs/PRD.md) and [Prototype Contract](docs/PROTOTYPE_CONTRACT.md) — the product source of truth +2. [Architecture](docs/ARCHITECTURE.md) — what the code does and why +3. [Interfaces](docs/INTERFACES.md) — the frozen contracts and where to extend them +4. [Decisions](docs/DECISIONS.md) — the trade-offs already made +5. [AI handoff](docs/AI_HANDOFF.md) — current state, known issues, next tasks +6. [Contributor rules](AGENTS.md) and [GitHub workflow](docs/GITHUB_WORKFLOW.md) + +Art assets are governed by [docs/ART_SPEC.md](docs/ART_SPEC.md). All sprites are placeholders, marked with a magenta border. + +This repository is a greybox prototype, not a finished or publicly supported game. diff --git a/actors/boss/boss.tscn b/actors/boss/boss.tscn new file mode 100644 index 0000000..4eaeb2a --- /dev/null +++ b/actors/boss/boss.tscn @@ -0,0 +1,48 @@ +[gd_scene load_steps=8 format=3 uid="uid://bnnbossgate001"] + +[ext_resource type="Script" path="res://actors/boss/boss_actor.gd" id="1_boss"] +[ext_resource type="SpriteFrames" path="res://actors/boss/boss_frames.tres" id="2_frames"] +[ext_resource type="Script" path="res://core/hurtbox.gd" id="3_hurtbox"] +[ext_resource type="Script" path="res://core/hitbox.gd" id="4_hitbox"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(30, 52) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurt"] +size = Vector2(38, 56) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hit"] +size = Vector2(46, 40) + +[node name="Boss" type="CharacterBody2D"] +collision_layer = 4 +collision_mask = 1 +script = ExtResource("1_boss") + +[node name="Sprite" type="AnimatedSprite2D" parent="."] +position = Vector2(0, -48) +sprite_frames = ExtResource("2_frames") +animation = &"idle" +autoplay = "idle" + +[node name="Body" type="CollisionShape2D" parent="."] +position = Vector2(0, -26) +shape = SubResource("RectangleShape2D_body") + +[node name="Hurtbox" type="Area2D" parent="."] +collision_layer = 16 +collision_mask = 0 +script = ExtResource("3_hurtbox") + +[node name="Shape" type="CollisionShape2D" parent="Hurtbox"] +position = Vector2(0, -28) +shape = SubResource("RectangleShape2D_hurt") + +[node name="AttackHitbox" type="Area2D" parent="."] +collision_layer = 64 +collision_mask = 8 +script = ExtResource("4_hitbox") + +[node name="Shape" type="CollisionShape2D" parent="AttackHitbox"] +position = Vector2(34, -26) +shape = SubResource("RectangleShape2D_hit") diff --git a/actors/boss/boss_actor.gd b/actors/boss/boss_actor.gd new file mode 100644 index 0000000..ba211ef --- /dev/null +++ b/actors/boss/boss_actor.gd @@ -0,0 +1,31 @@ +class_name BossActor +extends EnemyBase +## Prototype Boss — 镇关鬼将, the Gate Guardian Ghost General. +## +## Deliberately thin: it is an EnemyBase with boss-scale numbers, its own +## lifecycle events and a health bar. There is no second combat system, no +## second state machine and no phase controller in M1. +## +## Why only one attack: `assets/boss/` ships exactly one attack sheet +## (boss_attack.png, 5 frames). The Prototype Development Pack asks for three +## moves and two phases, and the M1 brief gates the second move on the art +## supporting it. It does not, so the charge and the ground slam are left to +## Codex X04 rather than faked by replaying the run cycle. See +## docs/AI_HANDOFF.md, "Asset integration gaps". +## +## Extension point for X04: add moves as EnemyConfig-driven AttackDefinitions +## selected by an AttackScheduler here. Do not modify EnemyBase or +## CombatResolver to do it. + +signal defeated(boss: BossActor) + +func start_encounter() -> void: + EventBus.boss_started.emit( + EventBus.context({"actor_id": actor_id(), "max_hp": max_hp()}) + ) + +func _publish_death(source_id: StringName) -> void: + EventBus.boss_died.emit( + EventBus.context({"actor_id": actor_id(), "source_id": source_id}) + ) + defeated.emit(self) diff --git a/actors/boss/boss_frames.tres b/actors/boss/boss_frames.tres new file mode 100644 index 0000000..fc3aaa8 --- /dev/null +++ b/actors/boss/boss_frames.tres @@ -0,0 +1,212 @@ +; Generated by tools/generate_sprite_frames.py from docs/ART_SPEC.md. +; Do not hand-edit: re-run the generator instead. +[gd_resource type="SpriteFrames" load_steps=31 format=3] + +[ext_resource type="Texture2D" path="res://assets/boss/boss_idle.png" id="1_idle"] +[ext_resource type="Texture2D" path="res://assets/boss/boss_run.png" id="2_run"] +[ext_resource type="Texture2D" path="res://assets/boss/boss_attack.png" id="3_attack"] +[ext_resource type="Texture2D" path="res://assets/boss/boss_hurt.png" id="4_hurt"] +[ext_resource type="Texture2D" path="res://assets/boss/boss_death.png" id="5_death"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] +atlas = ExtResource("1_idle") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] +atlas = ExtResource("1_idle") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] +atlas = ExtResource("1_idle") +region = Rect2(192, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] +atlas = ExtResource("1_idle") +region = Rect2(288, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] +atlas = ExtResource("2_run") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] +atlas = ExtResource("2_run") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] +atlas = ExtResource("2_run") +region = Rect2(192, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] +atlas = ExtResource("2_run") +region = Rect2(288, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] +atlas = ExtResource("2_run") +region = Rect2(384, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] +atlas = ExtResource("2_run") +region = Rect2(480, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] +atlas = ExtResource("3_attack") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] +atlas = ExtResource("3_attack") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] +atlas = ExtResource("3_attack") +region = Rect2(192, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] +atlas = ExtResource("3_attack") +region = Rect2(288, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_4"] +atlas = ExtResource("3_attack") +region = Rect2(384, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] +atlas = ExtResource("4_hurt") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] +atlas = ExtResource("4_hurt") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] +atlas = ExtResource("5_death") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] +atlas = ExtResource("5_death") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] +atlas = ExtResource("5_death") +region = Rect2(192, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] +atlas = ExtResource("5_death") +region = Rect2(288, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_4"] +atlas = ExtResource("5_death") +region = Rect2(384, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_5"] +atlas = ExtResource("5_death") +region = Rect2(480, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_6"] +atlas = ExtResource("5_death") +region = Rect2(576, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_7"] +atlas = ExtResource("5_death") +region = Rect2(672, 0, 96, 96) + +[resource] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_3") +}], +"loop": true, +"name": &"idle", +"speed": 6.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_5") +}], +"loop": true, +"name": &"run", +"speed": 8.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_4") +}], +"loop": false, +"name": &"attack", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_1") +}], +"loop": false, +"name": &"hurt", +"speed": 8.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_5") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_6") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_7") +}], +"loop": false, +"name": &"death", +"speed": 6.0 +}] diff --git a/actors/enemies/enemy_base.gd b/actors/enemies/enemy_base.gd new file mode 100644 index 0000000..beeffbf --- /dev/null +++ b/actors/enemies/enemy_base.gd @@ -0,0 +1,276 @@ +class_name EnemyBase +extends CharacterBody2D +## Shared contract for every hostile actor, including the Boss. +## +## Contract (frozen — see docs/INTERFACES.md): +## initialize / acquire_target / change_state / receive_damage / die +## +## Rules a concrete enemy must not break: +## * Damage is resolved by CombatResolver. Do not compute it here or in a +## subclass — build a DamageContext and let the resolver answer. +## * `die` is idempotent. A second hit landing in the same frame, or a DoT +## tick arriving after death, must not fire a second death event. +## * Enemies never touch RunState's structural values or the sacrifice +## system. They ask the target for its armour and current HP, nothing more. +## +## Implements the Damageable shape: actor_id / armour / current_hp / +## receive_damage. + +signal died(enemy: EnemyBase) + +enum State { IDLE, CHASE, WINDUP, ATTACK, RECOVER, HURT, DEAD } + +@onready var sprite: AnimatedSprite2D = $Sprite +@onready var attack_hitbox: Hitbox = $AttackHitbox +@onready var hurtbox: Hurtbox = $Hurtbox +@onready var body_shape: CollisionShape2D = $Body + +var config: EnemyConfig + +var _current_hp: float = 0.0 +var _state: int = State.IDLE +var _state_elapsed: float = 0.0 +var _cooldown: float = 0.0 +var _facing: int = -1 +var _target: Node2D +var _is_dead: bool = false +var _initialised: bool = false + + +func _ready() -> void: + attack_hitbox.hit_actor.connect(_on_attack_connected) + attack_hitbox.set_active(false) + + +## Must be called before the enemy takes its first physics step. Spawners do +## this immediately after `add_child`. +func initialize(enemy_config: EnemyConfig, target: Node2D) -> void: + config = enemy_config + _current_hp = config.max_hp + _target = target + _initialised = true + change_state(State.IDLE) + + +func acquire_target(target: Node2D) -> void: + _target = target + + +func actor_id() -> StringName: + return config.actor_id if config != null else &"enemy" + +func armour() -> float: + return config.armour if config != null else 0.0 + +func current_hp() -> float: + return _current_hp + +func max_hp() -> float: + return config.max_hp if config != null else 0.0 + +func hp_ratio() -> float: + if config == null or config.max_hp <= 0.0: + return 0.0 + return clampf(_current_hp / config.max_hp, 0.0, 1.0) + +func is_dead() -> bool: + return _is_dead + +func state() -> int: + return _state + + +func _physics_process(delta: float) -> void: + if not _initialised: + return + if _is_dead: + # A corpse has no body collider, so running gravity and move_and_slide + # would drop it through the floor while the death animation plays. + _state_elapsed += delta + if _state_elapsed >= config.death_duration: + queue_free() + return + _apply_gravity(delta) + _cooldown = maxf(0.0, _cooldown - delta) + _state_elapsed += delta + _update_state(delta) + move_and_slide() + + +## Single entry point for transitions, so enter-effects cannot be skipped. +func change_state(next: int) -> void: + if _state == next: + return + _exit_state(_state) + _state = next + _state_elapsed = 0.0 + _enter_state(next) + + +## Resolves one incoming attack and applies the result. Returns the result so +## the attacker can report it — the attacker does not get to decide the number. +func receive_damage(context: DamageContext) -> DamageResult: + var result := GameData.combat.resolve(context) + if _is_dead: + result.final_damage = 0.0 + result.is_lethal = false + return result + _current_hp = maxf(0.0, _current_hp - result.final_damage) + if _current_hp <= 0.0: + die(context.source_id) + else: + change_state(State.HURT) + return result + + +## Idempotent. The `_is_dead` guard is the reason a second lethal hit in the +## same frame cannot produce two death events or two sets of rewards. +func die(source_id: StringName = &"unknown") -> void: + if _is_dead: + return + _is_dead = true + _current_hp = 0.0 + velocity = Vector2.ZERO + attack_hitbox.set_active(false) + hurtbox.set_deferred(&"monitorable", false) + body_shape.set_deferred(&"disabled", true) + change_state(State.DEAD) + _publish_death(source_id) + died.emit(self) + + +# --- internals -------------------------------------------------------------- + +func _publish_death(source_id: StringName) -> void: + EventBus.enemy_died.emit( + EventBus.context({"actor_id": actor_id(), "source_id": source_id}) + ) + + +func _apply_gravity(delta: float) -> void: + velocity.y = minf(velocity.y + config.gravity * delta, config.max_fall_speed) + + +func _enter_state(next: int) -> void: + match next: + State.IDLE: + _play(&"idle") + State.CHASE: + _play(&"run") + State.WINDUP: + _play(&"attack") + sprite.frame = 0 + sprite.pause() + sprite.modulate = config.telegraph_tint + State.ATTACK: + sprite.modulate = Color.WHITE + sprite.play(&"attack") + attack_hitbox.set_active(true) + State.RECOVER: + attack_hitbox.set_active(false) + State.HURT: + _play(&"hurt") + sprite.frame = 0 + State.DEAD: + _play(&"death") + sprite.frame = 0 + + +func _exit_state(previous: int) -> void: + if previous == State.WINDUP: + sprite.modulate = Color.WHITE + if previous == State.ATTACK: + attack_hitbox.set_active(false) + + +func _update_state(delta: float) -> void: + match _state: + State.IDLE: + _decelerate(delta) + if _target_in_range(config.detection_range): + change_state(State.CHASE) + State.CHASE: + _chase(delta) + State.WINDUP: + _decelerate(delta) + _face_target() + if _state_elapsed >= config.attack_windup: + change_state(State.ATTACK) + State.ATTACK: + _decelerate(delta) + if _state_elapsed >= config.attack_active: + change_state(State.RECOVER) + State.RECOVER: + _decelerate(delta) + if _state_elapsed >= config.attack_recovery: + _cooldown = config.attack_cooldown + change_state(State.IDLE) + State.HURT: + _decelerate(delta, 400.0) + if _state_elapsed >= config.hurt_duration: + change_state(State.CHASE if _target_in_range(config.detection_range) else State.IDLE) + State.DEAD: + pass + + +func _chase(delta: float) -> void: + if not _target_in_range(config.detection_range): + change_state(State.IDLE) + return + _face_target() + var distance := _distance_to_target() + if distance <= config.attack_range and _cooldown <= 0.0: + change_state(State.WINDUP) + return + if distance <= config.preferred_gap: + _decelerate(delta) + return + velocity.x = _facing * config.move_speed + + +func _decelerate(delta: float, rate: float = 600.0) -> void: + velocity.x = move_toward(velocity.x, 0.0, rate * delta) + + +func _distance_to_target() -> float: + if _target == null: + return INF + return absf(_target.global_position.x - global_position.x) + + +func _target_in_range(range_px: float) -> bool: + if _target == null or not is_instance_valid(_target): + return false + return global_position.distance_to(_target.global_position) <= range_px + + +func _face_target() -> void: + if _target == null: + return + var direction := signi(int(_target.global_position.x - global_position.x)) + if direction == 0: + return + _facing = direction + # Every sheet in assets/ is authored facing right (docs/ART_SPEC.md). + sprite.flip_h = _facing < 0 + attack_hitbox.scale.x = absf(attack_hitbox.scale.x) * _facing + + +func _play(animation: StringName) -> void: + if sprite.animation != animation or not sprite.is_playing(): + sprite.play(animation) + + +func _on_attack_connected(target: Node) -> void: + if _is_dead or not target.has_method("receive_damage"): + return + var context := DamageContext.new() + context.source_id = actor_id() + context.target_id = target.call("actor_id") + context.base_damage = config.attack_damage + context.skill_multiplier = config.attack_skill_multiplier + context.crit_allowed = false + context.target_armour = float(target.call("armour")) + context.target_current_hp = float(target.call("current_hp")) + context.tags = [&"melee"] as Array[StringName] + target.call("receive_damage", context) diff --git a/actors/enemies/enemy_base.tscn b/actors/enemies/enemy_base.tscn new file mode 100644 index 0000000..d5804ec --- /dev/null +++ b/actors/enemies/enemy_base.tscn @@ -0,0 +1,44 @@ +[gd_scene load_steps=7 format=3 uid="uid://bnnenemybase01"] + +[ext_resource type="Script" path="res://actors/enemies/enemy_base.gd" id="1_enemy"] +[ext_resource type="Script" path="res://core/hurtbox.gd" id="2_hurtbox"] +[ext_resource type="Script" path="res://core/hitbox.gd" id="3_hitbox"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(14, 26) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurt"] +size = Vector2(18, 28) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hit"] +size = Vector2(26, 20) + +[node name="EnemyBase" type="CharacterBody2D"] +collision_layer = 4 +collision_mask = 1 +script = ExtResource("1_enemy") + +[node name="Sprite" type="AnimatedSprite2D" parent="."] +position = Vector2(0, -24) + +[node name="Body" type="CollisionShape2D" parent="."] +position = Vector2(0, -13) +shape = SubResource("RectangleShape2D_body") + +[node name="Hurtbox" type="Area2D" parent="."] +collision_layer = 16 +collision_mask = 0 +script = ExtResource("2_hurtbox") + +[node name="Shape" type="CollisionShape2D" parent="Hurtbox"] +position = Vector2(0, -14) +shape = SubResource("RectangleShape2D_hurt") + +[node name="AttackHitbox" type="Area2D" parent="."] +collision_layer = 64 +collision_mask = 8 +script = ExtResource("3_hitbox") + +[node name="Shape" type="CollisionShape2D" parent="AttackHitbox"] +position = Vector2(20, -14) +shape = SubResource("RectangleShape2D_hit") diff --git a/actors/enemies/enemy_config.gd b/actors/enemies/enemy_config.gd new file mode 100644 index 0000000..036ab09 --- /dev/null +++ b/actors/enemies/enemy_config.gd @@ -0,0 +1,41 @@ +class_name EnemyConfig +extends Resource +## Stats and timings for one enemy archetype. Instances live in +## res://data/actors/. No enemy script may hardcode these numbers. +## +## Attack timings are the contract the player reads: a wind-up long enough to +## see is the difference between a fair hit and a cheap one. + +@export var actor_id: StringName = &"enemy" +@export var display_name: String = "" + +@export_group("Vitals") +@export var max_hp: float = 30.0 +@export var armour: float = 0.0 + +@export_group("Movement") +@export var move_speed: float = 46.0 +@export var gravity: float = 780.0 +@export var max_fall_speed: float = 420.0 + +@export_group("Perception") +@export var detection_range: float = 180.0 +@export var attack_range: float = 65.0 +## Distance at which the enemy stops closing, so it does not stand inside the +## player. +@export var preferred_gap: float = 34.0 + +@export_group("Attack") +@export var attack_damage: float = 8.0 +@export var attack_skill_multiplier: float = 1.0 +## Readable telegraph. X01 specifies 0.40 / 0.15 / 0.60. +@export var attack_windup: float = 0.40 +@export var attack_active: float = 0.15 +@export var attack_recovery: float = 0.60 +@export var attack_cooldown: float = 0.35 + +@export_group("Reactions") +@export var hurt_duration: float = 0.18 +@export var death_duration: float = 0.6 +## Colour the sprite is tinted during wind-up. +@export var telegraph_tint: Color = Color(1.7, 1.1, 1.0) diff --git a/actors/enemies/ghost_melee.tscn b/actors/enemies/ghost_melee.tscn new file mode 100644 index 0000000..6dd41d2 --- /dev/null +++ b/actors/enemies/ghost_melee.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3 uid="uid://bnnghostmelee01"] + +[ext_resource type="PackedScene" path="res://actors/enemies/enemy_base.tscn" id="1_base"] +[ext_resource type="SpriteFrames" path="res://actors/enemies/ghost_melee_frames.tres" id="2_frames"] + +[node name="GhostMelee" instance=ExtResource("1_base")] + +[node name="Sprite" parent="." index="0"] +sprite_frames = ExtResource("2_frames") +animation = &"idle" +autoplay = "idle" diff --git a/actors/enemies/ghost_melee_frames.tres b/actors/enemies/ghost_melee_frames.tres new file mode 100644 index 0000000..7628485 --- /dev/null +++ b/actors/enemies/ghost_melee_frames.tres @@ -0,0 +1,177 @@ +; Generated by tools/generate_sprite_frames.py from docs/ART_SPEC.md. +; Do not hand-edit: re-run the generator instead. +[gd_resource type="SpriteFrames" load_steps=26 format=3] + +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_idle.png" id="1_idle"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_run.png" id="2_run"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_attack.png" id="3_attack"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_hurt.png" id="4_hurt"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_death.png" id="5_death"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] +atlas = ExtResource("1_idle") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] +atlas = ExtResource("1_idle") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] +atlas = ExtResource("1_idle") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] +atlas = ExtResource("1_idle") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] +atlas = ExtResource("2_run") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] +atlas = ExtResource("2_run") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] +atlas = ExtResource("2_run") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] +atlas = ExtResource("2_run") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] +atlas = ExtResource("2_run") +region = Rect2(192, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] +atlas = ExtResource("2_run") +region = Rect2(240, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] +atlas = ExtResource("3_attack") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] +atlas = ExtResource("3_attack") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] +atlas = ExtResource("3_attack") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] +atlas = ExtResource("3_attack") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] +atlas = ExtResource("4_hurt") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] +atlas = ExtResource("4_hurt") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] +atlas = ExtResource("5_death") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] +atlas = ExtResource("5_death") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] +atlas = ExtResource("5_death") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] +atlas = ExtResource("5_death") +region = Rect2(144, 0, 48, 48) + +[resource] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_3") +}], +"loop": true, +"name": &"idle", +"speed": 8.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_5") +}], +"loop": true, +"name": &"run", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_3") +}], +"loop": false, +"name": &"attack", +"speed": 12.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_1") +}], +"loop": false, +"name": &"hurt", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_3") +}], +"loop": false, +"name": &"death", +"speed": 8.0 +}] diff --git a/actors/player/player.gd b/actors/player/player.gd new file mode 100644 index 0000000..8ac77b6 --- /dev/null +++ b/actors/player/player.gd @@ -0,0 +1,184 @@ +class_name Player +extends CharacterBody2D +## Player actor. Holds movement, animation and hit resolution; the decision of +## *which* of those to run belongs to the state machine in states/. +## +## The player has no HP of its own. Current and maximum lifespan live in +## RunState, which is injected by RunCoordinator via `setup`. That keeps one +## authoritative copy of the run's numbers and means a sacrifice applied from +## the UI is visible here without any syncing. + +const ACTOR_ID := &"player" + +signal died + +@onready var sprite: AnimatedSprite2D = $Sprite +@onready var attack_hitbox: Hitbox = $AttackHitbox +@onready var camera: Camera2D = $Camera + +var input_enabled: bool = true + +var _run_state: RunState +var _balance: BalanceConfig +var _machine: PlayerStateMachine +var _facing: int = 1 +var _invulnerable_for: float = 0.0 +var _stamina_delay_for: float = 0.0 +var _spawn_point: Vector2 = Vector2.ZERO + +func _ready() -> void: + attack_hitbox.hit_actor.connect(_on_attack_connected) + attack_hitbox.set_active(false) + +## Injects the run this player belongs to. Must be called before the first +## physics frame; RunCoordinator does it at spawn time. +func setup(run_state: RunState, balance: BalanceConfig) -> void: + _run_state = run_state + _balance = balance + _spawn_point = global_position + _machine = PlayerStateMachine.new(self) + +func run_state() -> RunState: + return _run_state + +func balance() -> BalanceConfig: + return _balance + +func facing() -> int: + return _facing + +func state_id() -> StringName: + return _machine.current_id() if _machine != null else &"uninitialised" + +func is_dead() -> bool: + return _run_state != null and not _run_state.is_alive() + +func is_invulnerable() -> bool: + return _invulnerable_for > 0.0 + + +func _physics_process(delta: float) -> void: + if _machine == null: + return + _invulnerable_for = maxf(0.0, _invulnerable_for - delta) + _tick_stamina(delta) + _machine.physics_update(delta) + move_and_slide() + + +# --- shared movement helpers used by the states ----------------------------- + +func apply_gravity(delta: float) -> void: + velocity.y = minf( + velocity.y + _balance.player_gravity * delta, _balance.player_max_fall_speed + ) + +## `control` scales the input authority: 1.0 on the ground, less in the air. +func apply_horizontal_input(control: float = 1.0) -> void: + var direction := input_direction() + velocity.x = direction * _balance.player_move_speed * control + if direction != 0: + set_facing(direction) + +func input_direction() -> int: + if not input_enabled: + return 0 + return int(Input.get_axis(&"move_left", &"move_right")) + +func set_facing(direction: int) -> void: + if direction == 0: + return + _facing = signi(direction) + sprite.flip_h = _facing < 0 + # The attack box is authored facing right; mirror it with the sprite. + attack_hitbox.scale.x = absf(attack_hitbox.scale.x) * _facing + +func jump() -> void: + velocity.y = _balance.player_jump_velocity + +func decelerate(delta: float, rate: float = 600.0) -> void: + velocity.x = move_toward(velocity.x, 0.0, rate * delta) + +func play(animation: StringName) -> void: + if sprite.animation != animation: + sprite.play(animation) + +func animation_finished() -> bool: + return not sprite.is_playing() + + +# --- Damageable shape ------------------------------------------------------- +# Mirrors EnemyBase so an attacker can hit either side without special-casing. + +func actor_id() -> StringName: + return ACTOR_ID + +func armour() -> float: + return _run_state.armour() if _run_state != null else 0.0 + +func current_hp() -> float: + return _run_state.current_hp() if _run_state != null else 0.0 + + +## Resolves one incoming attack. `context` is built by the attacker; this method +## does not invent damage, it only applies what CombatResolver returned. +func receive_damage(context: DamageContext) -> DamageResult: + var result := GameData.combat.resolve(context) + if is_dead() or is_invulnerable(): + result.final_damage = 0.0 + result.is_lethal = false + return result + _run_state.apply_damage(result.final_damage) + _invulnerable_for = _balance.player_invulnerable_after_hit + EventBus.hit_taken.emit(EventBus.context(result.to_dictionary())) + if _run_state.is_alive(): + _machine.on_hurt(context.source_id) + else: + _machine.on_death() + EventBus.player_died.emit(EventBus.context({"source_id": context.source_id})) + died.emit() + return result + +func knockback_from(source_position: Vector2) -> void: + var direction := signf(global_position.x - source_position.x) + if is_zero_approx(direction): + direction = -_facing + velocity.x = direction * _balance.player_hurt_knockback + +## Debug-only teleport back to the spawn marker without restarting the run. +func return_to_spawn() -> void: + global_position = _spawn_point + velocity = Vector2.ZERO + + +func _on_attack_connected(target: Node) -> void: + if not target.has_method("receive_damage"): + return + var context := GameData.combat.player_attack_context( + _run_state, + target.call("actor_id"), + float(target.call("armour")), + float(target.call("current_hp")), + _balance.light_attack_skill_multiplier + ) + var result: DamageResult = target.call("receive_damage", context) + EventBus.hit_dealt.emit(EventBus.context(result.to_dictionary())) + + +## Stamina is a structural resource in M1: nothing spends it yet (light attack +## costs 0 by design, section 6.1) but it regenerates and the HUD shows it, so +## a sacrifice that cuts max stamina is visible immediately. Dodge and heavy +## attack, the first real consumers, are Codex work. +func _tick_stamina(delta: float) -> void: + if _run_state == null: + return + _stamina_delay_for = maxf(0.0, _stamina_delay_for - delta) + if _stamina_delay_for > 0.0: + return + _run_state.regenerate_stamina(_run_state.stamina_recovery() * delta) + +func spend_stamina(amount: float) -> void: + if amount <= 0.0: + return + _run_state.spend_stamina(amount) + _stamina_delay_for = _balance.stamina_recovery_delay diff --git a/actors/player/player.tscn b/actors/player/player.tscn new file mode 100644 index 0000000..db804e7 --- /dev/null +++ b/actors/player/player.tscn @@ -0,0 +1,53 @@ +[gd_scene load_steps=8 format=3 uid="uid://bnnplayer0001"] + +[ext_resource type="Script" path="res://actors/player/player.gd" id="1_player"] +[ext_resource type="SpriteFrames" path="res://actors/player/player_frames.tres" id="2_frames"] +[ext_resource type="Script" path="res://core/hurtbox.gd" id="3_hurtbox"] +[ext_resource type="Script" path="res://core/hitbox.gd" id="4_hitbox"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(14, 26) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurt"] +size = Vector2(16, 28) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hit"] +size = Vector2(24, 20) + +[node name="Player" type="CharacterBody2D"] +collision_layer = 2 +collision_mask = 1 +script = ExtResource("1_player") + +[node name="Sprite" type="AnimatedSprite2D" parent="."] +position = Vector2(0, -24) +sprite_frames = ExtResource("2_frames") +animation = &"idle" +autoplay = "idle" + +[node name="Body" type="CollisionShape2D" parent="."] +position = Vector2(0, -13) +shape = SubResource("RectangleShape2D_body") + +[node name="Hurtbox" type="Area2D" parent="."] +collision_layer = 8 +collision_mask = 0 +script = ExtResource("3_hurtbox") + +[node name="Shape" type="CollisionShape2D" parent="Hurtbox"] +position = Vector2(0, -14) +shape = SubResource("RectangleShape2D_hurt") + +[node name="AttackHitbox" type="Area2D" parent="."] +collision_layer = 32 +collision_mask = 16 +script = ExtResource("4_hitbox") + +[node name="Shape" type="CollisionShape2D" parent="AttackHitbox"] +position = Vector2(18, -14) +shape = SubResource("RectangleShape2D_hit") + +[node name="Camera" type="Camera2D" parent="."] +position = Vector2(0, -24) +position_smoothing_enabled = true +position_smoothing_speed = 6.0 diff --git a/actors/player/player_frames.tres b/actors/player/player_frames.tres new file mode 100644 index 0000000..1fdbe85 --- /dev/null +++ b/actors/player/player_frames.tres @@ -0,0 +1,211 @@ +; Generated by tools/generate_sprite_frames.py from docs/ART_SPEC.md. +; Do not hand-edit: re-run the generator instead. +[gd_resource type="SpriteFrames" load_steps=31 format=3] + +[ext_resource type="Texture2D" path="res://assets/player/player_idle.png" id="1_idle"] +[ext_resource type="Texture2D" path="res://assets/player/player_run.png" id="2_run"] +[ext_resource type="Texture2D" path="res://assets/player/player_jump.png" id="3_jump"] +[ext_resource type="Texture2D" path="res://assets/player/player_attack.png" id="4_attack"] +[ext_resource type="Texture2D" path="res://assets/player/player_hurt.png" id="5_hurt"] +[ext_resource type="Texture2D" path="res://assets/player/player_death.png" id="6_death"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] +atlas = ExtResource("1_idle") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] +atlas = ExtResource("1_idle") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] +atlas = ExtResource("1_idle") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] +atlas = ExtResource("1_idle") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] +atlas = ExtResource("2_run") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] +atlas = ExtResource("2_run") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] +atlas = ExtResource("2_run") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] +atlas = ExtResource("2_run") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] +atlas = ExtResource("2_run") +region = Rect2(192, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] +atlas = ExtResource("2_run") +region = Rect2(240, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_jump_0"] +atlas = ExtResource("3_jump") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_jump_1"] +atlas = ExtResource("3_jump") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] +atlas = ExtResource("4_attack") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] +atlas = ExtResource("4_attack") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] +atlas = ExtResource("4_attack") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] +atlas = ExtResource("4_attack") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] +atlas = ExtResource("5_hurt") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] +atlas = ExtResource("5_hurt") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] +atlas = ExtResource("6_death") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] +atlas = ExtResource("6_death") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] +atlas = ExtResource("6_death") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] +atlas = ExtResource("6_death") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_4"] +atlas = ExtResource("6_death") +region = Rect2(192, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_5"] +atlas = ExtResource("6_death") +region = Rect2(240, 0, 48, 48) + +[resource] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_3") +}], +"loop": true, +"name": &"idle", +"speed": 8.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_5") +}], +"loop": true, +"name": &"run", +"speed": 12.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_jump_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_jump_1") +}], +"loop": false, +"name": &"jump", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_3") +}], +"loop": false, +"name": &"attack", +"speed": 14.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_1") +}], +"loop": false, +"name": &"hurt", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_5") +}], +"loop": false, +"name": &"death", +"speed": 8.0 +}] diff --git a/actors/player/player_state_machine.gd b/actors/player/player_state_machine.gd new file mode 100644 index 0000000..e21c536 --- /dev/null +++ b/actors/player/player_state_machine.gd @@ -0,0 +1,61 @@ +class_name PlayerStateMachine +extends RefCounted +## Owns the player's action states and the transitions between them. +## +## Every transition goes through `_change_to`, so there is exactly one place +## that runs exit/enter and one place to look when a state gets stuck. States +## never call each other. +## +## Extension points (documented for Codex, deliberately not implemented in M1): +## * Dodge — three phases with i-frames; enter from GROUNDED/AIRBORNE on the +## dodge action, and gate it on StaminaService once that exists. +## * Exhausted — enter from anywhere when stamina hits zero; blocks dodge, +## skill and heavy attack while leaving light attack available. +## * SameDeath — must be checked *before* DEAD in `on_death`, since it is a +## lethal-damage interception, not a state the player can walk into. + +var _states: Dictionary = {} +var _current: PlayerState + +func _init(player: Player) -> void: + _register(GroundedState.new(player)) + _register(AirborneState.new(player)) + _register(AttackingState.new(player)) + _register(HurtState.new(player)) + _register(DeadState.new(player)) + _current = _states[PlayerState.GROUNDED] + _current.enter() + +func current_id() -> StringName: + return _current.id() + +func physics_update(delta: float) -> void: + var next: StringName = _current.physics_update(delta) + if not String(next).is_empty(): + _change_to(next) + +## Interruption from outside: taking a survivable hit. +func on_hurt(_source_id: StringName) -> void: + if _current.id() == PlayerState.DEAD: + return + _change_to(PlayerState.HURT) + +## Interruption from outside: lethal damage. When SameDeath lands, its +## eligibility check belongs at the top of this method. +func on_death() -> void: + if _current.id() == PlayerState.DEAD: + return + _change_to(PlayerState.DEAD) + +func _register(state: PlayerState) -> void: + _states[state.id()] = state + +func _change_to(next_id: StringName) -> void: + if not _states.has(next_id): + push_error("PlayerStateMachine: unknown state %s" % next_id) + return + if _current.id() == next_id: + return + _current.exit() + _current = _states[next_id] + _current.enter() diff --git a/actors/player/states/airborne_state.gd b/actors/player/states/airborne_state.gd new file mode 100644 index 0000000..49617ad --- /dev/null +++ b/actors/player/states/airborne_state.gd @@ -0,0 +1,20 @@ +class_name AirborneState +extends PlayerState +## Rising or falling. Air control is reduced but attacking stays available so +## the player is never fully passive mid-jump. + +func id() -> StringName: + return AIRBORNE + +func enter() -> void: + player.play(&"jump") + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + player.apply_horizontal_input(player.balance().player_air_control) + + if wants_attack(): + return ATTACKING + if player.is_on_floor(): + return GROUNDED + return &"" diff --git a/actors/player/states/attacking_state.gd b/actors/player/states/attacking_state.gd new file mode 100644 index 0000000..7246e86 --- /dev/null +++ b/actors/player/states/attacking_state.gd @@ -0,0 +1,49 @@ +class_name AttackingState +extends PlayerState +## Light attack: wind-up, active window, recovery. Timings and the stamina cost +## (0 by design — see research report 6.1) come from BalanceConfig. +## +## The hitbox is only live during the active window, so the swing's reach in +## time is data, not an animation coincidence. + +enum Phase { WINDUP, ACTIVE, RECOVERY } + +var _phase: int = Phase.WINDUP +var _elapsed: float = 0.0 + +func id() -> StringName: + return ATTACKING + +func enter() -> void: + _phase = Phase.WINDUP + _elapsed = 0.0 + player.play(&"attack") + player.sprite.frame = 0 + player.spend_stamina(player.balance().light_attack_stamina_cost) + +func exit() -> void: + player.attack_hitbox.set_active(false) + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + # Committed: the swing does not steer, but momentum is bled off so the + # player does not slide through the whole animation. + player.decelerate(delta) + _elapsed += delta + + var balance := player.balance() + match _phase: + Phase.WINDUP: + if _elapsed >= balance.light_attack_windup: + _phase = Phase.ACTIVE + _elapsed = 0.0 + player.attack_hitbox.set_active(true) + Phase.ACTIVE: + if _elapsed >= balance.light_attack_active: + _phase = Phase.RECOVERY + _elapsed = 0.0 + player.attack_hitbox.set_active(false) + Phase.RECOVERY: + if _elapsed >= balance.light_attack_recovery: + return GROUNDED if player.is_on_floor() else AIRBORNE + return &"" diff --git a/actors/player/states/dead_state.gd b/actors/player/states/dead_state.gd new file mode 100644 index 0000000..e076696 --- /dev/null +++ b/actors/player/states/dead_state.gd @@ -0,0 +1,18 @@ +class_name DeadState +extends PlayerState +## Terminal. The run is over; RunCoordinator decides what happens next. + +func id() -> StringName: + return DEAD + +func enter() -> void: + player.input_enabled = false + player.velocity = Vector2.ZERO + player.attack_hitbox.set_active(false) + player.play(&"death") + player.sprite.frame = 0 + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + player.decelerate(delta) + return &"" diff --git a/actors/player/states/grounded_state.gd b/actors/player/states/grounded_state.gd new file mode 100644 index 0000000..2a194f4 --- /dev/null +++ b/actors/player/states/grounded_state.gd @@ -0,0 +1,24 @@ +class_name GroundedState +extends PlayerState +## On the floor: idle, run, jump and attack all start here. + +func id() -> StringName: + return GROUNDED + +func enter() -> void: + player.play(&"idle") + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + player.apply_horizontal_input() + + if wants_attack(): + return ATTACKING + if player.input_enabled and Input.is_action_just_pressed(&"jump"): + player.jump() + return AIRBORNE + if not player.is_on_floor(): + return AIRBORNE + + player.play(&"run" if player.input_direction() != 0 else &"idle") + return &"" diff --git a/actors/player/states/hurt_state.gd b/actors/player/states/hurt_state.gd new file mode 100644 index 0000000..8cdbf7d --- /dev/null +++ b/actors/player/states/hurt_state.gd @@ -0,0 +1,22 @@ +class_name HurtState +extends PlayerState +## Brief stagger after taking a hit. Input is ignored for the duration, which is +## what makes damage cost tempo as well as lifespan. + +var _remaining: float = 0.0 + +func id() -> StringName: + return HURT + +func enter() -> void: + _remaining = player.balance().player_hurt_duration + player.play(&"hurt") + player.sprite.frame = 0 + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + player.decelerate(delta, 300.0) + _remaining -= delta + if _remaining <= 0.0: + return GROUNDED if player.is_on_floor() else AIRBORNE + return &"" diff --git a/actors/player/states/player_state.gd b/actors/player/states/player_state.gd new file mode 100644 index 0000000..44f6fe6 --- /dev/null +++ b/actors/player/states/player_state.gd @@ -0,0 +1,42 @@ +class_name PlayerState +extends RefCounted +## Base class for the player's action states. +## +## Contract (frozen — see docs/INTERFACES.md): a state changes the player by +## calling the helpers on Player, and requests a transition by returning the +## next state id from `physics_update`. Returning `&""` means "stay". No state +## writes another state's fields, and nothing outside the machine calls +## `enter`/`exit`. +## +## To add a state (Dodge, Exhausted, SameDeath are the planned ones), subclass +## this, register the id in PlayerStateMachine, and give an existing state a +## reason to return the new id. Nothing else needs to change. + +const GROUNDED := &"grounded" +const AIRBORNE := &"airborne" +const ATTACKING := &"attacking" +const HURT := &"hurt" +const DEAD := &"dead" + +var player: Player + +func _init(owner_player: Player) -> void: + player = owner_player + +func id() -> StringName: + return &"unknown" + +func enter() -> void: + pass + +func exit() -> void: + pass + +## Returns the next state id, or &"" to stay in this state. +func physics_update(_delta: float) -> StringName: + return &"" + +## Shared transition test: any state that allows attacking uses this, so the +## rule lives in one place. +func wants_attack() -> bool: + return player.input_enabled and Input.is_action_just_pressed(&"light_attack") diff --git a/assets/background/bg_broken_flag.png.import b/assets/background/bg_broken_flag.png.import new file mode 100644 index 0000000..f8bcde7 --- /dev/null +++ b/assets/background/bg_broken_flag.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhv3t65rpaibp" +path="res://.godot/imported/bg_broken_flag.png-f9c11b009239579970eda162d98e3dd8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/background/bg_broken_flag.png" +dest_files=["res://.godot/imported/bg_broken_flag.png-f9c11b009239579970eda162d98e3dd8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/background/bg_chains.png.import b/assets/background/bg_chains.png.import new file mode 100644 index 0000000..11b7e5a --- /dev/null +++ b/assets/background/bg_chains.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ds7h1dgasorgw" +path="res://.godot/imported/bg_chains.png-065cfe175d20058c3994acac3b2c065a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/background/bg_chains.png" +dest_files=["res://.godot/imported/bg_chains.png-065cfe175d20058c3994acac3b2c065a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/background/bg_deep.png.import b/assets/background/bg_deep.png.import new file mode 100644 index 0000000..429cdd4 --- /dev/null +++ b/assets/background/bg_deep.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://byfqbk1d8hduy" +path="res://.godot/imported/bg_deep.png-eed9817dfbc5b1972845c14742627109.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/background/bg_deep.png" +dest_files=["res://.godot/imported/bg_deep.png-eed9817dfbc5b1972845c14742627109.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/background/bg_tree.png.import b/assets/background/bg_tree.png.import new file mode 100644 index 0000000..418ef16 --- /dev/null +++ b/assets/background/bg_tree.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhgi5i47tfowh" +path="res://.godot/imported/bg_tree.png-efa66bcc88c2c944ec44360cfeae57aa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/background/bg_tree.png" +dest_files=["res://.godot/imported/bg_tree.png-efa66bcc88c2c944ec44360cfeae57aa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_attack.png.import b/assets/boss/boss_attack.png.import new file mode 100644 index 0000000..fb9cbe3 --- /dev/null +++ b/assets/boss/boss_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://f55djfeo0mdv" +path="res://.godot/imported/boss_attack.png-cc8d2cb44fc3265bc09020f79570bfc0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_attack.png" +dest_files=["res://.godot/imported/boss_attack.png-cc8d2cb44fc3265bc09020f79570bfc0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_death.png.import b/assets/boss/boss_death.png.import new file mode 100644 index 0000000..6a5acbf --- /dev/null +++ b/assets/boss/boss_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://425fsmbamyoy" +path="res://.godot/imported/boss_death.png-8cef6efa623363ddef6d9fa0e772ab49.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_death.png" +dest_files=["res://.godot/imported/boss_death.png-8cef6efa623363ddef6d9fa0e772ab49.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_hurt.png.import b/assets/boss/boss_hurt.png.import new file mode 100644 index 0000000..eb2f906 --- /dev/null +++ b/assets/boss/boss_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cpia32vxwflov" +path="res://.godot/imported/boss_hurt.png-15d40a6d567405598ecac98ebaa976fd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_hurt.png" +dest_files=["res://.godot/imported/boss_hurt.png-15d40a6d567405598ecac98ebaa976fd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_idle.png.import b/assets/boss/boss_idle.png.import new file mode 100644 index 0000000..1de7012 --- /dev/null +++ b/assets/boss/boss_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dpsbdyeqas3vf" +path="res://.godot/imported/boss_idle.png-26326c8f164d6b3fbd8b7aecc5e40b83.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_idle.png" +dest_files=["res://.godot/imported/boss_idle.png-26326c8f164d6b3fbd8b7aecc5e40b83.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_run.png.import b/assets/boss/boss_run.png.import new file mode 100644 index 0000000..d402500 --- /dev/null +++ b/assets/boss/boss_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bbk7gegw3fevd" +path="res://.godot/imported/boss_run.png-510f9badd5915d03995ad692a6c53ff0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_run.png" +dest_files=["res://.godot/imported/boss_run.png-510f9badd5915d03995ad692a6c53ff0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/effects/effect_blood_splash.png.import b/assets/effects/effect_blood_splash.png.import new file mode 100644 index 0000000..3b843ac --- /dev/null +++ b/assets/effects/effect_blood_splash.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://de2k4u7rr8hgu" +path="res://.godot/imported/effect_blood_splash.png-0e24bd9ff1c465c106def06c8dd1d37f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/effect_blood_splash.png" +dest_files=["res://.godot/imported/effect_blood_splash.png-0e24bd9ff1c465c106def06c8dd1d37f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/effects/effect_death_fade.png.import b/assets/effects/effect_death_fade.png.import new file mode 100644 index 0000000..8e313f7 --- /dev/null +++ b/assets/effects/effect_death_fade.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr54t0xaqwljw" +path="res://.godot/imported/effect_death_fade.png-42af3271e4d471eea4309621e6989208.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/effect_death_fade.png" +dest_files=["res://.godot/imported/effect_death_fade.png-42af3271e4d471eea4309621e6989208.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/effects/effect_ghost_fire.png.import b/assets/effects/effect_ghost_fire.png.import new file mode 100644 index 0000000..437b6e8 --- /dev/null +++ b/assets/effects/effect_ghost_fire.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://1wcqmy0j2rh6" +path="res://.godot/imported/effect_ghost_fire.png-5f55d939584e92b1c058f2c33c5d7655.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/effect_ghost_fire.png" +dest_files=["res://.godot/imported/effect_ghost_fire.png-5f55d939584e92b1c058f2c33c5d7655.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/effects/effect_hit_slash.png.import b/assets/effects/effect_hit_slash.png.import new file mode 100644 index 0000000..6ca21ac --- /dev/null +++ b/assets/effects/effect_hit_slash.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://e7eduq8qvwoi" +path="res://.godot/imported/effect_hit_slash.png-2265f09dab59d01552bf5d01d2c00954.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/effect_hit_slash.png" +dest_files=["res://.godot/imported/effect_hit_slash.png-2265f09dab59d01552bf5d01d2c00954.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_attack.png.import b/assets/enemy/corpse_beast_attack.png.import new file mode 100644 index 0000000..1315b33 --- /dev/null +++ b/assets/enemy/corpse_beast_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://f4a61lc3xp60" +path="res://.godot/imported/corpse_beast_attack.png-f581648e30e243193c400587e0a6c6c8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_attack.png" +dest_files=["res://.godot/imported/corpse_beast_attack.png-f581648e30e243193c400587e0a6c6c8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_death.png.import b/assets/enemy/corpse_beast_death.png.import new file mode 100644 index 0000000..574fbc8 --- /dev/null +++ b/assets/enemy/corpse_beast_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cutqpxkaiowv0" +path="res://.godot/imported/corpse_beast_death.png-6a59f72967609c66db3a4d35c88f9780.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_death.png" +dest_files=["res://.godot/imported/corpse_beast_death.png-6a59f72967609c66db3a4d35c88f9780.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_hurt.png.import b/assets/enemy/corpse_beast_hurt.png.import new file mode 100644 index 0000000..4e463e4 --- /dev/null +++ b/assets/enemy/corpse_beast_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sho6gtb1xmqs" +path="res://.godot/imported/corpse_beast_hurt.png-84bc2d4aad09ff5a156bc8cce5a122b0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_hurt.png" +dest_files=["res://.godot/imported/corpse_beast_hurt.png-84bc2d4aad09ff5a156bc8cce5a122b0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_idle.png.import b/assets/enemy/corpse_beast_idle.png.import new file mode 100644 index 0000000..90784e0 --- /dev/null +++ b/assets/enemy/corpse_beast_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsktg0aqhj8vj" +path="res://.godot/imported/corpse_beast_idle.png-e39fca8d9e094b7df704d487ad9c245e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_idle.png" +dest_files=["res://.godot/imported/corpse_beast_idle.png-e39fca8d9e094b7df704d487ad9c245e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_run.png.import b/assets/enemy/corpse_beast_run.png.import new file mode 100644 index 0000000..1b6350c --- /dev/null +++ b/assets/enemy/corpse_beast_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b3sqktkw1s4ss" +path="res://.godot/imported/corpse_beast_run.png-ec926f018d8733bb76bb9dcca39a73a6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_run.png" +dest_files=["res://.godot/imported/corpse_beast_run.png-ec926f018d8733bb76bb9dcca39a73a6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_attack.png.import b/assets/enemy/ghost_archer_attack.png.import new file mode 100644 index 0000000..a254f82 --- /dev/null +++ b/assets/enemy/ghost_archer_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drf451cmvg2d8" +path="res://.godot/imported/ghost_archer_attack.png-ea38d14beec5034462e16852a120c353.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_attack.png" +dest_files=["res://.godot/imported/ghost_archer_attack.png-ea38d14beec5034462e16852a120c353.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_death.png.import b/assets/enemy/ghost_archer_death.png.import new file mode 100644 index 0000000..bf129c6 --- /dev/null +++ b/assets/enemy/ghost_archer_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://co84p0q5k6hrx" +path="res://.godot/imported/ghost_archer_death.png-5351b3c5fae4f4bd2f424dc5bfc9bf9e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_death.png" +dest_files=["res://.godot/imported/ghost_archer_death.png-5351b3c5fae4f4bd2f424dc5bfc9bf9e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_hurt.png.import b/assets/enemy/ghost_archer_hurt.png.import new file mode 100644 index 0000000..fece4fb --- /dev/null +++ b/assets/enemy/ghost_archer_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckdalymryaoq" +path="res://.godot/imported/ghost_archer_hurt.png-71a6a81ec02fdaac217a95ac1aff1a48.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_hurt.png" +dest_files=["res://.godot/imported/ghost_archer_hurt.png-71a6a81ec02fdaac217a95ac1aff1a48.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_idle.png.import b/assets/enemy/ghost_archer_idle.png.import new file mode 100644 index 0000000..dc1c0e7 --- /dev/null +++ b/assets/enemy/ghost_archer_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dh4lg7jn6lh3y" +path="res://.godot/imported/ghost_archer_idle.png-56c44223141825735882190f60a5bfea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_idle.png" +dest_files=["res://.godot/imported/ghost_archer_idle.png-56c44223141825735882190f60a5bfea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_run.png.import b/assets/enemy/ghost_archer_run.png.import new file mode 100644 index 0000000..b8d1fb8 --- /dev/null +++ b/assets/enemy/ghost_archer_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://w0scfnpkvl3f" +path="res://.godot/imported/ghost_archer_run.png-86a3f3672a38fb9091f13c0c2723782a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_run.png" +dest_files=["res://.godot/imported/ghost_archer_run.png-86a3f3672a38fb9091f13c0c2723782a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_attack.png.import b/assets/enemy/ghost_melee_attack.png.import new file mode 100644 index 0000000..378bccf --- /dev/null +++ b/assets/enemy/ghost_melee_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://n6r0tsqaikgi" +path="res://.godot/imported/ghost_melee_attack.png-05e56701a913044d74b77b7511007a24.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_attack.png" +dest_files=["res://.godot/imported/ghost_melee_attack.png-05e56701a913044d74b77b7511007a24.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_death.png.import b/assets/enemy/ghost_melee_death.png.import new file mode 100644 index 0000000..a1980cf --- /dev/null +++ b/assets/enemy/ghost_melee_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://lxfjwtm3mf5d" +path="res://.godot/imported/ghost_melee_death.png-c16e2d731896969300c8599cd28a3675.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_death.png" +dest_files=["res://.godot/imported/ghost_melee_death.png-c16e2d731896969300c8599cd28a3675.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_hurt.png.import b/assets/enemy/ghost_melee_hurt.png.import new file mode 100644 index 0000000..30e1c4a --- /dev/null +++ b/assets/enemy/ghost_melee_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://divm24f2prjha" +path="res://.godot/imported/ghost_melee_hurt.png-832460fae6ae7d951c70ee2b4f1a1add.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_hurt.png" +dest_files=["res://.godot/imported/ghost_melee_hurt.png-832460fae6ae7d951c70ee2b4f1a1add.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_idle.png.import b/assets/enemy/ghost_melee_idle.png.import new file mode 100644 index 0000000..2cedbdb --- /dev/null +++ b/assets/enemy/ghost_melee_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1hf08wj8xlr4" +path="res://.godot/imported/ghost_melee_idle.png-c2f0c20b1e3e88b3006eab51a6cdf57b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_idle.png" +dest_files=["res://.godot/imported/ghost_melee_idle.png-c2f0c20b1e3e88b3006eab51a6cdf57b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_run.png.import b/assets/enemy/ghost_melee_run.png.import new file mode 100644 index 0000000..3cf428f --- /dev/null +++ b/assets/enemy/ghost_melee_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://br5fi23i7refg" +path="res://.godot/imported/ghost_melee_run.png-9af3bc7d8c3951e5b04fcc829b3adc76.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_run.png" +dest_files=["res://.godot/imported/ghost_melee_run.png-9af3bc7d8c3951e5b04fcc829b3adc76.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_armor.png.import b/assets/icons/ui/icon_armor.png.import new file mode 100644 index 0000000..a8df1bb --- /dev/null +++ b/assets/icons/ui/icon_armor.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dnbojlwtngory" +path="res://.godot/imported/icon_armor.png-0c1d59e09dbad92aa701e02c59911c80.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_armor.png" +dest_files=["res://.godot/imported/icon_armor.png-0c1d59e09dbad92aa701e02c59911c80.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_attack.png.import b/assets/icons/ui/icon_attack.png.import new file mode 100644 index 0000000..edb2fbf --- /dev/null +++ b/assets/icons/ui/icon_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6rfl3o86r7af" +path="res://.godot/imported/icon_attack.png-b8d243255885ae630858a76db5c15d77.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_attack.png" +dest_files=["res://.godot/imported/icon_attack.png-b8d243255885ae630858a76db5c15d77.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_boss.png.import b/assets/icons/ui/icon_boss.png.import new file mode 100644 index 0000000..d4c4887 --- /dev/null +++ b/assets/icons/ui/icon_boss.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b3o7vjs1alogy" +path="res://.godot/imported/icon_boss.png-0a405990ef0223b3c6321541b8229b62.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_boss.png" +dest_files=["res://.godot/imported/icon_boss.png-0a405990ef0223b3c6321541b8229b62.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_coin.png.import b/assets/icons/ui/icon_coin.png.import new file mode 100644 index 0000000..12b2ed5 --- /dev/null +++ b/assets/icons/ui/icon_coin.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cx7ghlegv366b" +path="res://.godot/imported/icon_coin.png-72b148a217a8ddbbb8e9c04808dcf46a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_coin.png" +dest_files=["res://.godot/imported/icon_coin.png-72b148a217a8ddbbb8e9c04808dcf46a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_crit.png.import b/assets/icons/ui/icon_crit.png.import new file mode 100644 index 0000000..d243ae0 --- /dev/null +++ b/assets/icons/ui/icon_crit.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dpr3lsosh38t1" +path="res://.godot/imported/icon_crit.png-7f6d473da08152516495b9e9851d05b6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_crit.png" +dest_files=["res://.godot/imported/icon_crit.png-7f6d473da08152516495b9e9851d05b6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_ghostfire.png.import b/assets/icons/ui/icon_ghostfire.png.import new file mode 100644 index 0000000..8170979 --- /dev/null +++ b/assets/icons/ui/icon_ghostfire.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://n8vybk0ng800" +path="res://.godot/imported/icon_ghostfire.png-667f276d4a0b64a79dfb81720e1edd8e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_ghostfire.png" +dest_files=["res://.godot/imported/icon_ghostfire.png-667f276d4a0b64a79dfb81720e1edd8e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_hp.png.import b/assets/icons/ui/icon_hp.png.import new file mode 100644 index 0000000..d6e316e --- /dev/null +++ b/assets/icons/ui/icon_hp.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dkqomhoim2742" +path="res://.godot/imported/icon_hp.png-5f5456432c62ca6f9e092c0bd19b0de3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_hp.png" +dest_files=["res://.godot/imported/icon_hp.png-5f5456432c62ca6f9e092c0bd19b0de3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_key.png.import b/assets/icons/ui/icon_key.png.import new file mode 100644 index 0000000..4f41c91 --- /dev/null +++ b/assets/icons/ui/icon_key.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhbabm5aa4241" +path="res://.godot/imported/icon_key.png-5df25ae3f110277a10f14bc1ca06274b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_key.png" +dest_files=["res://.godot/imported/icon_key.png-5df25ae3f110277a10f14bc1ca06274b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_sacrifice.png.import b/assets/icons/ui/icon_sacrifice.png.import new file mode 100644 index 0000000..647404c --- /dev/null +++ b/assets/icons/ui/icon_sacrifice.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0oxout2n20m6" +path="res://.godot/imported/icon_sacrifice.png-7a9bd2e0bcf7a1754d115f27a58a28b5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_sacrifice.png" +dest_files=["res://.godot/imported/icon_sacrifice.png-7a9bd2e0bcf7a1754d115f27a58a28b5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_speed.png.import b/assets/icons/ui/icon_speed.png.import new file mode 100644 index 0000000..9668150 --- /dev/null +++ b/assets/icons/ui/icon_speed.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://beosunmpmayvw" +path="res://.godot/imported/icon_speed.png-2d7d550435e2cd00693d6e84a89d23c2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_speed.png" +dest_files=["res://.godot/imported/icon_speed.png-2d7d550435e2cd00693d6e84a89d23c2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_stamina.png.import b/assets/icons/ui/icon_stamina.png.import new file mode 100644 index 0000000..19e941c --- /dev/null +++ b/assets/icons/ui/icon_stamina.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b2q620ud25l17" +path="res://.godot/imported/icon_stamina.png-94b1c48c8a9934ecd08b0ec76727b3e6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_stamina.png" +dest_files=["res://.godot/imported/icon_stamina.png-94b1c48c8a9934ecd08b0ec76727b3e6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_flag.png.import b/assets/icons/weapons/weapon_flag.png.import new file mode 100644 index 0000000..bba8617 --- /dev/null +++ b/assets/icons/weapons/weapon_flag.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6ti3in5rr8an" +path="res://.godot/imported/weapon_flag.png-62c7ab1a76fbe03241fda264e2a7e957.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_flag.png" +dest_files=["res://.godot/imported/weapon_flag.png-62c7ab1a76fbe03241fda264e2a7e957.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_ghostfire.png.import b/assets/icons/weapons/weapon_ghostfire.png.import new file mode 100644 index 0000000..697b7bb --- /dev/null +++ b/assets/icons/weapons/weapon_ghostfire.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0d1s1sefg5os" +path="res://.godot/imported/weapon_ghostfire.png-f2a92591998123e6405eae5a3f6cec8f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_ghostfire.png" +dest_files=["res://.godot/imported/weapon_ghostfire.png-f2a92591998123e6405eae5a3f6cec8f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_gourd.png.import b/assets/icons/weapons/weapon_gourd.png.import new file mode 100644 index 0000000..3cd4079 --- /dev/null +++ b/assets/icons/weapons/weapon_gourd.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7evsfccqy3c1" +path="res://.godot/imported/weapon_gourd.png-48632adf2b51377d15d7271e5a5028e5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_gourd.png" +dest_files=["res://.godot/imported/weapon_gourd.png-48632adf2b51377d15d7271e5a5028e5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_songdao.png.import b/assets/icons/weapons/weapon_songdao.png.import new file mode 100644 index 0000000..8c59d51 --- /dev/null +++ b/assets/icons/weapons/weapon_songdao.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blkmt1bhbfi23" +path="res://.godot/imported/weapon_songdao.png-2f0e169de0f3d0e4cc6d0d0a41453765.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_songdao.png" +dest_files=["res://.godot/imported/weapon_songdao.png-2f0e169de0f3d0e4cc6d0d0a41453765.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_spear.png.import b/assets/icons/weapons/weapon_spear.png.import new file mode 100644 index 0000000..8c03b07 --- /dev/null +++ b/assets/icons/weapons/weapon_spear.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c4e8j723h0fjr" +path="res://.godot/imported/weapon_spear.png-2a15fab0dfa7525f3a55333c8a499136.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_spear.png" +dest_files=["res://.godot/imported/weapon_spear.png-2a15fab0dfa7525f3a55333c8a499136.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_talisman.png.import b/assets/icons/weapons/weapon_talisman.png.import new file mode 100644 index 0000000..2ef8340 --- /dev/null +++ b/assets/icons/weapons/weapon_talisman.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rv5w4hvmw1vi" +path="res://.godot/imported/weapon_talisman.png-1ba53dd1c2bc777789258168ccc4e76e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_talisman.png" +dest_files=["res://.godot/imported/weapon_talisman.png-1ba53dd1c2bc777789258168ccc4e76e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_attack.png.import b/assets/player/player_attack.png.import new file mode 100644 index 0000000..2b4ebc7 --- /dev/null +++ b/assets/player/player_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8r73tvuox67j" +path="res://.godot/imported/player_attack.png-0af7dce842d1c697a05378aecb71f4af.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_attack.png" +dest_files=["res://.godot/imported/player_attack.png-0af7dce842d1c697a05378aecb71f4af.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_death.png.import b/assets/player/player_death.png.import new file mode 100644 index 0000000..0145933 --- /dev/null +++ b/assets/player/player_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dc7u5o45na5a" +path="res://.godot/imported/player_death.png-f4f037084ed9d3ca30606cf910681549.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_death.png" +dest_files=["res://.godot/imported/player_death.png-f4f037084ed9d3ca30606cf910681549.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_hurt.png.import b/assets/player/player_hurt.png.import new file mode 100644 index 0000000..d6da12a --- /dev/null +++ b/assets/player/player_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xef4cqawi822" +path="res://.godot/imported/player_hurt.png-9a125650bdca44663ea27ebf17dbece5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_hurt.png" +dest_files=["res://.godot/imported/player_hurt.png-9a125650bdca44663ea27ebf17dbece5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_idle.png.import b/assets/player/player_idle.png.import new file mode 100644 index 0000000..fa17604 --- /dev/null +++ b/assets/player/player_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ceiuoddfsnctk" +path="res://.godot/imported/player_idle.png-e26fadf165b07744f21b91a4fc32f036.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_idle.png" +dest_files=["res://.godot/imported/player_idle.png-e26fadf165b07744f21b91a4fc32f036.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_jump.png.import b/assets/player/player_jump.png.import new file mode 100644 index 0000000..38d1068 --- /dev/null +++ b/assets/player/player_jump.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c5jv0d7l3kge" +path="res://.godot/imported/player_jump.png-a9a55f2d2e8ee7e546d824cbe569976f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_jump.png" +dest_files=["res://.godot/imported/player_jump.png-a9a55f2d2e8ee7e546d824cbe569976f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_run.png.import b/assets/player/player_run.png.import new file mode 100644 index 0000000..018cb71 --- /dev/null +++ b/assets/player/player_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dil1reydutj3l" +path="res://.godot/imported/player_run.png-a8fa5f6477361258876a91c79d7aa2ec.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_run.png" +dest_files=["res://.godot/imported/player_run.png-a8fa5f6477361258876a91c79d7aa2ec.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_brazier.png.import b/assets/tiles/tile_brazier.png.import new file mode 100644 index 0000000..0250422 --- /dev/null +++ b/assets/tiles/tile_brazier.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cwl02m7md7wnd" +path="res://.godot/imported/tile_brazier.png-e7ad2a81ac1f3a6f39578455758e9c23.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_brazier.png" +dest_files=["res://.godot/imported/tile_brazier.png-e7ad2a81ac1f3a6f39578455758e9c23.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_floor.png.import b/assets/tiles/tile_floor.png.import new file mode 100644 index 0000000..ac127a1 --- /dev/null +++ b/assets/tiles/tile_floor.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nb7cn4mxoxin" +path="res://.godot/imported/tile_floor.png-71c8fc302ab1f3c453e0cd89aa9d343b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_floor.png" +dest_files=["res://.godot/imported/tile_floor.png-71c8fc302ab1f3c453e0cd89aa9d343b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_ground_spike.png.import b/assets/tiles/tile_ground_spike.png.import new file mode 100644 index 0000000..cb2169d --- /dev/null +++ b/assets/tiles/tile_ground_spike.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsy3orcp7rvsd" +path="res://.godot/imported/tile_ground_spike.png-4613cc623c68d23885250933b126e71f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_ground_spike.png" +dest_files=["res://.godot/imported/tile_ground_spike.png-4613cc623c68d23885250933b126e71f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_stone_brick.png.import b/assets/tiles/tile_stone_brick.png.import new file mode 100644 index 0000000..9e566ec --- /dev/null +++ b/assets/tiles/tile_stone_brick.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kce6o88v4bwx" +path="res://.godot/imported/tile_stone_brick.png-eb6eb5849c6fbc2f3a87c5d9af14d627.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_stone_brick.png" +dest_files=["res://.godot/imported/tile_stone_brick.png-eb6eb5849c6fbc2f3a87c5d9af14d627.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_tombstone.png.import b/assets/tiles/tile_tombstone.png.import new file mode 100644 index 0000000..2e1dee3 --- /dev/null +++ b/assets/tiles/tile_tombstone.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://x8uhovqw2pe8" +path="res://.godot/imported/tile_tombstone.png-e9c53699973fc0d41aee61dfcba3fd97.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_tombstone.png" +dest_files=["res://.godot/imported/tile_tombstone.png-e9c53699973fc0d41aee61dfcba3fd97.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_wall.png.import b/assets/tiles/tile_wall.png.import new file mode 100644 index 0000000..14210e9 --- /dev/null +++ b/assets/tiles/tile_wall.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ds6xketq47le1" +path="res://.godot/imported/tile_wall.png-043e603e1d656a0403799ed59a15fe68.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_wall.png" +dest_files=["res://.godot/imported/tile_wall.png-043e603e1d656a0403799ed59a15fe68.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_wood_bridge.png.import b/assets/tiles/tile_wood_bridge.png.import new file mode 100644 index 0000000..13f2b83 --- /dev/null +++ b/assets/tiles/tile_wood_bridge.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwlvgig7pgsph" +path="res://.godot/imported/tile_wood_bridge.png-b54c690ee1eb951f1dc789e010335657.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_wood_bridge.png" +dest_files=["res://.godot/imported/tile_wood_bridge.png-b54c690ee1eb951f1dc789e010335657.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/ui/ui_frame.png.import b/assets/ui/ui_frame.png.import new file mode 100644 index 0000000..0046668 --- /dev/null +++ b/assets/ui/ui_frame.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bew7m7456vh2s" +path="res://.godot/imported/ui_frame.png-41070a91e4690c57ec6a98035f6848cf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/ui_frame.png" +dest_files=["res://.godot/imported/ui_frame.png-41070a91e4690c57ec6a98035f6848cf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/ui/ui_health_bar.png.import b/assets/ui/ui_health_bar.png.import new file mode 100644 index 0000000..dd38fa5 --- /dev/null +++ b/assets/ui/ui_health_bar.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxm0vhxh27b8" +path="res://.godot/imported/ui_health_bar.png-ca4406dbebbb98771c21c8d269164abf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/ui_health_bar.png" +dest_files=["res://.godot/imported/ui_health_bar.png-ca4406dbebbb98771c21c8d269164abf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/ui/ui_minimap_frame.png.import b/assets/ui/ui_minimap_frame.png.import new file mode 100644 index 0000000..83ee62c --- /dev/null +++ b/assets/ui/ui_minimap_frame.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c48u3kcugdrdo" +path="res://.godot/imported/ui_minimap_frame.png-4751a59a033eb58be6f5b35a9d1a3549.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/ui_minimap_frame.png" +dest_files=["res://.godot/imported/ui_minimap_frame.png-4751a59a033eb58be6f5b35a9d1a3549.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/ui/ui_stamina_bar.png.import b/assets/ui/ui_stamina_bar.png.import new file mode 100644 index 0000000..d7d808f --- /dev/null +++ b/assets/ui/ui_stamina_bar.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cslgpurrd4o5e" +path="res://.godot/imported/ui_stamina_bar.png-df1c016c13ebef01b4b1e6b124e9940b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/ui_stamina_bar.png" +dest_files=["res://.godot/imported/ui_stamina_bar.png-df1c016c13ebef01b4b1e6b124e9940b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/core/balance_config.gd b/core/balance_config.gd new file mode 100644 index 0000000..24e56e2 --- /dev/null +++ b/core/balance_config.gd @@ -0,0 +1,109 @@ +class_name BalanceConfig +extends Resource +## Every tunable number in the prototype. Scripts read from here; they must not +## hardcode balance values. +## +## Baselines come from the research report "残躯换锋 · 2D 动作肉鸽核心机制深度研究报告" +## (Appendix A parameter table, sections 5.1–5.7) via the Prototype Development +## Pack. The shipped instance is res://data/balance_config.tres. + +@export_group("Player baseline") +## Section 5.1. `hp_0`, `st_0`, `arm_0`, `sr_0` in the integrity formula. +@export var base_max_hp: float = 100.0 +@export var base_max_stamina: float = 100.0 +@export var base_armour: float = 10.0 +@export var base_stamina_recovery: float = 12.0 +@export var base_attack: float = 10.0 +@export var base_crit_rate: float = 0.05 +@export var base_crit_damage: float = 1.5 +@export var base_attack_speed: float = 1.0 + +@export_group("Structural floors") +## Section 5.7. Sacrifices may not push a structural stat below these. +@export var min_max_hp: float = 10.0 +@export var min_max_stamina: float = 15.0 +@export var min_armour: float = 0.0 + +@export_group("Soft and hard caps") +## Section 5.7. Crit rate is a hard cap; the rest compress past the knee. +@export var crit_rate_hard_cap: float = 0.80 +@export var crit_damage_soft_cap: float = 4.5 +@export var crit_damage_soft_slope: float = 0.35 +@export var attack_speed_soft_cap: float = 2.4 +@export var attack_speed_soft_slope: float = 0.35 +@export var more_product_soft_cap: float = 12.0 +@export var more_product_soft_slope: float = 0.30 + +@export_group("Integrity weights") +## Section 5.4: I = clamp(0.34h + 0.24s + 0.16a + 0.14r + 0.12f, 0, 1) +@export var integrity_weight_hp: float = 0.34 +@export var integrity_weight_stamina: float = 0.24 +@export var integrity_weight_armour: float = 0.16 +@export var integrity_weight_recovery: float = 0.14 +@export var integrity_weight_freedom: float = 0.12 +@export var integrity_major_lock_penalty: float = 0.12 +@export var integrity_action_tax_penalty: float = 0.06 + +@export_group("Sacrifice reward curve") +## Section 5.5: D = (1 - I)^exponent, G = g_S * (base + slope * D) * Q +@export var deficiency_exponent: float = 1.35 +@export var reward_base: float = 0.55 +@export var reward_slope: float = 1.45 +## g_S indexed by strength 1..5; index 0 is unused padding. +@export var strength_gain: PackedFloat32Array = PackedFloat32Array( + [0.0, 0.12, 0.20, 0.32, 0.48, 0.70] +) +@export var synergy_bonus: float = 0.12 +@export var dilution_penalty: float = 0.08 +@export var synergy_quality_min: float = 0.85 +@export var synergy_quality_max: float = 1.45 + +@export_group("Sacrifice cost and imbalance") +## Section 5.6: C_res = 100*dh + 80*ds + 60*da + 50*dr; dB = C * (0.75 + 0.05*S) +@export var cost_weight_hp: float = 100.0 +@export var cost_weight_stamina: float = 80.0 +@export var cost_weight_armour: float = 60.0 +@export var cost_weight_recovery: float = 50.0 +@export var imbalance_base_factor: float = 0.75 +@export var imbalance_strength_factor: float = 0.05 +@export var imbalance_max: float = 200.0 +## Section 9.3 caps: at most 2 major locks and 3 action taxes in one run. +@export var max_major_locks: int = 2 +@export var max_action_taxes: int = 3 + +@export_group("Health thresholds") +## Section 6.4. `wounded` and `guttering candle` are momentary, not structural. +@export var wounded_threshold: float = 0.30 +@export var guttering_threshold: float = 0.15 +@export var guttering_more_multiplier: float = 1.20 + +@export_group("Player movement") +@export var player_move_speed: float = 110.0 +@export var player_air_control: float = 0.65 +@export var player_jump_velocity: float = -230.0 +@export var player_gravity: float = 780.0 +@export var player_max_fall_speed: float = 420.0 +@export var player_hurt_duration: float = 0.25 +@export var player_hurt_knockback: float = 90.0 +@export var player_invulnerable_after_hit: float = 0.45 + +@export_group("Player light attack") +## Light attack costs no stamina (section 6.1) so the player is never fully +## locked out of acting. +@export var light_attack_windup: float = 0.10 +@export var light_attack_active: float = 0.12 +@export var light_attack_recovery: float = 0.20 +@export var light_attack_skill_multiplier: float = 1.0 +@export var light_attack_stamina_cost: float = 0.0 + +@export_group("Stamina") +@export var stamina_recovery_delay: float = 0.10 +@export var stamina_exhausted_lock: float = 1.10 + +@export_group("Run flow") +@export var wave_enemy_count: int = 3 +@export var enemy_spawn_interval: float = 0.6 + +@export_group("Camera") +@export var camera_smoothing_speed: float = 6.0 +@export var camera_look_ahead: float = 24.0 diff --git a/core/combat_resolver.gd b/core/combat_resolver.gd new file mode 100644 index 0000000..943c3fe --- /dev/null +++ b/core/combat_resolver.gd @@ -0,0 +1,110 @@ +class_name CombatResolver +extends RefCounted +## The one and only damage pipeline — research report section 5.3. +## +## raw = ATK * SkillMult * (1 + AddSum) * MoreProd * CondProd +## crit applies after the buckets, armour after crit +## taken = raw * 100 / (100 + ARM_eff) * vulnerability +## +## Contract (frozen — see docs/INTERFACES.md): Player, Enemy, Boss, HUD and the +## sacrifice preview all call `resolve`. Re-deriving damage anywhere else is a +## defect, not an optimisation — the preview and the hit must agree exactly. + +var _balance: BalanceConfig +## Supplies the crit roll when a context does not pin one. Injected so tests can +## make crits deterministic without seeding the global RNG. +var _roll_provider: Callable + +func _init(balance: BalanceConfig, roll_provider: Callable = Callable()) -> void: + _balance = balance + _roll_provider = roll_provider if roll_provider.is_valid() else _default_roll + +func resolve(ctx: DamageContext) -> DamageResult: + var result := DamageResult.new() + result.source_id = ctx.source_id + result.target_id = ctx.target_id + + var additive_sum := ctx.additive_sum() + var more_product := SoftCaps.more_product(ctx.more_product(), _balance) + var conditional_product := ctx.conditional_product() + + var pre_crit: float = ( + ctx.base_damage + * ctx.skill_multiplier + * (1.0 + additive_sum) + * more_product + * conditional_product + ) + + var crit_rate := SoftCaps.crit_rate(ctx.crit_rate, _balance) + var crit_damage := SoftCaps.crit_damage(ctx.crit_damage, _balance) + var is_critical := _decide_crit(ctx, crit_rate) + var crit_multiplier := crit_damage if is_critical else 1.0 + var raw := pre_crit * crit_multiplier + + var effective_armour := maxf(0.0, ctx.target_armour - ctx.armour_penetration) + var mitigation_factor := DerivedStats.mitigation_factor(effective_armour) + var final_damage := maxf(0.0, raw * mitigation_factor * ctx.vulnerability) + + result.raw_damage = raw + result.final_damage = final_damage + result.is_critical = is_critical + result.mitigation = raw - final_damage + result.is_lethal = final_damage >= ctx.target_current_hp and ctx.target_current_hp > 0.0 + result.breakdown = { + "base_damage": ctx.base_damage, + "skill_multiplier": ctx.skill_multiplier, + "additive_sum": additive_sum, + "more_product": more_product, + "conditional_product": conditional_product, + "pre_crit": pre_crit, + "crit_rate_effective": crit_rate, + "crit_damage_effective": crit_damage, + "crit_multiplier": crit_multiplier, + "effective_armour": effective_armour, + "mitigation_factor": mitigation_factor, + "vulnerability": ctx.vulnerability, + "final_damage": final_damage, + } + return result + +## Builds the player's outgoing context from RunState, so the player's numbers +## come from one place instead of being reassembled at each call site. +func player_attack_context( + state: RunState, + target_id: StringName, + target_armour: float, + target_current_hp: float, + skill_multiplier: float +) -> DamageContext: + var ctx := DamageContext.new() + ctx.source_id = &"player" + ctx.target_id = target_id + ctx.base_damage = state.attack() + ctx.skill_multiplier = skill_multiplier + ctx.additive_modifiers = [state.additive_sum()] as Array[float] + ctx.more_modifiers = [state.more_product() - 1.0] as Array[float] + ctx.crit_allowed = true + ctx.crit_rate = state.crit_rate() + ctx.crit_damage = state.crit_damage() + ctx.target_armour = target_armour + ctx.target_current_hp = target_current_hp + ctx.tags = [&"melee"] as Array[StringName] + # "Guttering candle" band: momentary, so it lives in the conditional bucket + # rather than anywhere near structural integrity (section 6.4). + if state.is_guttering(): + ctx.conditional_modifiers.append(_balance.guttering_more_multiplier - 1.0) + return ctx + +func _decide_crit(ctx: DamageContext, effective_crit_rate: float) -> bool: + if not ctx.crit_allowed: + return false + if ctx.force_crit == 1: + return true + if ctx.force_crit == 0: + return false + var roll: float = ctx.crit_roll if ctx.crit_roll >= 0.0 else float(_roll_provider.call()) + return roll < effective_crit_rate + +func _default_roll() -> float: + return RNGService.randf(RNGService.STREAM_COMBAT) diff --git a/core/damage_context.gd b/core/damage_context.gd new file mode 100644 index 0000000..864e70c --- /dev/null +++ b/core/damage_context.gd @@ -0,0 +1,63 @@ +class_name DamageContext +extends RefCounted +## Input to CombatResolver. Everything the resolver needs, so it never has to +## reach back into a scene node or an autoload for gameplay values. +## +## Contract (frozen — see docs/INTERFACES.md): to add a new damage source, +## populate a context. Do not add a second damage path. + +var source_id: StringName = &"unknown" +var target_id: StringName = &"unknown" + +## Weapon or attack base value before any bucket is applied. +var base_damage: float = 0.0 +## Per-move scalar (light attack 1.0, heavy > 1.0, ...). +var skill_multiplier: float = 1.0 + +## Bucket 1: everything sums, then multiplies once as (1 + sum). +var additive_modifiers: Array[float] = [] +## Bucket 2: a small number of explicitly-"more" effects, multiplied together. +var more_modifiers: Array[float] = [] +## Bucket 3: situational effects (low HP, perfect dodge, backstab). +var conditional_modifiers: Array[float] = [] + +## Critical rules. +var crit_allowed: bool = true +var crit_rate: float = 0.0 +var crit_damage: float = 1.0 +## -1 draws from the combat RNG stream; 0.0..1.0 supplies a fixed roll. Tests +## and replays set this so a crit is never an untestable coin flip. +var crit_roll: float = -1.0 +## Overrides the roll entirely: 1 forces a crit, 0 forbids one, -1 is normal. +var force_crit: int = -1 + +## Armour interaction. Penetration is subtracted before mitigation. +var target_armour: float = 0.0 +var armour_penetration: float = 0.0 +## > 1.0 means the target takes extra damage. +var vulnerability: float = 1.0 + +## Target HP at resolve time, used to mark the result lethal. +var target_current_hp: float = 0.0 + +var tags: Array[StringName] = [] +var metadata: Dictionary = {} + + +func additive_sum() -> float: + var total := 0.0 + for value in additive_modifiers: + total += value + return total + +func more_product() -> float: + var product := 1.0 + for value in more_modifiers: + product *= (1.0 + value) + return product + +func conditional_product() -> float: + var product := 1.0 + for value in conditional_modifiers: + product *= (1.0 + value) + return product diff --git a/core/damage_result.gd b/core/damage_result.gd new file mode 100644 index 0000000..9d2e892 --- /dev/null +++ b/core/damage_result.gd @@ -0,0 +1,29 @@ +class_name DamageResult +extends RefCounted +## Output of CombatResolver. The only object combat, preview and telemetry read +## damage numbers from. + +var source_id: StringName = &"unknown" +var target_id: StringName = &"unknown" +## Damage after all buckets and crit, before armour. +var raw_damage: float = 0.0 +## Damage actually applied to the target. +var final_damage: float = 0.0 +var is_critical: bool = false +## raw_damage - final_damage, i.e. what armour and mitigation absorbed. +var mitigation: float = 0.0 +var is_lethal: bool = false +## Per-stage values, so a wrong number can be traced to the bucket that made it. +var breakdown: Dictionary = {} + +func to_dictionary() -> Dictionary: + return { + "source_id": source_id, + "target_id": target_id, + "raw_damage": raw_damage, + "final_damage": final_damage, + "is_critical": is_critical, + "mitigation": mitigation, + "is_lethal": is_lethal, + "breakdown": breakdown.duplicate(), + } diff --git a/core/derived_stats.gd b/core/derived_stats.gd new file mode 100644 index 0000000..5258769 --- /dev/null +++ b/core/derived_stats.gd @@ -0,0 +1,30 @@ +class_name DerivedStats +extends RefCounted +## Read-only projections of RunState used by the HUD, the sacrifice preview and +## telemetry. Takes primitives rather than a RunState so that RunState can +## depend on it without a class-level cycle. + +## Research report section 5.2. Armour is an equivalent-life model, so each +## point buys roughly linear EHP instead of exploding at high values. +static func effective_hp(max_hp: float, armour: float) -> float: + return max_hp * (1.0 + armour / 100.0) + +## Fraction of raw damage that survives armour mitigation. +static func mitigation_factor(armour: float) -> float: + return 100.0 / (100.0 + maxf(0.0, armour)) + +## Section 5.3, without uptime: a comparable number for "how sharp is the +## blade", not a simulation. `offence` comes from RunState.offence_snapshot(). +static func dps_estimate(offence: Dictionary, balance: BalanceConfig) -> float: + var crit_rate: float = SoftCaps.crit_rate(float(offence["crit_rate"]), balance) + var crit_damage: float = SoftCaps.crit_damage(float(offence["crit_damage"]), balance) + var attack_speed: float = SoftCaps.attack_speed(float(offence["attack_speed"]), balance) + var more_product: float = SoftCaps.more_product(float(offence["more_product"]), balance) + var crit_expectation: float = 1.0 + crit_rate * (crit_damage - 1.0) + return ( + float(offence["attack"]) + * attack_speed + * (1.0 + float(offence["additive_sum"])) + * more_product + * crit_expectation + ) diff --git a/core/event_bus.gd b/core/event_bus.gd new file mode 100644 index 0000000..d1de615 --- /dev/null +++ b/core/event_bus.gd @@ -0,0 +1,43 @@ +extends Node +## Global, decoupled notification hub. Autoloaded as `EventBus`. +## +## Contract (frozen — see docs/INTERFACES.md): +## * Events are for UI, telemetry and decoupled reactions ONLY. +## * Deterministic core resolution (damage, integrity, sacrifice) uses explicit +## service calls, never the bus. Do not move a calculation onto a signal. +## * Listeners must not mutate RunState in a handler; they observe. +## +## Every payload carries the fields produced by `context()` so telemetry can +## correlate events without each emitter inventing its own shape. + +signal run_started(payload: Dictionary) +signal run_restarted(payload: Dictionary) +signal hit_dealt(payload: Dictionary) +signal hit_taken(payload: Dictionary) +signal enemy_died(payload: Dictionary) +signal player_died(payload: Dictionary) +signal sacrifice_previewed(payload: Dictionary) +signal sacrifice_applied(payload: Dictionary) +signal boss_started(payload: Dictionary) +signal boss_died(payload: Dictionary) +signal run_completed(payload: Dictionary) + +var _run_id: int = 0 +var _stage_id: StringName = &"none" + +## Called by RunCoordinator when a run begins so later payloads are attributable. +func bind_run(run_id: int) -> void: + _run_id = run_id + +func set_stage(stage_id: StringName) -> void: + _stage_id = stage_id + +## Builds the common envelope. `extra` is merged in and wins on key collision. +func context(extra: Dictionary = {}) -> Dictionary: + var payload := { + "timestamp": Time.get_ticks_msec(), + "run_id": _run_id, + "stage_id": _stage_id, + } + payload.merge(extra, true) + return payload diff --git a/core/game_data.gd b/core/game_data.gd new file mode 100644 index 0000000..c552cea --- /dev/null +++ b/core/game_data.gd @@ -0,0 +1,70 @@ +extends Node +## Autoloaded as `GameData`. Owns the balance configuration, the stateless core +## services built from it, and the sacrifice library loaded from disk. +## +## It deliberately does NOT own the active RunState: that belongs to +## RunCoordinator, which hands it to the systems that need it. Keeping mutable +## run data out of an autoload is what stops "just read it from the global" +## becoming the way every script talks to the run. + +const BALANCE_PATH := "res://data/balance_config.tres" +const SACRIFICE_DIR := "res://data/sacrifices" + +var balance: BalanceConfig +var combat: CombatResolver +var sacrifices: SacrificeService + +var _library: Dictionary = {} + +func _ready() -> void: + var config: BalanceConfig = load(BALANCE_PATH) + if config == null: + push_error("GameData: cannot load %s" % BALANCE_PATH) + config = BalanceConfig.new() + use_balance(config) + _load_sacrifice_library() + +## Rebuilds the services around a different config. Tests use this to check +## behaviour at other tunings without editing the shipped resource. +func use_balance(config: BalanceConfig) -> void: + balance = config + combat = CombatResolver.new(config) + sacrifices = SacrificeService.new(config) + +func definition(id: StringName) -> SacrificeDefinition: + if not _library.has(id): + return null + return _library[id] + +func definition_ids() -> Array: + var ids: Array = _library.keys() + ids.sort() + return ids + +func all_definitions() -> Array[SacrificeDefinition]: + var out: Array[SacrificeDefinition] = [] + for id in definition_ids(): + out.append(_library[id]) + return out + +func _load_sacrifice_library() -> void: + _library.clear() + var dir := DirAccess.open(SACRIFICE_DIR) + if dir == null: + push_error("GameData: cannot open %s" % SACRIFICE_DIR) + return + for file_name in dir.get_files(): + # Exported builds rename .tres to .tres.remap; load() wants the original. + var resource_name := file_name.trim_suffix(".remap") + if not resource_name.ends_with(".tres"): + continue + var definition_resource: SacrificeDefinition = load( + "%s/%s" % [SACRIFICE_DIR, resource_name] + ) + if definition_resource == null: + push_error("GameData: %s is not a SacrificeDefinition" % resource_name) + continue + if _library.has(definition_resource.id): + push_error("GameData: duplicate sacrifice id %s" % definition_resource.id) + continue + _library[definition_resource.id] = definition_resource diff --git a/core/hitbox.gd b/core/hitbox.gd new file mode 100644 index 0000000..eb960aa --- /dev/null +++ b/core/hitbox.gd @@ -0,0 +1,48 @@ +class_name Hitbox +extends Area2D +## Dealing half of a damage exchange. Emits `hit_actor` once per actor per +## swing; the *attacker* decides what that hit means by building a +## DamageContext. The hitbox itself knows nothing about damage numbers. +## +## Physics layer: `player_hitbox` or `enemy_hitbox`. Mask: the opposing +## hurtbox layer. +## +## `monitoring` stays on for the node's whole life and activity is gated by +## `set_active`, because toggling `monitoring` mid-frame makes Godot drop +## overlaps that began on the same frame. + +signal hit_actor(actor: Node) + +var _active: bool = false +var _already_hit: Array[Node] = [] + +func _ready() -> void: + area_entered.connect(_on_area_entered) + +func is_active() -> bool: + return _active + +## Opens or closes the active window. Opening clears the per-swing memory and +## immediately resolves anything already inside the box, so a target standing on +## top of the attacker is not skipped. +func set_active(value: bool) -> void: + if _active == value: + return + _active = value + if not _active: + return + _already_hit.clear() + for area in get_overlapping_areas(): + _try_hit(area) + +func _on_area_entered(area: Area2D) -> void: + _try_hit(area) + +func _try_hit(area: Area2D) -> void: + if not _active or not (area is Hurtbox): + return + var target: Node = (area as Hurtbox).actor() + if target == null or _already_hit.has(target): + return + _already_hit.append(target) + hit_actor.emit(target) diff --git a/core/hurtbox.gd b/core/hurtbox.gd new file mode 100644 index 0000000..907ad04 --- /dev/null +++ b/core/hurtbox.gd @@ -0,0 +1,14 @@ +class_name Hurtbox +extends Area2D +## Receiving half of a damage exchange. Sits on the damageable actor and points +## back at it, so a Hitbox can find who to hurt without guessing at the scene +## structure. +## +## Physics layer: `player_hurtbox` or `enemy_hurtbox`. Mask stays empty — +## hurtboxes are detected, they do not detect. + +## Relative to this node. Defaults to the parent, which is the usual layout. +@export var actor_path: NodePath = ^".." + +func actor() -> Node: + return get_node_or_null(actor_path) diff --git a/core/rng_service.gd b/core/rng_service.gd new file mode 100644 index 0000000..a7eb240 --- /dev/null +++ b/core/rng_service.gd @@ -0,0 +1,63 @@ +extends Node +## Deterministic random number source, autoloaded as `RNGService`. +## +## Each subsystem draws from its own named stream so that an extra roll in one +## system cannot shift the sequence another system observes. Given the same +## `run_seed` and the same per-stream call order, results are reproducible. +## +## Contract (frozen — see docs/INTERFACES.md): never call `randi()`/`randf()` +## directly in gameplay code. Always go through a named stream. + +const STREAM_SACRIFICE := &"sacrifice" +const STREAM_COMBAT := &"combat" +const STREAM_SPAWN := &"spawn" + +var _run_seed: int = 0 +var _streams: Dictionary = {} + +func _ready() -> void: + configure(_generate_seed()) + +func run_seed() -> int: + return _run_seed + +## Re-seeds every stream. Call once per run, before anything draws. +func configure(new_seed: int) -> void: + _run_seed = new_seed + _streams.clear() + +## Returns the generator for `stream_name`, creating it on first use. +func stream(stream_name: StringName) -> RandomNumberGenerator: + var existing: RandomNumberGenerator = _streams.get(stream_name) + if existing != null: + return existing + var rng := RandomNumberGenerator.new() + rng.seed = _derive_seed(_run_seed, stream_name) + _streams[stream_name] = rng + return rng + +func randf(stream_name: StringName) -> float: + return stream(stream_name).randf() + +func randi_range(stream_name: StringName, from: int, to: int) -> int: + return stream(stream_name).randi_range(from, to) + +## FNV-1a over the stream name, mixed with the run seed. +## +## Godot's built-in `hash()` is not contractually stable across engine versions, +## and a reproducible seed is the entire point of this service, so the mix is +## spelled out here instead. +func _derive_seed(base_seed: int, stream_name: StringName) -> int: + # FNV-1a 64-bit offset basis (14695981039346656037) as a signed 64-bit int, + # because GDScript integers are signed and the unsigned literal will not parse. + var h: int = -3750763034362895579 + const FNV_PRIME: int = 1099511628211 + for byte in String(stream_name).to_utf8_buffer(): + h = (h ^ byte) * FNV_PRIME + h = (h ^ base_seed) * FNV_PRIME + return h + +func _generate_seed() -> int: + var rng := RandomNumberGenerator.new() + rng.randomize() + return rng.randi() diff --git a/core/run_state.gd b/core/run_state.gd new file mode 100644 index 0000000..45ab450 --- /dev/null +++ b/core/run_state.gd @@ -0,0 +1,296 @@ +class_name RunState +extends RefCounted +## Authoritative state for one run. +## +## Contract (frozen — see docs/INTERFACES.md): +## * Fields are private. Read through getters; write only through the named +## commands below. UI never calls a command — it observes and asks a +## service. +## * Structural values (max HP, max stamina, armour, recovery, locks, taxes) +## feed structural integrity. Momentary values (current HP, current +## stamina) never do. This split is the whole reason the class exists: +## taking a hit must not make the next sacrifice more rewarding. +## * Every structural mutation ends with `recalculate_derived()`, which is the +## single place derived values are produced. + +const STATUS_IDLE := &"idle" +const STATUS_ACTIVE := &"active" +const STATUS_VICTORY := &"victory" +const STATUS_DEFEAT := &"defeat" + +# --- structural ------------------------------------------------------------- +var _max_hp: float = 0.0 +var _max_stamina: float = 0.0 +var _armour: float = 0.0 +var _stamina_recovery: float = 0.0 +var _attack: float = 0.0 +var _crit_rate: float = 0.0 +var _crit_damage: float = 0.0 +var _attack_speed: float = 0.0 +var _additive_sum: float = 0.0 +var _more_product: float = 1.0 +var _structural_locks: Array[StringName] = [] +var _action_taxes: Array[StringName] = [] + +# --- momentary -------------------------------------------------------------- +var _current_hp: float = 0.0 +var _current_stamina: float = 0.0 + +# --- run bookkeeping -------------------------------------------------------- +var _integrity: float = 1.0 +var _imbalance: float = 0.0 +var _effective_hp: float = 0.0 +var _dps_estimate: float = 0.0 +var _sacrifice_history: Array[StringName] = [] +var _build_tags: Dictionary = {} +var _run_seed: int = 0 +var _run_status: StringName = STATUS_IDLE + +var _balance: BalanceConfig + + +static func create(balance: BalanceConfig, run_seed: int) -> RunState: + var state: RunState = RunState.new() + state._balance = balance + state._run_seed = run_seed + state._max_hp = balance.base_max_hp + state._max_stamina = balance.base_max_stamina + state._armour = balance.base_armour + state._stamina_recovery = balance.base_stamina_recovery + state._attack = balance.base_attack + state._crit_rate = balance.base_crit_rate + state._crit_damage = balance.base_crit_damage + state._attack_speed = balance.base_attack_speed + state._current_hp = state._max_hp + state._current_stamina = state._max_stamina + state.recalculate_derived() + return state + + +# --- reads ------------------------------------------------------------------ +func balance() -> BalanceConfig: return _balance +func max_hp() -> float: return _max_hp +func current_hp() -> float: return _current_hp +func max_stamina() -> float: return _max_stamina +func current_stamina() -> float: return _current_stamina +func armour() -> float: return _armour +func stamina_recovery() -> float: return _stamina_recovery +func attack() -> float: return _attack +func crit_rate() -> float: return _crit_rate +func crit_damage() -> float: return _crit_damage +func attack_speed() -> float: return _attack_speed +func additive_sum() -> float: return _additive_sum +func more_product() -> float: return _more_product +func structural_locks() -> Array[StringName]: return _structural_locks.duplicate() +func action_taxes() -> Array[StringName]: return _action_taxes.duplicate() +func integrity() -> float: return _integrity +func imbalance() -> float: return _imbalance +func effective_hp() -> float: return _effective_hp +func dps_estimate() -> float: return _dps_estimate +func sacrifice_history() -> Array[StringName]: return _sacrifice_history.duplicate() +func build_tags() -> Dictionary: return _build_tags.duplicate() +func run_seed() -> int: return _run_seed +func run_status() -> StringName: return _run_status +func is_alive() -> bool: return _current_hp > 0.0 + +func hp_ratio() -> float: + return 0.0 if _max_hp <= 0.0 else clampf(_current_hp / _max_hp, 0.0, 1.0) + +func stamina_ratio() -> float: + return 0.0 if _max_stamina <= 0.0 else clampf(_current_stamina / _max_stamina, 0.0, 1.0) + +## True while the player is in the "guttering candle" band (section 6.4). +## Momentary, deliberately not part of integrity. +func is_guttering() -> bool: + return hp_ratio() <= _balance.guttering_threshold and is_alive() + +func tag_count(tag: StringName) -> int: + return int(_build_tags.get(tag, 0)) + +## Structural inputs for IntegrityService. Passing primitives keeps the service +## pure and avoids a class-level cycle between RunState and the service. +func structural_snapshot() -> Dictionary: + return { + "max_hp": _max_hp, + "max_stamina": _max_stamina, + "armour": _armour, + "stamina_recovery": _stamina_recovery, + "major_locks": _structural_locks.size(), + "action_taxes": _action_taxes.size(), + } + +## Offensive inputs for DerivedStats and CombatResolver, as primitives. +func offence_snapshot() -> Dictionary: + return { + "attack": _attack, + "attack_speed": _attack_speed, + "additive_sum": _additive_sum, + "more_product": _more_product, + "crit_rate": _crit_rate, + "crit_damage": _crit_damage, + } + + +# --- momentary commands ----------------------------------------------------- +## Applies already-mitigated damage. Returns the amount actually removed. +func apply_damage(amount: float) -> float: + var before: float = _current_hp + _current_hp = maxf(0.0, _current_hp - maxf(0.0, amount)) + return before - _current_hp + +## Healing restores current HP only. It can never restore sacrificed structure, +## which is why it does not touch `_max_hp` or trigger a structural recalc. +func heal(amount: float) -> float: + var before: float = _current_hp + _current_hp = minf(_max_hp, _current_hp + maxf(0.0, amount)) + return _current_hp - before + +func set_current_hp(value: float) -> void: + _current_hp = clampf(value, 0.0, _max_hp) + +func spend_stamina(amount: float) -> void: + _current_stamina = clampf(_current_stamina - maxf(0.0, amount), 0.0, _max_stamina) + +func regenerate_stamina(amount: float) -> void: + _current_stamina = clampf(_current_stamina + maxf(0.0, amount), 0.0, _max_stamina) + +func set_run_status(status: StringName) -> void: + _run_status = status + + +# --- structural commands ---------------------------------------------------- +# Called by systems/sacrifice_service.gd and by debug tooling. Each one ends in +# recalculate_derived() so no caller can forget. + +func scale_max_hp(factor: float) -> void: + _max_hp = maxf(_balance.min_max_hp, _max_hp * factor) + _current_hp = minf(_current_hp, _max_hp) + recalculate_derived() + +func scale_max_stamina(factor: float) -> void: + _max_stamina = maxf(_balance.min_max_stamina, _max_stamina * factor) + _current_stamina = minf(_current_stamina, _max_stamina) + recalculate_derived() + +func set_armour(value: float) -> void: + _armour = maxf(_balance.min_armour, value) + recalculate_derived() + +func set_stamina_recovery(value: float) -> void: + _stamina_recovery = maxf(0.0, value) + recalculate_derived() + +func add_additive(value: float) -> void: + _additive_sum += value + recalculate_derived() + +func multiply_more(factor: float) -> void: + _more_product *= factor + recalculate_derived() + +func add_crit_rate(value: float) -> void: + _crit_rate += value + recalculate_derived() + +func add_crit_damage(value: float) -> void: + _crit_damage += value + recalculate_derived() + +func multiply_attack_speed(factor: float) -> void: + _attack_speed *= factor + recalculate_derived() + +func add_structural_lock(lock_id: StringName) -> void: + if not _structural_locks.has(lock_id): + _structural_locks.append(lock_id) + recalculate_derived() + +func add_action_tax(tax_id: StringName) -> void: + if not _action_taxes.has(tax_id): + _action_taxes.append(tax_id) + recalculate_derived() + +func add_imbalance(value: float) -> void: + _imbalance = clampf(_imbalance + value, 0.0, _balance.imbalance_max) + +func record_sacrifice(id: StringName, tags: Array[StringName]) -> void: + _sacrifice_history.append(id) + for tag in tags: + _build_tags[tag] = int(_build_tags.get(tag, 0)) + 1 + + +## The single place derived values are produced. Called by every structural +## command; call it directly only after a bulk restore. +func recalculate_derived() -> void: + _integrity = IntegrityService.compute(structural_snapshot(), _balance) + _effective_hp = DerivedStats.effective_hp(_max_hp, _armour) + _dps_estimate = DerivedStats.dps_estimate(offence_snapshot(), _balance) + + +# --- snapshots -------------------------------------------------------------- +## Immutable value copy, used for preview, rollback, logging and tests. +func snapshot() -> Dictionary: + return { + "max_hp": _max_hp, + "current_hp": _current_hp, + "max_stamina": _max_stamina, + "current_stamina": _current_stamina, + "armour": _armour, + "stamina_recovery": _stamina_recovery, + "attack": _attack, + "crit_rate": _crit_rate, + "crit_damage": _crit_damage, + "attack_speed": _attack_speed, + "additive_sum": _additive_sum, + "more_product": _more_product, + "structural_locks": _structural_locks.duplicate(), + "action_taxes": _action_taxes.duplicate(), + "integrity": _integrity, + "imbalance": _imbalance, + "effective_hp": _effective_hp, + "dps_estimate": _dps_estimate, + "sacrifice_history": _sacrifice_history.duplicate(), + "build_tags": _build_tags.duplicate(), + "run_seed": _run_seed, + "run_status": _run_status, + } + +func restore(snap: Dictionary) -> void: + _max_hp = float(snap["max_hp"]) + _current_hp = float(snap["current_hp"]) + _max_stamina = float(snap["max_stamina"]) + _current_stamina = float(snap["current_stamina"]) + _armour = float(snap["armour"]) + _stamina_recovery = float(snap["stamina_recovery"]) + _attack = float(snap["attack"]) + _crit_rate = float(snap["crit_rate"]) + _crit_damage = float(snap["crit_damage"]) + _attack_speed = float(snap["attack_speed"]) + _additive_sum = float(snap["additive_sum"]) + _more_product = float(snap["more_product"]) + _structural_locks = (snap["structural_locks"] as Array).duplicate() + _action_taxes = (snap["action_taxes"] as Array).duplicate() + _imbalance = float(snap["imbalance"]) + _sacrifice_history = (snap["sacrifice_history"] as Array).duplicate() + _build_tags = (snap["build_tags"] as Dictionary).duplicate() + _run_seed = int(snap["run_seed"]) + _run_status = snap["run_status"] + recalculate_derived() + +## Detached copy sharing the same BalanceConfig. Mutating the clone cannot +## touch the original — this is what `preview` runs against. +func clone() -> RunState: + var copy: RunState = RunState.new() + copy._balance = _balance + copy.restore(snapshot()) + return copy + +## Stable digest of the snapshot, for telemetry correlation and test asserts. +func snapshot_hash() -> String: + var snap: Dictionary = snapshot() + var keys: Array = snap.keys() + keys.sort() + var parts := PackedStringArray() + for key in keys: + parts.append("%s=%s" % [key, snap[key]]) + return "\n".join(parts).sha256_text().substr(0, 16) diff --git a/core/soft_caps.gd b/core/soft_caps.gd new file mode 100644 index 0000000..d8cf011 --- /dev/null +++ b/core/soft_caps.gd @@ -0,0 +1,26 @@ +class_name SoftCaps +extends RefCounted +## Diminishing-returns curves from research report section 5.7. +## +## Pure float maths, no state. Kept separate from CombatResolver because +## DerivedStats needs the identical curves — two copies of a cap is two places +## for the numbers to drift apart. + +## Values at or below `knee` pass through; beyond it only `slope` of the excess +## counts. Continuous at the knee, so no jump when a stat crosses it. +static func compress(value: float, knee: float, slope: float) -> float: + if value <= knee: + return value + return knee + (value - knee) * slope + +static func crit_rate(value: float, balance: BalanceConfig) -> float: + return clampf(value, 0.0, balance.crit_rate_hard_cap) + +static func crit_damage(value: float, balance: BalanceConfig) -> float: + return compress(value, balance.crit_damage_soft_cap, balance.crit_damage_soft_slope) + +static func attack_speed(value: float, balance: BalanceConfig) -> float: + return compress(value, balance.attack_speed_soft_cap, balance.attack_speed_soft_slope) + +static func more_product(value: float, balance: BalanceConfig) -> float: + return compress(value, balance.more_product_soft_cap, balance.more_product_soft_slope) diff --git a/data/actors/boss_gate_guardian.tres b/data/actors/boss_gate_guardian.tres new file mode 100644 index 0000000..c068267 --- /dev/null +++ b/data/actors/boss_gate_guardian.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="EnemyConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://actors/enemies/enemy_config.gd" id="1_config"] + +[resource] +script = ExtResource("1_config") +actor_id = &"boss_gate_guardian" +display_name = "镇关鬼将 — Gate Guardian Ghost General" +max_hp = 260.0 +armour = 12.0 +move_speed = 42.0 +gravity = 780.0 +max_fall_speed = 420.0 +detection_range = 420.0 +attack_range = 78.0 +preferred_gap = 48.0 +attack_damage = 22.0 +attack_skill_multiplier = 1.0 +attack_windup = 0.55 +attack_active = 0.18 +attack_recovery = 0.75 +attack_cooldown = 0.6 +hurt_duration = 0.14 +death_duration = 1.4 +telegraph_tint = Color(1.9, 1, 1.4, 1) diff --git a/data/actors/ghost_melee.tres b/data/actors/ghost_melee.tres new file mode 100644 index 0000000..7e45dde --- /dev/null +++ b/data/actors/ghost_melee.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="EnemyConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://actors/enemies/enemy_config.gd" id="1_config"] + +[resource] +script = ExtResource("1_config") +actor_id = &"ghost_melee" +display_name = "鬼卒 — Ghost Soldier" +max_hp = 34.0 +armour = 4.0 +move_speed = 48.0 +gravity = 780.0 +max_fall_speed = 420.0 +detection_range = 180.0 +attack_range = 65.0 +preferred_gap = 34.0 +attack_damage = 9.0 +attack_skill_multiplier = 1.0 +attack_windup = 0.4 +attack_active = 0.15 +attack_recovery = 0.6 +attack_cooldown = 0.35 +hurt_duration = 0.18 +death_duration = 0.5 +telegraph_tint = Color(1.7, 1.1, 1, 1) diff --git a/data/balance_config.tres b/data/balance_config.tres new file mode 100644 index 0000000..53fee48 --- /dev/null +++ b/data/balance_config.tres @@ -0,0 +1,70 @@ +[gd_resource type="Resource" script_class="BalanceConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://core/balance_config.gd" id="1_balance"] + +[resource] +script = ExtResource("1_balance") +base_max_hp = 100.0 +base_max_stamina = 100.0 +base_armour = 10.0 +base_stamina_recovery = 12.0 +base_attack = 10.0 +base_crit_rate = 0.05 +base_crit_damage = 1.5 +base_attack_speed = 1.0 +min_max_hp = 10.0 +min_max_stamina = 15.0 +min_armour = 0.0 +crit_rate_hard_cap = 0.8 +crit_damage_soft_cap = 4.5 +crit_damage_soft_slope = 0.35 +attack_speed_soft_cap = 2.4 +attack_speed_soft_slope = 0.35 +more_product_soft_cap = 12.0 +more_product_soft_slope = 0.3 +integrity_weight_hp = 0.34 +integrity_weight_stamina = 0.24 +integrity_weight_armour = 0.16 +integrity_weight_recovery = 0.14 +integrity_weight_freedom = 0.12 +integrity_major_lock_penalty = 0.12 +integrity_action_tax_penalty = 0.06 +deficiency_exponent = 1.35 +reward_base = 0.55 +reward_slope = 1.45 +strength_gain = PackedFloat32Array(0, 0.12, 0.2, 0.32, 0.48, 0.7) +synergy_bonus = 0.12 +dilution_penalty = 0.08 +synergy_quality_min = 0.85 +synergy_quality_max = 1.45 +cost_weight_hp = 100.0 +cost_weight_stamina = 80.0 +cost_weight_armour = 60.0 +cost_weight_recovery = 50.0 +imbalance_base_factor = 0.75 +imbalance_strength_factor = 0.05 +imbalance_max = 200.0 +max_major_locks = 2 +max_action_taxes = 3 +wounded_threshold = 0.3 +guttering_threshold = 0.15 +guttering_more_multiplier = 1.2 +player_move_speed = 110.0 +player_air_control = 0.65 +player_jump_velocity = -230.0 +player_gravity = 780.0 +player_max_fall_speed = 420.0 +player_hurt_duration = 0.25 +player_hurt_knockback = 90.0 +player_invulnerable_after_hit = 0.45 +light_attack_windup = 0.1 +light_attack_active = 0.12 +light_attack_recovery = 0.2 +light_attack_skill_multiplier = 1.0 +light_attack_stamina_cost = 0.0 +stamina_recovery_delay = 0.1 +stamina_exhausted_lock = 1.1 +wave_enemy_count = 3 +enemy_spawn_interval = 0.6 +camera_smoothing_speed = 6.0 +camera_look_ahead = 24.0 diff --git a/data/sacrifices/severed_lifespan.tres b/data/sacrifices/severed_lifespan.tres new file mode 100644 index 0000000..0ae2533 --- /dev/null +++ b/data/sacrifices/severed_lifespan.tres @@ -0,0 +1,36 @@ +[gd_resource type="Resource" script_class="SacrificeDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://systems/sacrifice_definition.gd" id="1_sacrifice"] + +[resource] +script = ExtResource("1_sacrifice") +id = &"severed_lifespan" +display_name = "断寿之契 — Oath of Severed Lifespan" +strength = 2 +rarity = &"common" +tags = Array[StringName]([&"attack", &"hp_sacrifice"]) +roles = Array[StringName]([&"CONTINUATION"]) +exclusive_group = &"" +base_weight = 100.0 +prerequisite_min_sacrifices = 0 +banned_with = Array[StringName]([]) +cost_max_hp_multiplier = 0.85 +cost_max_stamina_multiplier = 1.0 +cost_armour_multiplier = 1.0 +cost_stamina_recovery_multiplier = 1.0 +cost_structural_locks = Array[StringName]([]) +cost_action_taxes = Array[StringName]([]) +imbalance_flat = 16.0 +reward_additive = 0.18 +reward_generic_more = true +reward_crit_rate = 0.0 +reward_crit_damage = 0.0 +reward_attack_speed_multiplier = 1.0 +triggers = [{ +"condition": "hp_ratio <= 0.30", +"effect": "more_multiplier:1.12", +"event": "OnHitDealt" +}] +short_text = "Cut your lifespan short; the blade drinks what the body loses." +preview_template = "Max lifespan {max_hp_delta}, attack {attack_gain}, imbalance +{imbalance_delta}" +warning_level = &"medium" diff --git a/docs/AI_HANDOFF.md b/docs/AI_HANDOFF.md index d26648b..ef2bf06 100644 --- a/docs/AI_HANDOFF.md +++ b/docs/AI_HANDOFF.md @@ -1,16 +1,139 @@ # AI Handoff -- **Current phase:** Art asset pipeline complete; core framework not started. -- **Current playable state:** No playable build exists. -- **Completed repository work:** Collaboration rules, ownership, templates, task tracking, repository hygiene validation, and **prototype art asset pipeline** (63 placeholder sprites, 8 concept docs, master art spec, Godot import guide, all directory READMEs and metadata). -- **In progress:** Art PR (`workbuddy/prototype-art`) awaiting review/merge. -- **Next owner:** Claude. -- **Next task:** Create `claude/core-framework` from the latest `develop` branch. Art assets are ready in `assets/` — read `docs/ART_SPEC.md` and each directory's `metadata.md` for sprite sheet frame counts, sizes, and FPS values. -- **Frozen interfaces:** None; no gameplay interfaces are frozen yet. -- **Do not modify:** Product requirements without Game Director approval; `main`/`develop` directly; future frozen interfaces without the decision process; art concept documents (`docs/art/CONCEPT_*.md`) without consulting the Art Director. -- **Known blockers:** Approved detailed PRD and Prototype Contract source material is not present in the repository. -- **Art asset notes:** All PNGs in `assets/` are placeholders (magenta border). Replace by saving a new PNG with the same filename and dimensions. See `docs/ART_SPEC.md` Section 5 for replacement workflow. Godot import settings documented in `docs/art/GODOT_IMPORT_GUIDE.md` and `godot/import_presets.md`. -- **Last successful test:** Repository validation checks (2026-08-02). -- **Last updated:** 2026-08-02 (Australia/Melbourne). - -The PRD and Prototype Contract are the current highest-priority sources of truth. `docs/ART_SPEC.md` is the source of truth for all visual art assets. +- **Current branch:** `claude/core-framework` (PR open into `develop`, unmerged) +- **Current phase:** M1 — playable vertical slice foundation, delivered +- **Next owner:** Game Director (review), then Codex for X01–X08 +- **Last updated:** 2026-08-02 (Australia/Melbourne) + +--- + +## M1 playable status + +**Playable end to end.** Launch → arena → move, jump, attack → clear a wave of +three ghost soldiers → preview and accept one sacrifice → become measurably +sharper and structurally weaker → fight the prototype Boss → victory or death → +restart. + +Verified by the automated run-loop case and by capturing frames under a virtual +display during development. Engine: Godot 4.3 stable. + +## Implemented systems + +| System | State | +| --- | --- | +| `RunState` | Authoritative; private fields, named commands, snapshot/restore/clone/hash | +| `CombatResolver` | Single damage path, bucket order frozen, breakdown reported | +| `IntegrityService` | Five-component structural formula; momentary state excluded | +| `SacrificeService` | Shared preview/apply transaction with whole-state rollback | +| `EventBus` | 11 signals with a uniform correlation envelope | +| `RNGService` | Per-subsystem streams derived from the run seed | +| `BalanceConfig` | Every tunable number, in `data/balance_config.tres` | +| Player | Move, jump, light attack, hit reaction, death, restart, camera follow, 5-state machine | +| `EnemyBase` | Detect, approach, telegraphed wind-up, attack, recovery, hurt, idempotent death | +| Reference enemy | 鬼卒 Ghost Soldier at X01 timings | +| `BossActor` | Extends `EnemyBase`; idle, chase, one telegraphed melee attack, health bar, victory | +| Arena | One fixed 1024×360 space, collision, spawns, parallax, camera bounds | +| UI | HUD, sacrifice preview/confirm, death and victory screens, restart | +| Debug | Panel plus 9 commands, all routed through `RunCoordinator` | +| Tests + CI | 52 tests / 205 assertions; `godot-tests` workflow | + +## Frozen interfaces + +See `docs/INTERFACES.md` section 1 for signatures and guarantees. + +`RunState` · `DamageContext` / `DamageResult` / `CombatResolver` · +`IntegrityService` · `SacrificeDefinition` · `SacrificeService` · +the Damageable shape (`actor_id` / `armour` / `current_hp` / `receive_damage`) · +`EnemyBase` · `EventBus` · `RNGService` · `RunCoordinator` · `Hitbox` / `Hurtbox` + +Changing any of them requires a `docs/DECISIONS.md` proposal and Claude review. + +## Files Codex must not modify + +``` +core/run_state.gd core/combat_resolver.gd +core/damage_context.gd core/damage_result.gd +core/soft_caps.gd core/derived_stats.gd +core/event_bus.gd core/rng_service.gd +core/game_data.gd core/hitbox.gd core/hurtbox.gd +systems/integrity_service.gd systems/sacrifice_service.gd +systems/sacrifice_definition.gd systems/sacrifice_result.gd +systems/run_coordinator.gd +actors/enemies/enemy_base.gd actors/enemies/enemy_base.tscn +actors/enemies/enemy_config.gd +actors/player/player.gd actors/player/player_state_machine.gd +actors/player/states/player_state.gd +scenes/main.gd project.godot +.github/workflows/repository-validation.yml +``` + +Also unchanged without their owner: product requirements (Game Director) and +`docs/art/CONCEPT_*.md` (Art Director). + +## Available Codex tasks + +All interfaces these depend on are now frozen. See `docs/TASKS/README.md`. + +| ID | Task | Depends on | Notes | +| --- | --- | --- | --- | +| X01 | Melee ghost, full behaviour | `EnemyBase` | The reference enemy is a starting point, not the finished X01 | +| X02 | Ranged ghost | `EnemyBase` | Art present: `ghost_archer_*`. Needs a projectile; do not add a second damage path | +| X03 | Charger ghost | `EnemyBase` | Art present: `corpse_beast_*` at 64×48 — needs its own collider sizes | +| X04 | Boss attack pack | `BossActor` | Blocked on art or a ruling — see gaps below | +| X05 | Sacrifice selection UI | `SacrificeService` | Three A/B/C slots; preview through the service, confirm through the coordinator | +| X06 | Twelve sacrifice definitions | `SacrificeDefinition` | Data only. S4/S5 cards need at least one rule-type cost | +| X07 | Debug and telemetry panel | `EventBus`, `RunCoordinator` | Event timeline and JSON/CSV export; the current panel is the baseline | +| X08 | Test expansion | harness | Stamina/dodge and same-death cases once those systems exist | + +Not yet unblocked, because Claude has not built the framework they extend: the +three-slot generator, `StaminaService`, `SameDeathController`, the world +director, and the Boss `PhaseController`. + +## Known issues + +1. **`assets/effects/*.png` are single-frame.** All four are 48×48 while + `assets/effects/metadata.md` specifies 2–4 frames (96×48 to 192×48). No VFX + are used in M1. WorkBuddy needs to re-export the strips. +2. **The Boss has one attack.** `assets/boss/` ships one attack sheet, so the + charge and the ground slam from A14/X04 are absent (ADR-010). The fight is a + readable pattern with one answer — thin on purpose, not by oversight. +3. **`docs/PRD.md` and `docs/PROTOTYPE_CONTRACT.md` are still stubs.** M1 was + derived from the Development Pack and research report under ADR-001. Land the + real documents before Codex starts, so X01–X08 have something to be reviewed + against. +4. **Stamina has no consumer** (ADR-011). The bar sits full during normal play. +5. **Imbalance is tracked but inert.** Nothing reads it yet; the world director + is the consumer. +6. **No telemetry persistence.** Events are emitted with a correlation envelope + but nothing writes them to disk (X07). +7. **No audio.** Out of scope for M1. +8. **Placeholder art everywhere.** Magenta border = placeholder, per + `docs/ART_SPEC.md` section 5. + +## Test status + +- **Last successful run:** 2026-08-02 — 52 tests, 205 assertions, 0 failures, + Godot 4.3 stable headless. +- **Command:** `godot --headless --import` then + `godot --headless --path . res://tests/test_runner.tscn` +- **CI:** `repository-validation` (unchanged) and `godot-tests` (new). Both + should be required checks on `develop`. + +## Asset integration gaps + +| Asset group | Status | +| --- | --- | +| `assets/player/` | All 6 sheets used; dimensions match `docs/ART_SPEC.md` exactly | +| `assets/enemy/ghost_melee_*` | All 5 sheets used | +| `assets/enemy/ghost_archer_*`, `corpse_beast_*` | Verified, unused — X02 and X03 | +| `assets/boss/` | idle, run, attack, hurt, death used; only one attack sheet exists | +| `assets/tiles/` | floor, stone_brick, brazier, tombstone used; `tile_wall`, `tile_wood_bridge`, `tile_ground_spike` unused (no platforms or hazards in M1) | +| `assets/background/` | bg_deep, bg_tree, bg_broken_flag, bg_chains all used in three parallax layers | +| `assets/ui/` | ui_health_bar, ui_stamina_bar used; `ui_frame`, `ui_minimap_frame` unused (no inventory, no minimap) | +| `assets/icons/ui/` | icon_hp, icon_boss, icon_sacrifice used; the other 8 unused | +| `assets/icons/weapons/` | All 6 unused — no weapon system in M1 | +| `assets/effects/` | **Unusable as animations** — see known issue 1 | + +Nothing was stretched, scaled by a non-integer factor, or resized. Regenerate +`SpriteFrames` with `python3 tools/generate_sprite_frames.py` after replacing a +sheet; it aborts if the new dimensions disagree with the spec. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7e162b6..2d44a0f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,267 @@ # Architecture -> **STATUS: DRAFT — TO BE UPDATED BY CLAUDE AFTER FRAMEWORK IMPLEMENTATION** +> **STATUS: ACTIVE — M1 (playable vertical slice foundation)** +> **Owner:** Claude · **Branch:** `claude/core-framework` · **Engine:** Godot 4.3 stable / GDScript -No framework architecture has been implemented. Claude will document system boundaries, dependencies, state ownership, and extension points during `claude/core-framework` without overriding higher-priority product documents. +This document describes what the code actually does. It does not define product +rules; those come from `docs/PRD.md` and `docs/PROTOTYPE_CONTRACT.md`. + +--- + +## 1. Priorities + +From the Prototype Development Pack (A1): **runnable > testable > configurable > +extensible > elegant**. The architecture serves the current scope and does not +pre-build for systems that do not exist yet. + +Three rules shape almost every decision below: + +1. **One source of truth per calculation.** Damage is resolved in exactly one + place. Integrity is computed in exactly one place. The sacrifice preview and + the sacrifice apply are the same function. +2. **Structural and momentary state are different things.** Max HP is + structural; current HP is momentary. Confusing them is the bug the research + report was written to fix. +3. **UI observes, services mutate.** The HUD, the sacrifice card and the debug + panel all read state and call the coordinator. None of them writes a stat. + +--- + +## 2. Directory layout + +Follows Prototype Development Pack A3, plus `scenes/` (see ADR-009). + +| Path | Contents | Owner | +| --- | --- | --- | +| `core/` | EventBus, RNGService, GameData, RunState, BalanceConfig, damage pipeline, SoftCaps, DerivedStats, Hitbox/Hurtbox | Claude | +| `systems/` | IntegrityService, sacrifice definition/result/service, RunCoordinator | Claude | +| `actors/player/` | Player, state machine, action states, SpriteFrames | Claude | +| `actors/enemies/` | EnemyBase contract and scene, EnemyConfig schema, concrete enemies | Claude (base) / Codex (concrete) | +| `actors/boss/` | BossActor and scene | Codex + Claude review | +| `scenes/` | `main.tscn` (composition root), `arena.tscn` | Claude | +| `ui/` | HUD, sacrifice panel, result screen, debug panel, shape overlay | Codex | +| `data/` | `balance_config.tres`, `sacrifices/*.tres`, `actors/*.tres` | Shared, validated by tests | +| `tests/` | Harness and cases | Codex primary | +| `tools/` | Generators and asset utilities | Shared | + +--- + +## 3. Scene tree + +`scenes/main.tscn` matches Prototype Development Pack A4: + +``` +Main (Node2D, scenes/main.gd) ← composition root +├── Arena (scenes/arena.tscn) ← terrain, parallax, props, spawn markers, camera bounds +├── Actors (Node2D) ← player, wave enemies and boss are spawned here +├── RunCoordinator (systems/run_coordinator.gd) +├── UIRoot +│ ├── Hud +│ ├── SacrificePanel +│ └── ResultScreen +└── DebugRoot + ├── DebugPanel + └── DebugShapes (DebugShapeOverlay) +``` + +`Main._ready()` binds every observer and only then calls +`RunCoordinator.start_run()`. Starting the run from the coordinator's own +`_ready` would let observers bind after the opening `state_ready` had already +fired. + +Actor scenes: + +- `actors/player/player.tscn` — CharacterBody2D + AnimatedSprite2D + body + collider + Hurtbox + AttackHitbox + Camera2D. +- `actors/enemies/enemy_base.tscn` — the 48×48 humanoid base; `ghost_melee.tscn` + inherits it and supplies SpriteFrames only. +- `actors/boss/boss.tscn` — standalone because the Boss is 96×96 and needs its + own collider sizes; it runs `BossActor`, which extends `EnemyBase`. + +No core calculation lives in `Main`, in `Arena` or in any UI node. + +--- + +## 4. State ownership + +| State | Owner | Lifetime | +| --- | --- | --- | +| Balance configuration | `GameData` (autoload) | Process | +| Sacrifice library | `GameData` (autoload) | Process | +| RNG streams | `RNGService` (autoload) | Re-seeded per run | +| Active `RunState` | `RunCoordinator` | One run | +| Enemy and Boss HP | The actor instance | One actor | +| Everything the UI shows | Nobody — it is read each frame | — | + +`GameData` holds the stateless services (`CombatResolver`, `SacrificeService`) +and the loaded data. It deliberately does **not** hold the active `RunState`: +keeping mutable run data out of an autoload is what stops "just read it from the +global" becoming the way every script talks to the run. + +### RunState + +Fields are private. Reads go through getters; writes go through named commands +(`apply_damage`, `heal`, `scale_max_hp`, `add_additive`, `add_imbalance`, …). +Structural commands all end in `recalculate_derived()`, which is the single +place integrity, effective HP and the DPS estimate are produced. + +`snapshot()` / `restore()` / `clone()` / `snapshot_hash()` support preview, +rollback, telemetry correlation and test assertions. + +**Structural** (feeds integrity): max HP, max stamina, armour, stamina recovery, +structural locks, action taxes. +**Momentary** (never feeds integrity): current HP, current stamina. + +--- + +## 5. Combat + +`CombatResolver.resolve(DamageContext) -> DamageResult` is the only damage path, +implementing research report 5.3: + +``` +raw = ATK × SkillMult × (1 + AddSum) × MoreProd × CondProd × CritMult +final = raw × 100 / (100 + max(0, ARM − Pen)) × Vulnerability +``` + +- `DamageContext` carries every input, so the resolver never reaches into a + scene node or an autoload for a gameplay value. +- `DamageResult.breakdown` records each stage, so a wrong number can be traced + to the bucket that produced it. A test multiplies the breakdown back to the + final damage. +- The crit roll is injectable (`CombatResolver._roll_provider`) and a context can + pin it (`crit_roll`) or force it (`force_crit`), which is what makes crit + behaviour testable rather than a coin flip. +- Soft caps live in `SoftCaps` and are shared with `DerivedStats`, so the HUD's + DPS estimate and an actual hit compress the same way. + +**Damageable shape.** `Player` and `EnemyBase` both expose +`actor_id() / armour() / current_hp() / receive_damage(DamageContext)`. An +attacker can therefore hit either side without special-casing, and neither side +computes its own damage. + +--- + +## 6. Integrity + +`IntegrityService.compute(structural, balance)` — research report 5.4: + +``` +I = clamp(0.34h + 0.24s + 0.16a + 0.14r + 0.12f, 0, 1) +h = min(1, HPmax/100) s = min(1, STmax/100) +a = min(1, (1+ARM/100)/(1+ARM0/100)) r = min(1, SR/12) +f = max(0, 1 − 0.12·majorLocks − 0.06·actionTaxes) +``` + +The service takes primitives rather than a `RunState`, which keeps it pure and +avoids a class-level cycle. Each component is capped at 1 so an ordinary buff +cannot push integrity above the intact-body ceiling or launder away a sacrifice. + +--- + +## 7. Sacrifice transaction + +`SacrificeService` runs one ordered transaction (Prototype Development Pack A9): + +1. validate (`can_offer`) +2. snapshot — the rollback point +3. apply cost +4. recalculate integrity (each structural command does this) +5. apply reward, priced against post-cost integrity +6. recalculate derived values +7. apply imbalance +8. publish the event +9. return `SacrificeResult` + +`preview(state, def)` runs it on `state.clone()`. `apply(state, def)` runs it on +the real state. **Same function.** A preview therefore cannot disagree with the +result — there is a test asserting field-by-field equality. + +Reward curve (5.5): `D = (1 − I)^1.35`, `G = g_S × (0.55 + 1.45·D) × Q`, with +`Q = clamp(1 + 0.12·synergy − 0.08·dilution, 0.85, 1.45)`. Pricing the reward +*after* the cost is what makes a more broken body buy a sharper blade. + +Cost score (5.6): `C = 100Δh + 80Δs + 60Δa + 50Δr` over integrity component +drops. Imbalance uses the card's authored `imbalance_flat` when set, otherwise +`ΔB = C × (0.75 + 0.05·S)` — see ADR-005. + +**Invariant:** a sacrifice may never raise integrity. If a card's data would do +so, the transaction fails and the state is restored whole (ADR-008). + +--- + +## 8. Events and randomness + +`EventBus` (autoload) carries `run_started`, `run_restarted`, `hit_dealt`, +`hit_taken`, `enemy_died`, `player_died`, `sacrifice_previewed`, +`sacrifice_applied`, `boss_started`, `boss_died`, `run_completed`. Every payload +is wrapped by `EventBus.context()` so telemetry can correlate without each +emitter inventing a shape. + +Events are for UI, telemetry and decoupled reactions **only**. Deterministic +core resolution uses explicit service calls. Moving a calculation onto a signal +is a defect. + +`RNGService` (autoload) gives each subsystem its own stream, seeded from the run +seed via FNV-1a over the stream name. An extra roll in one system therefore +cannot shift the sequence another system observes; there is a test for that. +Gameplay must never call `randi()`/`randf()` directly. + +--- + +## 9. Player state machine + +States: `Grounded`, `Airborne`, `Attacking`, `Hurt`, `Dead` — one small class +each under `actors/player/states/`. A state changes the player through helpers +on `Player` and requests a transition by returning the next state id; every +transition runs through `PlayerStateMachine._change_to`, so exit/enter cannot be +skipped and there is one place to look when a state sticks. + +Documented extension points, deliberately not stubbed: + +- **Dodge** — three phases with i-frames; entered from Grounded/Airborne, gated + on a StaminaService that does not exist yet. +- **Exhausted** — entered from anywhere at zero stamina; blocks dodge, skill and + heavy attack while leaving light attack available. +- **SameDeath** — must be checked at the top of `on_death`, before `DEAD`, since + it is a lethal-damage interception rather than a state you walk into. + +--- + +## 10. Pixel art rendering configuration + +| Setting | Value | Reason | +| --- | --- | --- | +| Viewport | 640 × 360 | 48px player ≈ 13% of screen height; the 320×180 backdrop tiles cleanly | +| Window | 1280 × 720 | Exactly 2× the viewport | +| `stretch/mode` | `viewport` | Renders at the low resolution and upscales the whole frame | +| `stretch/scale_mode` | `integer` | No fractional scaling, so no shimmer at any window size | +| `default_texture_filter` | 0 (Nearest) | No smoothing on any canvas texture | +| `snap_2d_transforms_to_pixel` | true | Sprites land on whole pixels | +| `snap_2d_vertices_to_pixel` | true | No sub-pixel seams between tiles | +| `msaa_2d` | 0 | Anti-aliasing would soften pixel edges | +| Renderer | `gl_compatibility` | As recommended in `godot/import_presets.md` | +| Texture import | lossless, no mipmaps, `detect_3d/compress_to = 0` | Matches `docs/art/GODOT_IMPORT_GUIDE.md` | + +This deviates from the 1280×720 viewport suggested in `godot/import_presets.md` +— see ADR-002. No asset is scaled by a non-integer factor and no sprite +dimensions were changed. + +`SpriteFrames` resources are generated by `tools/generate_sprite_frames.py` from +the frame table in `docs/ART_SPEC.md`; the generator fails if a sheet's real +dimensions disagree with the table (ADR-004). + +--- + +## 11. Known architectural gaps + +Listed so nobody mistakes absence for oversight. Each is scoped to a later task. + +- No StaminaService: stamina regenerates and is displayed, but nothing spends it + (light attack costs 0 by design). Dodge and heavy attack are its first + consumers. +- No world director: imbalance is tracked but does not yet drive enemy pressure. +- No same-death controller. +- No three-slot sacrifice generator: M1 offers one card at a fixed point. +- No Boss phase controller or attack scheduler. +- No telemetry persistence: events are emitted but nothing writes them to disk. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e091f7f..876fa15 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,6 +1,6 @@ # Architecture Decisions -> **STATUS: DRAFT — TO BE UPDATED BY CLAUDE AFTER FRAMEWORK IMPLEMENTATION** +> **STATUS: ACTIVE** Append decisions in this format; do not rewrite accepted history. @@ -13,3 +13,203 @@ Append decisions in this format; do not rewrite accepted history. - **Decision:** - **Consequences:** - **Interfaces affected:** + +--- + +## ADR-001: Derive M1 scope from the Prototype Development Pack, not from a rewritten PRD + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** `docs/PRD.md` and `docs/PROTOTYPE_CONTRACT.md` are still + `AWAITING GAME DIRECTOR SOURCE MATERIAL` stubs. The Game Director supplied + *《九幽》白盒原型联合开发包 v1.0* and *《残躯换锋》2D 动作肉鸽核心机制深度研究报告* as + reference material for M1. `AGENTS.md` forbids engineers inventing product + rules, and the M1 brief forbids rewriting the PRD or replacing the Contract. +- **Decision:** Implement M1 against the Development Pack (Part I contract, + Part II architecture) and the research report's formulas. Leave `PRD.md` and + `PROTOTYPE_CONTRACT.md` untouched. Record every derived number's source + section in code comments and in `docs/ARCHITECTURE.md`. +- **Consequences:** The repository's declared highest source of truth is still + a stub, so the traceability chain runs through this ADR rather than through + the PRD. **The Game Director should land the real PRD and Prototype Contract + before Codex starts X01–X08**, so later work has a document to be reviewed + against rather than an ADR. +- **Interfaces affected:** none directly; all balance constants in + `data/balance_config.tres`. + +## ADR-002: 640×360 viewport upscaled to 1280×720 with integer scaling + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** `godot/import_presets.md` recommends `viewport_width = 1280`, + `viewport_height = 720` with `stretch/mode = viewport`. At that setting the + game renders 1:1, so a 48×48 player is about 6% of screen height and a 32px + tile is barely visible — the art reads as tiny rather than as pixel art. +- **Decision:** Viewport 640×360, window 1280×720, `stretch/mode = viewport`, + `stretch/scale_mode = integer`. Nearest filtering, no mipmaps, 2D pixel + snapping on, MSAA off, `gl_compatibility` renderer as recommended. +- **Consequences:** Every asset renders at an exact integer scale at any window + size; no blur and no shimmer during camera movement. 320×180 `bg_deep` tiles + cleanly at 1:1. The recommendation in `godot/import_presets.md` is superseded + for the viewport size only; every other setting in that file is honoured. + Asset dimensions were not changed. +- **Interfaces affected:** `project.godot` `[display]` and `[rendering]`. + +## ADR-003: Use the WorkBuddy pixel art rather than the whitebox rectangles + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Development Pack P12 specifies whitebox readability — blue-grey + rectangle player, dark red enemies, purple Boss. Since that pack was written, + WorkBuddy delivered a complete placeholder sprite pipeline (62 PNGs plus + `docs/ART_SPEC.md`, which declares itself the source of truth for visual + assets), and the M1 brief requires those assets to be used. +- **Decision:** Use the pixel art. Keep P12's *readability* requirements — the + enemy wind-up is a colour flash driven by `EnemyConfig.telegraph_tint`, and + F8 draws hit/hurt/body boxes. +- **Consequences:** The prototype looks further along than it is; the magenta + placeholder borders make that visible. Readability obligations are met by + behaviour rather than by flat colours. +- **Interfaces affected:** none. + +## ADR-004: Generate SpriteFrames from the art spec instead of hand-authoring + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Sixteen animations across player, reference enemy and Boss come + to about 70 `AtlasTexture` sub-resources. Hand-writing them duplicates the + frame counts and FPS that already exist in `docs/ART_SPEC.md` and each + directory's `metadata.md`. +- **Decision:** `tools/generate_sprite_frames.py` owns the frame table and emits + `actors/*/**_frames.tres`. The generated files are committed. The generator + verifies each sheet's real pixel dimensions against the table and aborts on a + mismatch. +- **Consequences:** When WorkBuddy replaces a sheet, re-run the generator and + commit the diff. A sheet whose frame count silently changes fails loudly + instead of producing sliced animations. +- **Interfaces affected:** none. + +## ADR-005: Authored `imbalance_flat` wins over the derived imbalance formula + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Research report 5.6 gives `ΔB = C × (0.75 + 0.05·S)`. For + 断寿之契 that yields 12.75, but the card table in section 9.2 lists +16, and + the JSON example in 13.3 carries an explicit `imbalanceFlat`. The two sources + disagree, and the card tables are the ones a designer will tune. +- **Decision:** `SacrificeDefinition.imbalance_flat` is authoritative when + greater than zero; otherwise the formula applies. The derived cost score `C` + is always computed and returned in `SacrificeResult.cost_score` for telemetry + and for the future slot `ValueRatio` scoring. +- **Consequences:** Designers tune imbalance per card without fighting a + formula, and the formula remains the default for cards that do not specify. + Reconcile if the Game Director rules one way. +- **Interfaces affected:** `SacrificeDefinition`, `SacrificeService`. + +## ADR-006: In-repo test harness, run as a scene rather than via `--script` + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Development Pack A2 requires the project to run and test straight + after clone with no extra downloads. Vendoring GUT or gdUnit4 for a dozen + assertions costs more than it saves. Separately, running the suite through + `godot --headless --script res://tests/run_tests.gd` was tried and failed: + a MainLoop script is compiled *before* the autoload singletons are registered, + so `CombatResolver` (which names `RNGService`) and `SacrificeService` (which + names `EventBus`) fail to compile, `GameData` degrades to null services, and + most tests fail with `Nonexistent function ... in base 'Nil'`. +- **Decision:** A ~90 line harness (`tests/framework/test_case.gd` + + `tests/test_runner.gd`) launched as a scene: + `godot --headless --path . res://tests/test_runner.tscn`. +- **Consequences:** No plugin dependency and no parse-order trap; the test + process boots exactly like the game. If the suite outgrows plain assertions — + parameterised cases, fixtures, mocking — swap in gdUnit4 rather than growing + the harness. +- **Interfaces affected:** `docs/TESTING.md`, `.github/workflows/godot-tests.yml`. + +## ADR-007: No TileMap in M1 + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** The arena is one fixed space with four collision rectangles. + Authoring a `TileSet` resource plus `TileMapLayer` data by hand adds a lot of + generated content for a layout that does not vary. +- **Decision:** Terrain visuals are `Sprite2D` nodes with repeating texture + regions; collision is explicit `StaticBody2D` + `RectangleShape2D`. +- **Consequences:** Simpler diffs and exact collision bounds. If the arena ever + needs varied terrain, one-way platforms or hazards, move to `TileMapLayer` — + the spawn markers and camera bounds on `Arena` do not change. +- **Interfaces affected:** `scenes/arena.gd` (`camera_bounds`, + `enemy_spawn_points`, `player_spawn`, `boss_spawn`). + +## ADR-008: A sacrifice may never raise structural integrity + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** The transaction needs a real failure mode for its rollback path + to be meaningful. The most likely data defect is a designer typing a cost + multiplier above 1 — `1.15` where `0.85` was meant — which would *increase* a + structural stat and quietly hand the player a reward for free. +- **Decision:** After the cost step, `SacrificeService` compares integrity with + its pre-transaction value. If integrity rose, the transaction fails with a + readable reason and the state is restored from the snapshot. +- **Consequences:** The rollback path is exercised by a real, reachable defect + rather than being dead code. Note the guard only trips when the affected + integrity component is below its cap, since components are clamped at 1. +- **Interfaces affected:** `SacrificeService.apply` / `preview` failure results. + +## ADR-009: `scenes/` directory beyond the A3 layout + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Development Pack A3 lists `core/`, `systems/`, `actors/*`, `ui/`, + `data/`, `tests/` and `docs/`, but A4 requires `Main.tscn` and `Arena.tscn`, + which belong to none of them. +- **Decision:** Add `res://scenes/` for `main.tscn`, `main.gd`, `arena.tscn` and + `arena.gd`. Claude-owned, same rules as `core/`. +- **Consequences:** One directory beyond the pack; the ownership table in + `docs/ARCHITECTURE.md` covers it. +- **Interfaces affected:** none. + +## ADR-010: The prototype Boss ships one attack + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Development Pack A14/X04 asks for three moves and two phases. The + M1 brief allows a second move "only if supported by the available animation + assets". `assets/boss/` contains exactly one attack sheet + (`boss_attack.png`, 5 frames) alongside idle, run, hurt and death. +- **Decision:** Ship one telegraphed melee attack. Do not fake a charge by + replaying the run cycle and do not add a phase controller. +- **Consequences:** The Boss fight is thin — a readable pattern with one answer. + Codex X04 adds the charge and the ground slam; it needs either new art from + WorkBuddy or an explicit ruling that reusing the run cycle for a charge is + acceptable. +- **Interfaces affected:** none; `BossActor` extends `EnemyBase` unchanged. + +## ADR-011: Stamina is structural in M1 with no consumer + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Research report 6.1 makes light attack cost 0 stamina on purpose, + so a player is never fully locked out of acting. M1's only player action is + the light attack, so nothing spends stamina. But a sacrifice that cuts max + stamina must still be visible, and `IntegrityService` weights it at 0.24. +- **Decision:** `RunState` tracks current and max stamina with delayed + regeneration; the HUD shows the bar; `Player.spend_stamina` exists and is + routed. No StaminaService, no exhaustion lock, no dodge. +- **Consequences:** The stamina bar sits at full during normal play, which is + correct rather than broken. A future StaminaService can wrap + `Player.spend_stamina` without changing its callers. +- **Interfaces affected:** `RunState` stamina commands, `Player.spend_stamina`. diff --git a/docs/INTERFACES.md b/docs/INTERFACES.md index 9b75ea5..0224289 100644 --- a/docs/INTERFACES.md +++ b/docs/INTERFACES.md @@ -1,5 +1,299 @@ # Interfaces -> **STATUS: DRAFT — TO BE UPDATED BY CLAUDE AFTER FRAMEWORK IMPLEMENTATION** +> **STATUS: ACTIVE — first interface freeze (M1)** +> **Owner:** Claude · **Frozen on:** `claude/core-framework` -No gameplay interfaces are frozen. Claude will record released contracts, schemas, invariants, and extension examples here. Codex module work must wait for the relevant interface freeze. +Everything in section 1 is **frozen**. Codex may call it, extend it at the +documented points, and read it — but not change a signature, a field name or an +ordering guarantee without opening a proposal in `docs/DECISIONS.md` and waiting +for Claude review, per `AGENTS.md`. + +Section 3 lists the files Codex must not modify at all. Section 4 lists the +extension points that are open for work right now. + +--- + +## 1. Frozen contracts + +### 1.1 `RunState` — `core/run_state.gd` + +Authoritative run-level player state. + +```gdscript +static func create(balance: BalanceConfig, run_seed: int) -> RunState + +# reads +func max_hp() -> float func current_hp() -> float +func max_stamina() -> float func current_stamina() -> float +func armour() -> float func stamina_recovery() -> float +func attack() -> float func additive_sum() -> float +func more_product() -> float func crit_rate() -> float +func crit_damage() -> float func attack_speed() -> float +func structural_locks() -> Array[StringName] +func action_taxes() -> Array[StringName] +func integrity() -> float func imbalance() -> float +func effective_hp() -> float func dps_estimate() -> float +func sacrifice_history() -> Array[StringName] +func build_tags() -> Dictionary func tag_count(tag) -> int +func run_seed() -> int func run_status() -> StringName +func hp_ratio() -> float func stamina_ratio() -> float +func is_alive() -> bool func is_guttering() -> bool +func structural_snapshot() -> Dictionary func offence_snapshot() -> Dictionary + +# momentary commands +func apply_damage(amount: float) -> float func heal(amount: float) -> float +func set_current_hp(value: float) -> void +func spend_stamina(amount: float) -> void +func regenerate_stamina(amount: float) -> void +func set_run_status(status: StringName) -> void + +# structural commands (each ends in recalculate_derived) +func scale_max_hp(factor: float) -> void +func scale_max_stamina(factor: float) -> void +func set_armour(value: float) -> void +func set_stamina_recovery(value: float) -> void +func add_additive(value: float) -> void func multiply_more(factor: float) -> void +func add_crit_rate(value: float) -> void func add_crit_damage(value: float) -> void +func multiply_attack_speed(factor: float) -> void +func add_structural_lock(lock_id: StringName) -> void +func add_action_tax(tax_id: StringName) -> void +func add_imbalance(value: float) -> void +func record_sacrifice(id: StringName, tags: Array[StringName]) -> void +func recalculate_derived() -> void + +# snapshots +func snapshot() -> Dictionary func restore(snap: Dictionary) -> void +func clone() -> RunState func snapshot_hash() -> String +``` + +**Invariants.** + +- Fields are private. There is no path to a stat that is not a command above. +- Current HP and current stamina never affect integrity. +- Healing never restores structure. +- Structural stats are clamped to the `BalanceConfig` floors. +- Derived values are produced only by `recalculate_derived()`. +- `clone()` shares the `BalanceConfig` and nothing else. + +**UI rule.** UI code may call reads only. Mutations go through +`RunCoordinator`. + +### 1.2 Damage pipeline — `core/damage_context.gd`, `core/damage_result.gd`, `core/combat_resolver.gd` + +```gdscript +CombatResolver.new(balance: BalanceConfig, roll_provider := Callable()) +func resolve(ctx: DamageContext) -> DamageResult +func player_attack_context(state, target_id, target_armour, target_current_hp, + skill_multiplier) -> DamageContext +``` + +`DamageContext` inputs: `source_id`, `target_id`, `base_damage`, +`skill_multiplier`, `additive_modifiers`, `more_modifiers`, +`conditional_modifiers`, `crit_allowed`, `crit_rate`, `crit_damage`, +`crit_roll`, `force_crit`, `target_armour`, `armour_penetration`, +`vulnerability`, `target_current_hp`, `tags`, `metadata`. + +`DamageResult` outputs: `raw_damage`, `final_damage`, `is_critical`, +`mitigation`, `is_lethal`, `breakdown`, plus `to_dictionary()`. + +**Ordering guarantee (frozen).** base → additive → more → conditional → crit → +armour/vulnerability. A new effect joins an existing bucket; it does not add a +stage. + +**Rule.** There is one damage path. Player, Enemy, Boss, HUD and the sacrifice +preview all call `resolve`. Re-deriving damage anywhere else is a defect. + +### 1.3 `IntegrityService` — `systems/integrity_service.gd` + +```gdscript +static func compute(structural: Dictionary, balance: BalanceConfig) -> float +static func components_of(structural: Dictionary, balance: BalanceConfig) -> Dictionary +``` + +`structural` comes from `RunState.structural_snapshot()`. `components_of` +returns `{h, s, a, r, f}`, each in `[0, 1]`. + +### 1.4 `SacrificeDefinition` — `systems/sacrifice_definition.gd` + +Resource schema; content lives in `res://data/sacrifices/*.tres`. + +| Group | Fields | +| --- | --- | +| Identity | `id`, `display_name`, `strength` (1–5), `rarity` | +| Classification | `tags`, `roles`, `exclusive_group` | +| Generation | `base_weight`, `prerequisite_min_sacrifices`, `banned_with` | +| Costs | `cost_max_hp_multiplier`, `cost_max_stamina_multiplier`, `cost_armour_multiplier`, `cost_stamina_recovery_multiplier`, `cost_structural_locks`, `cost_action_taxes`, `imbalance_flat` | +| Rewards | `reward_additive`, `reward_generic_more`, `reward_crit_rate`, `reward_crit_damage`, `reward_attack_speed_multiplier` | +| Triggers | `triggers` (declarative; nothing consumes them yet) | +| Display | `short_text`, `preview_template`, `warning_level` | + +`roles` must contain one or more of `CONTINUATION`, `RISK_ESCALATION`, +`PIVOT_STABILIZE`. `validate() -> Array[String]` returns data problems; the test +suite fails on any non-empty result for a shipped card. + +**Adding cards (X06) requires no code change.** Adding a *new kind of cost or +reward* does, and that is an interface change. + +### 1.5 `SacrificeService` — `systems/sacrifice_service.gd` + +```gdscript +SacrificeService.new(balance: BalanceConfig) +func can_offer(state: RunState, definition) -> Dictionary # {ok: bool, reason: String} +func preview(state: RunState, definition) -> SacrificeResult # runs on a clone +func apply(state: RunState, definition) -> SacrificeResult # runs on the real state +``` + +`SacrificeResult`: `ok`, `definition_id`, `failure_reason`, `before`, `after`, +`deltas`, `gain`, `cost_score`, `imbalance_delta`, `warnings`, plus +`matches(other)` and `to_dictionary()`. + +**Frozen guarantees.** + +- `preview` and `apply` execute the identical transaction function. +- `preview` leaves the passed state byte-identical. +- Transaction order is validate → snapshot → cost → integrity → reward → + derived → imbalance → publish → return. +- Any failure restores the pre-transaction snapshot whole. +- A sacrifice may never raise integrity. + +### 1.6 Damageable shape — `Player` and `EnemyBase` + +```gdscript +func actor_id() -> StringName +func armour() -> float +func current_hp() -> float +func receive_damage(context: DamageContext) -> DamageResult +``` + +Any new damageable actor implements all four. An attacker builds a +`DamageContext` and calls `receive_damage`; it never applies damage itself. + +### 1.7 `EnemyBase` — `actors/enemies/enemy_base.gd` + +```gdscript +func initialize(enemy_config: EnemyConfig, target: Node2D) -> void +func acquire_target(target: Node2D) -> void +func change_state(next: int) -> void # enum State +func receive_damage(context: DamageContext) -> DamageResult +func die(source_id: StringName = &"unknown") -> void +signal died(enemy: EnemyBase) +enum State { IDLE, CHASE, WINDUP, ATTACK, RECOVER, HURT, DEAD } +``` + +**Frozen guarantees.** + +- `die` is idempotent: one death event however many lethal hits land. +- A dead enemy takes no further damage and runs no behaviour. +- Every transition goes through `change_state`. +- Enemies never touch RunState structure or the sacrifice system. + +`EnemyConfig` (`actors/enemies/enemy_config.gd`) is the stat and timing schema; +instances live in `res://data/actors/`. + +### 1.8 `EventBus` — `core/event_bus.gd` + +Signals: `run_started`, `run_restarted`, `hit_dealt`, `hit_taken`, +`enemy_died`, `player_died`, `sacrifice_previewed`, `sacrifice_applied`, +`boss_started`, `boss_died`, `run_completed`. Each carries a `Dictionary` built +by `EventBus.context(extra)` containing `timestamp`, `run_id`, `stage_id` plus +the emitter's own fields. + +**Rule.** Observation and telemetry only. Never a calculation. + +### 1.9 `RNGService` — `core/rng_service.gd` + +```gdscript +func configure(new_seed: int) -> void +func run_seed() -> int +func stream(stream_name: StringName) -> RandomNumberGenerator +func randf(stream_name: StringName) -> float +func randi_range(stream_name: StringName, from: int, to: int) -> int +const STREAM_SACRIFICE, STREAM_COMBAT, STREAM_SPAWN +``` + +**Rule.** No gameplay code calls `randi()`/`randf()` directly. Add a new stream +constant rather than reusing an unrelated one. + +### 1.10 `RunCoordinator` — `systems/run_coordinator.gd` + +```gdscript +signal phase_changed(phase: StringName) signal state_ready(state: RunState) +signal sacrifice_offered(definition) signal boss_spawned(boss: BossActor) +signal run_finished(outcome: StringName) + +func start_run(run_seed: int, restarted := false) -> void +func restart_run(reuse_seed := true) -> void +func confirm_sacrifice(definition) -> SacrificeResult +func begin_boss() -> void +func state() -> RunState func player() -> Player +func boss() -> BossActor func phase() -> StringName +func live_enemies() -> Array[EnemyBase] func offered_sacrifice() -> SacrificeDefinition +func spawn_enemy_at(position: Vector2) -> EnemyBase + +# debug commands — the only route from a debug UI to the run +func debug_heal(amount: float) -> void func debug_damage(amount: float) -> void +func debug_spawn_reference_enemy() -> void +func debug_preview_sacrifice() -> SacrificeResult +func debug_apply_sacrifice() -> SacrificeResult +``` + +Phases: `boot`, `wave`, `sacrifice`, `boss`, `result`. + +### 1.11 `Hitbox` / `Hurtbox` — `core/hitbox.gd`, `core/hurtbox.gd` + +`Hitbox.set_active(bool)` opens and closes the damage window and emits +`hit_actor(actor)` at most once per actor per window. `Hurtbox.actor()` returns +the damageable that owns it. Neither knows anything about damage numbers. + +Physics layers: `world`(1) `player_body`(2) `enemy_body`(3) `player_hurtbox`(4) +`enemy_hurtbox`(5) `player_hitbox`(6) `enemy_hitbox`(7). + +--- + +## 2. Data contracts + +`BalanceConfig` (`core/balance_config.gd`, instance +`res://data/balance_config.tres`) holds every tunable number. Adding a field is +additive and safe. **Hardcoding a balance value in a script is a review +failure.** + +--- + +## 3. Files Codex must not modify + +Without a `docs/DECISIONS.md` proposal and Claude review: + +``` +core/run_state.gd core/combat_resolver.gd +core/damage_context.gd core/damage_result.gd +core/soft_caps.gd core/derived_stats.gd +core/event_bus.gd core/rng_service.gd +core/game_data.gd core/hitbox.gd core/hurtbox.gd +systems/integrity_service.gd systems/sacrifice_service.gd +systems/sacrifice_definition.gd systems/sacrifice_result.gd +systems/run_coordinator.gd +actors/enemies/enemy_base.gd actors/enemies/enemy_base.tscn +actors/enemies/enemy_config.gd +actors/player/player.gd actors/player/player_state_machine.gd +actors/player/states/player_state.gd +scenes/main.gd project.godot +.github/workflows/repository-validation.yml +``` + +Freely editable: `data/**`, `ui/**`, `tests/**`, concrete enemy scenes such as +`actors/enemies/ghost_*.tscn`, attack content in `actors/boss/boss_actor.gd`, +**new** files under `actors/player/states/`, and `tools/**`. + +--- + +## 4. Open extension points + +| Point | Where | What to do | +| --- | --- | --- | +| New enemy archetype | `actors/enemies/` + `data/actors/` | New scene inheriting `enemy_base.tscn`, new `EnemyConfig` .tres, add behaviour states — do not edit `EnemyBase` | +| Boss moves | `actors/boss/boss_actor.gd` | Add an attack scheduler over config-driven definitions; do not add a second damage path | +| Sacrifice cards | `data/sacrifices/*.tres` | Data only; `validate()` must pass | +| Three-slot generator | new file in `systems/` | Use `RNGService.STREAM_SACRIFICE`; call `can_offer`/`preview`; never re-implement scoring inside the UI | +| Player states | `actors/player/states/` | Add a file, register the id in `PlayerStateMachine._init`, give an existing state a reason to return it | +| Stamina consumers | `Player.spend_stamina` | Already routed through `RunState`; a StaminaService can wrap it without changing callers | +| Telemetry sink | subscribe to `EventBus` | Payloads already carry the correlation envelope | diff --git a/docs/TASKS/README.md b/docs/TASKS/README.md index acf0131..48640d5 100644 --- a/docs/TASKS/README.md +++ b/docs/TASKS/README.md @@ -1,22 +1,57 @@ # Task Register -No future task is started. Update status and PR only when work actually begins. +Update status and PR only when work actually begins. + +## Milestone M1 — playable vertical slice foundation + +All Claude tasks are delivered on `claude/core-framework` in one PR to +`develop`. The interfaces they release are frozen in `docs/INTERFACES.md`. + +| ID | Task | Owner | Status | Branch | Dependency | PR | +| --- | --- | --- | --- | --- | --- | --- | +| C01 | Repository and document review, Godot project skeleton | Claude | Done | `claude/core-framework` | Repository setup | M1 PR | +| C02 | RunState, EventBus, RNGService, BalanceConfig | Claude | Done | `claude/core-framework` | C01 | M1 PR | +| C03 | CombatResolver and IntegrityService | Claude | Done | `claude/core-framework` | C02 | M1 PR | +| C04 | Sacrifice transaction and example card | Claude | Done | `claude/core-framework` | C03 | M1 PR | +| C05 | Player controller, state machine, art integration, arena | Claude | Done | `claude/core-framework` | C03 | M1 PR | +| C06 | EnemyBase, reference enemy, Boss foundation | Claude | Done | `claude/core-framework` | C04 | M1 PR | +| C07 | Run flow, HUD, sacrifice panel, result screen, debug tools | Claude | Done | `claude/core-framework` | C05, C06 | M1 PR | +| C08 | Test harness, test suite, `godot-tests` workflow | Claude | Done | `claude/core-framework` | C07 | M1 PR | +| C09 | Architecture, interfaces, decisions, testing and handoff docs | Claude | Done | `claude/core-framework` | C08 | M1 PR | + +## Codex module tasks + +Unblocked once the M1 PR merges into `develop`. Each takes one branch and one PR, +stays inside `docs/INTERFACES.md` section 4, and must not touch the protected +files in section 3. | ID | Task | Owner | Status | Branch | Dependency | PR | | --- | --- | --- | --- | --- | --- | --- | -| C01 | Repository and document review | Claude | Waiting | `claude/core-framework` | Repository setup | — | -| C02 | Godot project skeleton | Claude | Waiting | TBD | C01 | — | -| C03 | RunState and core models | Claude | Blocked | TBD | C02 | — | -| C04 | CombatResolver | Claude | Blocked | TBD | C03 | — | -| C05 | Sacrifice framework | Claude | Blocked | TBD | C03 | — | -| C06 | EnemyBase reference | Claude | Blocked | TBD | C04 | — | -| C07 | Boss framework | Claude | Blocked | TBD | C06 | — | -| C08 | Tests and CI | Claude | Blocked | TBD | Core framework | — | -| X01 | Melee ghost | Codex | Waiting for interface freeze | `codex/x01-melee-ghost` | C06 | — | -| X02 | Ranged ghost | Codex | Waiting for interface freeze | `codex/x02-ranged-ghost` | C06 | — | -| X03 | Charger ghost | Codex | Waiting for interface freeze | `codex/x03-charger-ghost` | C06 | — | -| X04 | Placeholder Boss attack pack | Codex | Waiting for interface freeze | `codex/x04-boss-attacks` | C07 | — | -| X05 | Sacrifice UI | Codex | Waiting for interface freeze | `codex/x05-sacrifice-ui` | C05 | — | -| X06 | Twelve sacrifice definitions | Codex | Waiting for interface freeze | `codex/x06-sacrifice-data` | C05 | — | -| X07 | Debug and telemetry panel | Codex | Waiting for interface freeze | `codex/x07-debug-panel` | Core framework | — | -| X08 | Automated test expansion | Codex | Waiting for interface freeze | `codex/x08-test-expansion` | C08 | — | +| X01 | Melee ghost — full behaviour | Codex | Ready | `codex/x01-melee-ghost` | `EnemyBase` frozen | — | +| X02 | Ranged ghost — projectile, spacing, reposition | Codex | Ready | `codex/x02-ranged-ghost` | `EnemyBase` frozen | — | +| X03 | Charger ghost — telegraphed line charge, wall stagger | Codex | Ready | `codex/x03-charger-ghost` | `EnemyBase` frozen | — | +| X04 | Boss attack pack — three moves | Codex | Blocked on art | `codex/x04-boss-attacks` | ADR-010 | — | +| X05 | Sacrifice selection UI — A/B/C slots | Codex | Ready | `codex/x05-sacrifice-ui` | `SacrificeService` frozen | — | +| X06 | Twelve sacrifice definitions | Codex | Ready | `codex/x06-sacrifice-data` | `SacrificeDefinition` frozen | — | +| X07 | Debug and telemetry panel — timeline, JSON/CSV export | Codex | Ready | `codex/x07-debug-panel` | `EventBus` frozen | — | +| X08 | Automated test expansion | Codex | Partly blocked | `codex/x08-test-expansion` | Stamina and same-death systems | — | + +**X04 is blocked** because `assets/boss/` ships one attack sheet. It needs new +art from WorkBuddy, or a Game Director ruling that reusing the run cycle for a +charge is acceptable. + +**X08 is partly blocked**: the combat, integrity, sacrifice, actor, RNG and +run-loop cases exist. The stamina/dodge and same-death cases the Development +Pack asks for cannot be written until those systems do. + +## Framework work still owned by Claude + +Not yet scheduled; each unblocks further Codex tasks. + +| Task | Unblocks | +| --- | --- | +| Three-slot sacrifice generator (A/B/C roles, weighting, pity) | X05 at full scope | +| `StaminaService` — costs, recovery delay, exhaustion lock, consecutive dodge | Dodge, heavy attack, X08 | +| `SameDeathController` — lethal-damage priority chain and the 0.65 s window | X08 | +| World director — imbalance and build-threat driving enemy pressure | Elite affixes, tag counters | +| Boss `PhaseController` and `AttackScheduler` | X04 at full scope | diff --git a/docs/TESTING.md b/docs/TESTING.md index 655a56b..7f4a03d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,7 +1,115 @@ # Testing -> **STATUS: DRAFT — TO BE UPDATED BY CLAUDE AFTER FRAMEWORK IMPLEMENTATION** +> **STATUS: ACTIVE — M1 harness in place** -Every task must add or update proportionate automated tests, run existing tests, record commands and manual checks in its PR, and report the last successful result in `docs/AI_HANDOFF.md`. +Every task must add or update proportionate automated tests, run existing tests, +record commands and manual checks in its PR, and report the last successful +result in `docs/AI_HANDOFF.md`. -At present, CI runs only `repository-validation`; it checks repository structure and hygiene, not gameplay. Claude will define the Godot test harness and later add a separately named `godot-tests` workflow after it can run successfully. +--- + +## Running the suite locally + +Requires Godot **4.3 stable** on `PATH`. + +```bash +godot --headless --import # generates .godot/ and the class registry +godot --headless --path . res://tests/test_runner.tscn +``` + +The runner exits `0` when everything passes and `1` on the first failure it +records, so it drops straight into CI or a pre-push hook. The import step is not +optional on a fresh clone: it builds the global class-name registry the test +scripts resolve `RunState`, `TestCase` and friends against. + +Run it as a **scene**, not with `--script`. A MainLoop script is compiled before +the autoload singletons are registered, so any class naming `EventBus` or +`RNGService` fails to compile and every service silently becomes `null`. See +ADR-006. + +## Continuous integration + +Two workflows, both on pull requests into `develop` and `main`: + +| Workflow | Checks | +| --- | --- | +| `repository-validation` | Required files present and non-empty, no committed secrets, no oversized files, YAML/JSON parse. Pre-existing; unchanged. | +| `godot-tests` | Downloads and caches Godot 4.3, imports the project, fails on any import error, then runs the suite. | + +Both check names should be required in the branch ruleset for `develop`. + +## Layout + +``` +tests/ +├── test_runner.tscn / test_runner.gd entry point and discovery +├── framework/test_case.gd assertions, physics stepping, config helper +└── cases/ + ├── test_actors.gd EnemyBase and BossActor + ├── test_combat_resolver.gd damage pipeline + ├── test_integrity.gd structural integrity and snapshots + ├── test_rng.gd determinism and stream isolation + ├── test_run_loop.gd end-to-end run plus scene smoke checks + └── test_sacrifice.gd preview/apply transaction +``` + +Discovery is by convention: every `tests/cases/*.gd` extending `TestCase`, and +every method named `test_*`, on a fresh instance per method. Methods may `await`; +`step_physics(n)` advances real physics frames. + +`make_balance()` returns a detached copy of `data/balance_config.tres` so a test +can retune a value without leaking it into the next test. + +## What is covered + +**Combat** — bucket order, armour as an equivalent-life model, penetration, +forced and pinned criticals, the crit-rate hard cap and the crit-damage and +more-product soft caps, lethality boundaries, and a check that the reported +breakdown multiplies back to the final damage. + +**Integrity** — that current HP and current stamina do not move integrity while +max HP does (the arbitrage bug the research report was written to fix), the +intact-body ceiling, that a buff cannot offset a structural loss, structural +floors, exact snapshot round-tripping, and clone detachment. + +**Sacrifice** — preview leaves the real state byte-identical; apply matches the +preview field by field; a rejected transaction rolls back whole; the shipped +card's numbers come from its `.tres`; the reward is priced against post-cost +integrity; a more broken body buys a bigger reward; duplicate, prerequisite and +lock-budget gating; and every shipped card passes `validate()`. + +**Enemy and Boss** — the Damageable shape, damage through the resolver, death +firing exactly once across four routes (two lethal hits, a hit after death, and +two direct `die()` calls), a corpse taking no damage and running no behaviour, +target acquisition, and that `BossActor` extends `EnemyBase`. + +**RNG** — same seed replays, different seeds diverge, draining one stream does +not shift another, stream names change the derived seed, and integer draws stay +in range and replay. + +**Run loop** — the real `main.tscn` driven through wave spawn, a genuine hitbox +connection from simulated input, wave clear, sacrifice offer and confirm, boss +spawn, victory, restart, and player death — plus every debug command and a smoke +check that all ten critical scenes load and instantiate. + +## What is not covered + +Honest gaps, so nobody reads a green suite as more than it is: + +- No visual regression. The pixel-art rendering settings were verified by + capturing frames under a virtual display during development; nothing asserts + them automatically. +- No performance or frame-budget assertions. +- No input-remapping or controller coverage. +- No test for the enemy attack landing on the player through physics; the + player-side path is covered, and the enemy side uses the same `Hitbox`. +- Timing-sensitive assertions use physics-frame counts, not wall clock, so they + are deterministic but coarse. + +## Adding a test + +1. Add `tests/cases/test_.gd` extending `TestCase`. +2. Name methods `test_*`; use `before_each` / `after_each` for fixtures. +3. Assert on behaviour and invariants, not on implementation details. +4. If a test needs elaborate scaffolding to reach the thing it checks, treat + that as a signal about the design before reaching for a bigger hammer. diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..2dc9f50 --- /dev/null +++ b/project.godot @@ -0,0 +1,154 @@ +; Nine Nether — Godot 4 project configuration. +; +; Rendering and display values are documented in docs/ARCHITECTURE.md +; ("Pixel art rendering configuration") and ADR-002 in docs/DECISIONS.md. +; Balance values do NOT belong here: see res://data/balance_config.tres. + +config_version=5 + +[application] + +config/name="Nine Nether" +config/description="Godot 4 greybox prototype: sacrifice structural survival for offensive power." +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.3", "GL Compatibility") + +[autoload] + +EventBus="*res://core/event_bus.gd" +RNGService="*res://core/rng_service.gd" +GameData="*res://core/game_data.gd" + +[display] + +window/size/viewport_width=640 +window/size/viewport_height=360 +window/size/window_width_override=1280 +window/size/window_height_override=720 +window/stretch/mode="viewport" +window/stretch/aspect="keep" +window/stretch/scale_mode="integer" + +[importer_defaults] + +texture={ +"compress/channel_pack": 0, +"compress/hdr_compression": 1, +"compress/high_quality": false, +"compress/lossy_quality": 0.7, +"compress/mode": 0, +"compress/normal_map": 0, +"detect_3d/compress_to": 0, +"editor/convert_colors_with_editor_theme": false, +"editor/scale_with_editor_scale": false, +"mipmaps/generate": false, +"mipmaps/limit": -1, +"process/fix_alpha_border": true, +"process/hdr_as_srgb": false, +"process/hdr_clamp_exposure": false, +"process/normal_map_invert_y": false, +"process/premult_alpha": false, +"process/size_limit": 0, +"roughness/mode": 0, +"roughness/src_normal": "" +} + +[input] + +move_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +jump={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +light_attack={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +confirm={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +restart_run={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_toggle_panel={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_heal_player={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194333,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_damage_player={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_spawn_enemy={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_start_boss={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_preview_sacrifice={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_apply_sacrifice={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194338,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_toggle_hitboxes={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194339,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} + +[layer_names] + +2d_physics/layer_1="world" +2d_physics/layer_2="player_body" +2d_physics/layer_3="enemy_body" +2d_physics/layer_4="player_hurtbox" +2d_physics/layer_5="enemy_hurtbox" +2d_physics/layer_6="player_hitbox" +2d_physics/layer_7="enemy_hitbox" + +[physics] + +common/physics_ticks_per_second=60 + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +textures/canvas_textures/default_texture_filter=0 +2d/snap/snap_2d_transforms_to_pixel=true +2d/snap/snap_2d_vertices_to_pixel=true +anti_aliasing/quality/msaa_2d=0 diff --git a/scenes/arena.gd b/scenes/arena.gd new file mode 100644 index 0000000..14b049c --- /dev/null +++ b/scenes/arena.gd @@ -0,0 +1,27 @@ +class_name Arena +extends Node2D +## One fixed combat arena. No procedural generation in the prototype — the +## point of a fixed space is that two runs with the same seed are comparable. +## +## The arena owns geometry and markers only. It does not spawn anything and +## does not know what a wave is; RunCoordinator asks it where things go. + +## World-space bounds the player camera is clamped to. +@export var camera_bounds: Rect2 = Rect2(0.0, 0.0, 1024.0, 360.0) + +@onready var player_spawn: Marker2D = $Spawns/PlayerSpawn +@onready var boss_spawn: Marker2D = $Spawns/BossSpawn +@onready var _enemy_spawn_root: Node2D = $Spawns/EnemySpawns + +func enemy_spawn_points() -> Array[Marker2D]: + var points: Array[Marker2D] = [] + for child in _enemy_spawn_root.get_children(): + if child is Marker2D: + points.append(child) + return points + +func apply_camera_bounds(camera: Camera2D) -> void: + camera.limit_left = int(camera_bounds.position.x) + camera.limit_top = int(camera_bounds.position.y) + camera.limit_right = int(camera_bounds.position.x + camera_bounds.size.x) + camera.limit_bottom = int(camera_bounds.position.y + camera_bounds.size.y) diff --git a/scenes/arena.tscn b/scenes/arena.tscn new file mode 100644 index 0000000..b2a2975 --- /dev/null +++ b/scenes/arena.tscn @@ -0,0 +1,174 @@ +[gd_scene load_steps=14 format=3 uid="uid://bnnarena00001"] + +[ext_resource type="Script" path="res://scenes/arena.gd" id="1_arena"] +[ext_resource type="Texture2D" path="res://assets/background/bg_deep.png" id="2_bg_deep"] +[ext_resource type="Texture2D" path="res://assets/background/bg_tree.png" id="3_bg_tree"] +[ext_resource type="Texture2D" path="res://assets/background/bg_broken_flag.png" id="4_bg_flag"] +[ext_resource type="Texture2D" path="res://assets/background/bg_chains.png" id="5_bg_chains"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_floor.png" id="6_floor"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_stone_brick.png" id="7_brick"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_brazier.png" id="8_brazier"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_tombstone.png" id="9_tomb"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_floor"] +size = Vector2(1088, 56) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_wall"] +size = Vector2(32, 400) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_wall2"] +size = Vector2(32, 400) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_ceiling"] +size = Vector2(1088, 32) + +[node name="Arena" type="Node2D"] +script = ExtResource("1_arena") + +[node name="Void" type="CanvasLayer" parent="."] +layer = -3 + +[node name="Fill" type="ColorRect" parent="Void"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.058, 0.047, 0.07, 1) + +[node name="Parallax" type="ParallaxBackground" parent="."] +layer = -2 + +[node name="DeepLayer" type="ParallaxLayer" parent="Parallax"] +motion_scale = Vector2(0.2, 1) + +[node name="Deep" type="Sprite2D" parent="Parallax/DeepLayer"] +texture_repeat = 2 +position = Vector2(0, 124) +centered = false +texture = ExtResource("2_bg_deep") +region_enabled = true +region_rect = Rect2(0, 0, 1920, 180) + +[node name="PropLayer" type="ParallaxLayer" parent="Parallax"] +motion_scale = Vector2(0.5, 1) + +[node name="Tree" type="Sprite2D" parent="Parallax/PropLayer"] +position = Vector2(150, 176) +centered = false +texture = ExtResource("3_bg_tree") + +[node name="Flag" type="Sprite2D" parent="Parallax/PropLayer"] +position = Vector2(470, 208) +centered = false +texture = ExtResource("4_bg_flag") + +[node name="Tree2" type="Sprite2D" parent="Parallax/PropLayer"] +position = Vector2(760, 176) +centered = false +texture = ExtResource("3_bg_tree") + +[node name="ChainLayer" type="ParallaxLayer" parent="Parallax"] +motion_scale = Vector2(0.7, 1) + +[node name="Chains" type="Sprite2D" parent="Parallax/ChainLayer"] +position = Vector2(300, 0) +centered = false +texture = ExtResource("5_bg_chains") + +[node name="Chains2" type="Sprite2D" parent="Parallax/ChainLayer"] +position = Vector2(620, 0) +centered = false +texture = ExtResource("5_bg_chains") + +[node name="Terrain" type="Node2D" parent="."] + +[node name="Floor" type="StaticBody2D" parent="Terrain"] +collision_layer = 1 +collision_mask = 0 + +[node name="Shape" type="CollisionShape2D" parent="Terrain/Floor"] +position = Vector2(512, 332) +shape = SubResource("RectangleShape2D_floor") + +[node name="Surface" type="Sprite2D" parent="Terrain/Floor"] +texture_repeat = 2 +position = Vector2(-32, 304) +centered = false +texture = ExtResource("6_floor") +region_enabled = true +region_rect = Rect2(0, 0, 1088, 56) + +[node name="LeftWall" type="StaticBody2D" parent="Terrain"] +collision_layer = 1 +collision_mask = 0 + +[node name="Shape" type="CollisionShape2D" parent="Terrain/LeftWall"] +position = Vector2(16, 160) +shape = SubResource("RectangleShape2D_wall") + +[node name="Surface" type="Sprite2D" parent="Terrain/LeftWall"] +texture_repeat = 2 +position = Vector2(0, 112) +centered = false +texture = ExtResource("7_brick") +region_enabled = true +region_rect = Rect2(0, 0, 32, 192) + +[node name="RightWall" type="StaticBody2D" parent="Terrain"] +collision_layer = 1 +collision_mask = 0 + +[node name="Shape" type="CollisionShape2D" parent="Terrain/RightWall"] +position = Vector2(1008, 160) +shape = SubResource("RectangleShape2D_wall2") + +[node name="Surface" type="Sprite2D" parent="Terrain/RightWall"] +texture_repeat = 2 +position = Vector2(992, 112) +centered = false +texture = ExtResource("7_brick") +region_enabled = true +region_rect = Rect2(0, 0, 32, 192) + +[node name="Ceiling" type="StaticBody2D" parent="Terrain"] +collision_layer = 1 +collision_mask = 0 + +[node name="Shape" type="CollisionShape2D" parent="Terrain/Ceiling"] +position = Vector2(512, -32) +shape = SubResource("RectangleShape2D_ceiling") + +[node name="Props" type="Node2D" parent="."] + +[node name="BrazierLeft" type="Sprite2D" parent="Props"] +position = Vector2(224, 272) +centered = false +texture = ExtResource("8_brazier") + +[node name="BrazierRight" type="Sprite2D" parent="Props"] +position = Vector2(768, 272) +centered = false +texture = ExtResource("8_brazier") + +[node name="Tombstone" type="Sprite2D" parent="Props"] +position = Vector2(512, 272) +centered = false +texture = ExtResource("9_tomb") + +[node name="Spawns" type="Node2D" parent="."] + +[node name="PlayerSpawn" type="Marker2D" parent="Spawns"] +position = Vector2(96, 304) + +[node name="BossSpawn" type="Marker2D" parent="Spawns"] +position = Vector2(880, 304) + +[node name="EnemySpawns" type="Node2D" parent="Spawns"] + +[node name="A" type="Marker2D" parent="Spawns/EnemySpawns"] +position = Vector2(420, 304) + +[node name="B" type="Marker2D" parent="Spawns/EnemySpawns"] +position = Vector2(640, 304) + +[node name="C" type="Marker2D" parent="Spawns/EnemySpawns"] +position = Vector2(840, 304) diff --git a/scenes/main.gd b/scenes/main.gd new file mode 100644 index 0000000..da51e05 --- /dev/null +++ b/scenes/main.gd @@ -0,0 +1,24 @@ +class_name Main +extends Node2D +## Composition root. Builds the object graph and starts the run. +## +## Wiring lives here rather than inside each node so that every dependency is +## visible in one place, and so no observer can miss the opening `state_ready` +## by binding after the coordinator has already started. + +@onready var _coordinator: RunCoordinator = $RunCoordinator +@onready var _hud: Hud = $UIRoot/Hud +@onready var _sacrifice_panel: SacrificePanel = $UIRoot/SacrificePanel +@onready var _result_screen: ResultScreen = $UIRoot/ResultScreen +@onready var _debug_panel: DebugPanel = $DebugRoot/DebugPanel +@onready var _debug_shapes: DebugShapeOverlay = $DebugRoot/DebugShapes + +func _ready() -> void: + _hud.bind(_coordinator) + _sacrifice_panel.bind(_coordinator) + _result_screen.bind(_coordinator) + _debug_panel.bind(_coordinator, _debug_shapes) + _coordinator.start_run(RNGService.run_seed()) + +func coordinator() -> RunCoordinator: + return _coordinator diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..29930a8 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,47 @@ +[gd_scene load_steps=13 format=3 uid="uid://bnnmain0000001"] + +[ext_resource type="Script" path="res://scenes/main.gd" id="1_main"] +[ext_resource type="PackedScene" path="res://scenes/arena.tscn" id="2_arena"] +[ext_resource type="Script" path="res://systems/run_coordinator.gd" id="3_coordinator"] +[ext_resource type="PackedScene" path="res://actors/player/player.tscn" id="4_player"] +[ext_resource type="PackedScene" path="res://actors/enemies/ghost_melee.tscn" id="5_enemy"] +[ext_resource type="PackedScene" path="res://actors/boss/boss.tscn" id="6_boss"] +[ext_resource type="Resource" path="res://data/actors/ghost_melee.tres" id="7_enemy_config"] +[ext_resource type="Resource" path="res://data/actors/boss_gate_guardian.tres" id="8_boss_config"] +[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="9_hud"] +[ext_resource type="PackedScene" path="res://ui/sacrifice_panel.tscn" id="10_sacrifice"] +[ext_resource type="PackedScene" path="res://ui/result_screen.tscn" id="11_result"] +[ext_resource type="PackedScene" path="res://ui/debug_panel.tscn" id="12_debug"] +[ext_resource type="Script" path="res://ui/debug_shape_overlay.gd" id="13_shapes"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Arena" parent="." instance=ExtResource("2_arena")] + +[node name="Actors" type="Node2D" parent="."] + +[node name="RunCoordinator" type="Node" parent="."] +script = ExtResource("3_coordinator") +player_scene = ExtResource("4_player") +enemy_scene = ExtResource("5_enemy") +boss_scene = ExtResource("6_boss") +enemy_config = ExtResource("7_enemy_config") +boss_config = ExtResource("8_boss_config") +arena_path = NodePath("../Arena") +actor_root_path = NodePath("../Actors") + +[node name="UIRoot" type="Node" parent="."] + +[node name="Hud" parent="UIRoot" instance=ExtResource("9_hud")] + +[node name="SacrificePanel" parent="UIRoot" instance=ExtResource("10_sacrifice")] + +[node name="ResultScreen" parent="UIRoot" instance=ExtResource("11_result")] + +[node name="DebugRoot" type="Node" parent="."] + +[node name="DebugPanel" parent="DebugRoot" instance=ExtResource("12_debug")] + +[node name="DebugShapes" type="Node2D" parent="DebugRoot"] +script = ExtResource("13_shapes") diff --git a/systems/integrity_service.gd b/systems/integrity_service.gd new file mode 100644 index 0000000..c82e766 --- /dev/null +++ b/systems/integrity_service.gd @@ -0,0 +1,49 @@ +class_name IntegrityService +extends RefCounted +## Structural integrity I — research report section 5.4. +## +## I = clamp(0.34h + 0.24s + 0.16a + 0.14r + 0.12f, 0, 1) +## +## The service reads *only* structural values. Current HP and current stamina +## are deliberately absent: the original design mixed them in, which produced a +## "take damage → sacrifices get better → heal → sacrifice again" arbitrage +## loop. Integrity answers "how much of my original body is gone", never "how +## hurt am I right now". +## +## Each component is capped at 1, so ordinary buffs cannot push integrity above +## the intact-body ceiling and cannot launder away a sacrifice. + +## `structural` comes from RunState.structural_snapshot(). +static func compute(structural: Dictionary, balance: BalanceConfig) -> float: + var components := components_of(structural, balance) + var total: float = ( + balance.integrity_weight_hp * components["h"] + + balance.integrity_weight_stamina * components["s"] + + balance.integrity_weight_armour * components["a"] + + balance.integrity_weight_recovery * components["r"] + + balance.integrity_weight_freedom * components["f"] + ) + return clampf(total, 0.0, 1.0) + +## Individual components, exposed so the sacrifice cost score and the debug +## panel can show which part of the body was spent. +static func components_of(structural: Dictionary, balance: BalanceConfig) -> Dictionary: + var armour_now: float = 1.0 + float(structural["armour"]) / 100.0 + var armour_base: float = 1.0 + balance.base_armour / 100.0 + return { + "h": _ratio(float(structural["max_hp"]), balance.base_max_hp), + "s": _ratio(float(structural["max_stamina"]), balance.base_max_stamina), + "a": _ratio(armour_now, armour_base), + "r": _ratio(float(structural["stamina_recovery"]), balance.base_stamina_recovery), + "f": maxf( + 0.0, + 1.0 + - balance.integrity_major_lock_penalty * float(structural["major_locks"]) + - balance.integrity_action_tax_penalty * float(structural["action_taxes"]) + ), + } + +static func _ratio(current: float, baseline: float) -> float: + if baseline <= 0.0: + return 1.0 + return minf(1.0, current / baseline) diff --git a/systems/run_coordinator.gd b/systems/run_coordinator.gd new file mode 100644 index 0000000..1cc9ac1 --- /dev/null +++ b/systems/run_coordinator.gd @@ -0,0 +1,249 @@ +class_name RunCoordinator +extends Node +## Drives one run through its phases and owns the RunState while it lasts. +## +## BOOT → WAVE → SACRIFICE → BOSS → RESULT → (restart) → WAVE +## +## Everything that mutates the run passes through here: the HUD reads, the +## sacrifice panel asks, the debug panel requests. Keeping the mutations in one +## node is what lets the UI stay an observer. + +signal phase_changed(phase: StringName) +signal state_ready(state: RunState) +signal sacrifice_offered(definition: SacrificeDefinition) +signal boss_spawned(boss: BossActor) +signal run_finished(outcome: StringName) + +const PHASE_BOOT := &"boot" +const PHASE_WAVE := &"wave" +const PHASE_SACRIFICE := &"sacrifice" +const PHASE_BOSS := &"boss" +const PHASE_RESULT := &"result" + +## The single sacrifice offered in M1. Codex X06 replaces this with the +## three-slot generator over the full library; the offer point does not move. +const M1_SACRIFICE_ID := &"severed_lifespan" + +@export var player_scene: PackedScene +@export var enemy_scene: PackedScene +@export var boss_scene: PackedScene +@export var enemy_config: EnemyConfig +@export var boss_config: EnemyConfig +@export var arena_path: NodePath +@export var actor_root_path: NodePath + +var _arena: Arena +var _actor_root: Node2D +var _state: RunState +var _player: Player +var _boss: BossActor +var _phase: StringName = PHASE_BOOT +var _live_enemies: Array[EnemyBase] = [] +var _run_counter: int = 0 + + +## Resolves scene references only. The run is started by Main once every +## observer has bound, so nothing misses the opening `state_ready`. +func _ready() -> void: + _arena = get_node(arena_path) + _actor_root = get_node(actor_root_path) + + +func state() -> RunState: + return _state + +func player() -> Player: + return _player + +func boss() -> BossActor: + return _boss + +func phase() -> StringName: + return _phase + +## Live wave enemies, excluding the Boss. Read by the debug panel and the +## integration test; the coordinator stays the only thing that mutates the list. +func live_enemies() -> Array[EnemyBase]: + return _live_enemies.duplicate() + +func offered_sacrifice() -> SacrificeDefinition: + return GameData.definition(M1_SACRIFICE_ID) + + +## Fresh run. Reusing the scene rather than reloading it keeps the seed under +## our control, which is what makes a reported bug replayable. +func start_run(run_seed: int, restarted: bool = false) -> void: + get_tree().paused = false + RNGService.configure(run_seed) + _run_counter += 1 + EventBus.bind_run(_run_counter) + + _clear_actors() + _state = RunState.create(GameData.balance, run_seed) + _state.set_run_status(RunState.STATUS_ACTIVE) + _spawn_player() + state_ready.emit(_state) + + var payload := EventBus.context({"seed": run_seed}) + if restarted: + EventBus.run_restarted.emit(payload) + else: + EventBus.run_started.emit(payload) + + _begin_wave() + + +func restart_run(reuse_seed: bool = true) -> void: + # randi() here generates a *new seed*, not a gameplay roll — gameplay always + # draws from a named RNGService stream. + var next_seed: int = _state.run_seed() if (reuse_seed and _state != null) else randi() + start_run(next_seed, true) + + +# --- phases ----------------------------------------------------------------- + +func _set_phase(next: StringName) -> void: + _phase = next + EventBus.set_stage(next) + phase_changed.emit(next) + + +func _begin_wave() -> void: + _set_phase(PHASE_WAVE) + var points := _arena.enemy_spawn_points() + if points.is_empty(): + push_error("RunCoordinator: arena has no enemy spawn points") + _begin_sacrifice() + return + for index in range(GameData.balance.wave_enemy_count): + _spawn_enemy(points[index % points.size()].global_position) + + +func _begin_sacrifice() -> void: + _set_phase(PHASE_SACRIFICE) + var definition := offered_sacrifice() + if definition == null: + push_error("RunCoordinator: sacrifice %s missing from library" % M1_SACRIFICE_ID) + begin_boss() + return + get_tree().paused = true + sacrifice_offered.emit(definition) + + +## Called by the sacrifice panel once the player confirms. The panel showed a +## preview; this runs the same transaction for real. +func confirm_sacrifice(definition: SacrificeDefinition) -> SacrificeResult: + var result := GameData.sacrifices.apply(_state, definition) + if not result.ok: + push_warning("Sacrifice %s rejected: %s" % [definition.id, result.failure_reason]) + get_tree().paused = false + begin_boss() + return result + + +func begin_boss() -> void: + if _phase == PHASE_BOSS or _phase == PHASE_RESULT: + return + _set_phase(PHASE_BOSS) + _boss = boss_scene.instantiate() + _actor_root.add_child(_boss) + _boss.global_position = _arena.boss_spawn.global_position + _boss.initialize(boss_config, _player) + _boss.defeated.connect(_on_boss_defeated) + _boss.start_encounter() + boss_spawned.emit(_boss) + + +func _finish_run(outcome: StringName) -> void: + if _phase == PHASE_RESULT: + return + _set_phase(PHASE_RESULT) + _state.set_run_status(outcome) + EventBus.run_completed.emit(EventBus.context({"outcome": outcome})) + run_finished.emit(outcome) + + +# --- spawning --------------------------------------------------------------- + +func _spawn_player() -> void: + _player = player_scene.instantiate() + _actor_root.add_child(_player) + _player.global_position = _arena.player_spawn.global_position + _player.setup(_state, GameData.balance) + _player.died.connect(_on_player_died) + _arena.apply_camera_bounds(_player.camera) + _player.camera.make_current() + + +## Also the debug "spawn reference enemy" command, so the debug path exercises +## the same code the wave does. +func spawn_enemy_at(position: Vector2) -> EnemyBase: + return _spawn_enemy(position) + + +func _spawn_enemy(position: Vector2) -> EnemyBase: + var enemy: EnemyBase = enemy_scene.instantiate() + _actor_root.add_child(enemy) + enemy.global_position = position + enemy.initialize(enemy_config, _player) + enemy.died.connect(_on_enemy_died) + _live_enemies.append(enemy) + return enemy + + +func _clear_actors() -> void: + _live_enemies.clear() + _boss = null + _player = null + for child in _actor_root.get_children(): + _actor_root.remove_child(child) + child.queue_free() + + +# --- reactions -------------------------------------------------------------- + +func _on_enemy_died(enemy: EnemyBase) -> void: + _live_enemies.erase(enemy) + if _phase == PHASE_WAVE and _live_enemies.is_empty(): + _begin_sacrifice() + + +func _on_boss_defeated(_boss_actor: BossActor) -> void: + _finish_run(RunState.STATUS_VICTORY) + + +func _on_player_died() -> void: + _finish_run(RunState.STATUS_DEFEAT) + + +# --- debug commands --------------------------------------------------------- +# Routed through the coordinator so the debug panel never touches RunState. + +func debug_heal(amount: float) -> void: + _state.heal(amount) + +## Goes through the normal receive_damage path so a debug kill exercises the +## same death handling as a real killing blow. +func debug_damage(amount: float) -> void: + if _player == null: + return + var context := DamageContext.new() + context.source_id = &"debug" + context.target_id = Player.ACTOR_ID + context.base_damage = amount + context.crit_allowed = false + context.target_armour = 0.0 + context.target_current_hp = _state.current_hp() + _player.receive_damage(context) + +func debug_spawn_reference_enemy() -> void: + if _player == null: + return + var offset := 90.0 * (1 if _player.facing() >= 0 else -1) + spawn_enemy_at(_player.global_position + Vector2(offset, 0.0)) + +func debug_preview_sacrifice() -> SacrificeResult: + return GameData.sacrifices.preview(_state, offered_sacrifice()) + +func debug_apply_sacrifice() -> SacrificeResult: + return GameData.sacrifices.apply(_state, offered_sacrifice()) diff --git a/systems/sacrifice_definition.gd b/systems/sacrifice_definition.gd new file mode 100644 index 0000000..d9bfb31 --- /dev/null +++ b/systems/sacrifice_definition.gd @@ -0,0 +1,106 @@ +class_name SacrificeDefinition +extends Resource +## Data-driven description of one sacrifice. Content lives in +## res://data/sacrifices/*.tres; this file only declares the shape. +## +## Field groups follow Prototype Development Pack A8 (标识 / 分类 / 生成 / +## 代价 / 收益 / 触发 / 显示). + +const ROLE_CONTINUATION := &"CONTINUATION" +const ROLE_RISK_ESCALATION := &"RISK_ESCALATION" +const ROLE_PIVOT_STABILIZE := &"PIVOT_STABILIZE" + +@export_group("Identity") +@export var id: StringName = &"" +@export var display_name: String = "" +## S1..S5. Drives the base gain coefficient g_S and the imbalance factor. +@export_range(1, 5) var strength: int = 1 +@export var rarity: StringName = &"common" + +@export_group("Classification") +@export var tags: Array[StringName] = [] +@export var roles: Array[StringName] = [] +## At most one owned sacrifice per non-empty group. +@export var exclusive_group: StringName = &"" + +@export_group("Generation") +@export var base_weight: float = 100.0 +@export var prerequisite_min_sacrifices: int = 0 +@export var banned_with: Array[StringName] = [] + +@export_group("Costs") +## Multipliers applied to structural stats. 1.0 means untouched. +@export var cost_max_hp_multiplier: float = 1.0 +@export var cost_max_stamina_multiplier: float = 1.0 +@export var cost_armour_multiplier: float = 1.0 +@export var cost_stamina_recovery_multiplier: float = 1.0 +## Rule locks (healing disabled, block removed, ...) and action taxes +## (dodge costs more, exhaustion lasts longer, ...). +@export var cost_structural_locks: Array[StringName] = [] +@export var cost_action_taxes: Array[StringName] = [] +## Authored imbalance. When > 0 this wins over the derived cost formula; see +## ADR-005. 0 falls back to dB = C * (0.75 + 0.05 * S). +@export var imbalance_flat: float = 0.0 + +@export_group("Rewards") +@export var reward_additive: float = 0.0 +## When true the generic More bucket is multiplied by (1 + G), G from the +## saturating reward curve (research report 5.5). +@export var reward_generic_more: bool = false +@export var reward_crit_rate: float = 0.0 +@export var reward_crit_damage: float = 0.0 +@export var reward_attack_speed_multiplier: float = 1.0 + +@export_group("Triggers") +## Declarative only in M1 — nothing consumes these yet. Kept in the schema so +## card data authored by Codex X06 does not need a migration. +@export var triggers: Array = [] + +@export_group("Display") +@export var short_text: String = "" +@export var preview_template: String = "" +## low / medium / high — the sacrifice UI must not bury a high warning. +@export var warning_level: StringName = &"low" + + +## Static data validation, used by the data test. Returns human-readable +## problems; empty means valid. +func validate() -> Array[String]: + var problems: Array[String] = [] + if String(id).is_empty(): + problems.append("missing id") + if display_name.is_empty(): + problems.append("%s: missing display_name" % id) + if strength < 1 or strength > 5: + problems.append("%s: strength %d out of range 1..5" % [id, strength]) + if roles.is_empty(): + problems.append("%s: needs at least one role" % id) + for role in roles: + if role not in [ROLE_CONTINUATION, ROLE_RISK_ESCALATION, ROLE_PIVOT_STABILIZE]: + problems.append("%s: unknown role %s" % [id, role]) + if not _has_any_cost(): + problems.append("%s: has no cost — every power has a price" % id) + if not _has_any_reward(): + problems.append("%s: has no reward" % id) + if warning_level not in [&"low", &"medium", &"high"]: + problems.append("%s: unknown warning_level %s" % [id, warning_level]) + return problems + +func _has_any_cost() -> bool: + return ( + not is_equal_approx(cost_max_hp_multiplier, 1.0) + or not is_equal_approx(cost_max_stamina_multiplier, 1.0) + or not is_equal_approx(cost_armour_multiplier, 1.0) + or not is_equal_approx(cost_stamina_recovery_multiplier, 1.0) + or not cost_structural_locks.is_empty() + or not cost_action_taxes.is_empty() + ) + +func _has_any_reward() -> bool: + return ( + not is_zero_approx(reward_additive) + or reward_generic_more + or not is_zero_approx(reward_crit_rate) + or not is_zero_approx(reward_crit_damage) + or not is_equal_approx(reward_attack_speed_multiplier, 1.0) + ) diff --git a/systems/sacrifice_result.gd b/systems/sacrifice_result.gd new file mode 100644 index 0000000..1716fcb --- /dev/null +++ b/systems/sacrifice_result.gd @@ -0,0 +1,54 @@ +class_name SacrificeResult +extends RefCounted +## Outcome of a sacrifice transaction. The same object is produced by `preview` +## and by `apply`, which is how the UI can promise that what it showed is what +## happened. + +var ok: bool = false +var definition_id: StringName = &"" +var failure_reason: String = "" +## Full RunState snapshots either side of the transaction. +var before: Dictionary = {} +var after: Dictionary = {} +## Signed changes for the values the card is required to display. +var deltas: Dictionary = {} +## G from the saturating reward curve. +var gain: float = 0.0 +## Cost score C from the resource components. +var cost_score: float = 0.0 +var imbalance_delta: float = 0.0 +var warnings: Array[String] = [] + +static func failed(definition_id: StringName, reason: String) -> SacrificeResult: + var result := SacrificeResult.new() + result.ok = false + result.definition_id = definition_id + result.failure_reason = reason + return result + +## True when two results describe the same transaction. The preview/apply +## equality test uses this. +func matches(other: SacrificeResult, tolerance: float = 0.0001) -> bool: + if ok != other.ok or definition_id != other.definition_id: + return false + if deltas.keys().size() != other.deltas.keys().size(): + return false + for key in deltas: + if not other.deltas.has(key): + return false + if absf(float(deltas[key]) - float(other.deltas[key])) > tolerance: + return false + return absf(gain - other.gain) <= tolerance \ + and absf(imbalance_delta - other.imbalance_delta) <= tolerance + +func to_dictionary() -> Dictionary: + return { + "ok": ok, + "definition_id": definition_id, + "failure_reason": failure_reason, + "deltas": deltas.duplicate(), + "gain": gain, + "cost_score": cost_score, + "imbalance_delta": imbalance_delta, + "warnings": warnings.duplicate(), + } diff --git a/systems/sacrifice_service.gd b/systems/sacrifice_service.gd new file mode 100644 index 0000000..84f81e3 --- /dev/null +++ b/systems/sacrifice_service.gd @@ -0,0 +1,242 @@ +class_name SacrificeService +extends RefCounted +## The sacrifice transaction — Prototype Development Pack A9. +## +## Ordering is fixed and non-negotiable: +## validate → snapshot → apply cost → recalculate integrity → apply reward +## → recalculate derived → apply imbalance → publish → return +## +## The reward is computed from integrity *after* the cost, which is what makes +## "the more broken you are, the sharper the blade" true. Applying reward first +## would quietly change the whole economy. +## +## Contract (frozen — see docs/INTERFACES.md): `preview` and `apply` run the +## identical `_run_transaction`. `preview` runs it on a clone. There is no +## second code path, so a preview cannot lie. + +## Float slack for the "a sacrifice cannot restore integrity" invariant. +const INTEGRITY_EPSILON := 1e-9 + +var _balance: BalanceConfig + +func _init(balance: BalanceConfig) -> void: + _balance = balance + + +## Returns {ok: bool, reason: String}. Pure — inspects, never mutates. +func can_offer(state: RunState, definition: SacrificeDefinition) -> Dictionary: + if definition == null: + return {"ok": false, "reason": "no definition"} + var owned: Array[StringName] = state.sacrifice_history() + if owned.has(definition.id): + return {"ok": false, "reason": "already taken"} + if owned.size() < definition.prerequisite_min_sacrifices: + return { + "ok": false, + "reason": "requires %d prior sacrifices" % definition.prerequisite_min_sacrifices, + } + for banned in definition.banned_with: + if owned.has(banned): + return {"ok": false, "reason": "excluded by %s" % banned} + if not String(definition.exclusive_group).is_empty(): + if _group_is_taken(state, definition.exclusive_group): + return {"ok": false, "reason": "exclusive group %s already used" % definition.exclusive_group} + var locks_after: int = state.structural_locks().size() + definition.cost_structural_locks.size() + if locks_after > _balance.max_major_locks: + return {"ok": false, "reason": "would exceed %d major locks" % _balance.max_major_locks} + var taxes_after: int = state.action_taxes().size() + definition.cost_action_taxes.size() + if taxes_after > _balance.max_action_taxes: + return {"ok": false, "reason": "would exceed %d action taxes" % _balance.max_action_taxes} + return {"ok": true, "reason": ""} + + +## Simulates the transaction on a detached copy. `state` is untouched. +func preview(state: RunState, definition: SacrificeDefinition) -> SacrificeResult: + var result := _run_transaction(state.clone(), definition) + EventBus.sacrifice_previewed.emit(EventBus.context(result.to_dictionary())) + return result + + +## Commits the transaction to `state`. On any failure the state is rolled back +## whole — a half-applied sacrifice is never a valid outcome. +func apply(state: RunState, definition: SacrificeDefinition) -> SacrificeResult: + var result := _run_transaction(state, definition) + if result.ok: + EventBus.sacrifice_applied.emit(EventBus.context(result.to_dictionary())) + return result + + +func _run_transaction(state: RunState, definition: SacrificeDefinition) -> SacrificeResult: + # 1. validate + var validation: Dictionary = can_offer(state, definition) + if not bool(validation["ok"]): + return SacrificeResult.failed( + definition.id if definition != null else &"", validation["reason"] + ) + + # 2. snapshot (the rollback point) + var before: Dictionary = state.snapshot() + var components_before: Dictionary = IntegrityService.components_of( + state.structural_snapshot(), _balance + ) + + var result := SacrificeResult.new() + result.definition_id = definition.id + result.before = before + + # 3. apply cost, 4. integrity recalculates inside each structural command + var integrity_before: float = float(before["integrity"]) + _apply_cost(state, definition) + + # A sacrifice can never make the body more whole. If a card's data says + # otherwise — a designer typing 1.15 where 0.85 was meant — the transaction + # is rejected and rolled back rather than silently rewarding the player. + if state.integrity() > integrity_before + INTEGRITY_EPSILON: + state.restore(before) + return SacrificeResult.failed( + definition.id, + "cost raised integrity %.4f → %.4f" % [integrity_before, state.integrity()] + ) + + var components_after: Dictionary = IntegrityService.components_of( + state.structural_snapshot(), _balance + ) + result.cost_score = _cost_score(components_before, components_after) + + # 5. apply reward, priced against integrity *after* the cost + result.gain = _compute_gain(state, definition) + _apply_reward(state, definition, result.gain) + + # 6. derived values are recalculated by every structural command; this is a + # belt-and-braces call for reward paths that touch nothing structural. + state.recalculate_derived() + + # 7. imbalance + result.imbalance_delta = _imbalance_delta(definition, result.cost_score) + state.add_imbalance(result.imbalance_delta) + + state.record_sacrifice(definition.id, definition.tags) + + result.after = state.snapshot() + result.deltas = _deltas(result.before, result.after) + result.warnings = _warnings(definition, result) + result.ok = true + return result + + +func _apply_cost(state: RunState, definition: SacrificeDefinition) -> void: + if not is_equal_approx(definition.cost_max_hp_multiplier, 1.0): + state.scale_max_hp(definition.cost_max_hp_multiplier) + if not is_equal_approx(definition.cost_max_stamina_multiplier, 1.0): + state.scale_max_stamina(definition.cost_max_stamina_multiplier) + if not is_equal_approx(definition.cost_armour_multiplier, 1.0): + state.set_armour(state.armour() * definition.cost_armour_multiplier) + if not is_equal_approx(definition.cost_stamina_recovery_multiplier, 1.0): + state.set_stamina_recovery( + state.stamina_recovery() * definition.cost_stamina_recovery_multiplier + ) + for lock_id in definition.cost_structural_locks: + state.add_structural_lock(lock_id) + for tax_id in definition.cost_action_taxes: + state.add_action_tax(tax_id) + + +func _apply_reward(state: RunState, definition: SacrificeDefinition, gain: float) -> void: + if not is_zero_approx(definition.reward_additive): + state.add_additive(definition.reward_additive) + if definition.reward_generic_more: + state.multiply_more(1.0 + gain) + if not is_zero_approx(definition.reward_crit_rate): + state.add_crit_rate(definition.reward_crit_rate) + if not is_zero_approx(definition.reward_crit_damage): + state.add_crit_damage(definition.reward_crit_damage) + if not is_equal_approx(definition.reward_attack_speed_multiplier, 1.0): + state.multiply_attack_speed(definition.reward_attack_speed_multiplier) + + +## G = g_S * (0.55 + 1.45 * D) * Q, with D = (1 - I)^1.35 (section 5.5). +func _compute_gain(state: RunState, definition: SacrificeDefinition) -> float: + var deficiency: float = pow(maxf(0.0, 1.0 - state.integrity()), _balance.deficiency_exponent) + var base_gain: float = _strength_gain(definition.strength) + var quality: float = _synergy_quality(state, definition) + return base_gain * (_balance.reward_base + _balance.reward_slope * deficiency) * quality + + +func _strength_gain(strength: int) -> float: + var table: PackedFloat32Array = _balance.strength_gain + var index: int = clampi(strength, 0, table.size() - 1) + return table[index] + + +## Q = clamp(1 + 0.12 * N_synergy - 0.08 * N_dilution, 0.85, 1.45). +## Dilution starts once a tag is held four times over (section 9.3). +func _synergy_quality(state: RunState, definition: SacrificeDefinition) -> float: + var synergy := 0 + var dilution := 0 + for tag in definition.tags: + var owned: int = state.tag_count(tag) + if owned > 0: + synergy += 1 + dilution += maxi(0, owned - 2) + return clampf( + 1.0 + _balance.synergy_bonus * synergy - _balance.dilution_penalty * dilution, + _balance.synergy_quality_min, + _balance.synergy_quality_max + ) + + +## C_res = 100*dh + 80*ds + 60*da + 50*dr over integrity component drops. +func _cost_score(before: Dictionary, after: Dictionary) -> float: + return ( + _balance.cost_weight_hp * maxf(0.0, float(before["h"]) - float(after["h"])) + + _balance.cost_weight_stamina * maxf(0.0, float(before["s"]) - float(after["s"])) + + _balance.cost_weight_armour * maxf(0.0, float(before["a"]) - float(after["a"])) + + _balance.cost_weight_recovery * maxf(0.0, float(before["r"]) - float(after["r"])) + ) + + +func _imbalance_delta(definition: SacrificeDefinition, cost_score: float) -> float: + if definition.imbalance_flat > 0.0: + return definition.imbalance_flat + return cost_score * ( + _balance.imbalance_base_factor + _balance.imbalance_strength_factor * definition.strength + ) + + +func _deltas(before: Dictionary, after: Dictionary) -> Dictionary: + var tracked: Array[String] = [ + "max_hp", "current_hp", "max_stamina", "armour", "stamina_recovery", + "attack", "additive_sum", "more_product", "crit_rate", "crit_damage", + "attack_speed", "integrity", "imbalance", "effective_hp", "dps_estimate", + ] + var deltas: Dictionary = {} + for key in tracked: + deltas[key] = float(after[key]) - float(before[key]) + return deltas + + +func _warnings(definition: SacrificeDefinition, result: SacrificeResult) -> Array[String]: + var warnings: Array[String] = [] + if definition.warning_level == &"high": + warnings.append("High-risk rule cost") + for lock_id in definition.cost_structural_locks: + warnings.append("Permanent rule lock: %s" % lock_id) + for tax_id in definition.cost_action_taxes: + warnings.append("Action tax: %s" % tax_id) + # Deliberately not restating the numbers already on the card: a warning that + # repeats the cost block trains players to skip warnings. + var integrity_delta := float(result.deltas.get("integrity", 0.0)) + if integrity_delta <= -0.01: + warnings.append( + "Permanent structural loss — integrity %+.3f, and healing cannot undo it" + % integrity_delta + ) + return warnings + + +func _group_is_taken(state: RunState, group: StringName) -> bool: + for owned_id in state.sacrifice_history(): + var owned: SacrificeDefinition = GameData.definition(owned_id) + if owned != null and owned.exclusive_group == group: + return true + return false diff --git a/tests/cases/test_actors.gd b/tests/cases/test_actors.gd new file mode 100644 index 0000000..d40227f --- /dev/null +++ b/tests/cases/test_actors.gd @@ -0,0 +1,126 @@ +extends TestCase +## EnemyBase and BossActor: damage intake, idempotent death, and the shared +## Damageable shape. + +const ENEMY_SCENE := "res://actors/enemies/ghost_melee.tscn" +const BOSS_SCENE := "res://actors/boss/boss.tscn" +const ENEMY_CONFIG := "res://data/actors/ghost_melee.tres" +const BOSS_CONFIG := "res://data/actors/boss_gate_guardian.tres" + +var _spawned: Array[Node] = [] + +func after_each() -> void: + for node in _spawned: + if is_instance_valid(node): + node.queue_free() + _spawned.clear() + +func _spawn(scene_path: String, config_path: String) -> EnemyBase: + var enemy: EnemyBase = (load(scene_path) as PackedScene).instantiate() + tree.root.add_child(enemy) + _spawned.append(enemy) + enemy.initialize(load(config_path) as EnemyConfig, null) + return enemy + +func _blow(target: EnemyBase, amount: float) -> DamageContext: + var context := DamageContext.new() + context.source_id = &"test" + context.target_id = target.actor_id() + context.base_damage = amount + context.crit_allowed = false + context.target_armour = target.armour() + context.target_current_hp = target.current_hp() + return context + + +func test_enemy_exposes_the_damageable_shape() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + assert_equal(enemy.actor_id(), &"ghost_melee", "actor_id comes from the config") + assert_almost(enemy.max_hp(), 34.0, "max HP comes from the config") + assert_almost(enemy.current_hp(), 34.0, "spawns at full HP") + assert_almost(enemy.armour(), 4.0, "armour comes from the config") + + +func test_enemy_takes_damage_through_the_resolver() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + var result := enemy.receive_damage(_blow(enemy, 10.0)) + # Armour 4 mitigates: 10 * 100 / 104 + assert_almost(result.final_damage, 1000.0 / 104.0, "armour applied by CombatResolver", 1e-4) + assert_almost(enemy.current_hp(), 34.0 - 1000.0 / 104.0, "HP dropped by exactly that", 1e-4) + assert_false(enemy.is_dead(), "still alive") + + +func test_death_fires_exactly_once_however_many_lethal_hits_land() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + var signal_count := [0] + var bus_count := [0] + enemy.died.connect(func(_e: EnemyBase) -> void: signal_count[0] += 1) + var bus_handler := func(_payload: Dictionary) -> void: bus_count[0] += 1 + EventBus.enemy_died.connect(bus_handler) + + enemy.receive_damage(_blow(enemy, 1000.0)) + enemy.receive_damage(_blow(enemy, 1000.0)) + enemy.die(&"test") + enemy.die(&"test") + + EventBus.enemy_died.disconnect(bus_handler) + assert_equal(signal_count[0], 1, "the died signal fired once") + assert_equal(bus_count[0], 1, "the enemy_died event fired once") + + +func test_damage_after_death_is_ignored() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + enemy.die(&"test") + var result := enemy.receive_damage(_blow(enemy, 50.0)) + assert_almost(result.final_damage, 0.0, "a corpse takes no damage") + assert_almost(enemy.current_hp(), 0.0, "HP stays at zero") + + +func test_death_disables_behaviour() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + enemy.die(&"test") + await step_physics(2) + assert_equal(enemy.state(), EnemyBase.State.DEAD, "state machine parked in DEAD") + assert_false(enemy.attack_hitbox.is_active(), "attack box is closed") + assert_false(enemy.hurtbox.monitorable, "hurtbox no longer registers hits") + assert_true(enemy.body_shape.disabled, "body collision is off") + assert_almost(enemy.velocity.x, 0.0, "no residual movement", 1.0) + + +func test_boss_reuses_the_enemy_foundation() -> void: + var boss := _spawn(BOSS_SCENE, BOSS_CONFIG) as BossActor + assert_true(boss is EnemyBase, "BossActor extends EnemyBase — one combat system") + assert_almost(boss.max_hp(), 260.0, "boss vitals come from its own config") + boss.receive_damage(_blow(boss, 100.0)) + assert_less(boss.current_hp(), 260.0, "boss takes damage through the same resolver") + + +func test_boss_death_publishes_its_own_events_once() -> void: + var boss := _spawn(BOSS_SCENE, BOSS_CONFIG) as BossActor + var defeated := [0] + var bus := [0] + boss.defeated.connect(func(_b: BossActor) -> void: defeated[0] += 1) + var handler := func(_payload: Dictionary) -> void: bus[0] += 1 + EventBus.boss_died.connect(handler) + + boss.receive_damage(_blow(boss, 9999.0)) + boss.die(&"test") + + EventBus.boss_died.disconnect(handler) + assert_equal(defeated[0], 1, "defeated fired once") + assert_equal(bus[0], 1, "boss_died fired once") + + +func test_enemy_chases_a_target_in_range() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + var target := Node2D.new() + tree.root.add_child(target) + _spawned.append(target) + enemy.global_position = Vector2.ZERO + target.global_position = Vector2(140.0, 0.0) + enemy.acquire_target(target) + await step_physics(4) + assert_true( + enemy.state() == EnemyBase.State.CHASE, + "a target inside the 180px detection range is chased" + ) diff --git a/tests/cases/test_combat_resolver.gd b/tests/cases/test_combat_resolver.gd new file mode 100644 index 0000000..d046778 --- /dev/null +++ b/tests/cases/test_combat_resolver.gd @@ -0,0 +1,132 @@ +extends TestCase +## CombatResolver — the bucket pipeline, crit rules, armour and lethality. + +var balance: BalanceConfig +var resolver: CombatResolver + +func before_each() -> void: + balance = make_balance() + resolver = CombatResolver.new(balance) + +func _context(base: float, armour: float = 0.0, target_hp: float = 1000.0) -> DamageContext: + var context := DamageContext.new() + context.source_id = &"test_source" + context.target_id = &"test_target" + context.base_damage = base + context.target_armour = armour + context.target_current_hp = target_hp + context.force_crit = 0 + return context + + +func test_normal_damage_applies_every_bucket_in_order() -> void: + var context := _context(10.0) + context.skill_multiplier = 2.0 + context.additive_modifiers = [0.25, 0.25] as Array[float] + context.more_modifiers = [0.5] as Array[float] + context.conditional_modifiers = [0.2] as Array[float] + var result := resolver.resolve(context) + # 10 * 2 * (1 + 0.5) * 1.5 * 1.2 + assert_almost(result.raw_damage, 54.0, "raw damage follows base->add->more->cond", 1e-4) + assert_almost(result.final_damage, 54.0, "no armour means no mitigation", 1e-4) + + +func test_armour_mitigates_by_equivalent_life_model() -> void: + var result := resolver.resolve(_context(100.0, 100.0)) + # 100 * 100 / (100 + 100) + assert_almost(result.final_damage, 50.0, "100 armour halves incoming damage", 1e-4) + assert_almost(result.mitigation, 50.0, "mitigation is raw minus final", 1e-4) + + +func test_armour_penetration_reduces_effective_armour() -> void: + var context := _context(100.0, 100.0) + context.armour_penetration = 60.0 + var result := resolver.resolve(context) + assert_almost(result.breakdown["effective_armour"], 40.0, "penetration subtracts", 1e-6) + assert_almost(result.final_damage, 100.0 * 100.0 / 140.0, "mitigation uses effective armour", 1e-4) + + +func test_forced_critical_multiplies_by_crit_damage() -> void: + var context := _context(10.0) + context.crit_damage = 2.5 + context.force_crit = 1 + var result := resolver.resolve(context) + assert_true(result.is_critical, "force_crit = 1 always crits") + assert_almost(result.final_damage, 25.0, "crit multiplies by crit damage", 1e-4) + + +func test_forbidden_critical_never_crits_even_at_full_rate() -> void: + var context := _context(10.0) + context.crit_rate = 1.0 + context.crit_damage = 5.0 + context.force_crit = 0 + assert_false(resolver.resolve(context).is_critical, "force_crit = 0 suppresses the roll") + + +func test_pinned_roll_makes_crit_deterministic() -> void: + var context := _context(10.0) + context.crit_rate = 0.30 + context.crit_damage = 2.0 + context.force_crit = -1 + + context.crit_roll = 0.29 + assert_true(resolver.resolve(context).is_critical, "roll below rate crits") + + context.crit_roll = 0.31 + assert_false(resolver.resolve(context).is_critical, "roll above rate does not crit") + + +func test_crit_rate_is_hard_capped_and_crit_damage_is_soft_capped() -> void: + var context := _context(10.0) + context.crit_rate = 0.99 + context.crit_damage = 6.5 + context.crit_roll = 0.85 + var result := resolver.resolve(context) + assert_false(result.is_critical, "0.85 is above the 0.80 hard cap so no crit") + assert_almost( + result.breakdown["crit_damage_effective"], + 4.5 + (6.5 - 4.5) * 0.35, + "crit damage compresses past the soft cap", + 1e-6 + ) + + +func test_more_product_is_soft_capped() -> void: + var context := _context(1.0) + context.more_modifiers = [19.0] as Array[float] # raw product 20 + var result := resolver.resolve(context) + assert_almost( + result.breakdown["more_product"], + 12.0 + (20.0 - 12.0) * 0.30, + "more product compresses past 12", + 1e-6 + ) + + +func test_lethal_is_flagged_only_when_the_hit_finishes_the_target() -> void: + assert_true(resolver.resolve(_context(30.0, 0.0, 30.0)).is_lethal, "exactly lethal counts") + assert_true(resolver.resolve(_context(31.0, 0.0, 30.0)).is_lethal, "overkill counts") + assert_false(resolver.resolve(_context(29.9, 0.0, 30.0)).is_lethal, "a survivable hit does not") + + +func test_breakdown_reproduces_the_final_number() -> void: + var context := _context(12.0, 25.0) + context.skill_multiplier = 1.4 + context.additive_modifiers = [0.3] as Array[float] + context.more_modifiers = [0.25] as Array[float] + context.conditional_modifiers = [0.1] as Array[float] + context.crit_damage = 2.0 + context.force_crit = 1 + var result := resolver.resolve(context) + var breakdown := result.breakdown + var recomputed: float = ( + float(breakdown["base_damage"]) + * float(breakdown["skill_multiplier"]) + * (1.0 + float(breakdown["additive_sum"])) + * float(breakdown["more_product"]) + * float(breakdown["conditional_product"]) + * float(breakdown["crit_multiplier"]) + * float(breakdown["mitigation_factor"]) + * float(breakdown["vulnerability"]) + ) + assert_almost(recomputed, result.final_damage, "breakdown multiplies back to final damage", 1e-6) diff --git a/tests/cases/test_integrity.gd b/tests/cases/test_integrity.gd new file mode 100644 index 0000000..58a1ef5 --- /dev/null +++ b/tests/cases/test_integrity.gd @@ -0,0 +1,105 @@ +extends TestCase +## IntegrityService and the structural/momentary split. +## +## The first test is the one that matters most: it is the bug the research +## report was written to fix. + +var balance: BalanceConfig +var state: RunState + +func before_each() -> void: + balance = make_balance() + state = RunState.create(balance, 1) + + +func test_intact_run_starts_at_full_integrity() -> void: + assert_almost(state.integrity(), 1.0, "a fresh body is whole") + + +func test_current_hp_changes_do_not_move_integrity() -> void: + var before := state.integrity() + state.apply_damage(70.0) + assert_almost(state.integrity(), before, "taking damage is momentary, not structural") + assert_almost(state.hp_ratio(), 0.30, "the damage did land", 1e-6) + state.heal(70.0) + assert_almost(state.integrity(), before, "healing does not restore structure either") + + +func test_current_stamina_changes_do_not_move_integrity() -> void: + var before := state.integrity() + state.spend_stamina(80.0) + assert_almost(state.integrity(), before, "spending stamina is momentary") + + +func test_max_hp_loss_lowers_integrity_by_its_weight() -> void: + state.scale_max_hp(0.85) + # h drops 0.15, weighted 0.34 + assert_almost(state.integrity(), 1.0 - 0.34 * 0.15, "integrity falls by weight * component drop", 1e-6) + + +func test_every_structural_stat_lowers_integrity() -> void: + var checks := { + "max stamina": func() -> void: state.scale_max_stamina(0.5), + "armour": func() -> void: state.set_armour(0.0), + "recovery": func() -> void: state.set_stamina_recovery(6.0), + "lock": func() -> void: state.add_structural_lock(&"no_healing"), + "tax": func() -> void: state.add_action_tax(&"dodge_costs_more"), + } + for label in checks: + var fresh := RunState.create(balance, 1) + state = fresh + (checks[label] as Callable).call() + assert_less(state.integrity(), 1.0, "%s should reduce integrity" % label) + + +func test_buffs_cannot_push_integrity_above_the_intact_ceiling() -> void: + state.scale_max_hp(3.0) + state.set_armour(500.0) + state.set_stamina_recovery(90.0) + assert_almost(state.integrity(), 1.0, "components are capped at 1 — no laundering a sacrifice") + + +func test_a_buff_cannot_offset_a_structural_loss() -> void: + state.scale_max_hp(0.5) + var wounded := state.integrity() + state.set_armour(400.0) + assert_almost(state.integrity(), wounded, "over-capped armour cannot repay lost lifespan") + + +func test_structural_floors_are_respected() -> void: + for _i in range(40): + state.scale_max_hp(0.5) + state.scale_max_stamina(0.5) + assert_almost(state.max_hp(), balance.min_max_hp, "max HP stops at the floor") + assert_almost(state.max_stamina(), balance.min_max_stamina, "max stamina stops at the floor") + + +func test_snapshot_restore_round_trips_exactly() -> void: + state.scale_max_hp(0.7) + state.add_additive(0.25) + state.multiply_more(1.4) + state.add_imbalance(31.0) + state.record_sacrifice(&"probe", [&"attack"] as Array[StringName]) + state.apply_damage(12.0) + var snapshot := state.snapshot() + var digest := state.snapshot_hash() + + state.scale_max_hp(0.1) + state.add_imbalance(50.0) + assert_true(state.snapshot_hash() != digest, "the disturbance actually changed the state") + + state.restore(snapshot) + assert_equal(state.snapshot_hash(), digest, "restore reproduces the exact snapshot") + assert_almost(state.integrity(), float(snapshot["integrity"]), "restore recomputes derived values") + + +func test_clone_is_detached_from_the_original() -> void: + var copy := state.clone() + copy.scale_max_hp(0.1) + copy.add_imbalance(99.0) + assert_almost(state.max_hp(), balance.base_max_hp, "the original max HP is untouched") + assert_almost(state.imbalance(), 0.0, "the original imbalance is untouched") + + +func test_effective_hp_follows_the_equivalent_life_model() -> void: + assert_almost(state.effective_hp(), 100.0 * 1.1, "EHP = max HP * (1 + armour/100)", 1e-6) diff --git a/tests/cases/test_rng.gd b/tests/cases/test_rng.gd new file mode 100644 index 0000000..bb85350 --- /dev/null +++ b/tests/cases/test_rng.gd @@ -0,0 +1,60 @@ +extends TestCase +## RNGService determinism and stream isolation. + +func _sequence(stream: StringName, count: int) -> Array[float]: + var values: Array[float] = [] + for _i in range(count): + values.append(RNGService.randf(stream)) + return values + + +func test_same_seed_reproduces_the_same_sequence() -> void: + RNGService.configure(20260802) + var first := _sequence(RNGService.STREAM_SACRIFICE, 24) + RNGService.configure(20260802) + var second := _sequence(RNGService.STREAM_SACRIFICE, 24) + assert_equal(first, second, "identical seeds produce identical draws") + + +func test_different_seeds_diverge() -> void: + RNGService.configure(1) + var first := _sequence(RNGService.STREAM_SACRIFICE, 8) + RNGService.configure(2) + var second := _sequence(RNGService.STREAM_SACRIFICE, 8) + assert_true(first != second, "a different seed produces a different sequence") + + +func test_streams_are_independent() -> void: + # Draining one stream must not shift another: this is what stops an extra + # loot roll from changing which sacrifices appear. + RNGService.configure(4242) + var baseline := _sequence(RNGService.STREAM_SACRIFICE, 6) + + RNGService.configure(4242) + for _i in range(50): + RNGService.randf(RNGService.STREAM_COMBAT) + var after_noise := _sequence(RNGService.STREAM_SACRIFICE, 6) + + assert_equal(after_noise, baseline, "combat draws do not perturb the sacrifice stream") + + +func test_streams_with_different_names_differ() -> void: + RNGService.configure(99) + var sacrifice := _sequence(RNGService.STREAM_SACRIFICE, 6) + RNGService.configure(99) + var spawn := _sequence(RNGService.STREAM_SPAWN, 6) + assert_true(sacrifice != spawn, "stream name is mixed into the derived seed") + + +func test_randi_range_stays_within_bounds_and_is_reproducible() -> void: + RNGService.configure(7) + var first: Array[int] = [] + for _i in range(40): + var value := RNGService.randi_range(RNGService.STREAM_SPAWN, 3, 9) + assert_true(value >= 3 and value <= 9, "value %d is inside [3, 9]" % value) + first.append(value) + RNGService.configure(7) + var second: Array[int] = [] + for _i in range(40): + second.append(RNGService.randi_range(RNGService.STREAM_SPAWN, 3, 9)) + assert_equal(first, second, "integer draws replay identically") diff --git a/tests/cases/test_run_loop.gd b/tests/cases/test_run_loop.gd new file mode 100644 index 0000000..e28fc4b --- /dev/null +++ b/tests/cases/test_run_loop.gd @@ -0,0 +1,175 @@ +extends TestCase +## End-to-end: the M1 loop, plus the scene smoke checks. +## +## This case is the one that would notice if the game stopped being playable — +## it drives the real Main scene through wave, sacrifice, boss, victory, +## restart and death. + +const MAIN_SCENE := "res://scenes/main.tscn" +const CRITICAL_SCENES := [ + "res://scenes/main.tscn", + "res://scenes/arena.tscn", + "res://actors/player/player.tscn", + "res://actors/enemies/enemy_base.tscn", + "res://actors/enemies/ghost_melee.tscn", + "res://actors/boss/boss.tscn", + "res://ui/hud.tscn", + "res://ui/sacrifice_panel.tscn", + "res://ui/result_screen.tscn", + "res://ui/debug_panel.tscn", +] + +var _main: Main + +func after_each() -> void: + tree.paused = false + if _main != null and is_instance_valid(_main): + _main.queue_free() + _main = null + +func _launch() -> RunCoordinator: + _main = (load(MAIN_SCENE) as PackedScene).instantiate() + tree.root.add_child(_main) + await step_physics(2) + return _main.coordinator() + +func _clear_wave(coordinator: RunCoordinator) -> void: + for enemy in coordinator.live_enemies(): + enemy.die(&"test") + await step_physics(2) + + +func test_critical_scenes_instantiate_without_missing_dependencies() -> void: + for path in CRITICAL_SCENES: + var packed: PackedScene = load(path) + assert_not_null(packed, "%s loads" % path) + if packed == null: + continue + var instance := packed.instantiate() + assert_not_null(instance, "%s instantiates" % path) + if instance != null: + instance.free() + + +func test_main_scene_boots_into_a_playable_wave() -> void: + var coordinator := await _launch() + assert_equal(coordinator.phase(), RunCoordinator.PHASE_WAVE, "boots straight into the wave") + assert_not_null(coordinator.player(), "a player exists") + assert_equal( + coordinator.live_enemies().size(), GameData.balance.wave_enemy_count, + "the wave spawned the configured number of enemies" + ) + var state := coordinator.state() + assert_almost(state.current_hp(), state.max_hp(), "the player starts intact") + assert_equal(state.run_status(), RunState.STATUS_ACTIVE, "run is active") + + +func test_player_light_attack_damages_an_enemy_through_the_hitbox() -> void: + var coordinator := await _launch() + var player := coordinator.player() + var enemy := coordinator.live_enemies()[0] + enemy.acquire_target(null) + enemy.global_position = player.global_position + Vector2(20.0, 0.0) + await step_physics(2) + var before := enemy.current_hp() + + Input.action_press(&"light_attack") + await step_physics(2) + Input.action_release(&"light_attack") + # Long enough to cover wind-up plus the active window. + await step_physics(30) + + assert_less(enemy.current_hp(), before, "the swing actually connected") + + +func test_clearing_the_wave_offers_the_sacrifice() -> void: + var coordinator := await _launch() + await _clear_wave(coordinator) + assert_equal(coordinator.phase(), RunCoordinator.PHASE_SACRIFICE, "wave clear offers a choice") + assert_true(tree.paused, "the run pauses while the player decides") + assert_not_null(coordinator.offered_sacrifice(), "a card is on offer") + + +func test_full_loop_reaches_victory_and_restarts() -> void: + var coordinator := await _launch() + await _clear_wave(coordinator) + + var before := coordinator.state().snapshot() + coordinator.confirm_sacrifice(coordinator.offered_sacrifice()) + await step_physics(2) + + assert_false(tree.paused, "confirming resumes the run") + assert_equal(coordinator.phase(), RunCoordinator.PHASE_BOSS, "the boss follows the sacrifice") + assert_not_null(coordinator.boss(), "the boss exists") + + var state := coordinator.state() + assert_less(state.max_hp(), float(before["max_hp"]), "structurally weaker") + assert_greater(state.dps_estimate(), float(before["dps_estimate"]), "offensively stronger") + assert_greater(state.imbalance(), float(before["imbalance"]), "imbalance rose") + + coordinator.boss().die(&"test") + await step_physics(2) + assert_equal(coordinator.phase(), RunCoordinator.PHASE_RESULT, "the run ends") + assert_equal(coordinator.state().run_status(), RunState.STATUS_VICTORY, "as a victory") + + coordinator.restart_run() + await step_physics(2) + var restarted := coordinator.state() + assert_equal(coordinator.phase(), RunCoordinator.PHASE_WAVE, "restart returns to the wave") + assert_almost(restarted.max_hp(), GameData.balance.base_max_hp, "structure is restored") + assert_true(restarted.sacrifice_history().is_empty(), "the sacrifice history is cleared") + assert_equal( + coordinator.live_enemies().size(), GameData.balance.wave_enemy_count, + "a fresh wave spawned" + ) + + +func test_player_death_ends_the_run_as_a_defeat() -> void: + var coordinator := await _launch() + coordinator.debug_damage(10000.0) + await step_physics(2) + assert_false(coordinator.state().is_alive(), "the player is dead") + assert_equal(coordinator.player().state_id(), PlayerState.DEAD, "player parked in DEAD") + assert_equal(coordinator.phase(), RunCoordinator.PHASE_RESULT, "the run ended") + assert_equal(coordinator.state().run_status(), RunState.STATUS_DEFEAT, "as a defeat") + + +func test_restart_with_the_same_seed_reproduces_the_run_setup() -> void: + var coordinator := await _launch() + var seed_before := coordinator.state().run_seed() + var digest := coordinator.state().snapshot_hash() + coordinator.restart_run(true) + await step_physics(2) + assert_equal(coordinator.state().run_seed(), seed_before, "the seed is reused") + assert_equal(coordinator.state().snapshot_hash(), digest, "the opening state is identical") + + +func test_debug_commands_route_through_the_coordinator() -> void: + var coordinator := await _launch() + var state := coordinator.state() + + coordinator.debug_damage(20.0) + await step_physics(2) + assert_less(state.current_hp(), state.max_hp(), "debug damage landed") + + coordinator.debug_heal(1000.0) + assert_almost(state.current_hp(), state.max_hp(), "debug heal tops the player up") + + var enemies_before := coordinator.live_enemies().size() + coordinator.debug_spawn_reference_enemy() + assert_equal( + coordinator.live_enemies().size(), enemies_before + 1, "debug spawn added an enemy" + ) + + var digest := state.snapshot_hash() + var preview := coordinator.debug_preview_sacrifice() + assert_true(preview.ok, "debug preview succeeds") + assert_equal(state.snapshot_hash(), digest, "debug preview did not mutate the run") + + var applied := coordinator.debug_apply_sacrifice() + assert_true(applied.ok, "debug apply succeeds") + assert_true(preview.matches(applied), "the debug preview matched the debug apply") + + coordinator.begin_boss() + await step_physics(2) + assert_equal(coordinator.phase(), RunCoordinator.PHASE_BOSS, "debug can start the boss") diff --git a/tests/cases/test_sacrifice.gd b/tests/cases/test_sacrifice.gd new file mode 100644 index 0000000..e52841c --- /dev/null +++ b/tests/cases/test_sacrifice.gd @@ -0,0 +1,155 @@ +extends TestCase +## The sacrifice transaction: preview honesty, rollback, and the shipped card. + +var balance: BalanceConfig +var service: SacrificeService +var state: RunState + +func before_each() -> void: + balance = make_balance() + service = SacrificeService.new(balance) + state = RunState.create(balance, 7) + +func _shipped() -> SacrificeDefinition: + return GameData.definition(RunCoordinator.M1_SACRIFICE_ID) + +## A definition built in memory, so a test can describe the exact shape it needs +## without adding a card to the shipped library. +func _definition(overrides: Dictionary = {}) -> SacrificeDefinition: + var definition := SacrificeDefinition.new() + definition.id = &"test_card" + definition.display_name = "Test Card" + definition.strength = 2 + definition.roles = [SacrificeDefinition.ROLE_CONTINUATION] as Array[StringName] + definition.tags = [&"attack"] as Array[StringName] + definition.cost_max_hp_multiplier = 0.85 + definition.reward_additive = 0.18 + definition.reward_generic_more = true + for key in overrides: + definition.set(key, overrides[key]) + return definition + + +func test_preview_does_not_touch_the_real_state() -> void: + var digest := state.snapshot_hash() + var preview := service.preview(state, _shipped()) + assert_true(preview.ok, "the shipped card previews cleanly") + assert_equal(state.snapshot_hash(), digest, "preview ran on a clone") + + +func test_apply_matches_the_preview_it_showed() -> void: + var definition := _shipped() + var preview := service.preview(state, definition) + var applied := service.apply(state, definition) + assert_true(applied.ok, "apply succeeded") + assert_true(preview.matches(applied), "preview and apply describe the same transaction") + assert_almost(preview.gain, applied.gain, "gain is identical", 1e-9) + for key in preview.deltas: + assert_almost( + float(applied.deltas[key]), float(preview.deltas[key]), + "delta %s matches the preview" % key, 1e-9 + ) + + +func test_shipped_card_trades_structure_for_damage() -> void: + var before := state.snapshot() + var result := service.apply(state, _shipped()) + assert_true(result.ok, "apply succeeded") + assert_less(state.max_hp(), float(before["max_hp"]), "max lifespan falls") + assert_less(state.integrity(), float(before["integrity"]), "structural integrity falls") + assert_less(state.effective_hp(), float(before["effective_hp"]), "effective HP falls") + assert_greater(state.additive_sum(), float(before["additive_sum"]), "attack additive rises") + assert_greater(state.more_product(), float(before["more_product"]), "damage multiplier rises") + assert_greater(state.dps_estimate(), float(before["dps_estimate"]), "estimated DPS rises") + assert_greater(state.imbalance(), float(before["imbalance"]), "imbalance rises") + + +func test_shipped_card_values_come_from_the_data_file() -> void: + var definition := _shipped() + assert_not_null(definition, "the shipped card loads from res://data/sacrifices") + service.apply(state, definition) + assert_almost( + state.max_hp(), balance.base_max_hp * definition.cost_max_hp_multiplier, + "max HP uses the multiplier from the .tres, not a literal in script", 1e-6 + ) + assert_almost( + state.additive_sum(), definition.reward_additive, + "additive reward comes from the .tres", 1e-6 + ) + assert_almost( + state.imbalance(), definition.imbalance_flat, + "authored imbalance wins over the derived formula", 1e-6 + ) + + +func test_reward_is_priced_after_the_cost() -> void: + # G = g_S * (0.55 + 1.45 * D) * Q, with D taken from post-cost integrity. + var definition := _shipped() + var result := service.apply(state, definition) + var integrity_after_cost := IntegrityService.compute( + { + "max_hp": balance.base_max_hp * definition.cost_max_hp_multiplier, + "max_stamina": balance.base_max_stamina, + "armour": balance.base_armour, + "stamina_recovery": balance.base_stamina_recovery, + "major_locks": 0, + "action_taxes": 0, + }, + balance + ) + var deficiency := pow(1.0 - integrity_after_cost, balance.deficiency_exponent) + var expected := balance.strength_gain[definition.strength] * ( + balance.reward_base + balance.reward_slope * deficiency + ) + assert_almost(result.gain, expected, "gain uses post-cost integrity", 1e-6) + + +func test_a_more_broken_body_buys_a_bigger_reward() -> void: + var healthy := RunState.create(balance, 1) + var broken := RunState.create(balance, 1) + broken.scale_max_stamina(0.4) + broken.set_armour(0.0) + + var healthy_gain := service.preview(healthy, _definition()).gain + var broken_gain := service.preview(broken, _definition()).gain + assert_greater(broken_gain, healthy_gain, "the sharper trade needs a more damaged body") + + +func test_failed_transaction_rolls_the_state_back_whole() -> void: + # Already damaged, so an integrity component is below its cap and can move. + state.scale_max_hp(0.5) + # A cost multiplier above 1 *raises* max HP, which no sacrifice may do. + var malformed := _definition({"cost_max_hp_multiplier": 1.5}) + var digest := state.snapshot_hash() + var result := service.apply(state, malformed) + assert_false(result.ok, "a sacrifice that restores integrity is rejected") + assert_equal(state.snapshot_hash(), digest, "no half-applied state survives the failure") + assert_true(state.sacrifice_history().is_empty(), "a rejected card is not recorded") + + +func test_the_same_card_cannot_be_taken_twice() -> void: + assert_true(service.apply(state, _shipped()).ok, "first take succeeds") + var second := service.apply(state, _shipped()) + assert_false(second.ok, "second take is rejected") + assert_equal(second.failure_reason, "already taken", "with a readable reason") + + +func test_exclusive_groups_and_prerequisites_gate_offers() -> void: + var gated := _definition({"prerequisite_min_sacrifices": 2}) + assert_false(bool(service.can_offer(state, gated)["ok"]), "prerequisite blocks the offer") + + var locked := _definition({ + "cost_structural_locks": [&"a", &"b", &"c"] as Array[StringName], + }) + assert_false(bool(service.can_offer(state, locked)["ok"]), "lock budget blocks the offer") + + +func test_shipped_library_is_valid_data() -> void: + var definitions := GameData.all_definitions() + assert_greater(float(definitions.size()), 0.0, "the library is not empty") + var seen: Array[StringName] = [] + for definition in definitions: + for problem in definition.validate(): + fail(problem) + assert_false(seen.has(definition.id), "duplicate sacrifice id %s" % definition.id) + seen.append(definition.id) diff --git a/tests/framework/test_case.gd b/tests/framework/test_case.gd new file mode 100644 index 0000000..c770906 --- /dev/null +++ b/tests/framework/test_case.gd @@ -0,0 +1,78 @@ +class_name TestCase +extends RefCounted +## Minimal assertion base for the headless suite. +## +## Why not GUT or gdUnit4: the Prototype Development Pack requires the project +## to run and test straight after clone with no extra downloads (A2). Vendoring +## a plugin for a dozen asserts would cost more than it saves. If the suite +## outgrows this — parameterised cases, fixtures, mocking — swap in a real +## framework rather than growing this file. + +var failures: Array[String] = [] +var assertions: int = 0 +## Injected by the runner. Tests that need real frames await tree.process_frame +## or tree.physics_frame. +var tree: SceneTree + +## Test methods are discovered by name: anything starting with `test_`. +func method_names() -> Array[String]: + var names: Array[String] = [] + for method in get_method_list(): + var method_name: String = method["name"] + if method_name.begins_with("test_"): + names.append(method_name) + names.sort() + return names + +## Per-test setup hook. +func before_each() -> void: + pass + +func after_each() -> void: + pass + +## Advances the engine by `count` physics frames. Only the integration case +## needs this; unit tests should not. +func step_physics(count: int) -> void: + for _i in range(count): + await tree.physics_frame + +## A BalanceConfig detached from the shipped resource, so a test can retune a +## value without leaking the change into the next test. +func make_balance() -> BalanceConfig: + return (load(GameData.BALANCE_PATH) as BalanceConfig).duplicate(true) + +func fail(message: String) -> void: + failures.append(message) + +func check(condition: bool, message: String) -> void: + assertions += 1 + if not condition: + fail(message) + +func assert_true(condition: bool, message: String) -> void: + check(condition, "expected true — %s" % message) + +func assert_false(condition: bool, message: String) -> void: + check(not condition, "expected false — %s" % message) + +func assert_equal(actual: Variant, expected: Variant, message: String) -> void: + check(actual == expected, "expected %s, got %s — %s" % [expected, actual, message]) + +func assert_almost(actual: float, expected: float, message: String, tolerance: float = 1e-6) -> void: + check( + absf(actual - expected) <= tolerance, + "expected %.9f, got %.9f (tolerance %.9f) — %s" % [expected, actual, tolerance, message] + ) + +func assert_greater(actual: float, threshold: float, message: String) -> void: + check(actual > threshold, "expected > %.6f, got %.6f — %s" % [threshold, actual, message]) + +func assert_less(actual: float, threshold: float, message: String) -> void: + check(actual < threshold, "expected < %.6f, got %.6f — %s" % [threshold, actual, message]) + +func assert_null(value: Variant, message: String) -> void: + check(value == null, "expected null, got %s — %s" % [value, message]) + +func assert_not_null(value: Variant, message: String) -> void: + check(value != null, "expected non-null — %s" % message) diff --git a/tests/test_runner.gd b/tests/test_runner.gd new file mode 100644 index 0000000..07c6837 --- /dev/null +++ b/tests/test_runner.gd @@ -0,0 +1,81 @@ +extends Node +## Headless test entry point. +## +## godot --headless --path . res://tests/test_runner.tscn +## +## Runs as a scene rather than via `--script` on purpose: a MainLoop script is +## compiled before the autoload singletons are registered, so any class that +## names EventBus or RNGService fails to compile and the whole graph silently +## degrades to null services. Booting a scene means the run behaves exactly like +## the game does. +## +## Discovers every res://tests/cases/*.gd, runs each `test_*` method on a fresh +## instance, and exits non-zero if any assertion failed. + +const CASE_DIR := "res://tests/cases" + +var _total: int = 0 +var _failed: int = 0 +var _assertions: int = 0 +var _failed_names: Array[String] = [] + +func _ready() -> void: + _run_all() + +func _run_all() -> void: + await get_tree().process_frame + + var script_paths := _discover_cases() + if script_paths.is_empty(): + push_error("No test cases found in %s" % CASE_DIR) + get_tree().quit(1) + return + + for path in script_paths: + await _run_case(path) + + print("") + print("──────────────────────────────────────────────") + print("%d tests, %d assertions, %d failed" % [_total, _assertions, _failed]) + for name in _failed_names: + print(" failed: %s" % name) + print("──────────────────────────────────────────────") + get_tree().quit(1 if _failed > 0 else 0) + +func _discover_cases() -> Array[String]: + var paths: Array[String] = [] + var dir := DirAccess.open(CASE_DIR) + if dir == null: + return paths + for file_name in dir.get_files(): + var script_name := file_name.trim_suffix(".remap") + if script_name.ends_with(".gd"): + paths.append("%s/%s" % [CASE_DIR, script_name]) + paths.sort() + return paths + +func _run_case(path: String) -> void: + var script: GDScript = load(path) + if script == null: + push_error("Cannot load test case %s" % path) + _failed += 1 + return + print("\n%s" % path.get_file()) + var probe: TestCase = script.new() + for method_name in probe.method_names(): + var instance: TestCase = script.new() + instance.tree = get_tree() + _total += 1 + instance.before_each() + # Works for both plain and coroutine test methods in Godot 4. + await instance.call(method_name) + await instance.after_each() + _assertions += instance.assertions + if instance.failures.is_empty(): + print(" ok %s" % method_name) + else: + _failed += 1 + _failed_names.append("%s::%s" % [path.get_file(), method_name]) + print(" FAIL %s" % method_name) + for failure in instance.failures: + print(" %s" % failure) diff --git a/tests/test_runner.tscn b/tests/test_runner.tscn new file mode 100644 index 0000000..902b3f1 --- /dev/null +++ b/tests/test_runner.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bnntestrunner1"] + +[ext_resource type="Script" path="res://tests/test_runner.gd" id="1_runner"] + +[node name="TestRunner" type="Node"] +script = ExtResource("1_runner") diff --git a/tools/generate_sprite_frames.py b/tools/generate_sprite_frames.py new file mode 100644 index 0000000..19b14f2 --- /dev/null +++ b/tools/generate_sprite_frames.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Generate Godot SpriteFrames resources from the art pipeline's sprite sheets. + +The frame counts, frame sizes, FPS and loop flags below are transcribed from +`docs/ART_SPEC.md` section 7 and the per-directory `metadata.md` files, which +are the art pipeline's source of truth. Writing the resources by hand would mean +~70 AtlasTexture blocks maintained by copy-paste; generating them means the +frame table exists once and the .tres files can be regenerated whenever +WorkBuddy replaces a sheet. + +Usage (from the repository root): + + python3 tools/generate_sprite_frames.py + +The generated .tres files are committed. Re-run and commit the diff if a sheet's +frame count changes. +""" + +from __future__ import annotations + +import pathlib +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent + +# actor -> (output path, frame width, frame height, [(anim, sheet, frames, fps, loop)]) +ACTORS = { + "player": ( + "actors/player/player_frames.tres", + 48, + 48, + [ + ("idle", "assets/player/player_idle.png", 4, 8.0, True), + ("run", "assets/player/player_run.png", 6, 12.0, True), + ("jump", "assets/player/player_jump.png", 2, 10.0, False), + ("attack", "assets/player/player_attack.png", 4, 14.0, False), + ("hurt", "assets/player/player_hurt.png", 2, 10.0, False), + ("death", "assets/player/player_death.png", 6, 8.0, False), + ], + ), + "ghost_melee": ( + "actors/enemies/ghost_melee_frames.tres", + 48, + 48, + [ + ("idle", "assets/enemy/ghost_melee_idle.png", 4, 8.0, True), + ("run", "assets/enemy/ghost_melee_run.png", 6, 10.0, True), + ("attack", "assets/enemy/ghost_melee_attack.png", 4, 12.0, False), + ("hurt", "assets/enemy/ghost_melee_hurt.png", 2, 10.0, False), + ("death", "assets/enemy/ghost_melee_death.png", 4, 8.0, False), + ], + ), + "boss": ( + "actors/boss/boss_frames.tres", + 96, + 96, + [ + ("idle", "assets/boss/boss_idle.png", 4, 6.0, True), + ("run", "assets/boss/boss_run.png", 6, 8.0, True), + ("attack", "assets/boss/boss_attack.png", 5, 10.0, False), + ("hurt", "assets/boss/boss_hurt.png", 2, 8.0, False), + ("death", "assets/boss/boss_death.png", 8, 6.0, False), + ], + ), +} + +HEADER = ( + "; Generated by tools/generate_sprite_frames.py from docs/ART_SPEC.md.\n" + "; Do not hand-edit: re-run the generator instead.\n" +) + + +def verify_sheet(sheet: pathlib.Path, frames: int, width: int, height: int) -> None: + """Fail loudly if a sheet does not match the frame table.""" + try: + from PIL import Image # optional; skip verification when unavailable + except ImportError: + return + with Image.open(sheet) as image: + expected = (frames * width, height) + if image.size != expected: + raise SystemExit( + f"{sheet.relative_to(REPO_ROOT)} is {image.size}, " + f"expected {expected} for {frames} frames of {width}x{height}" + ) + + +def build(actor: str) -> str: + output_path, frame_w, frame_h, animations = ACTORS[actor] + ext_lines: list[str] = [] + sub_lines: list[str] = [] + animation_entries: list[str] = [] + + for index, (anim, sheet, frames, fps, loop) in enumerate(animations): + verify_sheet(REPO_ROOT / sheet, frames, frame_w, frame_h) + ext_id = f"{index + 1}_{anim}" + ext_lines.append( + f'[ext_resource type="Texture2D" path="res://{sheet}" id="{ext_id}"]' + ) + frame_refs = [] + for frame in range(frames): + sub_id = f"AtlasTexture_{anim}_{frame}" + sub_lines.append( + f'[sub_resource type="AtlasTexture" id="{sub_id}"]\n' + f'atlas = ExtResource("{ext_id}")\n' + f"region = Rect2({frame * frame_w}, 0, {frame_w}, {frame_h})" + ) + frame_refs.append( + '{\n"duration": 1.0,\n' + f'"texture": SubResource("{sub_id}")\n' + "}" + ) + animation_entries.append( + "{\n" + f'"frames": [{", ".join(frame_refs)}],\n' + f'"loop": {"true" if loop else "false"},\n' + f'"name": &"{anim}",\n' + f'"speed": {fps}\n' + "}" + ) + + load_steps = len(ext_lines) + len(sub_lines) + 1 + body = [ + HEADER + f'[gd_resource type="SpriteFrames" load_steps={load_steps} format=3]', + "", + "\n".join(ext_lines), + "", + "\n\n".join(sub_lines), + "", + "[resource]", + "animations = [" + ", ".join(animation_entries) + "]", + "", + ] + return "\n".join(body) + + +def main() -> int: + for actor in ACTORS: + output_path = REPO_ROOT / ACTORS[actor][0] + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(build(actor), encoding="utf-8") + print(f"wrote {output_path.relative_to(REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ui/debug_panel.gd b/ui/debug_panel.gd new file mode 100644 index 0000000..82d15d9 --- /dev/null +++ b/ui/debug_panel.gd @@ -0,0 +1,112 @@ +class_name DebugPanel +extends CanvasLayer +## Development-only inspector and command surface. +## +## Every command is a call into RunCoordinator, so debug tooling exercises the +## same code paths as play. Nothing here reaches into RunState, an actor's +## internals, or a private field — a debug button that takes a shortcut is a +## debug button that hides the bug you are hunting. +## +## Keys: F1 panel · F2 heal · F3 damage · F4 spawn enemy · F5 start boss +## F6 preview sacrifice · F7 apply sacrifice · F8 hitboxes · R restart + +const HEAL_AMOUNT := 25.0 +const DAMAGE_AMOUNT := 15.0 + +@onready var _panel: Panel = $Root/Panel +@onready var _readout: Label = $Root/Panel/Readout +@onready var _log_label: Label = $Root/Panel/Log + +var _coordinator: RunCoordinator +var _overlay: DebugShapeOverlay +var _last_message: String = "F1 toggles this panel." + +func bind(coordinator: RunCoordinator, overlay: DebugShapeOverlay) -> void: + _coordinator = coordinator + _overlay = overlay + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + # The readout starts closed so it cannot sit on top of the sacrifice card; + # the key hint below it stays visible so F1 is discoverable. + _panel.visible = false + +func _unhandled_input(event: InputEvent) -> void: + if not event.is_pressed() or event.is_echo(): + return + if event.is_action_pressed(&"debug_toggle_panel"): + _panel.visible = not _panel.visible + elif event.is_action_pressed(&"debug_heal_player"): + _coordinator.debug_heal(HEAL_AMOUNT) + _note("healed %.0f" % HEAL_AMOUNT) + elif event.is_action_pressed(&"debug_damage_player"): + _coordinator.debug_damage(DAMAGE_AMOUNT) + _note("damaged %.0f" % DAMAGE_AMOUNT) + elif event.is_action_pressed(&"debug_spawn_enemy"): + _coordinator.debug_spawn_reference_enemy() + _note("spawned reference enemy") + elif event.is_action_pressed(&"debug_start_boss"): + _coordinator.begin_boss() + _note("boss encounter started") + elif event.is_action_pressed(&"debug_preview_sacrifice"): + _note(_describe(_coordinator.debug_preview_sacrifice(), "preview")) + elif event.is_action_pressed(&"debug_apply_sacrifice"): + _note(_describe(_coordinator.debug_apply_sacrifice(), "applied")) + elif event.is_action_pressed(&"debug_toggle_hitboxes"): + _overlay.toggle() + _note("hitboxes %s" % ("on" if _overlay.is_enabled() else "off")) + elif event.is_action_pressed(&"restart_run"): + _coordinator.restart_run() + _note("run restarted") + else: + return + get_viewport().set_input_as_handled() + +func _process(_delta: float) -> void: + if not _panel.visible or _coordinator == null: + return + var state := _coordinator.state() + if state == null: + return + var player := _coordinator.player() + _readout.text = ( + "seed %d\nphase %s\nplayer %s\nstatus %s\n" + + "hp %.1f / %.1f\nstamina %.1f / %.1f\n" + + "attack %.2f add %.2f more x%.3f\n" + + "armour %.1f recovery %.1f\n" + + "integrity %.4f\nimbalance %.1f\nEHP %.1f\nDPS est. %.1f\n" + + "locks %d taxes %d\nsacrifices %s\nsnapshot %s" + ) % [ + state.run_seed(), + _coordinator.phase(), + player.state_id() if player != null else &"none", + state.run_status(), + state.current_hp(), state.max_hp(), + state.current_stamina(), state.max_stamina(), + state.attack(), state.additive_sum(), state.more_product(), + state.armour(), state.stamina_recovery(), + state.integrity(), + state.imbalance(), + state.effective_hp(), + state.dps_estimate(), + state.structural_locks().size(), state.action_taxes().size(), + str(state.sacrifice_history()), + state.snapshot_hash(), + ] + _log_label.text = _last_message + +func _describe(result: SacrificeResult, verb: String) -> String: + if not result.ok: + return "%s failed: %s" % [verb, result.failure_reason] + return "%s %s: HP %+.1f, DPS %+.1f, I %+.4f, B %+.0f" % [ + verb, + result.definition_id, + result.deltas["max_hp"], + result.deltas["dps_estimate"], + result.deltas["integrity"], + result.deltas["imbalance"], + ] + +func _note(message: String) -> void: + _last_message = message + print("[debug] ", message) diff --git a/ui/debug_panel.tscn b/ui/debug_panel.tscn new file mode 100644 index 0000000..c8d7628 --- /dev/null +++ b/ui/debug_panel.tscn @@ -0,0 +1,47 @@ +[gd_scene load_steps=2 format=3 uid="uid://bnndebugpanel1"] + +[ext_resource type="Script" path="res://ui/debug_panel.gd" id="1_debug"] + +[node name="DebugPanel" type="CanvasLayer"] +layer = 8 +script = ExtResource("1_debug") + +[node name="Root" type="Control" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 + +[node name="Panel" type="Panel" parent="Root"] +offset_left = 396.0 +offset_top = 46.0 +offset_right = 636.0 +offset_bottom = 322.0 +modulate = Color(1, 1, 1, 0.88) +mouse_filter = 2 + +[node name="Readout" type="Label" parent="Root/Panel"] +offset_left = 6.0 +offset_top = 4.0 +offset_right = 234.0 +offset_bottom = 224.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 9 +text = "debug" + +[node name="Log" type="Label" parent="Root/Panel"] +offset_left = 6.0 +offset_top = 228.0 +offset_right = 234.0 +offset_bottom = 272.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 + +[node name="Keys" type="Label" parent="Root"] +offset_left = 6.0 +offset_top = 330.0 +offset_right = 636.0 +offset_bottom = 356.0 +theme_override_colors/font_color = Color(0.463, 0.451, 0.416, 1) +theme_override_font_sizes/font_size = 9 +text = "A/D move Space jump J attack | F1 debug F2 heal F3 hurt F4 enemy F5 boss F6 preview F7 apply F8 boxes R restart" diff --git a/ui/debug_shape_overlay.gd b/ui/debug_shape_overlay.gd new file mode 100644 index 0000000..00cd845 --- /dev/null +++ b/ui/debug_shape_overlay.gd @@ -0,0 +1,71 @@ +class_name DebugShapeOverlay +extends Node2D +## Draws hit, hurt and body collision rectangles in world space. +## +## Godot only renders collision shapes when the whole run was started with +## debug collision hints, which is not something a key can turn on mid-session. +## Drawing them here makes the F8 toggle actually work while playing. +## +## Every shape in the prototype is a RectangleShape2D; anything else is skipped +## rather than approximated, so the overlay never draws a box that is not there. + +const HITBOX_COLOUR := Color(0.9, 0.25, 0.25, 0.85) +const HITBOX_INACTIVE_COLOUR := Color(0.45, 0.2, 0.2, 0.45) +const HURTBOX_COLOUR := Color(0.3, 0.85, 0.45, 0.7) +const BODY_COLOUR := Color(0.6, 0.6, 0.7, 0.5) + +var _enabled: bool = false + +func _ready() -> void: + z_index = 100 + visible = false + +func is_enabled() -> bool: + return _enabled + +func toggle() -> void: + set_enabled(not _enabled) + +func set_enabled(value: bool) -> void: + _enabled = value + visible = value + queue_redraw() + +func _process(_delta: float) -> void: + if _enabled: + queue_redraw() + +func _draw() -> void: + if not _enabled: + return + _draw_subtree(get_tree().current_scene) + +func _draw_subtree(node: Node) -> void: + if node == null: + return + if node is CollisionShape2D: + _draw_shape(node as CollisionShape2D) + for child in node.get_children(): + _draw_subtree(child) + +func _draw_shape(shape_node: CollisionShape2D) -> void: + var rectangle := shape_node.shape as RectangleShape2D + if rectangle == null or shape_node.disabled: + return + var half := rectangle.size * 0.5 + var transform := shape_node.global_transform + var corners := PackedVector2Array([ + to_local(transform * Vector2(-half.x, -half.y)), + to_local(transform * Vector2(half.x, -half.y)), + to_local(transform * Vector2(half.x, half.y)), + to_local(transform * Vector2(-half.x, half.y)), + ]) + corners.append(corners[0]) + draw_polyline(corners, _colour_for(shape_node.get_parent()), 1.0) + +func _colour_for(owner_node: Node) -> Color: + if owner_node is Hitbox: + return HITBOX_COLOUR if (owner_node as Hitbox).is_active() else HITBOX_INACTIVE_COLOUR + if owner_node is Hurtbox: + return HURTBOX_COLOUR + return BODY_COLOUR diff --git a/ui/hud.gd b/ui/hud.gd new file mode 100644 index 0000000..37f8660 --- /dev/null +++ b/ui/hud.gd @@ -0,0 +1,68 @@ +class_name Hud +extends CanvasLayer +## Read-only run display. +## +## The HUD holds a reference to RunState but only ever calls getters. It has no +## path to a command and never talks to an actor — if something on screen is +## wrong, the bug is upstream in a service, not here. + +const FILL_INSET := 1.0 +const BAR_INTERIOR_WIDTH := 94.0 + +@onready var _hp_fill: ColorRect = $Player/HealthBar/Fill +@onready var _stamina_fill: ColorRect = $Player/StaminaBar/Fill +@onready var _stats: Label = $Player/Stats +@onready var _boss_root: Control = $Boss +@onready var _boss_fill: ColorRect = $Boss/BossBar/Fill +@onready var _boss_name: Label = $Boss/Name +@onready var _phase_label: Label = $Phase + +var _state: RunState +var _boss: EnemyBase + +func bind(coordinator: RunCoordinator) -> void: + coordinator.state_ready.connect(_on_state_ready) + coordinator.boss_spawned.connect(_on_boss_spawned) + coordinator.phase_changed.connect(_on_phase_changed) + if coordinator.state() != null: + _on_state_ready(coordinator.state()) + +func _ready() -> void: + _boss_root.visible = false + +func _process(_delta: float) -> void: + if _state == null: + return + _set_fill(_hp_fill, _state.hp_ratio()) + _set_fill(_stamina_fill, _state.stamina_ratio()) + _stats.text = ( + "ATK %.1f DPS %.1f\nEHP %.0f HP %.0f/%.0f\nIntegrity %.3f Imbalance %.0f" + % [ + _state.attack(), + _state.dps_estimate(), + _state.effective_hp(), + _state.current_hp(), + _state.max_hp(), + _state.integrity(), + _state.imbalance(), + ] + ) + if _boss != null and is_instance_valid(_boss): + _set_fill(_boss_fill, _boss.hp_ratio()) + +func _on_state_ready(state: RunState) -> void: + _state = state + _boss = null + _boss_root.visible = false + +func _on_boss_spawned(boss: BossActor) -> void: + _boss = boss + _boss_name.text = boss.config.display_name + _boss_root.visible = true + +func _on_phase_changed(phase: StringName) -> void: + _phase_label.text = String(phase).to_upper() + +func _set_fill(fill: ColorRect, ratio: float) -> void: + fill.size.x = BAR_INTERIOR_WIDTH * clampf(ratio, 0.0, 1.0) + fill.visible = fill.size.x > 0.0 diff --git a/ui/hud.tscn b/ui/hud.tscn new file mode 100644 index 0000000..c4b68a0 --- /dev/null +++ b/ui/hud.tscn @@ -0,0 +1,105 @@ +[gd_scene load_steps=6 format=3 uid="uid://bnnhud0000001"] + +[ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"] +[ext_resource type="Texture2D" path="res://assets/ui/ui_health_bar.png" id="2_health_bar"] +[ext_resource type="Texture2D" path="res://assets/ui/ui_stamina_bar.png" id="3_stamina_bar"] +[ext_resource type="Texture2D" path="res://assets/icons/ui/icon_hp.png" id="4_icon_hp"] +[ext_resource type="Texture2D" path="res://assets/icons/ui/icon_boss.png" id="5_icon_boss"] + +[node name="Hud" type="CanvasLayer"] +script = ExtResource("1_hud") + +[node name="Player" type="Control" parent="."] +offset_right = 240.0 +offset_bottom = 120.0 + +[node name="Icon" type="TextureRect" parent="Player"] +offset_left = 4.0 +offset_top = 4.0 +offset_right = 36.0 +offset_bottom = 36.0 +texture = ExtResource("4_icon_hp") + +[node name="HealthBar" type="TextureRect" parent="Player"] +offset_left = 40.0 +offset_top = 6.0 +offset_right = 136.0 +offset_bottom = 22.0 +texture = ExtResource("2_health_bar") + +[node name="Fill" type="ColorRect" parent="Player/HealthBar"] +offset_left = 1.0 +offset_top = 1.0 +offset_right = 95.0 +offset_bottom = 15.0 +color = Color(0.541, 0.157, 0.157, 1) + +[node name="StaminaBar" type="TextureRect" parent="Player"] +offset_left = 40.0 +offset_top = 24.0 +offset_right = 136.0 +offset_bottom = 40.0 +texture = ExtResource("3_stamina_bar") + +[node name="Fill" type="ColorRect" parent="Player/StaminaBar"] +offset_left = 1.0 +offset_top = 1.0 +offset_right = 95.0 +offset_bottom = 15.0 +color = Color(0.165, 0.541, 0.29, 1) + +[node name="Stats" type="Label" parent="Player"] +offset_left = 6.0 +offset_top = 44.0 +offset_right = 236.0 +offset_bottom = 96.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 10 +text = "ATK +EHP +Integrity" + +[node name="Boss" type="Control" parent="."] +offset_left = 220.0 +offset_right = 480.0 +offset_bottom = 48.0 + +[node name="Icon" type="TextureRect" parent="Boss"] +offset_left = 4.0 +offset_top = 4.0 +offset_right = 36.0 +offset_bottom = 36.0 +texture = ExtResource("5_icon_boss") + +[node name="BossBar" type="TextureRect" parent="Boss"] +offset_left = 40.0 +offset_top = 6.0 +offset_right = 136.0 +offset_bottom = 22.0 +texture = ExtResource("2_health_bar") + +[node name="Fill" type="ColorRect" parent="Boss/BossBar"] +offset_left = 1.0 +offset_top = 1.0 +offset_right = 95.0 +offset_bottom = 15.0 +color = Color(0.706, 0.392, 0.118, 1) + +[node name="Name" type="Label" parent="Boss"] +offset_left = 40.0 +offset_top = 24.0 +offset_right = 256.0 +offset_bottom = 40.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 8 +text = "Boss" + +[node name="Phase" type="Label" parent="."] +offset_left = 560.0 +offset_top = 6.0 +offset_right = 636.0 +offset_bottom = 22.0 +theme_override_colors/font_color = Color(0.463, 0.451, 0.416, 1) +theme_override_font_sizes/font_size = 10 +text = "BOOT" +horizontal_alignment = 2 diff --git a/ui/result_screen.gd b/ui/result_screen.gd new file mode 100644 index 0000000..d71ef2c --- /dev/null +++ b/ui/result_screen.gd @@ -0,0 +1,54 @@ +class_name ResultScreen +extends CanvasLayer +## End-of-run screen for both outcomes. Restart is requested from the +## coordinator; the screen does not reset anything itself. + +@onready var _title: Label = $Root/Title +@onready var _summary: Label = $Root/Summary +@onready var _restart_button: Button = $Root/Restart + +var _coordinator: RunCoordinator + +func bind(coordinator: RunCoordinator) -> void: + _coordinator = coordinator + coordinator.run_finished.connect(show_outcome) + coordinator.state_ready.connect(func(_state: RunState) -> void: visible = false) + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + visible = false + _restart_button.pressed.connect(_on_restart) + +func show_outcome(outcome: StringName) -> void: + var state := _coordinator.state() + var victory := outcome == RunState.STATUS_VICTORY + # Not 同归: the same-death mechanic is not implemented in M1, so a win here + # is an ordinary victory and the screen must not claim otherwise. + _title.text = "VICTORY" if victory else "DEATH" + _title.modulate = Color(0.706, 0.392, 0.118) if victory else Color(0.831, 0.353, 0.294) + _summary.text = ( + "Seed %d\nSacrifices %d\nAttack %.1f Estimated DPS %.1f\n" + + "Max lifespan %.0f Effective HP %.0f\nIntegrity %.3f Imbalance %.0f" + ) % [ + state.run_seed(), + state.sacrifice_history().size(), + state.attack(), + state.dps_estimate(), + state.max_hp(), + state.effective_hp(), + state.integrity(), + state.imbalance(), + ] + visible = true + _restart_button.grab_focus() + +func _unhandled_input(event: InputEvent) -> void: + if visible and (event.is_action_pressed(&"confirm") or event.is_action_pressed(&"restart_run")): + _on_restart() + get_viewport().set_input_as_handled() + +func _on_restart() -> void: + if not visible: + return + visible = false + _coordinator.restart_run() diff --git a/ui/result_screen.tscn b/ui/result_screen.tscn new file mode 100644 index 0000000..890b37e --- /dev/null +++ b/ui/result_screen.tscn @@ -0,0 +1,42 @@ +[gd_scene load_steps=2 format=3 uid="uid://bnnresult00001"] + +[ext_resource type="Script" path="res://ui/result_screen.gd" id="1_result"] + +[node name="ResultScreen" type="CanvasLayer"] +layer = 6 +script = ExtResource("1_result") + +[node name="Root" type="Control" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Dim" type="ColorRect" parent="Root"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.058, 0.047, 0.07, 0.88) + +[node name="Title" type="Label" parent="Root"] +offset_left = 120.0 +offset_top = 86.0 +offset_right = 520.0 +offset_bottom = 118.0 +theme_override_font_sizes/font_size = 20 +text = "DEATH" +horizontal_alignment = 1 + +[node name="Summary" type="Label" parent="Root"] +offset_left = 120.0 +offset_top = 132.0 +offset_right = 520.0 +offset_bottom = 232.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 11 +horizontal_alignment = 1 + +[node name="Restart" type="Button" parent="Root"] +offset_left = 258.0 +offset_top = 246.0 +offset_right = 382.0 +offset_bottom = 272.0 +theme_override_font_sizes/font_size = 11 +text = "Restart (R)" diff --git a/ui/sacrifice_panel.gd b/ui/sacrifice_panel.gd new file mode 100644 index 0000000..64c5c4f --- /dev/null +++ b/ui/sacrifice_panel.gd @@ -0,0 +1,77 @@ +class_name SacrificePanel +extends CanvasLayer +## Sacrifice preview and confirmation. +## +## The panel calls SacrificeService.preview, which runs the real transaction on +## a clone, and renders the numbers that came back. It never computes a benefit +## or a cost itself, and on confirm it hands the definition to RunCoordinator +## rather than applying anything — the rule "UI is an observer" only holds if +## the preview and the apply are the same code, which they are. +## +## Codex X05 replaces this with the A/B/C three-slot card layout. The contract +## it must keep: preview through the service, confirm through the coordinator. + +@onready var _title: Label = $Root/Panel/Title +@onready var _body: Label = $Root/Panel/Body +@onready var _warnings: Label = $Root/Panel/Warnings +@onready var _confirm_button: Button = $Root/Panel/Confirm + +var _coordinator: RunCoordinator +var _definition: SacrificeDefinition + +func bind(coordinator: RunCoordinator) -> void: + _coordinator = coordinator + coordinator.sacrifice_offered.connect(show_offer) + # Visibility follows the phase rather than the confirm button, so the panel + # also closes when the sacrifice is applied from the debug panel or a test. + coordinator.phase_changed.connect(_on_phase_changed) + +func _ready() -> void: + # The run is paused while this panel is up, so it must keep processing. + process_mode = Node.PROCESS_MODE_ALWAYS + visible = false + _confirm_button.pressed.connect(_on_confirmed) + +func show_offer(definition: SacrificeDefinition) -> void: + _definition = definition + var preview := GameData.sacrifices.preview(_coordinator.state(), definition) + _title.text = definition.display_name + _body.text = _format_preview(definition, preview) + _warnings.text = "\n".join(preview.warnings) + visible = true + _confirm_button.grab_focus() + +func _unhandled_input(event: InputEvent) -> void: + if visible and event.is_action_pressed(&"confirm"): + _on_confirmed() + get_viewport().set_input_as_handled() + +func _on_phase_changed(phase: StringName) -> void: + if phase != RunCoordinator.PHASE_SACRIFICE: + visible = false + + +func _on_confirmed() -> void: + if not visible or _definition == null: + return + visible = false + _coordinator.confirm_sacrifice(_definition) + +func _format_preview(definition: SacrificeDefinition, preview: SacrificeResult) -> String: + if not preview.ok: + return "Unavailable: %s" % preview.failure_reason + return ( + "%s\n\nGAIN\n Attack additive %+.2f\n Damage multiplier x%.3f\n" + + " Estimated DPS %.1f → %.1f\n\nCOST\n Max lifespan %.0f → %.0f\n" + + " Effective HP %.0f → %.0f\n Structural integrity %.3f → %.3f\n" + + " Imbalance %.0f → %.0f" + ) % [ + definition.short_text, + definition.reward_additive, + 1.0 + preview.gain, + preview.before["dps_estimate"], preview.after["dps_estimate"], + preview.before["max_hp"], preview.after["max_hp"], + preview.before["effective_hp"], preview.after["effective_hp"], + preview.before["integrity"], preview.after["integrity"], + preview.before["imbalance"], preview.after["imbalance"], + ] diff --git a/ui/sacrifice_panel.tscn b/ui/sacrifice_panel.tscn new file mode 100644 index 0000000..71c551d --- /dev/null +++ b/ui/sacrifice_panel.tscn @@ -0,0 +1,66 @@ +[gd_scene load_steps=3 format=3 uid="uid://bnnsacpanel001"] + +[ext_resource type="Script" path="res://ui/sacrifice_panel.gd" id="1_panel"] +[ext_resource type="Texture2D" path="res://assets/icons/ui/icon_sacrifice.png" id="2_icon"] + +[node name="SacrificePanel" type="CanvasLayer"] +layer = 5 +script = ExtResource("1_panel") + +[node name="Root" type="Control" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Dim" type="ColorRect" parent="Root"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.058, 0.047, 0.07, 0.78) + +[node name="Panel" type="Panel" parent="Root"] +offset_left = 150.0 +offset_top = 22.0 +offset_right = 490.0 +offset_bottom = 322.0 + +[node name="Icon" type="TextureRect" parent="Root/Panel"] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 40.0 +offset_bottom = 40.0 +texture = ExtResource("2_icon") + +[node name="Title" type="Label" parent="Root/Panel"] +offset_left = 46.0 +offset_top = 8.0 +offset_right = 332.0 +offset_bottom = 46.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 12 +autowrap_mode = 2 +text = "Sacrifice" + +[node name="Body" type="Label" parent="Root/Panel"] +offset_left = 12.0 +offset_top = 50.0 +offset_right = 328.0 +offset_bottom = 244.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 + +[node name="Warnings" type="Label" parent="Root/Panel"] +offset_left = 12.0 +offset_top = 246.0 +offset_right = 328.0 +offset_bottom = 276.0 +theme_override_colors/font_color = Color(0.831, 0.353, 0.294, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 + +[node name="Confirm" type="Button" parent="Root/Panel"] +offset_left = 108.0 +offset_top = 278.0 +offset_right = 232.0 +offset_bottom = 298.0 +theme_override_font_sizes/font_size = 11 +text = "Accept (Enter)"