feat: discrete party controller + aligned stair pairs (PR 2)
Adds the Wizardry/M&M core loop: you walk cell-by-cell with 90° turns and descend stairs between levels that actually line up. C side: - pipeline.c: after per-level 2D generation, a link_stairs() pass replaces the randomly-placed down/up stairs with aligned pairs (room cells preferred). Bottom level loses its down-stair; top level keeps the up-stair as the entry point. - dungeon_to_dict.cpp: expose sizeof(cell3d_t) as "cell_stride" so GDScript can index raw cell bytes without hardcoding layout. Godot side: - scripts/blobber_party.gd: reads cell3d_t bytes directly for wall queries, tweens position/rotation on step/turn, swaps level when stair cell is activated. - scripts/dungeon_builder.gd: now hands the generated Dictionary to a party node via `party_path` and groups mesh instances under a "Meshes" child for clean regeneration. - scenes/demo_blobber.tscn: FlyCamera replaced with a Party node (script-driven) holding a child Camera3D. num_levels=3 by default. Still deferred to later PRs: the full port/retirement of src/gen/, and a standalone plan.c/h module (linkage is currently inlined in pipeline.c with just StairPair-equivalent data). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06ef034866
commit
5235b5bb22
6 changed files with 345 additions and 11 deletions
219
demo/scripts/blobber_party.gd
Normal file
219
demo/scripts/blobber_party.gd
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
class_name BlobberParty
|
||||
extends Node3D
|
||||
|
||||
# Discrete cell-step party controller (Wizardry / M&M style).
|
||||
# Reads cell3d_t bytes straight out of the dungeon Dictionary to query wall
|
||||
# flags; no collision shape needed. Stair cells trigger a level switch.
|
||||
|
||||
signal level_changed(new_level: int)
|
||||
signal stepped(cell: Vector2i, level: int)
|
||||
signal blocked(face: int)
|
||||
|
||||
const FACE_N := 0
|
||||
const FACE_E := 1
|
||||
const FACE_S := 2
|
||||
const FACE_W := 3
|
||||
|
||||
# cell3d_t byte offsets (see src/blobber/cell3d.h)
|
||||
const OFF_FLOOR := 0
|
||||
const OFF_WALLS := 2 # walls[0..3]
|
||||
|
||||
# b_floor_t values
|
||||
const FT_VOID := 0
|
||||
const FT_STONE := 1
|
||||
const FT_STAIR_UP := 5
|
||||
const FT_STAIR_DOWN := 6
|
||||
|
||||
@export var step_duration: float = 0.18
|
||||
@export var turn_duration: float = 0.14
|
||||
@export var eye_height_ratio: float = 0.55 # fraction of cell_size above floor
|
||||
|
||||
var cell: Vector2i = Vector2i.ZERO
|
||||
var level: int = 0
|
||||
var facing: int = FACE_N
|
||||
|
||||
var _cells: PackedByteArray
|
||||
var _width: int = 0
|
||||
var _height: int = 0
|
||||
var _n_levels: int = 1
|
||||
var _cell_size: float = 3.0
|
||||
var _cell_stride: int = 12
|
||||
var _levels_meta: Array = []
|
||||
var _busy: bool = false
|
||||
|
||||
func setup(dungeon: Dictionary) -> void:
|
||||
_cells = dungeon.get("cells", PackedByteArray())
|
||||
var dims: Vector3i = dungeon.get("dimensions", Vector3i(79, 1, 29))
|
||||
_width = dims.x
|
||||
_n_levels = dims.y
|
||||
_height = dims.z
|
||||
_cell_size = float(dungeon.get("cell_size", 3.0))
|
||||
_cell_stride = int(dungeon.get("cell_stride", 12))
|
||||
_levels_meta = dungeon.get("levels", [])
|
||||
|
||||
var entry := _entry_cell()
|
||||
cell = entry
|
||||
level = 0
|
||||
facing = FACE_N
|
||||
_snap_to_cell(true)
|
||||
|
||||
# --- Public controls -------------------------------------------------------
|
||||
|
||||
func try_step_forward() -> void:
|
||||
_try_step(_facing_delta(facing))
|
||||
|
||||
func try_step_back() -> void:
|
||||
_try_step(-_facing_delta(facing))
|
||||
|
||||
func try_step_left() -> void:
|
||||
_try_step(_facing_delta((facing + 3) % 4))
|
||||
|
||||
func try_step_right() -> void:
|
||||
_try_step(_facing_delta((facing + 1) % 4))
|
||||
|
||||
func try_turn_left() -> void:
|
||||
if _busy: return
|
||||
facing = (facing + 3) % 4
|
||||
_tween_rotation()
|
||||
|
||||
func try_turn_right() -> void:
|
||||
if _busy: return
|
||||
facing = (facing + 1) % 4
|
||||
_tween_rotation()
|
||||
|
||||
func try_use_stair() -> void:
|
||||
if _busy: return
|
||||
var f := _cell_floor(level, cell.x, cell.y)
|
||||
if f == FT_STAIR_DOWN and level + 1 < _n_levels:
|
||||
_change_level(level + 1)
|
||||
elif f == FT_STAIR_UP and level > 0:
|
||||
_change_level(level - 1)
|
||||
|
||||
# --- Input -----------------------------------------------------------------
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if not (event is InputEventKey) or not event.pressed or event.echo:
|
||||
return
|
||||
var key: int = event.keycode
|
||||
match key:
|
||||
KEY_W, KEY_UP: try_step_forward()
|
||||
KEY_S, KEY_DOWN: try_step_back()
|
||||
KEY_A: try_step_left()
|
||||
KEY_D: try_step_right()
|
||||
KEY_Q, KEY_LEFT: try_turn_left()
|
||||
KEY_E, KEY_RIGHT: try_turn_right()
|
||||
KEY_SPACE, KEY_ENTER, KEY_PERIOD, KEY_GREATER, KEY_LESS: try_use_stair()
|
||||
|
||||
# --- Movement internals ----------------------------------------------------
|
||||
|
||||
func _try_step(delta: Vector2i) -> void:
|
||||
if _busy: return
|
||||
var face: int = _delta_to_face(delta)
|
||||
if face < 0:
|
||||
return
|
||||
if _cell_wall(level, cell.x, cell.y, face) != 0: # W_NONE == 0
|
||||
blocked.emit(face)
|
||||
return
|
||||
var nx := cell.x + delta.x
|
||||
var ny := cell.y + delta.y
|
||||
if nx < 0 or ny < 0 or nx >= _width or ny >= _height:
|
||||
return
|
||||
var dest_floor := _cell_floor(level, nx, ny)
|
||||
if dest_floor == FT_VOID:
|
||||
return
|
||||
cell = Vector2i(nx, ny)
|
||||
stepped.emit(cell, level)
|
||||
_tween_position()
|
||||
|
||||
func _change_level(new_level: int) -> void:
|
||||
# Land on the paired stair on the adjacent level. Plan guarantees the XYs
|
||||
# match, so we keep our cell coordinates.
|
||||
level = new_level
|
||||
level_changed.emit(level)
|
||||
_snap_to_cell(true)
|
||||
|
||||
# --- Cell byte reads -------------------------------------------------------
|
||||
|
||||
func _cell_index(lvl: int, x: int, y: int) -> int:
|
||||
return ((lvl * _height + y) * _width + x) * _cell_stride
|
||||
|
||||
func _cell_floor(lvl: int, x: int, y: int) -> int:
|
||||
return _cells[_cell_index(lvl, x, y) + OFF_FLOOR]
|
||||
|
||||
func _cell_wall(lvl: int, x: int, y: int, face: int) -> int:
|
||||
return _cells[_cell_index(lvl, x, y) + OFF_WALLS + face]
|
||||
|
||||
# --- Geometry --------------------------------------------------------------
|
||||
|
||||
func _facing_delta(f: int) -> Vector2i:
|
||||
match f:
|
||||
FACE_N: return Vector2i(0, -1)
|
||||
FACE_E: return Vector2i(1, 0)
|
||||
FACE_S: return Vector2i(0, 1)
|
||||
FACE_W: return Vector2i(-1, 0)
|
||||
return Vector2i.ZERO
|
||||
|
||||
func _delta_to_face(d: Vector2i) -> int:
|
||||
if d == Vector2i(0, -1): return FACE_N
|
||||
if d == Vector2i(1, 0): return FACE_E
|
||||
if d == Vector2i(0, 1): return FACE_S
|
||||
if d == Vector2i(-1, 0): return FACE_W
|
||||
return -1
|
||||
|
||||
func _cell_world_position(lvl: int, x: int, y: int) -> Vector3:
|
||||
var s := _cell_size
|
||||
return Vector3(
|
||||
(float(x) + 0.5) * s,
|
||||
-float(lvl) * s - s * (1.0 - eye_height_ratio),
|
||||
(float(y) + 0.5) * s)
|
||||
|
||||
func _facing_yaw(f: int) -> float:
|
||||
# Camera default forward = -Z. N (-y → -z) = 0. Then +x is -PI/2, etc.
|
||||
match f:
|
||||
FACE_N: return 0.0
|
||||
FACE_E: return -PI * 0.5
|
||||
FACE_S: return PI
|
||||
FACE_W: return PI * 0.5
|
||||
return 0.0
|
||||
|
||||
func _snap_to_cell(reset_rotation: bool) -> void:
|
||||
position = _cell_world_position(level, cell.x, cell.y)
|
||||
if reset_rotation:
|
||||
rotation.y = _facing_yaw(facing)
|
||||
|
||||
func _tween_position() -> void:
|
||||
var target := _cell_world_position(level, cell.x, cell.y)
|
||||
_busy = true
|
||||
var tw := create_tween()
|
||||
tw.tween_property(self, "position", target, step_duration)
|
||||
tw.finished.connect(func(): _busy = false)
|
||||
|
||||
func _tween_rotation() -> void:
|
||||
var target_yaw := _facing_yaw(facing)
|
||||
# Take the shortest angular path.
|
||||
var cur := rotation.y
|
||||
var diff := wrapf(target_yaw - cur, -PI, PI)
|
||||
target_yaw = cur + diff
|
||||
_busy = true
|
||||
var tw := create_tween()
|
||||
tw.tween_property(self, "rotation:y", target_yaw, turn_duration)
|
||||
tw.finished.connect(func(): _busy = false)
|
||||
|
||||
# --- Entry point -----------------------------------------------------------
|
||||
|
||||
func _entry_cell() -> Vector2i:
|
||||
if _levels_meta.size() > 0:
|
||||
var lv0: Dictionary = _levels_meta[0]
|
||||
var su: Vector2i = lv0.get("stairs_up", Vector2i(-1, -1))
|
||||
if su.x >= 0:
|
||||
return su
|
||||
var sd: Vector2i = lv0.get("stairs_down", Vector2i(-1, -1))
|
||||
if sd.x >= 0:
|
||||
return sd
|
||||
# Fallback: first standable cell on level 0.
|
||||
for y in _height:
|
||||
for x in _width:
|
||||
var f := _cell_floor(0, x, y)
|
||||
if f != FT_VOID:
|
||||
return Vector2i(x, y)
|
||||
return Vector2i.ZERO
|
||||
Loading…
Add table
Add a link
Reference in a new issue