diff --git a/demo/scenes/demo_blobber.tscn b/demo/scenes/demo_blobber.tscn index ef5adb3..4394820 100644 --- a/demo/scenes/demo_blobber.tscn +++ b/demo/scenes/demo_blobber.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://c8blbrbrbr001"] [ext_resource type="Script" uid="uid://b2r6hnyvt7ef7" path="res://scripts/dungeon_builder.gd" id="1_builder"] -[ext_resource type="Script" uid="uid://d0srrm35g1m0t" path="res://scripts/fly_camera.gd" id="2_flycam"] +[ext_resource type="Script" path="res://scripts/blobber_party.gd" id="2_party"] [sub_resource type="Environment" id="Environment_1"] background_mode = 1 @@ -15,8 +15,9 @@ ambient_light_energy = 0.5 [node name="Dungeon" type="Node3D" parent="." unique_id=2089249369] script = ExtResource("1_builder") seed_value = 43 -num_levels = 8 +num_levels = 3 depth = 20 +party_path = NodePath("../Party") [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1913401313] transform = Transform3D(0.86602527, -0.35310844, 0.35399818, 0, 0.70799595, 0.70621645, -0.5000003, -0.6116013, 0.6131424, 80, 40, 80) @@ -25,9 +26,10 @@ shadow_enabled = true [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1337946831] environment = SubResource("Environment_1") -[node name="FlyCamera" type="Camera3D" parent="." unique_id=987300286] -transform = Transform3D(0.9, 0, 0, 0, 0.7, 0.7, 0, -0.7, 0.7, 120, 40, 80) -fov = 65.0 +[node name="Party" type="Node3D" parent="." unique_id=987300286] +script = ExtResource("2_party") + +[node name="Camera3D" type="Camera3D" parent="Party"] +fov = 75.0 near = 0.1 far = 500.0 -script = ExtResource("2_flycam") diff --git a/demo/scripts/blobber_party.gd b/demo/scripts/blobber_party.gd new file mode 100644 index 0000000..191c7e9 --- /dev/null +++ b/demo/scripts/blobber_party.gd @@ -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 diff --git a/demo/scripts/dungeon_builder.gd b/demo/scripts/dungeon_builder.gd index 6b81314..b93c159 100644 --- a/demo/scripts/dungeon_builder.gd +++ b/demo/scripts/dungeon_builder.gd @@ -3,6 +3,11 @@ extends Node3D # Blobber dungeon builder. Calls BrogueGen.generate_dungeon() and assembles # per-material MeshInstance3D children from the returned mesh surface arrays. # Material ids mirror src/mesh/material_ids.h. +# +# If `party_path` is set, the builder also feeds the generated Dictionary to +# the party controller so it lands on the entry stair with fresh cell data. + +const BlobberPartyScript := preload("res://scripts/blobber_party.gd") const MAT_STONE_FLOOR := 0 const MAT_STONE_CEILING := 1 @@ -21,8 +26,10 @@ const MAT_CAVE_WALL := 11 @export var num_levels: int = 1 @export var depth: int = 1 @export var regenerate_on_ready: bool = true +@export var party_path: NodePath var _materials: Dictionary = {} +var _mesh_parent: Node3D func _ready() -> void: _materials = _build_materials() @@ -30,8 +37,11 @@ func _ready() -> void: regenerate() func regenerate() -> void: - for child in get_children(): - child.queue_free() + if _mesh_parent and is_instance_valid(_mesh_parent): + _mesh_parent.queue_free() + _mesh_parent = Node3D.new() + _mesh_parent.name = "Meshes" + add_child(_mesh_parent) var gen := BrogueGen.new() var dungeon: Dictionary = gen.generate_dungeon(seed_value, num_levels, depth) @@ -56,13 +66,18 @@ func regenerate() -> void: var mi := MeshInstance3D.new() mi.mesh = mesh mi.name = "Surface_%d" % material_id - add_child(mi) + _mesh_parent.add_child(mi) var dims: Vector3i = dungeon.get("dimensions", Vector3i(79, 1, 29)) var levels: Array = dungeon.get("levels", []) print("Dungeon built: seed=%d dims=%s surfaces=%d levels=%d" % [seed_value, dims, meshes.size(), levels.size()]) + if party_path != NodePath(""): + var party := get_node_or_null(party_path) + if party and party.has_method("setup"): + party.setup(dungeon) + # Distinct albedo colors per material for instant visual debugging. # Later PRs replace these with proper StandardMaterial3D + textures. func _build_materials() -> Dictionary: diff --git a/godot/src/dungeon_to_dict.cpp b/godot/src/dungeon_to_dict.cpp index b36181b..de2e260 100644 --- a/godot/src/dungeon_to_dict.cpp +++ b/godot/src/dungeon_to_dict.cpp @@ -88,6 +88,7 @@ Dictionary godot::dungeon_to_dictionary(const dungeon_t *d, const mesh_build_t * out["depth"] = depth; out["num_levels"] = num_levels; out["cell_size"] = mb->cell_size; + out["cell_stride"] = (int)sizeof(cell3d_t); out["dimensions"] = Vector3i(BL_DCOLS, num_levels, BL_DROWS); // Raw voxel cell data for future mutation. Order: z-major (level,y,x). diff --git a/src/blobber/pipeline.c b/src/blobber/pipeline.c index 7b972b7..7fc5309 100644 --- a/src/blobber/pipeline.c +++ b/src/blobber/pipeline.c @@ -109,6 +109,99 @@ static void materialize_level(dungeon_t *d, int lvl, grid_t g) { compute_face_walls(d, lvl); } +/* Count cells that are FT_STONE (and optionally S_ROOM) on both levels. */ +static int count_shared_floor(const dungeon_t *d, int la, int lb, int require_room) { + int n = 0; + for (int y = 0; y < BL_DROWS; y++) { + for (int x = 0; x < BL_DCOLS; x++) { + const cell3d_t *ca = dungeon_cell_c(d, la, x, y); + const cell3d_t *cb = dungeon_cell_c(d, lb, x, y); + if (ca->floor != FT_STONE || cb->floor != FT_STONE) continue; + if (require_room && (ca->style != S_ROOM || cb->style != S_ROOM)) continue; + n++; + } + } + return n; +} + +static int pick_nth_shared(const dungeon_t *d, int la, int lb, int require_room, + int target, int *ox, int *oy) { + int n = 0; + for (int y = 0; y < BL_DROWS; y++) { + for (int x = 0; x < BL_DCOLS; x++) { + const cell3d_t *ca = dungeon_cell_c(d, la, x, y); + const cell3d_t *cb = dungeon_cell_c(d, lb, x, y); + if (ca->floor != FT_STONE || cb->floor != FT_STONE) continue; + if (require_room && (ca->style != S_ROOM || cb->style != S_ROOM)) continue; + if (n == target) { *ox = x; *oy = y; return 1; } + n++; + } + } + return 0; +} + +/* If (x,y) on level lvl currently holds expected_floor, reset it to FT_STONE. + Used to undo the per-level 2D stair placement before dropping an aligned + stair pair at a new XY. */ +static void clear_stair_cell(dungeon_t *d, int lvl, int x, int y, uint8_t expected_floor) { + if (x < 0 || y < 0 || !b_in_bounds(x, y)) return; + cell3d_t *c = dungeon_cell(d, lvl, x, y); + if (c->floor == expected_floor) c->floor = FT_STONE; +} + +/* Replace each level's per-level 2D stair placement with aligned stair pairs + so level L's down-stair and level L+1's up-stair share the same XY. The + bottom level's down-stair is removed (no level below). The top level keeps + its existing up-stair as the party's entry point. Does nothing when + n_levels < 2. */ +static void link_stairs(dungeon_t *d, uint64_t seed) { + if (d->n_levels < 2) return; + + rng_t rng; + rng_seed(&rng, seed ^ 0xB106B3D5B4B83A07ULL); + + for (int lvl = 0; lvl < d->n_levels - 1; lvl++) { + clear_stair_cell(d, lvl, d->levels[lvl].stairs_down_x, + d->levels[lvl].stairs_down_y, FT_STAIR_DOWN); + clear_stair_cell(d, lvl + 1, d->levels[lvl + 1].stairs_up_x, + d->levels[lvl + 1].stairs_up_y, FT_STAIR_UP); + + int sx = -1, sy = -1; + int n_rooms = count_shared_floor(d, lvl, lvl + 1, 1); + if (n_rooms > 0) { + int pick = (int)(rng_u32(&rng) % (uint32_t)n_rooms); + pick_nth_shared(d, lvl, lvl + 1, 1, pick, &sx, &sy); + } else { + int n_any = count_shared_floor(d, lvl, lvl + 1, 0); + if (n_any > 0) { + int pick = (int)(rng_u32(&rng) % (uint32_t)n_any); + pick_nth_shared(d, lvl, lvl + 1, 0, pick, &sx, &sy); + } + } + if (sx < 0) { + /* No shared standable cell — leave this boundary unlinked. */ + d->levels[lvl].stairs_down_x = -1; + d->levels[lvl].stairs_down_y = -1; + d->levels[lvl + 1].stairs_up_x = -1; + d->levels[lvl + 1].stairs_up_y = -1; + continue; + } + + dungeon_cell(d, lvl, sx, sy)->floor = FT_STAIR_DOWN; + dungeon_cell(d, lvl + 1, sx, sy)->floor = FT_STAIR_UP; + d->levels[lvl].stairs_down_x = sx; + d->levels[lvl].stairs_down_y = sy; + d->levels[lvl + 1].stairs_up_x = sx; + d->levels[lvl + 1].stairs_up_y = sy; + } + + int last = d->n_levels - 1; + clear_stair_cell(d, last, d->levels[last].stairs_down_x, + d->levels[last].stairs_down_y, FT_STAIR_DOWN); + d->levels[last].stairs_down_x = -1; + d->levels[last].stairs_down_y = -1; +} + dungeon_t *blobber_generate(uint64_t seed, int n_levels, int depth) { if (n_levels < 1) n_levels = 1; dungeon_t *d = dungeon_create(n_levels); @@ -120,5 +213,6 @@ dungeon_t *blobber_generate(uint64_t seed, int n_levels, int depth) { gen_level_2d(g, level_seed, depth); materialize_level(d, lvl, g); } + link_stairs(d, seed); return d; } diff --git a/src/blobber/pipeline.h b/src/blobber/pipeline.h index d728fe0..3c83371 100644 --- a/src/blobber/pipeline.h +++ b/src/blobber/pipeline.h @@ -7,8 +7,11 @@ /* Generate a dungeon using the existing 2D pipeline under the hood, then materialize into a 3D cell3d_t grid with per-face wall flags. - PR 1: each of the n_levels runs an independent 2D generation with seed + - level_index. Inter-level linkage is absent (lands in later PRs). + Each of the n_levels runs an independent 2D generation with seed + + level_index. When n_levels > 1, a post-pass replaces the per-level stair + placement with aligned pairs: level L's down-stair and level L+1's + up-stair share the same XY. The bottom level has no down-stair, the top + level keeps its up-stair as the party's entry point. Returns a newly-allocated dungeon_t; caller must dungeon_destroy() it. Returns NULL on allocation failure. */