diff --git a/src/assets/gfx/cursor.png b/src/assets/gfx/cursor.png new file mode 100644 index 0000000..2589e81 Binary files /dev/null and b/src/assets/gfx/cursor.png differ diff --git a/src/assets/gfx/cursor.png.import b/src/assets/gfx/cursor.png.import new file mode 100644 index 0000000..d1fc0ab --- /dev/null +++ b/src/assets/gfx/cursor.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxyhsfq22vxfn" +path="res://.godot/imported/cursor.png-308a6879f38fa7a626935612b5aab578.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/cursor.png" +dest_files=["res://.godot/imported/cursor.png-308a6879f38fa7a626935612b5aab578.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 diff --git a/src/assets/gfx/ui/ELR_Corsshairs.png b/src/assets/gfx/ui/ELR_Corsshairs.png new file mode 100644 index 0000000..6c79aa8 Binary files /dev/null and b/src/assets/gfx/ui/ELR_Corsshairs.png differ diff --git a/src/assets/gfx/ui/ELR_Corsshairs.png.import b/src/assets/gfx/ui/ELR_Corsshairs.png.import new file mode 100644 index 0000000..15181aa --- /dev/null +++ b/src/assets/gfx/ui/ELR_Corsshairs.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bc0eg8xeht1m0" +path="res://.godot/imported/ELR_Corsshairs.png-1be59a5a0530c35e5b746d569963749c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/ui/ELR_Corsshairs.png" +dest_files=["res://.godot/imported/ELR_Corsshairs.png-1be59a5a0530c35e5b746d569963749c.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 diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index 399826f..8951cb4 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -11,6 +11,17 @@ const BASE_CAMERA_ZOOM: float = 4.0 const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices const REFERENCE_ASPECT: float = 16.0 / 9.0 +# Mouse cursor system +var cursor_sprite: Sprite2D = null # Free movement cursor (frame 0) +var grid_cursor_sprite: Sprite2D = null # Grid-locked cursor (frame 1) +var cursor_layer: CanvasLayer = null +const CURSOR_LAYER_Z: int = 2000 # Very high Z index for cursor +var use_mouse_control: bool = true # Enable/disable mouse control +var camera_lerp_to_cursor: bool = false # Optional: lerp camera slightly toward cursor +const CURSOR_CAMERA_LERP_AMOUNT: float = 0.15 # How much camera lerps toward cursor (0.0 = none, 1.0 = full) +var cursor_pulse_time: float = 0.0 # Time accumulator for pulsing animation +const CURSOR_PULSE_SPEED: float = 3.0 # Speed of color pulse animation + # Fog of war const FOG_TILE_SIZE: int = 16 const FOG_VIEW_RANGE_TILES: float = 10.0 @@ -130,6 +141,9 @@ func _ready(): # Create inventory UI _create_inventory_ui() + # Initialize mouse cursor system + _init_mouse_cursor() + # Generate dungeon on host only # Only generate if we're the server (not just "no multiplayer peer") # This prevents clients from generating their own dungeon before connecting @@ -1512,7 +1526,11 @@ func _check_tab_visibility(): return window_visible return true # Always visible on non-web platforms -func _process(_delta): +func _process(delta): + # Update mouse cursor + if use_mouse_control: + _update_mouse_cursor(delta) + # Check tab visibility for buffer overflow protection (clients only) if not multiplayer.is_server(): var is_tab_visible = _check_tab_visibility() @@ -1560,11 +1578,11 @@ func _process(_delta): # Update camera to follow local players _update_camera() - _update_fog_of_war(_delta) + _update_fog_of_war(delta) # Periodic cleanup of disconnected peers (server only) if multiplayer.is_server() and multiplayer.has_multiplayer_peer(): - peer_cleanup_timer += _delta + peer_cleanup_timer += delta if peer_cleanup_timer >= PEER_CLEANUP_INTERVAL: peer_cleanup_timer = 0.0 _cleanup_disconnected_peers() @@ -1696,7 +1714,7 @@ func _update_camera(): # Adjust zoom based on player spread (for split-screen effect) if local_players.size() > 1: - var max_distance = 0.0a + var max_distance = 0.0 for player in local_players: var distance = center.distance_to(player.position) max_distance = max(max_distance, distance) @@ -1707,6 +1725,121 @@ func _update_camera(): # Always update zoom (for both single and multi-player) camera.zoom = camera.zoom.lerp(Vector2.ONE * target_zoom, 0.05) + + # Optional: Lerp camera slightly toward cursor direction + if camera_lerp_to_cursor and use_mouse_control: + var cursor_world_pos = camera.get_global_mouse_position() + var cursor_offset = cursor_world_pos - center + var lerped_center = center + cursor_offset * CURSOR_CAMERA_LERP_AMOUNT + camera.position = camera.position.lerp(lerped_center, 0.1) + +func _init_mouse_cursor(): + # Create cursor layer with high Z index + cursor_layer = CanvasLayer.new() + cursor_layer.name = "MouseCursorLayer" + cursor_layer.layer = CURSOR_LAYER_Z + add_child(cursor_layer) + + # Load cursor texture + var cursor_texture = load("res://assets/gfx/cursor.png") + if not cursor_texture: + push_error("GameWorld: Could not load cursor.png!") + return + + # Create free movement cursor sprite (frame 0) + cursor_sprite = Sprite2D.new() + cursor_sprite.name = "MouseCursorFree" + cursor_sprite.texture = cursor_texture + # Set up sprite sheet (32x16 with 2 frames of 16x16 each) + cursor_sprite.hframes = 2 + cursor_sprite.vframes = 1 + cursor_sprite.frame = 0 # Frame 0 = free movement + cursor_layer.add_child(cursor_sprite) + + # Create grid-locked cursor sprite (frame 1) + grid_cursor_sprite = Sprite2D.new() + grid_cursor_sprite.name = "MouseCursorGrid" + grid_cursor_sprite.texture = cursor_texture + grid_cursor_sprite.hframes = 2 + grid_cursor_sprite.vframes = 1 + grid_cursor_sprite.frame = 1 # Frame 1 = grid-locked + grid_cursor_sprite.modulate.a = 0.5 # 50% opacity + cursor_layer.add_child(grid_cursor_sprite) + + # Hide system cursor + Input.mouse_mode = Input.MOUSE_MODE_HIDDEN + +func _update_mouse_cursor(delta: float): + if not use_mouse_control: + return + + if not cursor_sprite or not is_instance_valid(cursor_sprite): + return + + if not grid_cursor_sprite or not is_instance_valid(grid_cursor_sprite): + return + + # Update pulse time for grid cursor color animation + cursor_pulse_time += delta * CURSOR_PULSE_SPEED + + # Get mouse position in viewport (screen space) + var mouse_pos = get_viewport().get_mouse_position() + + # Convert to world position for game logic + var world_pos = camera.get_global_mouse_position() + + # Scale cursors to match camera zoom level (so grid-locked cursor aligns with tiles) + # Each cursor frame is 16x16 pixels, and tiles are 16x16 pixels + # Scale by camera zoom to maintain 1:1 pixel ratio + var cursor_scale = camera.zoom.x # Use x zoom (should be same as y) + cursor_sprite.scale = Vector2.ONE * cursor_scale + grid_cursor_sprite.scale = Vector2.ONE * cursor_scale + + # Check if we should show grid-locked cursor (when mouse is over game world tiles) + var show_grid_cursor = false + var grid_locked_world_pos = world_pos + if dungeon_tilemap_layer: + var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position) + var tile_data = dungeon_tilemap_layer.get_cell_source_id(tile_pos) + if tile_data >= 0: # Valid tile + show_grid_cursor = true + # Snap to tile center for world position calculation + grid_locked_world_pos = dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position + # Convert grid-locked world position to screen position for cursor display + # Formula: (world_pos - camera.position) * camera.zoom + viewport_center + var viewport_size = get_viewport().get_visible_rect().size + var viewport_center = viewport_size / 2.0 + var grid_locked_screen_pos = (grid_locked_world_pos - camera.position) * camera.zoom.x + viewport_center + grid_cursor_sprite.position = grid_locked_screen_pos + else: + grid_cursor_sprite.position = Vector2(-1000, -1000) # Hide off-screen + + # Update free cursor position (always follows mouse) + cursor_sprite.position = mouse_pos + + # Update grid cursor visibility and pulsing color + grid_cursor_sprite.visible = show_grid_cursor + if show_grid_cursor: + # Pulse color: oscillate between normal and brighter color + var pulse_value = (sin(cursor_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 + # Interpolate between normal (1,1,1) and brighter (1.5, 1.2, 1.0) for a warm pulse + var base_color = Color(1.0, 1.0, 1.0) + var pulse_color = Color(1.5, 1.2, 1.0) + grid_cursor_sprite.modulate = base_color.lerp(pulse_color, pulse_value * 0.5) # 50% of the way to pulse color + grid_cursor_sprite.modulate.a = 0.5 # Keep opacity at 50% + + # Update player facing direction based on mouse position (use world position) + if local_players.size() > 0: + var player = local_players[0] # Use first local player + if player and is_instance_valid(player) and player.is_local_player: + var player_pos = player.global_position + # Use grid-locked position if available, otherwise use free mouse position + var target_world_pos = grid_locked_world_pos if show_grid_cursor else world_pos + var mouse_direction = (target_world_pos - player_pos).normalized() + + # Only update facing if mouse is far enough from player + if mouse_direction.length() > 0.1: + player._update_facing_from_mouse(mouse_direction) func _init_fog_of_war(): if dungeon_data.is_empty() or not dungeon_data.has("map_size"): @@ -1832,7 +1965,7 @@ func _update_fog_of_war(delta: float) -> void: cached_corridor_rooms = _get_rooms_connected_to_corridor(cached_corridor_mask, player_tile) cached_corridor_player_tile = player_tile - # Build a set of allowed room IDs for fast lookup + # Build a set of allowed room IDs for fast lookup cached_corridor_allowed_room_ids = {} for room in cached_corridor_rooms: var room_id = str(room.x) + "," + str(room.y) + "," + str(room.w) + "," + str(room.h) diff --git a/src/scripts/player.gd b/src/scripts/player.gd index d3323f2..d92817a 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -23,6 +23,8 @@ var teleported_this_frame: bool = false # Flag to prevent position sync from ove # Input device (for local multiplayer) var input_device: int = -1 # -1 for keyboard, 0+ for gamepad index var virtual_joystick_input: Vector2 = Vector2.ZERO # Virtual joystick input from mobile controls +var was_mouse_right_pressed: bool = false # Track previous mouse right button state +var was_mouse_left_pressed: bool = false # Track previous mouse left button state # Interaction var held_object = null @@ -1251,6 +1253,23 @@ func _get_direction_from_vector(vec: Vector2) -> int: else: # 292.5 to 337.5 return Direction.UP_RIGHT +# Update facing direction from mouse position (called by GameWorld) +func _update_facing_from_mouse(mouse_direction: Vector2): + # Only update if using keyboard input (not gamepad) + if input_device != -1: + return + + # Don't update if pushing (locked direction) + if is_pushing: + return + + var new_direction = _get_direction_from_vector(mouse_direction) as Direction + + # Update direction and cone light rotation if changed + if new_direction != current_direction: + current_direction = new_direction + _update_cone_light_rotation() + func _set_animation(anim_name: String): if current_animation != anim_name: current_animation = anim_name @@ -1684,6 +1703,7 @@ func _handle_input(): last_movement_direction = input_vector.normalized() # Update facing direction (except when pushing - locked direction) + # Note: Mouse control will override this if mouse is being used var new_direction = current_direction if not is_pushing: new_direction = _get_direction_from_vector(input_vector) as Direction @@ -1774,10 +1794,12 @@ func _handle_interactions(): var grab_just_released = false if input_device == -1: - # Keyboard input - grab_button_down = Input.is_action_pressed("grab") - grab_just_pressed = Input.is_action_just_pressed("grab") - grab_just_released = Input.is_action_just_released("grab") + # Keyboard or Mouse input + var mouse_right_pressed = Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) + grab_button_down = Input.is_action_pressed("grab") or mouse_right_pressed + grab_just_pressed = Input.is_action_just_pressed("grab") or (mouse_right_pressed and not was_mouse_right_pressed) + grab_just_released = Input.is_action_just_released("grab") or (not mouse_right_pressed and was_mouse_right_pressed) + was_mouse_right_pressed = mouse_right_pressed # DEBUG: Log button states if there's a conflict if grab_just_pressed and grab_just_released: @@ -1907,8 +1929,10 @@ func _handle_interactions(): # Handle attack input var attack_just_pressed = false if input_device == -1: - # Keyboard - attack_just_pressed = Input.is_action_just_pressed("attack") + # Keyboard or Mouse + var mouse_left_pressed = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) + attack_just_pressed = Input.is_action_just_pressed("attack") or (mouse_left_pressed and not was_mouse_left_pressed) + was_mouse_left_pressed = mouse_left_pressed else: # Gamepad (X button) attack_just_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X)