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