From d9061b3b47319153047c996a2c72d69a8c48eb05 Mon Sep 17 00:00:00 2001 From: saurabh Date: Wed, 26 Nov 2025 18:57:24 +0530 Subject: [PATCH] feat: Q/E rotation, camera transform fix, 4th pitch level, controller support, chunk loading, undo/redo heights --- .gitignore | 16 + CONTROLS.md | 33 ++ LICENSE | 21 ++ README.md | 63 ++++ assets/blocks/isometric-grass.svg | 4 + assets/blocks/isometric-grass.svg.import | 43 +++ assets/blocks/isometric-soil.svg | 5 + assets/blocks/isometric-soil.svg.import | 43 +++ assets/blocks/isometric-stone.svg | 5 + assets/blocks/isometric-stone.svg.import | 43 +++ assets/blocks/isometric-water.svg | 5 + assets/blocks/isometric-water.svg.import | 43 +++ assets/blocks/isometric-wood.svg | 4 + assets/blocks/isometric-wood.svg.import | 43 +++ docs/API.md | 376 +++++++++++++++++++++ docs/DEBUGGING.md | 362 ++++++++++++++++++++ icon.svg | 1 + icon.svg.import | 43 +++ project.godot | 108 ++++++ scenes/Main.tscn | 120 +++++++ scripts/Main.gd | 191 +++++++++++ scripts/Main.gd.uid | 1 + scripts/core/BlockInstance.gd | 45 +++ scripts/core/BlockInstance.gd.uid | 1 + scripts/core/BlockType.gd | 52 +++ scripts/core/BlockType.gd.uid | 1 + scripts/core/Chunk.gd | 82 +++++ scripts/core/Chunk.gd.uid | 1 + scripts/core/WorldAPI.gd | 256 ++++++++++++++ scripts/core/WorldAPI.gd.uid | 1 + scripts/rendering/IsometricCamera.gd | 182 ++++++++++ scripts/rendering/IsometricCamera.gd.uid | 1 + scripts/rendering/IsometricRenderer.gd | 172 ++++++++++ scripts/rendering/IsometricRenderer.gd.uid | 1 + scripts/systems/FogOfWarSystem.gd | 128 +++++++ scripts/systems/FogOfWarSystem.gd.uid | 1 + scripts/systems/InteractionSystem.gd | 351 +++++++++++++++++++ scripts/systems/InteractionSystem.gd.uid | 1 + scripts/systems/LightingSystem.gd | 155 +++++++++ scripts/systems/LightingSystem.gd.uid | 1 + 40 files changed, 3005 insertions(+) create mode 100644 .gitignore create mode 100644 CONTROLS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/blocks/isometric-grass.svg create mode 100644 assets/blocks/isometric-grass.svg.import create mode 100644 assets/blocks/isometric-soil.svg create mode 100644 assets/blocks/isometric-soil.svg.import create mode 100644 assets/blocks/isometric-stone.svg create mode 100644 assets/blocks/isometric-stone.svg.import create mode 100644 assets/blocks/isometric-water.svg create mode 100644 assets/blocks/isometric-water.svg.import create mode 100644 assets/blocks/isometric-wood.svg create mode 100644 assets/blocks/isometric-wood.svg.import create mode 100644 docs/API.md create mode 100644 docs/DEBUGGING.md create mode 100644 icon.svg create mode 100644 icon.svg.import create mode 100644 project.godot create mode 100644 scenes/Main.tscn create mode 100644 scripts/Main.gd create mode 100644 scripts/Main.gd.uid create mode 100644 scripts/core/BlockInstance.gd create mode 100644 scripts/core/BlockInstance.gd.uid create mode 100644 scripts/core/BlockType.gd create mode 100644 scripts/core/BlockType.gd.uid create mode 100644 scripts/core/Chunk.gd create mode 100644 scripts/core/Chunk.gd.uid create mode 100644 scripts/core/WorldAPI.gd create mode 100644 scripts/core/WorldAPI.gd.uid create mode 100644 scripts/rendering/IsometricCamera.gd create mode 100644 scripts/rendering/IsometricCamera.gd.uid create mode 100644 scripts/rendering/IsometricRenderer.gd create mode 100644 scripts/rendering/IsometricRenderer.gd.uid create mode 100644 scripts/systems/FogOfWarSystem.gd create mode 100644 scripts/systems/FogOfWarSystem.gd.uid create mode 100644 scripts/systems/InteractionSystem.gd create mode 100644 scripts/systems/InteractionSystem.gd.uid create mode 100644 scripts/systems/LightingSystem.gd create mode 100644 scripts/systems/LightingSystem.gd.uid diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50335d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Godot 4+ specific ignores +.godot/ +.nomedia + +# Godot-specific ignores +.import/ +export.cfg +export_credentials.cfg + +# Imported translations (automatically generated from CSV files) +*.translation + +# Mono-specific ignores +.mono/ +data_*/ +mono_crash.*.json diff --git a/CONTROLS.md b/CONTROLS.md new file mode 100644 index 0000000..9e18622 --- /dev/null +++ b/CONTROLS.md @@ -0,0 +1,33 @@ +# OpenIsopix Controls + +## Camera Controls +- **Arrow Keys** - Move camera around the world +- **Q/E** - Rotate camera view (4 directions: N, E, S, W) +- **P** - Change pitch angle (cycle through 30°, 45°, 60°, 90°) +- **Mouse Wheel / +/-** - Zoom in/out + +## Block Selection (1-5 Keys) +- **1** - Select Grass block +- **2** - Select Stone block +- **3** - Select Water block +- **4** - Select Wood block +- **5** - Select Soil block + +## Interaction Modes (Space to Cycle) +- **SELECT Mode** - Click blocks to query/select +- **PLACE Mode** - Click to place selected block type +- **REMOVE Mode** - Click to remove blocks +- **QUERY Mode** - Click to get block information + +## Mouse Actions +- **Left Click** - Place block (when in PLACE mode) or Select/Query +- **Right Click** - Remove block (when in REMOVE mode) + +## Special Features +- **F** - Toggle Fog of War (shows/hides explored areas) + +## Tips +1. Use **Space** to cycle between interaction modes +2. Select block type with **1-5** before placing +3. Right-click to quickly remove unwanted blocks +4. Use **Q/E** to rotate and view from different angles diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..073c6c6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 OpenSword + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a989795 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# OpenIsopix +An open project for 2d isometric pixel-art games. + +## Bounty status + + + +
+Instructions / more info + +> You may claim a bounty by closing the issue linked above with a PR that meets the goals/requirements below. + +Choose one of the platforms below to claim the bounty: + +- [Boss.dev](https://www.boss.dev/issue/github/I_kwDOQSBVes7W1sRk) +- [IssueHunt](https://oss.issuehunt.io/r/OpenSword/OpenIsopix/issues/1) +- [Gitpay](https://gitpay.me/#/task/1312): send me a bounty suggestion at the e-mail `ggondim7@proton.me` using the announced price at Boss.dev/IssueHunt as reference + +
+ +## Goals / requirements + +A reality system using **Godot Engine** with the following features: + +- [ ] Isometric tilemap rendering _`(in. AoE2, RollerCoaster, SimCity 2000)`_ +- [ ] Pixel-art style graphics _`(in. Stardew Valley, Terraria)`_ +- [ ] Block-based world, clustered in 16x16 chunks, with 2 heights (0.5 and 1.0 block height) _`(in. Minecraft)`_ +- [ ] Different Point of Views (POV), supporting: + - [ ] Heading/rotation (NSWE) + - [ ] Zoom in/out + - [ ] Pitch/tilt in 4 levels: low (closer to the ground), normal, high (closer to top-down), top-down _`(in. Final Fantasy Tactics)`_ +- [ ] Smooth scrolling and POV movement +- [ ] Lighting system with different environmental lighting levels _`(in. Stardew Valley)`_ +- [ ] Map revelation/fog support _`(in. AoE2, Warcraft 3)`_ +- [ ] Blocks as entities with attributes that affect their rendering and behavior: + - [ ] Behavior flags: solid/non-solid, opaque/non-opaque, climbable/non-climbable, etc. + - [ ] Visual attributes: animation, light emission, etc. + - [ ] Numeric attributes: HP, material, etc. + - [ ] Interaction attributes: on-click, on-walk-over, etc. +- [ ] Basic world interaction: + - [ ] Selecting and highlighting blocks under the cursor + - [ ] Adding/removing/modifying blocks + - [ ] Querying block attributes + - [ ] Chunk management: loading/unloading chunks +- [ ] Mouse and controller support + +_*Legend: `(in.)` Inspiration_ + +## Rules + +- It MUST have an API that supports interacting with the world regardless of any user interface, including: + - Adding/removing/modifying blocks + - Querying block attributes + - Handling events (e.g., block updates, lighting changes) +- It MAY have a basic user interface only for demonstration purposes, but the core functionality MUST be decoupled from any specific UI implementation. +- It MUST not depend on any specific game logic or assets; it should allow for easy creation of new blocks and behaviors. +- It SHOULD be made with extensibility in mind, specifically to allow for future features that are not in the requirements, like: + - Player/NPC entities with movement and interaction + - Automatic generation or manual building of worlds + - In-game user interfaces (HUD, inventory, menus, chat, etc.) + - Global/biome-specific environmental effects (weather, seasons, day/night cycle, etc.) +- It SHOULD be optimized for performance, especially regarding rendering and chunk management. +- It SHOULD be compatible with different platforms supported by Godot Engine (PC, mobile, web, etc.). diff --git a/assets/blocks/isometric-grass.svg b/assets/blocks/isometric-grass.svg new file mode 100644 index 0000000..51e632e --- /dev/null +++ b/assets/blocks/isometric-grass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/blocks/isometric-grass.svg.import b/assets/blocks/isometric-grass.svg.import new file mode 100644 index 0000000..983a847 --- /dev/null +++ b/assets/blocks/isometric-grass.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://jd0e4pm36sth" +path="res://.godot/imported/isometric-grass.svg-c7f0d58de74f553c43ff0d8fa326480d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/blocks/isometric-grass.svg" +dest_files=["res://.godot/imported/isometric-grass.svg-c7f0d58de74f553c43ff0d8fa326480d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/blocks/isometric-soil.svg b/assets/blocks/isometric-soil.svg new file mode 100644 index 0000000..d2f09aa --- /dev/null +++ b/assets/blocks/isometric-soil.svg @@ -0,0 +1,5 @@ + + + + diff --git a/assets/blocks/isometric-soil.svg.import b/assets/blocks/isometric-soil.svg.import new file mode 100644 index 0000000..1fb8663 --- /dev/null +++ b/assets/blocks/isometric-soil.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cqtcsxq50k0im" +path="res://.godot/imported/isometric-soil.svg-df29826697cd6ad1a28c2b8095c7e1fd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/blocks/isometric-soil.svg" +dest_files=["res://.godot/imported/isometric-soil.svg-df29826697cd6ad1a28c2b8095c7e1fd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/blocks/isometric-stone.svg b/assets/blocks/isometric-stone.svg new file mode 100644 index 0000000..2040822 --- /dev/null +++ b/assets/blocks/isometric-stone.svg @@ -0,0 +1,5 @@ + + + + diff --git a/assets/blocks/isometric-stone.svg.import b/assets/blocks/isometric-stone.svg.import new file mode 100644 index 0000000..e87419f --- /dev/null +++ b/assets/blocks/isometric-stone.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3v4mwol1m174" +path="res://.godot/imported/isometric-stone.svg-e3e8771d77841634b3ec647f7ae6f1ee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/blocks/isometric-stone.svg" +dest_files=["res://.godot/imported/isometric-stone.svg-e3e8771d77841634b3ec647f7ae6f1ee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/blocks/isometric-water.svg b/assets/blocks/isometric-water.svg new file mode 100644 index 0000000..9ab0e93 --- /dev/null +++ b/assets/blocks/isometric-water.svg @@ -0,0 +1,5 @@ + + + + diff --git a/assets/blocks/isometric-water.svg.import b/assets/blocks/isometric-water.svg.import new file mode 100644 index 0000000..9ae51a1 --- /dev/null +++ b/assets/blocks/isometric-water.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://tyveqw0qdm4x" +path="res://.godot/imported/isometric-water.svg-22d3c45208538c9115ac6824dc639ebc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/blocks/isometric-water.svg" +dest_files=["res://.godot/imported/isometric-water.svg-22d3c45208538c9115ac6824dc639ebc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/blocks/isometric-wood.svg b/assets/blocks/isometric-wood.svg new file mode 100644 index 0000000..ec7b4c1 --- /dev/null +++ b/assets/blocks/isometric-wood.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/blocks/isometric-wood.svg.import b/assets/blocks/isometric-wood.svg.import new file mode 100644 index 0000000..546dd6f --- /dev/null +++ b/assets/blocks/isometric-wood.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cjngmjk8bvb38" +path="res://.godot/imported/isometric-wood.svg-b1373981494e38c702b8c03d2116c4e9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/blocks/isometric-wood.svg" +dest_files=["res://.godot/imported/isometric-wood.svg-b1373981494e38c702b8c03d2116c4e9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..e54707e --- /dev/null +++ b/docs/API.md @@ -0,0 +1,376 @@ +# OpenIsopix API Documentation + +## Overview + +OpenIsopix provides a complete API for building 2D isometric block-based worlds with pixel-art graphics. The system is fully decoupled from UI, allowing easy integration into any game or application. + +## Core Components + +### 1. WorldAPI (`scripts/core/WorldAPI.gd`) + +The main interface for world manipulation. All world interactions should go through this API. + +#### Key Methods + +```gdscript +# Register a new block type +func register_block_type(block_type: BlockType) -> void + +# Add a block to the world +func add_block(world_pos: Vector3i, type_id: String, height: float = 1.0) -> BlockInstance + +# Remove a block from the world +func remove_block(world_pos: Vector3i) -> void + +# Get a block at world position +func get_block(world_pos: Vector3i) -> BlockInstance + +# Modify a block's properties +func modify_block(world_pos: Vector3i, property: String, value: Variant) -> void + +# Query block attributes +func get_block_attribute(world_pos: Vector3i, attribute: String) -> Variant + +# Chunk management +func load_chunk(chunk_pos: Vector2i) -> Chunk +func unload_chunk(chunk_pos: Vector2i) -> void +func get_chunk_blocks(chunk_pos: Vector2i) -> Array[BlockInstance] + +# Trigger events +func trigger_block_click(world_pos: Vector3i) -> void +func trigger_block_walk_over(world_pos: Vector3i) -> void +``` + +#### Signals + +```gdscript +signal block_added(position: Vector3i, block: BlockInstance) +signal block_removed(position: Vector3i) +signal block_modified(position: Vector3i, block: BlockInstance) +signal block_clicked(position: Vector3i, block: BlockInstance) +signal block_walked_over(position: Vector3i, block: BlockInstance) +signal chunk_loaded(chunk_pos: Vector2i) +signal chunk_unloaded(chunk_pos: Vector2i) +signal lighting_changed(position: Vector3i, light_level: float) +``` + +#### Example Usage + +```gdscript +# Get reference to WorldAPI +var world_api = get_node("/root/Main/WorldAPI") + +# Create a custom block type +var custom_block = BlockType.new("my_block", "My Custom Block") +custom_block.is_solid = true +custom_block.emits_light = true +custom_block.light_color = Color.CYAN +world_api.register_block_type(custom_block) + +# Place blocks +world_api.add_block(Vector3i(0, 0, 0), "grass") +world_api.add_block(Vector3i(1, 0, 0), "my_block") + +# Query a block +var block = world_api.get_block(Vector3i(0, 0, 0)) +if block: + print("Block type: ", block.get_type_id()) + print("Block HP: ", block.hp) + +# Listen for events +world_api.block_added.connect(_on_block_added) +func _on_block_added(pos: Vector3i, block: BlockInstance): + print("Block added at ", pos) +``` + +### 2. BlockType (`scripts/core/BlockType.gd`) + +Defines a block type with all its properties. + +#### Properties + +```gdscript +# Identification +@export var id: String +@export var display_name: String + +# Behavior flags +@export var is_solid: bool = true +@export var is_opaque: bool = true +@export var is_climbable: bool = false +@export var is_walkable: bool = true + +# Visual attributes +@export var texture: Texture2D +@export var animated: bool = false +@export var animation_frames: Array[Texture2D] +@export var emits_light: bool = false +@export var light_color: Color = Color.WHITE +@export var light_intensity: float = 1.0 +@export var light_radius: float = 100.0 + +# Numeric attributes +@export var max_hp: float = 100.0 +@export var material_type: String = "generic" +@export var hardness: float = 1.0 + +# Interaction +@export var on_click_enabled: bool = true +@export var on_walk_over_enabled: bool = false +``` + +### 3. BlockInstance (`scripts/core/BlockInstance.gd`) + +Represents a single block in the world. + +#### Properties + +```gdscript +var position: Vector3i # World position +var block_type: BlockType # Type reference +var hp: float # Current health +var height: float = 1.0 # Height (0.5 or 1.0) +var light_level: float = 1.0 # Lighting (0.0 to 1.0) +var is_revealed: bool = false # Fog of war state +var custom_data: Dictionary # Extensible properties +``` + +#### Methods + +```gdscript +func take_damage(amount: float) -> bool # Returns true if destroyed +func heal(amount: float) -> void +func is_destroyed() -> bool +func get_type_id() -> String +``` + +### 4. Chunk (`scripts/core/Chunk.gd`) + +Manages a 16x16 region of blocks. + +#### Constants + +```gdscript +const CHUNK_SIZE = 16 +``` + +#### Methods + +```gdscript +func set_block(local_pos: Vector3i, block: BlockInstance) -> void +func get_block(local_pos: Vector3i) -> BlockInstance +func remove_block(local_pos: Vector3i) -> void +func to_world_pos(local_pos: Vector3i) -> Vector3i +func get_all_blocks() -> Array[BlockInstance] +``` + +### 5. IsometricCamera (`scripts/rendering/IsometricCamera.gd`) + +Controls camera POV with rotation, zoom, and pitch. + +#### Enums + +```gdscript +enum Heading { NORTH, EAST, SOUTH, WEST } +enum PitchLevel { LOW, NORMAL, HIGH, TOP_DOWN } +``` + +#### Methods + +```gdscript +func rotate_heading(clockwise: bool = true) -> void +func cycle_pitch() -> void +func zoom_camera(zoom_in: bool) -> void +func set_camera_position(new_position: Vector2) -> void +func world_to_iso(world_pos: Vector3) -> Vector2 +func screen_to_world(screen_pos: Vector2) -> Vector3 +``` + +#### Signals + +```gdscript +signal heading_changed(new_heading: Heading) +signal pitch_changed(new_pitch: PitchLevel) +signal zoom_changed(new_zoom: float) +``` + +### 6. LightingSystem (`scripts/systems/LightingSystem.gd`) + +Manages environmental and block-emitted lighting. + +#### Enums + +```gdscript +enum EnvironmentalLevel { + PITCH_BLACK, VERY_DARK, DARK, DIM, + NORMAL, BRIGHT, VERY_BRIGHT +} +``` + +#### Methods + +```gdscript +func set_environmental_level(level: EnvironmentalLevel) -> void +func calculate_block_lighting(world_pos: Vector3i) -> float +``` + +#### Signals + +```gdscript +signal lighting_updated(position: Vector3i, light_level: float) +``` + +### 7. FogOfWarSystem (`scripts/systems/FogOfWarSystem.gd`) + +Manages map revelation and exploration. + +#### Methods + +```gdscript +func reveal_area(center: Vector3i, radius: float = -1.0) -> void +func is_revealed(world_pos: Vector3i) -> bool +func hide_area(center: Vector3i, radius: float = -1.0) -> void +func reveal_all() -> void +func hide_all() -> void +``` + +#### Signals + +```gdscript +signal area_revealed(center: Vector3i, radius: float) +signal block_revealed(position: Vector3i) +``` + +### 8. InteractionSystem (`scripts/systems/InteractionSystem.gd`) + +Handles user input and world interaction. + +#### Enums + +```gdscript +enum InteractionMode { SELECT, PLACE, REMOVE, QUERY } +``` + +#### Methods + +```gdscript +func cycle_mode() -> void +func set_mode(mode: InteractionMode) -> void +``` + +#### Signals + +```gdscript +signal block_selected(position: Vector3i) +signal block_hovered(position: Vector3i) +signal interaction_mode_changed(mode: InteractionMode) +``` + +## Complete Example: Creating a Custom Game + +```gdscript +extends Node2D + +var world_api: WorldAPI +var camera: IsometricCamera +var lighting: LightingSystem + +func _ready(): + # Get system references + world_api = $WorldAPI + camera = $IsometricCamera + lighting = $LightingSystem + + # Register custom blocks + _register_custom_blocks() + + # Generate world + _generate_world() + + # Setup game logic + world_api.block_clicked.connect(_on_block_clicked) + +func _register_custom_blocks(): + # Create a glowing crystal block + var crystal = BlockType.new("crystal", "Magic Crystal") + crystal.is_solid = true + crystal.is_opaque = false + crystal.emits_light = true + crystal.light_color = Color(0.5, 0.0, 1.0) + crystal.light_intensity = 1.5 + crystal.max_hp = 50.0 + world_api.register_block_type(crystal) + + # Create a trap block + var trap = BlockType.new("trap", "Spike Trap") + trap.is_solid = true + trap.on_walk_over_enabled = true + world_api.register_block_type(trap) + world_api.block_walked_over.connect(_on_trap_activated) + +func _generate_world(): + # Create a 20x20 world + for x in range(-10, 10): + for z in range(-10, 10): + world_api.add_block(Vector3i(x, 0, z), "grass") + + # Add some crystals + world_api.add_block(Vector3i(0, 1, 0), "crystal") + world_api.add_block(Vector3i(5, 1, 5), "crystal") + + # Set darker environment + lighting.set_environmental_level(LightingSystem.EnvironmentalLevel.DARK) + +func _on_block_clicked(pos: Vector3i, block: BlockInstance): + if block.get_type_id() == "crystal": + print("Collected magic crystal!") + world_api.remove_block(pos) + +func _on_trap_activated(pos: Vector3i, block: BlockInstance): + print("Player took damage from trap!") +``` + +## Architecture Notes + +### Decoupling + +The system is designed with clear separation: +- **Core**: Data structures and world management (no rendering) +- **Rendering**: Visual representation (uses core data) +- **Systems**: Game logic (lighting, fog, interaction) +- **UI**: User interface (optional, demo only) + +### Extensibility + +Add new features by: +1. Creating new `BlockType` definitions +2. Using `custom_data` dictionary in `BlockInstance` +3. Connecting to WorldAPI signals +4. Extending systems (create new nodes) + +### Performance + +- Chunk-based loading (16x16 blocks per chunk) +- Incremental lighting updates (100 blocks per frame) +- Sprite caching for rendered blocks +- Z-index based rendering order + +## Input Mapping + +Default input actions (defined in `project.godot`): + +- `ui_left/right/up/down`: Camera movement +- `zoom_in/out`: Camera zoom +- `rotate_left/right`: Camera rotation (Q/E) +- `change_pitch`: Cycle pitch levels (P) +- `place_block`: Place block (LMB) +- `remove_block`: Remove block (RMB) +- `toggle_fog`: Toggle fog reveal (F) + +## Next Steps + +1. **Add Custom Blocks**: Create new `BlockType` resources +2. **Implement Game Logic**: Connect to WorldAPI signals +3. **Create Assets**: Replace placeholder textures with pixel art +4. **Extend Systems**: Add entities, AI, procedural generation +5. **Optimize**: Profile and optimize for target platform diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md new file mode 100644 index 0000000..52f53b2 --- /dev/null +++ b/docs/DEBUGGING.md @@ -0,0 +1,362 @@ +# OpenIsopix - Debugging & Testing Guide + +## Project Setup + +### Requirements + +- Godot Engine 4.3 or later +- Windows/Linux/macOS + +### Opening the Project + +1. Clone or download the repository +2. Open Godot Engine +3. Click "Import" and select the `project.godot` file +4. Click "Import & Edit" + +### Running the Demo + +1. Press F5 or click the "Play" button +2. The main scene (`scenes/Main.tscn`) will launch automatically + +## Debug Tools + +### Console Output + +The demo prints useful debug information: +- Block placement/removal confirmations +- Interaction mode changes +- Block queries (position, type, attributes) +- System initialization messages + +### In-Game Debug Commands + +Press the following keys to test features: + +1. **World Manipulation** + - `1-4`: Select block type (Grass/Stone/Water/Torch) + - `LMB`: Place selected block + - `RMB`: Remove block at cursor + - `Space`: Cycle interaction modes (SELECT/PLACE/REMOVE/QUERY) + +2. **Camera Controls** + - `Arrow Keys`: Pan camera + - `Q/E`: Rotate heading (90° increments) + - `P`: Cycle pitch (LOW/NORMAL/HIGH/TOP_DOWN) + - `+/-` or `Mouse Wheel`: Zoom in/out + +3. **System Testing** + - `F`: Reveal fog of war around cursor + - Click blocks in QUERY mode: Print block attributes + +### Query Mode + +Set interaction mode to QUERY (press Space until mode shows "QUERY"), then click any block to see: + +``` +=== Block Query === +Position: (x, y, z) +Type: block_type_id +HP: current/max +Height: 0.5 or 1.0 +Light Level: 0.0 to 1.0 +Revealed: true/false +Solid: true/false +Opaque: true/false +Climbable: true/false +Emits Light: true/false +================== +``` + +## Testing Scenarios + +### 1. Block Management + +**Test: Add and Remove Blocks** +``` +1. Press '1' to select Grass +2. Left-click empty spaces to place grass blocks +3. Right-click placed blocks to remove them +4. Press '2' to select Stone, repeat +``` + +**Expected**: Blocks appear/disappear correctly, world updates immediately + +### 2. Chunk System + +**Test: Chunk Loading** +``` +1. Move camera far from origin (use arrow keys) +2. Place blocks in different areas +3. Return to origin +``` + +**Expected**: Blocks persist, chunks load/unload seamlessly + +### 3. Lighting System + +**Test: Light Sources** +``` +1. Press '4' to select Torch +2. Place torches in different locations +3. Observe lighting on nearby blocks +4. Remove torches and watch lighting update +``` + +**Expected**: +- Torches emit yellow-orange light +- Light attenuates with distance +- Blocks in darkness are darker +- Lighting updates when torches are added/removed + +### 4. Fog of War + +**Test: Map Revelation** +``` +1. Notice some areas are semi-transparent (unrevealed) +2. Press 'F' to reveal area around cursor +3. Move camera to unrevealed areas +4. Press 'F' to reveal more +``` + +**Expected**: +- Unrevealed blocks are transparent/dim +- Revealed blocks are fully visible +- Revelation persists + +### 5. Camera System + +**Test: Point of View Changes** +``` +1. Press 'Q' repeatedly - world rotates counter-clockwise +2. Press 'E' repeatedly - world rotates clockwise +3. Press 'P' repeatedly - camera pitch changes (4 levels) +4. Use mouse wheel or +/- to zoom +``` + +**Expected**: +- Each rotation is 90° (4 total headings) +- Pitch changes vertical viewing angle +- Zoom maintains center point +- Movement controls adjust to rotation + +### 6. Interaction Modes + +**Test: Mode Switching** +``` +1. Press Space to cycle modes: SELECT → PLACE → REMOVE → QUERY +2. Observe cursor highlight color changes: + - SELECT: Yellow + - PLACE: Green + - REMOVE: Red + - QUERY: Blue +``` + +**Expected**: Mode changes reflected in cursor and behavior + +### 7. Block Heights + +**Test: Multi-Level Building** +``` +1. Place a block at ground level (y=0) +2. Click the same position - new block appears on top (y=1) +3. Continue clicking to stack blocks +4. Right-click to remove top blocks first +``` + +**Expected**: Blocks stack vertically, removal works top-to-bottom + +## Common Issues & Solutions + +### Issue: Blocks Not Appearing + +**Possible Causes:** +1. Camera too far from world origin +2. Blocks placed outside visible area +3. Renderer not connected to WorldAPI + +**Solution:** +- Reset camera position to (0, 0) +- Check console for errors +- Verify `renderer.world_api` is set in Main scene + +### Issue: Input Not Working + +**Possible Causes:** +1. UI overlay capturing input +2. Wrong scene running +3. Input map not configured + +**Solution:** +- Ensure Main.tscn is running +- Check Project Settings → Input Map +- Verify mouse is not over UI panels + +### Issue: Lighting Not Updating + +**Possible Causes:** +1. LightingSystem not processing +2. Too many blocks causing queue backlog +3. WorldAPI not connected to lighting system + +**Solution:** +- Check `lighting_system.world_api` reference +- Reduce world size for testing +- Verify signals are connected + +### Issue: Fog Not Working + +**Possible Causes:** +1. FogSystem not initialized +2. Starting area not revealed +3. Fog disabled in settings + +**Solution:** +- Check `_generate_demo_world()` calls `fog_system.reveal_area()` +- Manually press 'F' to reveal +- Verify `fog_system.world_api` is set + +## Unit Testing + +### Manual Test Checklist + +Create `tests/manual_tests.md` and track: + +- [ ] Place blocks of each type +- [ ] Remove blocks +- [ ] Rotate camera all 4 directions +- [ ] Test all 4 pitch levels +- [ ] Zoom in to max +- [ ] Zoom out to min +- [ ] Place torch and verify lighting +- [ ] Remove torch and verify lighting updates +- [ ] Reveal fog of war +- [ ] Query block in QUERY mode +- [ ] Stack blocks vertically +- [ ] Move camera to chunk boundaries +- [ ] Place blocks across multiple chunks +- [ ] Test all interaction modes + +### Automated Testing (Future) + +For automated tests, use Godot's GUT framework: + +```gdscript +# Example test structure +extends GutTest + +var world_api: WorldAPI + +func before_each(): + world_api = WorldAPI.new() + add_child(world_api) + +func test_add_block(): + var block = world_api.add_block(Vector3i(0, 0, 0), "grass") + assert_not_null(block) + assert_eq(block.get_type_id(), "grass") + +func test_remove_block(): + world_api.add_block(Vector3i(0, 0, 0), "grass") + world_api.remove_block(Vector3i(0, 0, 0)) + var block = world_api.get_block(Vector3i(0, 0, 0)) + assert_null(block) +``` + +## Performance Profiling + +### Using Godot Profiler + +1. Run project (F5) +2. Go to Debugger tab → Profiler +3. Monitor key metrics: + - **FPS**: Should stay above 60 + - **Process Time**: Watch for spikes + - **Physics Time**: Should be minimal (no physics used) + - **Script Functions**: Check WorldAPI and Renderer calls + +### Optimization Checkpoints + +Monitor these values during gameplay: + +- **Block Count**: < 10,000 for smooth performance +- **Chunk Count**: Only nearby chunks loaded (determined by `chunk_load_distance`) +- **Sprite Count**: One sprite per visible block +- **Light Updates**: Max 100 per frame (controlled by `LightingSystem._process`) + +### Memory Usage + +Check memory in Debugger → Monitor: +- **Object Count**: Should stabilize after world generation +- **Resource Count**: Textures cached, not recreated +- **Memory**: Should not continuously increase + +## Debugging Scripts + +### Enable Verbose Logging + +Add to any script: + +```gdscript +const DEBUG = true + +func _log(message: String): + if DEBUG: + print("[", get_script().get_path().get_file(), "] ", message) +``` + +### Visualize Chunk Boundaries + +Add to `IsometricRenderer`: + +```gdscript +func _draw_chunk_boundaries(): + for chunk_key in world_api.chunks.keys(): + var chunk = world_api.chunks[chunk_key] + var min_pos = chunk.min_bound + var max_pos = chunk.max_bound + # Draw rectangles at chunk edges + # (implement using Line2D nodes) +``` + +### Inspect World State + +At any time in code: + +```gdscript +func _print_world_stats(): + print("=== World Stats ===") + print("Chunks loaded: ", world_api.chunks.size()) + var total_blocks = 0 + for chunk in world_api.chunks.values(): + total_blocks += chunk.get_all_blocks().size() + print("Total blocks: ", total_blocks) + print("Block types: ", world_api.block_types.keys()) + print("==================") +``` + +## Continuous Integration + +For automated testing in CI/CD: + +```bash +# Example GitHub Actions workflow +- name: Run Godot Tests + run: | + godot --headless --path . --script tests/run_tests.gd +``` + +## Getting Help + +If issues persist: + +1. Check console output for errors +2. Verify all node paths in scenes match script expectations +3. Ensure Godot version is 4.3+ +4. Review API.md for correct usage +5. Create minimal reproduction scene +6. Report issues with: + - Console output + - Steps to reproduce + - Expected vs actual behavior + - Godot version and OS diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..b370ceb --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..502cb4f --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nn2cavvjr85b" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..ab5c71e --- /dev/null +++ b/project.godot @@ -0,0 +1,108 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="OpenIsopix" +config/description="An open project for 2D isometric pixel-art games" +run/main_scene="res://scenes/Main.tscn" +config/features=PackedStringArray("4.5", "Forward Plus") +config/icon="res://icon.svg" + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 +window/stretch/mode="viewport" + +[input] + +ui_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":13,"pressure":0.0,"pressed":false,"script":null) +] +} +ui_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":14,"pressure":0.0,"pressed":false,"script":null) +] +} +ui_up={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":11,"pressure":0.0,"pressed":false,"script":null) +] +} +ui_down={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194322,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":12,"pressure":0.0,"pressed":false,"script":null) +] +} +zoom_in={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":61,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +zoom_out={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":45,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +rotate_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":81,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":9,"pressure":0.0,"pressed":false,"script":null) +] +} +rotate_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null) +] +} +change_pitch={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":80,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":3,"pressure":0.0,"pressed":false,"script":null) +] +} +place_block={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":0,"pressure":0.0,"pressed":false,"script":null) +] +} +remove_block={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":1,"pressure":0.0,"pressed":false,"script":null) +] +} +toggle_fog={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":70,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} + +[layer_names] + +2d_render/layer_1="World" +2d_render/layer_2="Fog" +2d_render/layer_3="UI" + +[rendering] + +textures/canvas_textures/default_texture_filter=0 +anti_aliasing/quality/msaa_2d=1 diff --git a/scenes/Main.tscn b/scenes/Main.tscn new file mode 100644 index 0000000..541dac6 --- /dev/null +++ b/scenes/Main.tscn @@ -0,0 +1,120 @@ +[gd_scene load_steps=8 format=3 uid="uid://b8wd8jvjfx4aw"] + +[ext_resource type="Script" path="res://scripts/Main.gd" id="1"] +[ext_resource type="Script" path="res://scripts/core/WorldAPI.gd" id="2"] +[ext_resource type="Script" path="res://scripts/rendering/IsometricCamera.gd" id="3"] +[ext_resource type="Script" path="res://scripts/rendering/IsometricRenderer.gd" id="4"] +[ext_resource type="Script" path="res://scripts/systems/LightingSystem.gd" id="5"] +[ext_resource type="Script" path="res://scripts/systems/FogOfWarSystem.gd" id="6"] +[ext_resource type="Script" path="res://scripts/systems/InteractionSystem.gd" id="7"] + +[node name="Main" type="Node2D"] +script = ExtResource("1") + +[node name="WorldAPI" type="Node" parent="."] +script = ExtResource("2") + +[node name="IsometricCamera" type="Camera2D" parent="."] +script = ExtResource("3") + +[node name="IsometricRenderer" type="Node2D" parent="."] +script = ExtResource("4") + +[node name="LightingSystem" type="Node" parent="."] +script = ExtResource("5") + +[node name="FogOfWarSystem" type="Node" parent="."] +script = ExtResource("6") + +[node name="InteractionSystem" type="Node" parent="."] +script = ExtResource("7") + +[node name="UI" type="CanvasLayer" parent="."] + +[node name="HUD" type="Control" parent="UI"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="InfoPanel" type="PanelContainer" parent="UI/HUD"] +layout_mode = 0 +offset_left = 10.0 +offset_top = 10.0 +offset_right = 350.0 +offset_bottom = 200.0 + +[node name="VBox" type="VBoxContainer" parent="UI/HUD/InfoPanel"] +layout_mode = 2 + +[node name="Title" type="Label" parent="UI/HUD/InfoPanel/VBox"] +layout_mode = 2 +text = "OpenIsopix Demo" +horizontal_alignment = 1 + +[node name="HSeparator" type="HSeparator" parent="UI/HUD/InfoPanel/VBox"] +layout_mode = 2 + +[node name="Controls" type="Label" parent="UI/HUD/InfoPanel/VBox"] +layout_mode = 2 +text = "Arrow Keys: Move Camera +P: Change Pitch +Q/E: Rotate Camera ++/-: Zoom +LMB: Place Block +RMB: Remove Block +1-5: Block Type +F: Reveal Fog +Space: Cycle Mode" + +[node name="HSeparator2" type="HSeparator" parent="UI/HUD/InfoPanel/VBox"] +layout_mode = 2 + +[node name="Status" type="Label" parent="UI/HUD/InfoPanel/VBox"] +layout_mode = 2 +text = "Mode: SELECT +Block: Grass" + +[node name="BottomPanel" type="PanelContainer" parent="UI/HUD"] +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -200.0 +offset_top = -60.0 +offset_right = 200.0 +offset_bottom = -10.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="HBox" type="HBoxContainer" parent="UI/HUD/BottomPanel"] +layout_mode = 2 +alignment = 1 + +[node name="BlockButtons" type="HBoxContainer" parent="UI/HUD/BottomPanel/HBox"] +layout_mode = 2 + +[node name="GrassBtn" type="Button" parent="UI/HUD/BottomPanel/HBox/BlockButtons"] +layout_mode = 2 +text = "1: Grass" + +[node name="StoneBtn" type="Button" parent="UI/HUD/BottomPanel/HBox/BlockButtons"] +layout_mode = 2 +text = "2: Stone" + +[node name="WaterBtn" type="Button" parent="UI/HUD/BottomPanel/HBox/BlockButtons"] +layout_mode = 2 +text = "3: Water" + +[node name="WoodBtn" type="Button" parent="UI/HUD/BottomPanel/HBox/BlockButtons"] +layout_mode = 2 +text = "4: Wood" + +[node name="SoilBtn" type="Button" parent="UI/HUD/BottomPanel/HBox/BlockButtons"] +layout_mode = 2 +text = "5: Soil" \ No newline at end of file diff --git a/scripts/Main.gd b/scripts/Main.gd new file mode 100644 index 0000000..f1519b7 --- /dev/null +++ b/scripts/Main.gd @@ -0,0 +1,191 @@ +## OpenIsopix - Main Game Manager +## Orchestrates all systems + +extends Node2D + +## System references +@onready var world_api = $WorldAPI +@onready var camera = $IsometricCamera +@onready var renderer = $IsometricRenderer +@onready var lighting_system = $LightingSystem +@onready var fog_system = $FogOfWarSystem +@onready var interaction_system = $InteractionSystem +@onready var ui = $UI + +## Track camera world position for chunk loading +var last_camera_world_pos: Vector3 = Vector3.ZERO + +func _ready(): + print("OpenIsopix - Initializing...") + + # Connect systems + _setup_systems() + + # Generate demo world + _generate_demo_world() + + # Initial render + if renderer: + renderer.render_world() + + print("OpenIsopix - Ready!") + _print_controls() + +func _process(_delta): + # Update chunk loading based on camera movement + if world_api and camera: + # Convert camera screen position to approximate world position + # Camera position represents screen coordinates, we need to track actual world focus + var cam_world_pos = Vector3(camera.position.x / 100.0, 0, camera.position.y / 100.0) + + # Only check chunk loading periodically (not every frame) + if cam_world_pos.distance_to(last_camera_world_pos) > 1.0: + last_camera_world_pos = cam_world_pos + var chunks_changed = world_api.update_chunk_loading(cam_world_pos) + + # Only re-render if chunks actually changed + if chunks_changed and renderer: + print("Chunks changed - re-rendering world") + renderer.render_world() + +func _setup_systems(): + if renderer: + renderer.world_api = world_api + renderer.camera = camera + renderer.initialize() + + if lighting_system: + lighting_system.world_api = world_api + + if fog_system: + fog_system.world_api = world_api + + if interaction_system: + interaction_system.world_api = world_api + interaction_system.camera = camera + interaction_system.fog_system = fog_system + interaction_system.renderer = renderer + interaction_system.status_label = $UI/HUD/InfoPanel/VBox/Status + interaction_system.initialize() + + var block_buttons = $UI/HUD/BottomPanel/HBox/BlockButtons + if block_buttons: + block_buttons.get_node("GrassBtn").pressed.connect(_on_grass_btn_pressed) + block_buttons.get_node("StoneBtn").pressed.connect(_on_stone_btn_pressed) + block_buttons.get_node("WaterBtn").pressed.connect(_on_water_btn_pressed) + block_buttons.get_node("WoodBtn").pressed.connect(_on_wood_btn_pressed) + block_buttons.get_node("SoilBtn").pressed.connect(_on_soil_btn_pressed) + +func _generate_demo_world(): + if not world_api: + return + + print("Generating demo world...") + + # Create a simple test world with various block types + var world_size = 10 + + # Ground layer + for x in range(-world_size, world_size): + for z in range(-world_size, world_size): + # Create varied terrain + var block_type = "grass" + + # Add some water + if (x + z) % 7 == 0: + block_type = "water" + # Add some stone + elif abs(x) > world_size / 2 or abs(z) > world_size / 2: + block_type = "stone" + + world_api.add_block(Vector3i(x, 0, z), block_type) + + # Add some elevated blocks + for i in range(5): + var x = randi() % (world_size * 2) - world_size + var z = randi() % (world_size * 2) - world_size + world_api.add_block(Vector3i(x, 1, z), "stone", 0.5) + + # Reveal starting area + if fog_system: + fog_system.reveal_area(Vector3i(0, 0, 0), 12.0) + + print("Demo world generated!") + +func _print_controls(): + print("\n=== CONTROLS ===") + print("Arrow Keys / WASD: Move camera") + print("P: Cycle camera pitch") + print("+/-: Zoom in/out") + print("Mouse Wheel: Zoom in/out") + print("Left Click: Place block / Select") + print("Right Click: Remove block") + print("1-5: Select block type (press twice for full height)") + print("F: Reveal fog area") + print("G: Toggle fog globally") + print("Space: Cycle interaction mode") + print("Ctrl+Z: Undo") + print("Ctrl+Y: Redo") + print("================\n") + +func _input(event): + # Handle pitch change + if event.is_action_pressed("change_pitch"): + if camera: + camera.cycle_pitch() + if renderer: + renderer.render_world() + + # Handle rotation + if event.is_action_pressed("rotate_left"): + print("Q pressed - rotating left") + if camera: + camera.rotate_heading(false) # Counter-clockwise + if renderer: + renderer.render_world() + + if event.is_action_pressed("rotate_right"): + print("E pressed - rotating right") + if camera: + camera.rotate_heading(true) # Clockwise + if renderer: + renderer.render_world() + + # Handle zoom + if event.is_action_pressed("zoom_in"): + if camera: + camera.zoom_camera(true) + + if event.is_action_pressed("zoom_out"): + if camera: + camera.zoom_camera(false) + +func _on_grass_btn_pressed(): + if interaction_system: + interaction_system.selected_block_type = "grass" + interaction_system._update_cursor_texture() + interaction_system._update_status_ui() + +func _on_stone_btn_pressed(): + if interaction_system: + interaction_system.selected_block_type = "stone" + interaction_system._update_cursor_texture() + interaction_system._update_status_ui() + +func _on_water_btn_pressed(): + if interaction_system: + interaction_system.selected_block_type = "water" + interaction_system._update_cursor_texture() + interaction_system._update_status_ui() + +func _on_wood_btn_pressed(): + if interaction_system: + interaction_system.selected_block_type = "wood" + interaction_system._update_cursor_texture() + interaction_system._update_status_ui() + +func _on_soil_btn_pressed(): + if interaction_system: + interaction_system.selected_block_type = "soil" + interaction_system._update_cursor_texture() + interaction_system._update_status_ui() diff --git a/scripts/Main.gd.uid b/scripts/Main.gd.uid new file mode 100644 index 0000000..d451b66 --- /dev/null +++ b/scripts/Main.gd.uid @@ -0,0 +1 @@ +uid://bwtrvs1s7tmq8 diff --git a/scripts/core/BlockInstance.gd b/scripts/core/BlockInstance.gd new file mode 100644 index 0000000..6c798db --- /dev/null +++ b/scripts/core/BlockInstance.gd @@ -0,0 +1,45 @@ +## OpenIsopix - Block Instance + +# Represents a single block instance in the world +class_name BlockInstance +extends RefCounted + +## Block position in world space (x, y, z) +var position: Vector3i + +## Block type reference +var block_type: BlockType + +## Current health points +var hp: float + +## Custom properties (extensible for game-specific data) +var custom_data: Dictionary = {} + +## Height level (0.5 or 1.0) +var height: float = 1.0 + +## Current lighting level (0.0 to 1.0) +var light_level: float = 1.0 + +## Whether this block has been revealed (for fog of war) +var is_revealed: bool = false + +func _init(p_position: Vector3i, p_block_type: BlockType, p_height: float = 1.0): + position = p_position + block_type = p_block_type + height = p_height + hp = p_block_type.max_hp if p_block_type else 100.0 + +func take_damage(amount: float) -> bool: + hp -= amount + return hp <= 0 + +func heal(amount: float) -> void: + hp = min(hp + amount, block_type.max_hp if block_type else 100.0) + +func is_destroyed() -> bool: + return hp <= 0 + +func get_type_id() -> String: + return block_type.id if block_type else "" diff --git a/scripts/core/BlockInstance.gd.uid b/scripts/core/BlockInstance.gd.uid new file mode 100644 index 0000000..17c2a91 --- /dev/null +++ b/scripts/core/BlockInstance.gd.uid @@ -0,0 +1 @@ +uid://0hxdy3k3ynur diff --git a/scripts/core/BlockType.gd b/scripts/core/BlockType.gd new file mode 100644 index 0000000..a87534f --- /dev/null +++ b/scripts/core/BlockType.gd @@ -0,0 +1,52 @@ +## OpenIsopix - Core Block Types + +# Represents a single block type definition +class_name BlockType +extends Resource + +## Block type identifier (e.g., "grass", "stone", "water") +@export var id: String = "" + +## Display name +@export var display_name: String = "" + +## Behavior flags +@export var is_solid: bool = true +@export var is_opaque: bool = true +@export var is_climbable: bool = false +@export var is_walkable: bool = true + +## Visual attributes +@export var texture: Texture2D = null +@export var animated: bool = false +@export var animation_frames: Array[Texture2D] = [] +@export var animation_speed: float = 1.0 +@export var emits_light: bool = false +@export var light_color: Color = Color.WHITE +@export var light_intensity: float = 1.0 +@export var light_radius: float = 100.0 + +## Numeric attributes +@export var max_hp: float = 100.0 +@export var material_type: String = "generic" +@export var hardness: float = 1.0 +@export var height: float = 1.0 # Block height: 0.5 or 1.0 + +## Interaction callbacks (will be handled by signals) +@export var on_click_enabled: bool = true +@export var on_walk_over_enabled: bool = false + +func _init( + p_id: String = "", + p_display_name: String = "", + p_texture: Texture2D = null +): + id = p_id + display_name = p_display_name + texture = p_texture + +func get_current_frame(time: float) -> Texture2D: + if not animated or animation_frames.is_empty(): + return texture + var frame_index = int(time * animation_speed) % animation_frames.size() + return animation_frames[frame_index] diff --git a/scripts/core/BlockType.gd.uid b/scripts/core/BlockType.gd.uid new file mode 100644 index 0000000..e80566c --- /dev/null +++ b/scripts/core/BlockType.gd.uid @@ -0,0 +1 @@ +uid://7h4sromk1lsu diff --git a/scripts/core/Chunk.gd b/scripts/core/Chunk.gd new file mode 100644 index 0000000..cc31069 --- /dev/null +++ b/scripts/core/Chunk.gd @@ -0,0 +1,82 @@ +## OpenIsopix - World Chunk + +# Represents a 16x16 chunk of blocks +class_name Chunk +extends RefCounted + +const CHUNK_SIZE = 16 + +## Chunk position in chunk coordinates +var chunk_pos: Vector2i + +## 3D array of block instances [x][y][z] +## y represents height layers (we support multiple height levels) +var blocks: Array = [] + +## Whether this chunk is currently loaded +var is_loaded: bool = false + +## Chunk bounds (for optimization) +var min_bound: Vector3i +var max_bound: Vector3i + +func _init(p_chunk_pos: Vector2i): + chunk_pos = p_chunk_pos + min_bound = Vector3i(chunk_pos.x * CHUNK_SIZE, 0, chunk_pos.y * CHUNK_SIZE) + max_bound = Vector3i((chunk_pos.x + 1) * CHUNK_SIZE, 10, (chunk_pos.y + 1) * CHUNK_SIZE) + _initialize_blocks() + +func _initialize_blocks(): + blocks = [] + for x in range(CHUNK_SIZE): + blocks.append([]) + for z in range(CHUNK_SIZE): + blocks[x].append([]) + +func set_block(local_pos: Vector3i, block: BlockInstance) -> void: + if not _is_valid_local_pos(local_pos): + return + + # Ensure height array exists + while blocks[local_pos.x][local_pos.z].size() <= local_pos.y: + blocks[local_pos.x][local_pos.z].append(null) + + blocks[local_pos.x][local_pos.z][local_pos.y] = block + +func get_block(local_pos: Vector3i) -> BlockInstance: + if not _is_valid_local_pos(local_pos): + return null + + if local_pos.y >= blocks[local_pos.x][local_pos.z].size(): + return null + + return blocks[local_pos.x][local_pos.z][local_pos.y] + +func remove_block(local_pos: Vector3i) -> void: + if not _is_valid_local_pos(local_pos): + return + + if local_pos.y < blocks[local_pos.x][local_pos.z].size(): + blocks[local_pos.x][local_pos.z][local_pos.y] = null + +func _is_valid_local_pos(local_pos: Vector3i) -> bool: + return (local_pos.x >= 0 and local_pos.x < CHUNK_SIZE and + local_pos.z >= 0 and local_pos.z < CHUNK_SIZE and + local_pos.y >= 0) + +func to_world_pos(local_pos: Vector3i) -> Vector3i: + return Vector3i( + chunk_pos.x * CHUNK_SIZE + local_pos.x, + local_pos.y, + chunk_pos.y * CHUNK_SIZE + local_pos.z + ) + +func get_all_blocks() -> Array[BlockInstance]: + var result: Array[BlockInstance] = [] + for x in range(CHUNK_SIZE): + for z in range(CHUNK_SIZE): + for y in range(blocks[x][z].size()): + var block = blocks[x][z][y] + if block != null: + result.append(block) + return result diff --git a/scripts/core/Chunk.gd.uid b/scripts/core/Chunk.gd.uid new file mode 100644 index 0000000..98d131e --- /dev/null +++ b/scripts/core/Chunk.gd.uid @@ -0,0 +1 @@ +uid://xxm3up8xxu6s diff --git a/scripts/core/WorldAPI.gd b/scripts/core/WorldAPI.gd new file mode 100644 index 0000000..350d479 --- /dev/null +++ b/scripts/core/WorldAPI.gd @@ -0,0 +1,256 @@ +## OpenIsopix - World Manager API +## Core API for world interaction - decoupled from UI + +class_name WorldAPI +extends Node + +## Signals for world events +signal block_added(position: Vector3i, block: BlockInstance) +signal block_removed(position: Vector3i) +signal block_modified(position: Vector3i, block: BlockInstance) +signal block_clicked(position: Vector3i, block: BlockInstance) +signal block_walked_over(position: Vector3i, block: BlockInstance) +signal chunk_loaded(chunk_pos: Vector2i) +signal chunk_unloaded(chunk_pos: Vector2i) +signal lighting_changed(position: Vector3i, light_level: float) + +const CHUNK_SIZE = 16 + +## All loaded chunks [chunk_pos_key] = Chunk +var chunks: Dictionary = {} + +## Block type registry [type_id] = BlockType +var block_types: Dictionary = {} + +## Chunk load distance (in chunks) +@export var chunk_load_distance: int = 3 + +## Current camera position for chunk loading +var camera_chunk_pos: Vector2i = Vector2i.ZERO + +## Loaded chunk positions +var loaded_chunk_positions: Array[Vector2i] = [] + +func _ready(): + _register_default_block_types() + +## Register a new block type +func register_block_type(block_type: BlockType) -> void: + if block_type.id.is_empty(): + push_error("Cannot register block type with empty ID") + return + block_types[block_type.id] = block_type + +## Get a registered block type +func get_block_type(type_id: String) -> BlockType: + return block_types.get(type_id) + +## Add a block to the world +func add_block(world_pos: Vector3i, type_id: String, height: float = 1.0) -> BlockInstance: + var block_type = get_block_type(type_id) + if not block_type: + push_error("Unknown block type: " + type_id) + return null + + var chunk_pos = _world_to_chunk_pos(world_pos) + var chunk = _get_or_create_chunk(chunk_pos) + var local_pos = _world_to_local_pos(world_pos) + + var block = BlockInstance.new(world_pos, block_type, height) + chunk.set_block(local_pos, block) + + block_added.emit(world_pos, block) + return block + +## Remove a block from the world +func remove_block(world_pos: Vector3i) -> void: + var chunk_pos = _world_to_chunk_pos(world_pos) + var chunk = _get_chunk(chunk_pos) + if not chunk: + return + + var local_pos = _world_to_local_pos(world_pos) + chunk.remove_block(local_pos) + block_removed.emit(world_pos) + +## Get a block at world position +func get_block(world_pos: Vector3i) -> BlockInstance: + var chunk_pos = _world_to_chunk_pos(world_pos) + var chunk = _get_chunk(chunk_pos) + if not chunk: + return null + + var local_pos = _world_to_local_pos(world_pos) + return chunk.get_block(local_pos) + +## Modify a block's properties +func modify_block(world_pos: Vector3i, property: String, value: Variant) -> void: + var block = get_block(world_pos) + if not block: + return + + match property: + "hp": + block.hp = value + "light_level": + block.light_level = value + lighting_changed.emit(world_pos, value) + "is_revealed": + block.is_revealed = value + _: + block.custom_data[property] = value + + block_modified.emit(world_pos, block) + +## Query block attributes +func get_block_attribute(world_pos: Vector3i, attribute: String) -> Variant: + var block = get_block(world_pos) + if not block: + return null + + match attribute: + "position": return block.position + "type_id": return block.get_type_id() + "hp": return block.hp + "height": return block.height + "light_level": return block.light_level + "is_revealed": return block.is_revealed + "is_solid": return block.block_type.is_solid if block.block_type else false + "is_opaque": return block.block_type.is_opaque if block.block_type else false + "is_climbable": return block.block_type.is_climbable if block.block_type else false + _: return block.custom_data.get(attribute) + +## Load a chunk +func load_chunk(chunk_pos: Vector2i) -> Chunk: + var chunk = _get_or_create_chunk(chunk_pos) + if not chunk.is_loaded: + chunk.is_loaded = true + chunk_loaded.emit(chunk_pos) + return chunk + +## Unload a chunk +func unload_chunk(chunk_pos: Vector2i) -> void: + var key = _chunk_pos_to_key(chunk_pos) + if chunks.has(key): + chunks[key].is_loaded = false + chunks.erase(key) + chunk_unloaded.emit(chunk_pos) + +## Get all blocks in a chunk +func get_chunk_blocks(chunk_pos: Vector2i) -> Array[BlockInstance]: + var chunk = _get_chunk(chunk_pos) + if not chunk: + return [] + return chunk.get_all_blocks() + +## Trigger block click event +func trigger_block_click(world_pos: Vector3i) -> void: + var block = get_block(world_pos) + if block and block.block_type and block.block_type.on_click_enabled: + block_clicked.emit(world_pos, block) + +## Trigger block walk over event +func trigger_block_walk_over(world_pos: Vector3i) -> void: + var block = get_block(world_pos) + if block and block.block_type and block.block_type.on_walk_over_enabled: + block_walked_over.emit(world_pos, block) + +## Helper functions +func _world_to_chunk_pos(world_pos: Vector3i) -> Vector2i: + return Vector2i( + floori(float(world_pos.x) / CHUNK_SIZE), + floori(float(world_pos.z) / CHUNK_SIZE) + ) + +func _world_to_local_pos(world_pos: Vector3i) -> Vector3i: + return Vector3i( + posmod(world_pos.x, CHUNK_SIZE), + world_pos.y, + posmod(world_pos.z, CHUNK_SIZE) + ) + +func _chunk_pos_to_key(chunk_pos: Vector2i) -> String: + return str(chunk_pos.x) + "," + str(chunk_pos.y) + +func _get_chunk(chunk_pos: Vector2i) -> Chunk: + var key = _chunk_pos_to_key(chunk_pos) + return chunks.get(key) + +func _get_or_create_chunk(chunk_pos: Vector2i) -> Chunk: + var key = _chunk_pos_to_key(chunk_pos) + if not chunks.has(key): + var chunk = Chunk.new(chunk_pos) + chunks[key] = chunk + return chunks[key] + +## Update chunk loading based on camera position +func update_chunk_loading(camera_world_pos: Vector3) -> bool: + var new_camera_chunk = Vector2i( + floori(camera_world_pos.x / CHUNK_SIZE), + floori(camera_world_pos.z / CHUNK_SIZE) + ) + + # Only update if camera moved to a different chunk + if new_camera_chunk == camera_chunk_pos: + return false + + camera_chunk_pos = new_camera_chunk + + # Calculate which chunks should be loaded + var chunks_to_load: Array[Vector2i] = [] + for x in range(-chunk_load_distance, chunk_load_distance + 1): + for z in range(-chunk_load_distance, chunk_load_distance + 1): + var chunk_pos = Vector2i(camera_chunk_pos.x + x, camera_chunk_pos.y + z) + chunks_to_load.append(chunk_pos) + + var chunks_changed = false + + # Unload chunks that are too far + for loaded_pos in loaded_chunk_positions.duplicate(): + if not chunks_to_load.has(loaded_pos): + unload_chunk(loaded_pos) + loaded_chunk_positions.erase(loaded_pos) + chunks_changed = true + + # Load new chunks + for chunk_pos in chunks_to_load: + if not loaded_chunk_positions.has(chunk_pos): + load_chunk(chunk_pos) + loaded_chunk_positions.append(chunk_pos) + chunks_changed = true + + return chunks_changed + +func _register_default_block_types(): + # Register some default block types + var grass = BlockType.new("grass", "Grass Block") + grass.is_solid = true + grass.is_opaque = true + grass.height = 0.5 # Half block + register_block_type(grass) + + var stone = BlockType.new("stone", "Stone Block") + stone.is_solid = true + stone.is_opaque = true + stone.hardness = 2.0 + stone.height = 0.5 # Half block (can be toggled to 1.0) + register_block_type(stone) + + var water = BlockType.new("water", "Water") + water.is_solid = false + water.is_opaque = false + water.is_walkable = false + water.height = 0.5 # Half block + register_block_type(water) + + var wood = BlockType.new("wood", "Wood") + wood.is_solid = true + wood.is_opaque = true + wood.height = 0.5 # Half block (can be toggled to 1.0) + register_block_type(wood) + + var soil = BlockType.new("soil", "Soil") + soil.is_solid = true + soil.is_opaque = true + soil.height = 0.5 # Half block + register_block_type(soil) diff --git a/scripts/core/WorldAPI.gd.uid b/scripts/core/WorldAPI.gd.uid new file mode 100644 index 0000000..3b4d213 --- /dev/null +++ b/scripts/core/WorldAPI.gd.uid @@ -0,0 +1 @@ +uid://j8j6ler26sos diff --git a/scripts/rendering/IsometricCamera.gd b/scripts/rendering/IsometricCamera.gd new file mode 100644 index 0000000..4f0e16e --- /dev/null +++ b/scripts/rendering/IsometricCamera.gd @@ -0,0 +1,182 @@ +## OpenIsopix - Camera Controller +## Manages POV (Point of View) with rotation, zoom, and pitch + +class_name IsometricCamera +extends Camera2D + +## POV settings +enum Heading { NORTH, EAST, SOUTH, WEST } +enum PitchLevel { LOW, NORMAL, HIGH, TOP_DOWN } + +## Current POV state +var current_heading: Heading = Heading.NORTH +var current_pitch: PitchLevel = PitchLevel.NORMAL +var current_zoom_level: float = 1.0 + +## Zoom settings +@export var min_zoom: float = 0.5 +@export var max_zoom: float = 3.0 +@export var zoom_speed: float = 0.1 + +## Pitch angles (in degrees for visual reference) +const PITCH_ANGLES = { + PitchLevel.LOW: 30.0, # Closer to ground + PitchLevel.NORMAL: 45.0, # Standard isometric + PitchLevel.HIGH: 60.0, # Closer to top-down + PitchLevel.TOP_DOWN: 90.0 # Pure top-down +} + +## Movement settings +@export var pan_speed: float = 500.0 +@export var smooth_movement: bool = true +@export var smoothing_factor: float = 5.0 + +const TILE_HALF_WIDTH: float = 24.0 +const TILE_HALF_HEIGHT: float = 12.0 +const BLOCK_TOP_FACE_HEIGHT: float = 24.0 + +var target_position: Vector2 +var velocity: Vector2 = Vector2.ZERO + +## Signals +signal heading_changed(new_heading: Heading) +signal pitch_changed(new_pitch: PitchLevel) +signal zoom_changed(new_zoom: float) + +func _ready(): + target_position = position + zoom = Vector2(current_zoom_level, current_zoom_level) + +func _process(delta): + if smooth_movement: + position = position.lerp(target_position, smoothing_factor * delta) + else: + position = target_position + + # Handle camera movement + _handle_movement(delta) + +func _handle_movement(delta: float): + var move_input = Vector2.ZERO + + # Get input direction + if Input.is_action_pressed("ui_left"): + move_input.x -= 1 + if Input.is_action_pressed("ui_right"): + move_input.x += 1 + if Input.is_action_pressed("ui_up"): + move_input.y -= 1 + if Input.is_action_pressed("ui_down"): + move_input.y += 1 + + # Apply rotation to movement based on heading + var rotated_input = _rotate_input_by_heading(move_input) + + # Update target position + if rotated_input.length() > 0: + target_position += rotated_input.normalized() * pan_speed * delta / current_zoom_level + +func _rotate_input_by_heading(input: Vector2) -> Vector2: + match current_heading: + Heading.NORTH: + return input + Heading.EAST: + return Vector2(-input.y, input.x) + Heading.SOUTH: + return Vector2(-input.x, -input.y) + Heading.WEST: + return Vector2(input.y, -input.x) + return input + +## Rotate camera heading +func rotate_heading(clockwise: bool = true): + if clockwise: + current_heading = (current_heading + 1) % 4 as Heading + else: + current_heading = (current_heading - 1 + 4) % 4 as Heading + heading_changed.emit(current_heading) + +## Change pitch level +func cycle_pitch(): + current_pitch = (current_pitch + 1) % 4 as PitchLevel + pitch_changed.emit(current_pitch) + +## Zoom in/out +func zoom_camera(zoom_in: bool): + var zoom_delta = zoom_speed if zoom_in else -zoom_speed + current_zoom_level = clamp(current_zoom_level + zoom_delta, min_zoom, max_zoom) + zoom = Vector2(current_zoom_level, current_zoom_level) + zoom_changed.emit(current_zoom_level) + +## Set camera position +func set_camera_position(new_position: Vector2): + target_position = new_position + if not smooth_movement: + position = new_position + +## Get current heading as rotation angle +func get_heading_rotation() -> float: + return current_heading * 90.0 + +## Get current pitch angle +func get_pitch_angle() -> float: + return PITCH_ANGLES[current_pitch] + +## Convert world position to isometric screen position +func world_to_iso(world_pos: Vector3) -> Vector2: + var rotated_pos = _rotate_world_pos_by_heading(world_pos) + var pitch_factor = get_pitch_angle() / 45.0 + + # Isometric projection: x-z plane to diamond + var iso_x = (rotated_pos.x - rotated_pos.z) * TILE_HALF_WIDTH + var iso_y_xz = (rotated_pos.x + rotated_pos.z) * TILE_HALF_HEIGHT * pitch_factor + var iso_y_height = rotated_pos.y * BLOCK_TOP_FACE_HEIGHT + + return Vector2(iso_x, iso_y_xz - iso_y_height) + +## Convert screen position to world position (approximate) +func screen_to_world(screen_pos: Vector2) -> Vector3: + var cam_pos = get_screen_center_position() + var relative_pos = (screen_pos - cam_pos) + + var pitch_factor = get_pitch_angle() / 45.0 + if pitch_factor == 0: pitch_factor = 0.001 + + # Inverse isometric transform + var denom_x = TILE_HALF_WIDTH * 2.0 + var denom_y_xz = TILE_HALF_HEIGHT * 2.0 * pitch_factor + + var world_x = (relative_pos.x / denom_x) + (relative_pos.y / denom_y_xz) + var world_z = (relative_pos.y / denom_y_xz) - (relative_pos.x / denom_x) + + # Undo camera rotation to get actual world coords + var rotated_result = Vector3(world_x, 0, world_z) + var actual_world_pos = _unrotate_world_pos_by_heading(rotated_result) + + return actual_world_pos + +## Rotate world position based on current heading +func _rotate_world_pos_by_heading(pos: Vector3) -> Vector3: + match current_heading: + Heading.NORTH: + return pos # No rotation + Heading.EAST: + return Vector3(pos.z, pos.y, -pos.x) # 90° clockwise + Heading.SOUTH: + return Vector3(-pos.x, pos.y, -pos.z) # 180° + Heading.WEST: + return Vector3(-pos.z, pos.y, pos.x) # 270° clockwise + return pos + +## Reverse rotation to get actual world coordinates +func _unrotate_world_pos_by_heading(pos: Vector3) -> Vector3: + match current_heading: + Heading.NORTH: + return pos # No rotation + Heading.EAST: + return Vector3(-pos.z, pos.y, pos.x) # Reverse 90° clockwise + Heading.SOUTH: + return Vector3(-pos.x, pos.y, -pos.z) # Reverse 180° + Heading.WEST: + return Vector3(pos.z, pos.y, -pos.x) # Reverse 270° clockwise + return pos \ No newline at end of file diff --git a/scripts/rendering/IsometricCamera.gd.uid b/scripts/rendering/IsometricCamera.gd.uid new file mode 100644 index 0000000..eb02a0d --- /dev/null +++ b/scripts/rendering/IsometricCamera.gd.uid @@ -0,0 +1 @@ +uid://dbrr7kmaodqs5 diff --git a/scripts/rendering/IsometricRenderer.gd b/scripts/rendering/IsometricRenderer.gd new file mode 100644 index 0000000..ea8bdbc --- /dev/null +++ b/scripts/rendering/IsometricRenderer.gd @@ -0,0 +1,172 @@ +## OpenIsopix - Isometric Renderer +## Renders the block-based world in isometric view + +class_name IsometricRenderer +extends Node2D + +@export var world_api: WorldAPI +@export var camera: IsometricCamera + +## Tile size in pixels (increased for isometric diamond tiles) +const TILE_SIZE = 64 +const HALF_TILE = TILE_SIZE / 2 + +## Rendering layers +var world_layer: Node2D +var fog_layer: CanvasLayer + +## Cache for rendered sprites +var block_sprites: Dictionary = {} +var textures: Dictionary = {} + +func _ready(): + pass + +func initialize(): + _setup_layers() + _generate_placeholder_textures() + + if world_api: + world_api.block_added.connect(_on_block_added) + world_api.block_removed.connect(_on_block_removed) + world_api.block_modified.connect(_on_block_modified) + world_api.chunk_loaded.connect(_on_chunk_loaded) + + if camera: + camera.heading_changed.connect(_on_heading_changed) + camera.pitch_changed.connect(_on_pitch_changed) + +func _setup_layers(): + world_layer = Node2D.new() + add_child(world_layer) + + fog_layer = CanvasLayer.new() + fog_layer.layer = 2 + add_child(fog_layer) + +func _generate_placeholder_textures(): + # Load isometric SVG textures for block types + textures["grass"] = load("res://assets/blocks/isometric-grass.svg") + textures["stone"] = load("res://assets/blocks/isometric-stone.svg") + textures["water"] = load("res://assets/blocks/isometric-water.svg") + textures["wood"] = load("res://assets/blocks/isometric-wood.svg") + textures["soil"] = load("res://assets/blocks/isometric-soil.svg") + + print("Loaded isometric block textures: ", textures.keys()) + +func _create_colored_texture(color: Color) -> ImageTexture: + var image = Image.create(TILE_SIZE, TILE_SIZE, false, Image.FORMAT_RGBA8) + image.fill(color) + + # Add some shading for 3D effect + for y in range(TILE_SIZE): + for x in range(TILE_SIZE): + if x < 4 or y < 4: # Top and left edges lighter + var current = image.get_pixel(x, y) + image.set_pixel(x, y, current.lightened(0.2)) + elif x >= TILE_SIZE - 4 or y >= TILE_SIZE - 4: # Bottom and right edges darker + var current = image.get_pixel(x, y) + image.set_pixel(x, y, current.darkened(0.2)) + + return ImageTexture.create_from_image(image) + +func render_world(): + # Clear existing sprites + _clear_all_sprites() + + if not world_api: + return + + # Get all chunks and render blocks + for chunk_key in world_api.chunks.keys(): + var chunk = world_api.chunks[chunk_key] + if chunk.is_loaded: + _render_chunk(chunk) + +func _render_chunk(chunk: Chunk): + var blocks = chunk.get_all_blocks() + # Sort for painter's algorithm (back to front) + blocks.sort_custom(_sort_blocks_for_rendering) + + for block in blocks: + _render_block(block) + +func _render_block(block: BlockInstance): + if not block or not block.block_type: + return + + var world_pos = block.position + var key = _world_pos_to_key(world_pos) + + # Create sprite if it doesn't exist + if not block_sprites.has(key): + var sprite = Sprite2D.new() + world_layer.add_child(sprite) + block_sprites[key] = sprite + + var sprite = block_sprites[key] + + # Set texture + var texture = textures.get(block.block_type.id) + if texture: + sprite.texture = texture + else: + print("WARNING: No texture for block type: ", block.block_type.id) + + var iso_pos = camera.world_to_iso(Vector3(world_pos.x, world_pos.y, world_pos.z)) + sprite.position = iso_pos + var height_scale = block.height + sprite.scale = Vector2(1, height_scale) + + # Apply lighting + sprite.modulate = Color(block.light_level, block.light_level, block.light_level, 1.0) + + # Apply fog of war + if not block.is_revealed: + sprite.modulate.a = 0.3 + + # Z-index for proper layering + sprite.z_index = world_pos.x + world_pos.z + world_pos.y * 1000 + +func _clear_all_sprites(): + for key in block_sprites.keys(): + var sprite = block_sprites[key] + if sprite: + sprite.queue_free() + block_sprites.clear() + +func _remove_sprite(world_pos: Vector3i): + var key = _world_pos_to_key(world_pos) + if block_sprites.has(key): + var sprite = block_sprites[key] + sprite.queue_free() + block_sprites.erase(key) + +func _world_pos_to_key(pos: Vector3i) -> String: + return str(pos.x) + "," + str(pos.y) + "," + str(pos.z) + +func _sort_blocks_for_rendering(a: BlockInstance, b: BlockInstance) -> bool: + var depth_a = a.position.x + a.position.z + a.position.y * 1000 + var depth_b = b.position.x + b.position.z + b.position.y * 1000 + return depth_a < depth_b + +## Signal handlers +func _on_block_added(position: Vector3i, block: BlockInstance): + _render_block(block) + +func _on_block_removed(position: Vector3i): + _remove_sprite(position) + +func _on_block_modified(position: Vector3i, block: BlockInstance): + _render_block(block) + +func _on_chunk_loaded(chunk_pos: Vector2i): + render_world() + +func _on_heading_changed(new_heading): + # Re-render world with new rotation + render_world() + +func _on_pitch_changed(new_pitch): + # Re-render world with new pitch + render_world() diff --git a/scripts/rendering/IsometricRenderer.gd.uid b/scripts/rendering/IsometricRenderer.gd.uid new file mode 100644 index 0000000..231612f --- /dev/null +++ b/scripts/rendering/IsometricRenderer.gd.uid @@ -0,0 +1 @@ +uid://bx2jhvbbsu6lx diff --git a/scripts/systems/FogOfWarSystem.gd b/scripts/systems/FogOfWarSystem.gd new file mode 100644 index 0000000..c35fa1d --- /dev/null +++ b/scripts/systems/FogOfWarSystem.gd @@ -0,0 +1,128 @@ +## OpenIsopix - Fog of War System +## Manages map revelation and exploration + +class_name FogOfWarSystem +extends Node + +@export var world_api: WorldAPI + +## Revelation settings +@export var revelation_radius: float = 8.0 +@export var auto_reveal: bool = false + +## Global fog state +var is_fog_disabled: bool = false + +## Revealed positions cache +var revealed_positions: Dictionary = {} # [position_key] = true + +signal area_revealed(center: Vector3i, radius: float) +signal block_revealed(position: Vector3i) + +func _ready(): + pass + +## Reveal an area around a position +func reveal_area(center: Vector3i, radius: float = -1.0): + if radius < 0: + radius = revelation_radius + + var radius_squared = radius * radius + var int_radius = int(ceil(radius)) + + for x in range(-int_radius, int_radius + 1): + for z in range(-int_radius, int_radius + 1): + for y in range(-2, 3): # Check a few height levels + var offset = Vector3i(x, y, z) + var check_pos = center + offset + + # Check if within radius + var distance_squared = offset.x * offset.x + offset.z * offset.z + if distance_squared <= radius_squared: + _reveal_block(check_pos) + + area_revealed.emit(center, radius) + +## Reveal a single block +func _reveal_block(world_pos: Vector3i): + var key = _pos_to_key(world_pos) + + if revealed_positions.has(key): + return # Already revealed + + revealed_positions[key] = true + + if world_api: + var block = world_api.get_block(world_pos) + if block and not block.is_revealed: + world_api.modify_block(world_pos, "is_revealed", true) + block_revealed.emit(world_pos) + +## Check if a position is revealed +func is_revealed(world_pos: Vector3i) -> bool: + var key = _pos_to_key(world_pos) + return revealed_positions.has(key) + +## Hide an area (for dynamic fog) +func hide_area(center: Vector3i, radius: float = -1.0): + if radius < 0: + radius = revelation_radius + + var radius_squared = radius * radius + var int_radius = int(ceil(radius)) + + for x in range(-int_radius, int_radius + 1): + for z in range(-int_radius, int_radius + 1): + for y in range(-2, 3): + var offset = Vector3i(x, y, z) + var check_pos = center + offset + + var distance_squared = offset.x * offset.x + offset.z * offset.z + if distance_squared <= radius_squared: + _hide_block(check_pos) + +func _hide_block(world_pos: Vector3i): + var key = _pos_to_key(world_pos) + revealed_positions.erase(key) + + if world_api: + world_api.modify_block(world_pos, "is_revealed", false) + +## Reveal entire map (cheat/debug mode) +func reveal_all(): + if not world_api: + return + + for chunk_key in world_api.chunks.keys(): + var chunk = world_api.chunks[chunk_key] + if chunk.is_loaded: + var blocks = chunk.get_all_blocks() + for block in blocks: + if block: + _reveal_block(block.position) + +## Hide entire map +func hide_all(): + revealed_positions.clear() + + if world_api: + for chunk_key in world_api.chunks.keys(): + var chunk = world_api.chunks[chunk_key] + if chunk.is_loaded: + var blocks = chunk.get_all_blocks() + for block in blocks: + if block: + world_api.modify_block(block.position, "is_revealed", false) + +## Toggle fog globally (reveal all or hide all) +func toggle_fog_globally(): + is_fog_disabled = not is_fog_disabled + if is_fog_disabled: + reveal_all() + print("Fog disabled - all blocks revealed") + else: + hide_all() + print("Fog enabled - all blocks hidden") + +func _pos_to_key(pos: Vector3i) -> String: + return str(pos.x) + "," + str(pos.y) + "," + str(pos.z) diff --git a/scripts/systems/FogOfWarSystem.gd.uid b/scripts/systems/FogOfWarSystem.gd.uid new file mode 100644 index 0000000..a7b181c --- /dev/null +++ b/scripts/systems/FogOfWarSystem.gd.uid @@ -0,0 +1 @@ +uid://c5s2ublj2ylh2 diff --git a/scripts/systems/InteractionSystem.gd b/scripts/systems/InteractionSystem.gd new file mode 100644 index 0000000..49ff89b --- /dev/null +++ b/scripts/systems/InteractionSystem.gd @@ -0,0 +1,351 @@ +## OpenIsopix - Interaction System +## Handles user input and world interaction + +class_name InteractionSystem +extends Node + +@export var world_api: WorldAPI +@export var camera: IsometricCamera +@export var fog_system: FogOfWarSystem +@export var renderer: Node2D + +## UI References +var status_label: Label + +## Current interaction mode +enum InteractionMode { SELECT, PLACE, REMOVE } +var current_mode: InteractionMode = InteractionMode.SELECT + +## Currently selected block type for placement +var selected_block_type: String = "grass" +var selected_block_height_override: float = -1.0 # -1 means use default from block type +var last_selected_block: String = "" # Track for double-press detection + +## Undo/Redo history +var action_history: Array[Dictionary] = [] +var history_index: int = -1 +const MAX_HISTORY_SIZE: int = 50 + +## Currently hovered/selected block +var hovered_position: Vector3i = Vector3i.ZERO +var selected_position: Vector3i = Vector3i.ZERO +var is_position_valid: bool = false + +## Highlight sprite for cursor +var highlight_sprite: Sprite2D + +## Mouse/controller state +var mouse_position: Vector2 = Vector2.ZERO + +signal block_selected(position: Vector3i) +signal block_hovered(position: Vector3i) +signal interaction_mode_changed(mode: InteractionMode) + +func _ready(): + pass + +func initialize(): + _create_highlight_sprite() + _update_cursor_texture() + _update_status_ui() + +func _process(_delta): + _update_mouse_position() + _update_hovered_block() + +func _input(event): + # Handle mouse clicks + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT and event.is_pressed(): + _handle_place_block() + if event.button_index == MOUSE_BUTTON_RIGHT and event.is_pressed(): + _handle_remove_block() + +func _unhandled_key_input(event): + if event.pressed and not event.echo: + match event.keycode: + KEY_1: + _select_block_with_height_toggle("grass") + KEY_2: + _select_block_with_height_toggle("stone") + KEY_3: + _select_block_with_height_toggle("water") + KEY_4: + _select_block_with_height_toggle("wood") + KEY_5: + _select_block_with_height_toggle("soil") + KEY_SPACE: + cycle_mode() + KEY_F: + _toggle_fog() + KEY_G: + _toggle_fog_globally() + KEY_Z: + if event.ctrl_pressed: + undo() + KEY_Y: + if event.ctrl_pressed: + redo() + +func _create_highlight_sprite(): + highlight_sprite = Sprite2D.new() + # Add to renderer's world_layer instead of interaction system + if renderer: + renderer.world_layer.add_child(highlight_sprite) + else: + add_child(highlight_sprite) + highlight_sprite.z_index = 10000 + highlight_sprite.centered = true + +func _update_mouse_position(): + mouse_position = get_viewport().get_mouse_position() + +func _update_hovered_block(): + if not camera: + return + + # Convert screen position to world position + var world_pos_3d = camera.screen_to_world(mouse_position) + var block_pos = Vector3i( + roundi(world_pos_3d.x), + 0, # We'll check the topmost block + roundi(world_pos_3d.z) + ) + + # Find the highest block at this x,z position + if world_api: + var found_block = false + for y in range(10, -1, -1): # Check from top to bottom + block_pos.y = y + var block = world_api.get_block(block_pos) + if block: + hovered_position = block_pos + found_block = true + is_position_valid = true + break + + if not found_block: + # No block found, hover over ground level + hovered_position = Vector3i(block_pos.x, 0, block_pos.z) + is_position_valid = true + + # Update highlight position + _update_highlight() + block_hovered.emit(hovered_position) + +func _update_highlight(): + if not is_position_valid or not highlight_sprite or not camera: + if highlight_sprite: + highlight_sprite.visible = false + return + + highlight_sprite.visible = true + + var iso_pos = camera.world_to_iso(Vector3(hovered_position.x, hovered_position.y, hovered_position.z)) + highlight_sprite.position = iso_pos + + # Use actual block texture with transparency based on mode + var alpha = 0.7 + match current_mode: + InteractionMode.SELECT: + alpha = 0.5 + InteractionMode.PLACE: + alpha = 0.8 + InteractionMode.REMOVE: + alpha = 0.6 + + highlight_sprite.modulate = Color(1, 1, 1, alpha) + +func _handle_place_block(): + if not world_api or not is_position_valid: + print("ERROR: world_api is null or position invalid!") + return + + if current_mode == InteractionMode.PLACE: + var place_pos = hovered_position + + # Place below cursor when hovering elevated blocks + if hovered_position.y > 0: + place_pos.y -= 1 + else: + place_pos.y = 0 + + print("Placing ", selected_block_type, " at ", place_pos) + + # Check if a block already exists at the target location + var existing_block = world_api.get_block(place_pos) + var old_block_type = existing_block.get_type_id() if existing_block else null + var old_block_height = existing_block.height if existing_block else 0.0 + + if existing_block: + if existing_block.get_type_id() == selected_block_type: + return + world_api.remove_block(place_pos) + + var block_height = _get_selected_block_height() + world_api.add_block(place_pos, selected_block_type, block_height) + + _record_action({ + "type": "place", + "position": place_pos, + "block_type": selected_block_type, + "block_height": block_height, + "old_block_type": old_block_type, + "old_block_height": old_block_height + }) + + elif current_mode == InteractionMode.SELECT: + selected_position = hovered_position + block_selected.emit(selected_position) + +func _handle_remove_block(): + if not world_api or not is_position_valid: + return + + var block = world_api.get_block(hovered_position) + if block: + var block_type = block.get_type_id() + var block_height = block.height + world_api.remove_block(hovered_position) + + _record_action({ + "type": "remove", + "position": hovered_position, + "block_type": block_type, + "block_height": block_height + }) + +func cycle_mode(): + current_mode = (current_mode + 1) % 3 as InteractionMode + interaction_mode_changed.emit(current_mode) + _update_status_ui() + +func set_mode(mode: InteractionMode): + current_mode = mode + interaction_mode_changed.emit(current_mode) + _update_status_ui() + +func _update_status_ui(): + if status_label: + var mode_text = InteractionMode.keys()[current_mode] + var block_text = selected_block_type.capitalize() + status_label.text = "Mode: " + mode_text + "\nBlock: " + block_text + +func _update_cursor_texture(): + if not highlight_sprite: + return + + # Load the actual block SVG texture for the cursor + var texture_path = "res://assets/blocks/isometric-" + selected_block_type + ".svg" + var texture = load(texture_path) + if texture: + highlight_sprite.texture = texture + + # Scale cursor based on selected height + var display_height = _get_selected_block_height() + highlight_sprite.scale = Vector2(1, display_height) + else: + print("WARNING: Could not load cursor texture: ", texture_path) + +func _select_block_with_height_toggle(block_type: String): + # Check if same block pressed twice + if selected_block_type == block_type and last_selected_block == block_type: + # Toggle between default and full height + var default_height = world_api.get_block_type(block_type).height if world_api else 1.0 + if selected_block_height_override < 0 or selected_block_height_override == default_height: + # Switch to full height + selected_block_height_override = 1.0 + print("Selected: ", block_type.capitalize(), " (Full height)") + else: + # Switch back to default height + selected_block_height_override = -1.0 + print("Selected: ", block_type.capitalize(), " (Default height)") + else: + # New block type selected, use default height + selected_block_type = block_type + selected_block_height_override = -1.0 + print("Selected: ", block_type.capitalize()) + + last_selected_block = block_type + _update_cursor_texture() + _update_status_ui() + +func _get_selected_block_height() -> float: + # Return override height if set, otherwise use block type's default + if selected_block_height_override >= 0: + return selected_block_height_override + var block_type = world_api.get_block_type(selected_block_type) if world_api else null + return block_type.height if block_type else 1.0 + +func _toggle_fog(): + if fog_system: + # Reveal area around camera center + var center_pos = Vector3i(0, 0, 0) + if camera: + var viewport_size = get_viewport().get_visible_rect().size + var world_center = camera.screen_to_world(viewport_size / 2) + center_pos = Vector3i(roundi(world_center.x), 0, roundi(world_center.z)) + + fog_system.reveal_area(center_pos, fog_system.revelation_radius * 2) + +func _toggle_fog_globally(): + if fog_system: + fog_system.toggle_fog_globally() + +func _record_action(action: Dictionary): + # Clear any redo history when a new action is recorded + if history_index < action_history.size() - 1: + action_history.resize(history_index + 1) + + # Add the new action + action_history.append(action) + history_index += 1 + + # Limit history size + if action_history.size() > MAX_HISTORY_SIZE: + action_history.pop_front() + history_index = MAX_HISTORY_SIZE - 1 + +func undo(): + if history_index < 0 or action_history.is_empty(): + return + + var action = action_history[history_index] + history_index -= 1 + + match action["type"]: + "place": + # Undo a place: remove the placed block + world_api.remove_block(action["position"]) + # If there was an old block, restore it with original height + if action["old_block_type"]: + var old_height = action.get("old_block_height", 0.5) + world_api.add_block(action["position"], action["old_block_type"], old_height) + print("Undone place at ", action["position"]) + + "remove": + # Undo a remove: restore the removed block with original height + var block_height = action.get("block_height", 0.5) + world_api.add_block(action["position"], action["block_type"], block_height) + print("Undone remove at ", action["position"]) + +func redo(): + if history_index >= action_history.size() - 1: + return + + history_index += 1 + var action = action_history[history_index] + + match action["type"]: + "place": + # Redo a place: remove old block if any, then place new block with height + if action["old_block_type"]: + world_api.remove_block(action["position"]) + var block_height = action.get("block_height", 0.5) + world_api.add_block(action["position"], action["block_type"], block_height) + print("Redone place at ", action["position"]) + + "remove": + # Redo a remove: remove the block again + world_api.remove_block(action["position"]) + print("Redone remove at ", action["position"]) diff --git a/scripts/systems/InteractionSystem.gd.uid b/scripts/systems/InteractionSystem.gd.uid new file mode 100644 index 0000000..6cf0a68 --- /dev/null +++ b/scripts/systems/InteractionSystem.gd.uid @@ -0,0 +1 @@ +uid://bav4mk0r2x4xt diff --git a/scripts/systems/LightingSystem.gd b/scripts/systems/LightingSystem.gd new file mode 100644 index 0000000..3fac346 --- /dev/null +++ b/scripts/systems/LightingSystem.gd @@ -0,0 +1,155 @@ +## OpenIsopix - Lighting System +## Manages environmental and block-emitted lighting + +class_name LightingSystem +extends Node + +@export var world_api: WorldAPI + +## Environmental lighting levels (0.0 to 1.0) +enum EnvironmentalLevel { + PITCH_BLACK, + VERY_DARK, + DARK, + DIM, + NORMAL, + BRIGHT, + VERY_BRIGHT +} + +const LIGHTING_VALUES = { + EnvironmentalLevel.PITCH_BLACK: 0.1, + EnvironmentalLevel.VERY_DARK: 0.2, + EnvironmentalLevel.DARK: 0.4, + EnvironmentalLevel.DIM: 0.6, + EnvironmentalLevel.NORMAL: 0.8, + EnvironmentalLevel.BRIGHT: 0.9, + EnvironmentalLevel.VERY_BRIGHT: 1.0 +} + +var current_environmental_level: EnvironmentalLevel = EnvironmentalLevel.NORMAL +var base_light_level: float = 0.8 + +## Light propagation queue +var light_update_queue: Array[Vector3i] = [] + +signal lighting_updated(position: Vector3i, light_level: float) + +func _ready(): + if world_api: + world_api.block_added.connect(_on_block_added) + world_api.block_removed.connect(_on_block_removed) + +func _process(_delta): + # Process light updates in batches + var updates_per_frame = 100 + while not light_update_queue.is_empty() and updates_per_frame > 0: + var pos = light_update_queue.pop_front() + _update_block_lighting(pos) + updates_per_frame -= 1 + +## Set environmental lighting level +func set_environmental_level(level: EnvironmentalLevel): + current_environmental_level = level + base_light_level = LIGHTING_VALUES[level] + _recalculate_all_lighting() + +## Calculate lighting for a specific block +func calculate_block_lighting(world_pos: Vector3i) -> float: + var light_level = base_light_level + + # Check for nearby light-emitting blocks + var nearby_light = _get_nearby_light_contribution(world_pos) + light_level = max(light_level, nearby_light) + + # Check for occlusion (blocks above reducing light) + var occlusion = _calculate_occlusion(world_pos) + light_level *= (1.0 - occlusion * 0.5) + + return clamp(light_level, 0.0, 1.0) + +func _get_nearby_light_contribution(world_pos: Vector3i) -> float: + if not world_api: + return 0.0 + + var max_light = 0.0 + var search_radius = 5 # Search in a reasonable radius + + for x in range(-search_radius, search_radius + 1): + for y in range(-search_radius, search_radius + 1): + for z in range(-search_radius, search_radius + 1): + var check_pos = world_pos + Vector3i(x, y, z) + var block = world_api.get_block(check_pos) + + if block and block.block_type and block.block_type.emits_light: + var distance = world_pos.distance_to(check_pos) + var light_radius = block.block_type.light_radius / 32.0 # Convert to world units + + if distance <= light_radius: + var attenuation = 1.0 - (distance / light_radius) + var contribution = block.block_type.light_intensity * attenuation + max_light = max(max_light, contribution) + + return max_light + +func _calculate_occlusion(world_pos: Vector3i) -> float: + if not world_api: + return 0.0 + + var occlusion = 0.0 + var check_height = 5 # Check up to 5 blocks above + + for y in range(1, check_height + 1): + var check_pos = world_pos + Vector3i(0, y, 0) + var block = world_api.get_block(check_pos) + + if block and block.block_type and block.block_type.is_opaque: + occlusion += 0.2 # Each opaque block above reduces light + + return clamp(occlusion, 0.0, 1.0) + +func _update_block_lighting(world_pos: Vector3i): + var light_level = calculate_block_lighting(world_pos) + + if world_api: + world_api.modify_block(world_pos, "light_level", light_level) + + lighting_updated.emit(world_pos, light_level) + +func _recalculate_all_lighting(): + if not world_api: + return + + # Queue all loaded blocks for lighting update + for chunk_key in world_api.chunks.keys(): + var chunk = world_api.chunks[chunk_key] + if chunk.is_loaded: + var blocks = chunk.get_all_blocks() + for block in blocks: + if block: + light_update_queue.append(block.position) + +func _propagate_light_from_source(world_pos: Vector3i): + # Add neighboring blocks to update queue + var neighbors = [ + world_pos + Vector3i(1, 0, 0), + world_pos + Vector3i(-1, 0, 0), + world_pos + Vector3i(0, 1, 0), + world_pos + Vector3i(0, -1, 0), + world_pos + Vector3i(0, 0, 1), + world_pos + Vector3i(0, 0, -1), + ] + + for neighbor in neighbors: + if neighbor not in light_update_queue: + light_update_queue.append(neighbor) + +## Signal handlers +func _on_block_added(position: Vector3i, block: BlockInstance): + light_update_queue.append(position) + if block and block.block_type and block.block_type.emits_light: + _propagate_light_from_source(position) + +func _on_block_removed(position: Vector3i): + light_update_queue.append(position) + _propagate_light_from_source(position) diff --git a/scripts/systems/LightingSystem.gd.uid b/scripts/systems/LightingSystem.gd.uid new file mode 100644 index 0000000..b7102bb --- /dev/null +++ b/scripts/systems/LightingSystem.gd.uid @@ -0,0 +1 @@ +uid://7rojcms7vus6