Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/godot-tests.yml
Original file line number Diff line number Diff line change
@@ -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
63 changes: 57 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions actors/boss/boss.tscn
Original file line number Diff line number Diff line change
@@ -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")
31 changes: 31 additions & 0 deletions actors/boss/boss_actor.gd
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading