class_name ChunkEditorModel extends RefCounted ## In-memory chunk being edited (docs/architecture/editor.md §7.1's ## `ChunkEditorModel`). ## ## Wraps a `ChunkDataDecoder.ChunkPayload` directly rather than inventing a ## parallel data model -- that payload is already the exact structure that ## round-trips through `ChunkDataDecoder.encode_binary()` / ## `decode_binary()` (world.md §3.6, and the interop bugs fixed in that file ## are proof this contract is exercised for real). Editing this model and ## re-encoding it must always produce a valid, byte-compatible chunk, since ## the sibling save-path task depends on that being true. ## ## Every method here is a "raw" mutator: it changes the payload and returns ## whatever an undo needs to reverse the change, but it does **not** know ## about undo/redo itself. `scripts/editor/editor_core.gd`'s tools ## (scripts/editor/tools/*.gd) are the only things that call these -- they ## wrap each call in an `EditorUndoRedoStack` action. Nothing here should be ## called directly by UI code; that would bypass undo/redo silently. var payload: ChunkDataDecoder.ChunkPayload ## Monotonic counter seeded from the highest existing "obj_NNNN"-style ## numeric suffix already in the chunk, so freshly generated ids never ## collide with ids that arrived from a decoded chunk. var _next_object_seq: int = 0 func _init(p_payload: ChunkDataDecoder.ChunkPayload = null) -> void: payload = p_payload if p_payload != null else ChunkDataDecoder.ChunkPayload.new() _seed_object_sequence() func _seed_object_sequence() -> void: for obj in payload.objects: var n := _numeric_suffix(obj.id) if n >= _next_object_seq: _next_object_seq = n + 1 static func _numeric_suffix(id: String) -> int: var digits := "" for i in range(id.length() - 1, -1, -1): var c: String = id[i] if c.is_valid_int(): digits = c + digits else: break return int(digits) if digits != "" else -1 ## Build a fresh, empty single-layer chunk model -- convenient for tests and ## for a "new chunk" starting point in the in-game editor before a real ## chunk has been loaded from the backend (loading real chunks over HTTP is ## HttpChunkSource's job; this just gives the editor something valid to ## operate on). static func create_empty(width: int = 32, height: int = 32, world_id: String = "", room_id: String = "main", coord: Vector3i = Vector3i.ZERO) -> ChunkEditorModel: var layer := ChunkDataDecoder.TileLayerData.new() layer.name = "ground" layer.layer_id = 0 layer.width = width layer.height = height layer.depth = 1 layer.palette = [ TileDef.new(0, "", false, 0, 1), TileDef.new(1, "tiles/default", true, 0, 1), ] var tiles := PackedInt32Array() tiles.resize(width * height) layer.tiles = tiles var payload := ChunkDataDecoder.ChunkPayload.new() payload.world_id = world_id payload.room_id = room_id payload.coord = coord payload.bounds = AABB(Vector3(coord.x * width, coord.y * height, coord.z), Vector3(width, height, 1)) payload.tile_layers = [layer] payload.revision = 0 payload.created_at = Time.get_unix_time_from_system() payload.updated_at = payload.created_at return ChunkEditorModel.new(payload) static func from_binary(data: PackedByteArray) -> ChunkEditorModel: var decoded := ChunkDataDecoder.decode_binary(data) if decoded == null: return null return ChunkEditorModel.new(decoded) func encode_binary() -> PackedByteArray: return ChunkDataDecoder.encode_binary(payload) # ───────────────────────── tiles ───────────────────────── func get_layer(layer_idx: int) -> ChunkDataDecoder.TileLayerData: if layer_idx < 0 or layer_idx >= payload.tile_layers.size(): return null return payload.tile_layers[layer_idx] func is_tile_in_bounds(layer_idx: int, x: int, y: int, z: int = 0) -> bool: var layer := get_layer(layer_idx) if layer == null: return false return x >= 0 and x < layer.width and y >= 0 and y < layer.height and z >= 0 and z < layer.depth func get_tile_raw(layer_idx: int, x: int, y: int, z: int = 0) -> int: var layer := get_layer(layer_idx) if layer == null: return 0 return layer.get_tile(x, y, z) ## Raw setter -- undo-unaware. Only ever called (in both directions) by ## BrushTool/EraseTool via EditorUndoRedoStack. func set_tile_raw(layer_idx: int, x: int, y: int, z: int, tile_id: int) -> void: var layer := get_layer(layer_idx) if layer == null: return layer.set_tile(x, y, z, tile_id) # ───────────────────────── objects ───────────────────────── func find_object(object_id: String) -> ChunkDataDecoder.WorldObjectData: for obj in payload.objects: if obj.id == object_id: return obj return null func get_object_index(object_id: String) -> int: for i in range(payload.objects.size()): if payload.objects[i].id == object_id: return i return -1 func generate_object_id() -> String: var id := "obj_%04d" % _next_object_seq _next_object_seq += 1 return id ## Raw mutators -- undo-unaware. Only ever called (in both directions) by ## ObjectPlaceTool/EraseTool/TransformTool via EditorUndoRedoStack. func add_object_raw(obj: ChunkDataDecoder.WorldObjectData) -> void: payload.objects.append(obj) func insert_object_raw(obj: ChunkDataDecoder.WorldObjectData, index: int) -> void: var clamped: int = clampi(index, 0, payload.objects.size()) payload.objects.insert(clamped, obj) func remove_object_raw(object_id: String) -> ChunkDataDecoder.WorldObjectData: var idx := get_object_index(object_id) if idx == -1: return null var obj := payload.objects[idx] payload.objects.remove_at(idx) return obj func set_object_position_raw(object_id: String, position: Vector3) -> void: var obj := find_object(object_id) if obj != null: obj.position = position func set_object_rotation_raw(object_id: String, rotation: Vector4) -> void: var obj := find_object(object_id) if obj != null: obj.rotation = rotation func set_object_scale_raw(object_id: String, scale: Vector3) -> void: var obj := find_object(object_id) if obj != null: obj.scale = scale func set_object_property_raw(object_id: String, key: String, value: Variant) -> void: var obj := find_object(object_id) if obj != null: if value == null: obj.properties.erase(key) else: obj.properties[key] = value