diff --git a/src/assets/gfx/enemies/hand_monster.png b/src/assets/gfx/enemies/hand_monster.png new file mode 100644 index 0000000..e5f3d73 Binary files /dev/null and b/src/assets/gfx/enemies/hand_monster.png differ diff --git a/src/assets/gfx/enemies/hand_monster.png.import b/src/assets/gfx/enemies/hand_monster.png.import new file mode 100644 index 0000000..d2b177d --- /dev/null +++ b/src/assets/gfx/enemies/hand_monster.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dyid2xlxo1gnn" +path="res://.godot/imported/hand_monster.png-a541f67ec1e51b48eea2792ad28a46cc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/gfx/enemies/hand_monster.png" +dest_files=["res://.godot/imported/hand_monster.png-a541f67ec1e51b48eea2792ad28a46cc.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/scenes/game_world.tscn b/src/scenes/game_world.tscn index 41516c4..d1a7cc7 100644 --- a/src/scenes/game_world.tscn +++ b/src/scenes/game_world.tscn @@ -3,10 +3,77 @@ [ext_resource type="Script" uid="uid://bax7e73v836nx" path="res://scripts/player_manager.gd" id="1"] [ext_resource type="PackedScene" uid="uid://cxfvw8y7jqn2p" path="res://scenes/player.tscn" id="2"] [ext_resource type="Script" uid="uid://db58xcyo4cjk" path="res://scripts/game_world.gd" id="4"] +[ext_resource type="Shader" uid="uid://dob36l1rwi2en" path="res://shaders/game_world.gdshader" id="4_bhwwd"] [ext_resource type="Script" uid="uid://wff5063ctp7g" path="res://scripts/debug_overlay.gd" id="5"] [ext_resource type="AudioStream" uid="uid://dthr2w8x0cj6v" path="res://assets/audio/sfx/ambience/wind-castle-loop.wav.mp3" id="6_6c6v5"] [ext_resource type="TileSet" uid="uid://dqem5tbvooxrg" path="res://assets/gfx/RPG DUNGEON VOL 3.tres" id="9"] +[sub_resource type="ShaderMaterial" id="ShaderMaterial_pdbwf"] +shader = ExtResource("4_bhwwd") +shader_parameter/original_0 = Color(0, 0, 0, 1) +shader_parameter/original_1 = Color(0, 0, 0, 1) +shader_parameter/original_2 = Color(0, 0, 0, 1) +shader_parameter/original_3 = Color(0, 0, 0, 1) +shader_parameter/original_4 = Color(0, 0, 0, 1) +shader_parameter/original_5 = Color(0, 0, 0, 1) +shader_parameter/original_6 = Color(0, 0, 0, 1) +shader_parameter/original_7 = Color(0, 0, 0, 1) +shader_parameter/original_8 = Color(0, 0, 0, 1) +shader_parameter/original_9 = Color(0, 0, 0, 1) +shader_parameter/original_10 = Color(0, 0, 0, 1) +shader_parameter/original_11 = Color(0, 0, 0, 1) +shader_parameter/original_12 = Color(0, 0, 0, 1) +shader_parameter/original_13 = Color(0, 0, 0, 1) +shader_parameter/replace_0 = Color(0, 0, 0, 1) +shader_parameter/replace_1 = Color(0, 0, 0, 1) +shader_parameter/replace_2 = Color(0, 0, 0, 1) +shader_parameter/replace_3 = Color(0, 0, 0, 1) +shader_parameter/replace_4 = Color(0, 0, 0, 1) +shader_parameter/replace_5 = Color(0, 0, 0, 1) +shader_parameter/replace_6 = Color(0, 0, 0, 1) +shader_parameter/replace_7 = Color(0, 0, 0, 1) +shader_parameter/replace_8 = Color(0, 0, 0, 1) +shader_parameter/replace_9 = Color(0, 0, 0, 1) +shader_parameter/replace_10 = Color(0, 0, 0, 1) +shader_parameter/replace_11 = Color(0, 0, 0, 1) +shader_parameter/replace_12 = Color(0, 0, 0, 1) +shader_parameter/replace_13 = Color(0, 0, 0, 1) +shader_parameter/tint = Color(1, 1, 1, 1) +shader_parameter/ambient = Color(1, 1, 1, 1) + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_bhwwd"] +shader = ExtResource("4_bhwwd") +shader_parameter/original_0 = Color(0, 0, 0, 1) +shader_parameter/original_1 = Color(0, 0, 0, 1) +shader_parameter/original_2 = Color(0, 0, 0, 1) +shader_parameter/original_3 = Color(0, 0, 0, 1) +shader_parameter/original_4 = Color(0, 0, 0, 1) +shader_parameter/original_5 = Color(0, 0, 0, 1) +shader_parameter/original_6 = Color(0, 0, 0, 1) +shader_parameter/original_7 = Color(0, 0, 0, 1) +shader_parameter/original_8 = Color(0, 0, 0, 1) +shader_parameter/original_9 = Color(0, 0, 0, 1) +shader_parameter/original_10 = Color(0, 0, 0, 1) +shader_parameter/original_11 = Color(0, 0, 0, 1) +shader_parameter/original_12 = Color(0, 0, 0, 1) +shader_parameter/original_13 = Color(0, 0, 0, 1) +shader_parameter/replace_0 = Color(0, 0, 0, 1) +shader_parameter/replace_1 = Color(0, 0, 0, 1) +shader_parameter/replace_2 = Color(0, 0, 0, 1) +shader_parameter/replace_3 = Color(0, 0, 0, 1) +shader_parameter/replace_4 = Color(0, 0, 0, 1) +shader_parameter/replace_5 = Color(0, 0, 0, 1) +shader_parameter/replace_6 = Color(0, 0, 0, 1) +shader_parameter/replace_7 = Color(0, 0, 0, 1) +shader_parameter/replace_8 = Color(0, 0, 0, 1) +shader_parameter/replace_9 = Color(0, 0, 0, 1) +shader_parameter/replace_10 = Color(0, 0, 0, 1) +shader_parameter/replace_11 = Color(0, 0, 0, 1) +shader_parameter/replace_12 = Color(0, 0, 0, 1) +shader_parameter/replace_13 = Color(0, 0, 0, 1) +shader_parameter/tint = Color(1, 1, 1, 1) +shader_parameter/ambient = Color(1, 1, 1, 1) + [node name="GameWorld" type="Node2D" unique_id=430665106] script = ExtResource("4") @@ -21,11 +88,13 @@ zoom = Vector2(3, 3) [node name="DungeonLayer0" type="TileMapLayer" parent="Environment" unique_id=1234567891] z_index = -2 +material = SubResource("ShaderMaterial_pdbwf") tile_set = ExtResource("9") [node name="TileMapLayerAbove" type="TileMapLayer" parent="Environment" unique_id=1234567892] modulate = Color(1, 1, 1, 0.46666667) z_index = 1 +material = SubResource("ShaderMaterial_bhwwd") tile_set = ExtResource("9") [node name="Entities" type="Node2D" parent="." unique_id=1447395523] diff --git a/src/scripts/attack_bomb.gd b/src/scripts/attack_bomb.gd index ad13fc1..902be2a 100644 --- a/src/scripts/attack_bomb.gd +++ b/src/scripts/attack_bomb.gd @@ -366,6 +366,12 @@ func _deal_explosion_damage(): body.rpc_take_damage.rpc(final_damage, attacker_pos, false, false, false) print("Bomb hit enemy: ", body.name, " for ", final_damage, " damage!") + + # Deal damage to interactable objects (pots, boxes, etc.) + elif body.has_method("can_be_destroyed") and body.can_be_destroyed() and body.has_method("take_damage"): + var attacker_pos = player_owner.global_position if player_owner else global_position + body.take_damage(final_damage, attacker_pos) + print("Bomb hit interactable: ", body.name, " for ", final_damage, " damage!") func _spawn_explosion_tile_particles(): var game_world = get_tree().get_first_node_in_group("game_world") diff --git a/src/scripts/attack_spell_frostspike.gd b/src/scripts/attack_spell_frostspike.gd index b2601e6..8bd6031 100644 --- a/src/scripts/attack_spell_frostspike.gd +++ b/src/scripts/attack_spell_frostspike.gd @@ -1,10 +1,11 @@ extends Node2D # Frostspike spell — instant damage, no debuff. Frames 4413–4416, blue PointLight2D. -# If is_center: spawn center spike, then 0.5s later spawn 4 adjacent spikes. +# If is_center: spawn center spike, then 0.5s later spawn 4 adjacent, then third-wave center (2x scale, 2x damage). var player_owner: Node = null var damage: float = 15.0 +var damage_mult: float = 1.0 var is_center: bool = false var damage_dealt: bool = false var elapsed: float = 0.0 @@ -25,11 +26,14 @@ func _ready() -> void: if is_center: _spawn_adjacent_after_delay() -func setup(target_pos: Vector2, owner_player: Node, damage_value: float, center: bool) -> void: +func setup(target_pos: Vector2, owner_player: Node, damage_value: float, center: bool, scale_mult: float = 1.0, dmg_mult: float = 1.0) -> void: global_position = target_pos player_owner = owner_player damage = damage_value + damage_mult = dmg_mult is_center = center + if scale_mult != 1.0: + scale = Vector2(scale_mult, scale_mult) func _spawn_adjacent_after_delay() -> void: await get_tree().create_timer(0.5).timeout @@ -48,8 +52,12 @@ func _spawn_adjacent_after_delay() -> void: var par = get_parent() for pos in adjacent: var sp = scene.instantiate() - par.add_child(sp) sp.setup(pos, player_owner, damage, false) + par.add_child(sp) + # Third wave: center again, 2x scale, 2x damage (most damage) + var third = scene.instantiate() + third.setup(global_position, player_owner, damage, false, 2.0, 2.0) + par.add_child(third) _finish_center_spike() func _finish_center_spike() -> void: @@ -77,7 +85,7 @@ func _deal_damage_once() -> void: for body in hit_area.get_overlapping_bodies(): if body == player_owner: continue - var final_damage = damage + var final_damage = damage * damage_mult if player_owner and player_owner.character_stats: var int_stat = player_owner.character_stats.baseStats.int + player_owner.character_stats.get_pass("int") final_damage += int_stat * 0.5 @@ -100,3 +108,5 @@ func _deal_damage_once() -> void: body.rpc_take_damage.rpc_id(eid, final_damage, attacker_pos, false, false, false) else: body.rpc_take_damage.rpc(final_damage, attacker_pos, false, false, false) + elif body.has_method("can_be_destroyed") and body.can_be_destroyed() and body.has_method("take_damage"): + body.take_damage(final_damage, attacker_pos) diff --git a/src/scripts/character_stats.gd b/src/scripts/character_stats.gd index a7e3487..a5aac90 100644 --- a/src/scripts/character_stats.gd +++ b/src/scripts/character_stats.gd @@ -279,8 +279,12 @@ func level_up() -> void: health_changed.emit(hp, maxhp) mana_changed.emit(mp, maxmp) -func modify_health(amount: float) -> void: - hp = clamp(hp + amount, 0, maxhp) +func modify_health(amount: float, allow_overheal: bool = false) -> void: + hp += amount + if allow_overheal: + hp = max(0.0, hp) + else: + hp = clamp(hp, 0.0, maxhp) health_changed.emit(hp, maxhp) character_changed.emit(self) @@ -320,8 +324,8 @@ func take_damage(amount: float, is_magical: bool = false) -> float: character_changed.emit(self) return actual_damage -func heal(amount: float) -> void: - modify_health(amount) +func heal(amount: float, allow_overheal: bool = false) -> void: + modify_health(amount, allow_overheal) func use_mana(amount: float) -> bool: if mp >= amount: diff --git a/src/scripts/door.gd b/src/scripts/door.gd index 12dc185..18ce982 100644 --- a/src/scripts/door.gd +++ b/src/scripts/door.gd @@ -108,6 +108,12 @@ func _ready() -> void: if index_str.is_valid_int(): set_meta("door_index", index_str.to_int()) +func _update_door_visibility(): + # Hide door sprite when open; show when closed. Re-evaluate on any state change. + var sprite = get_node_or_null("Sprite2D") + if sprite: + sprite.visible = is_closed + func _update_door_texture(): # Update door texture based on door type var sprite = get_node_or_null("Sprite2D") @@ -296,6 +302,8 @@ func _process(delta: float) -> void: position = closed_position is_closed = true set_collision_layer_value(7, true) + + _update_door_visibility() func _update_collision_based_on_position(): # Update collision based on whether door is at closed position or moved away @@ -607,6 +615,8 @@ func _ready_after_setup(): set_collision_layer_value(7, true) LogManager.log("Door: Fixed state - door is now CLOSED (is_closed: " + str(is_closed) + ", collision: " + str(get_collision_layer_value(7)) + ")", LogManager.CATEGORY_DOOR) + _update_door_visibility() + # NOTE: Doors are NOT connected via signals to room triggers # Instead, room triggers call door._on_room_entered() directly # This prevents doors from reacting to ALL room entries, only their own blocking room diff --git a/src/scripts/game_world.gd b/src/scripts/game_world.gd index e3a0bb3..04d85fb 100644 --- a/src/scripts/game_world.gd +++ b/src/scripts/game_world.gd @@ -1127,7 +1127,7 @@ func _show_loot_floating_text(player: Node, text: String, color: Color, item_tex floating_text.global_position = Vector2(player.global_position.x, player.global_position.y - 20) floating_text.setup(text, color, 0.5, 0.5, item_texture, sprite_hframes, sprite_vframes, sprite_frame) -func _apply_heal_spell_sync(target_name: String, amount: float): +func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool): var target: Node = null for p in get_tree().get_nodes_in_group("player"): if p.name == target_name and is_instance_valid(p): @@ -1137,8 +1137,8 @@ func _apply_heal_spell_sync(target_name: String, amount: float): return var me = multiplayer.get_unique_id() var tid = target.get_multiplayer_authority() - if me == tid and target.has_method("heal"): - target.heal(amount) + if me == tid and target.has_method("heal") and amount_to_apply > 0: + target.heal(amount_to_apply, allow_overheal) var entities = get_node_or_null("Entities") var parent = entities if entities else target.get_parent() if not parent: @@ -1150,7 +1150,15 @@ func _apply_heal_spell_sync(target_name: String, amount: float): eff.global_position = target.global_position if eff.has_method("setup"): eff.setup(target) - _show_loot_floating_text(target, "+" + str(int(amount)) + " HP", Color.GREEN, null, 1, 1, 0) + var prefix = "" + if is_crit and is_overheal: + prefix = "CRIT OVERHEAL! " + elif is_crit: + prefix = "CRIT! " + elif is_overheal: + prefix = "OVERHEAL! " + var heal_text = prefix + "+" + str(display_amount) + " HP" + _show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0) @rpc("authority", "unreliable") func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int): @@ -2102,6 +2110,20 @@ func _is_valid_spell_target(target_pos: Vector2, player_pos: Vector2) -> bool: return true +func _is_walkable_tile(tile_center: Vector2) -> bool: + """True if tile is floor/door/corridor (not a wall). No raycast - use for adjacent spikes only.""" + if dungeon_data.is_empty() or not dungeon_data.has("grid"): + return false + var tile_size = 16 + var tile_x = int(tile_center.x / tile_size) + var tile_y = int(tile_center.y / tile_size) + var grid = dungeon_data.grid + var map_size = dungeon_data.map_size + if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y: + return false + var v = grid[tile_x][tile_y] + return v == 1 or v == 2 or v == 3 + func _get_adjacent_valid_spell_tile_centers(center_world_pos: Vector2, player_pos: Vector2) -> Array: var out: Array = [] if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("grid"): @@ -2111,7 +2133,7 @@ func _get_adjacent_valid_spell_tile_centers(center_world_pos: Vector2, player_po for off in offsets: var t = center_tile + off var tile_center = dungeon_tilemap_layer.map_to_local(t) + dungeon_tilemap_layer.global_position - if _is_valid_spell_target(tile_center, player_pos): + if _is_walkable_tile(tile_center): out.append(tile_center) return out @@ -2693,6 +2715,183 @@ func _generate_dungeon(): LogManager.log("GameWorld: Dungeon generation completed successfully", LogManager.CATEGORY_DUNGEON) +# Dungeon shader color replacement: 13 original colors (wall x6, ground x5, fallout x2) +const _DUNGEON_ORIGINALS: Array = [ + Color(24/255.0, 59/255.0, 255/255.0), # 0 wall + Color(33/255.0, 50/255.0, 195/255.0), # 1 wall + Color(98/255.0, 29/255.0, 93/255.0), # 2 wall + Color(66/255.0, 13/255.0, 52/255.0), # 3 wall + Color(74/255.0, 33/255.0, 134/255.0), # 4 wall + Color(50/255.0, 12/255.0, 23/255.0), # 5 wall + Color(149/255.0, 79/255.0, 111/255.0), # 6 ground + Color(192/255.0, 95/255.0, 193/255.0), # 7 ground + Color(48/255.0, 38/255.0, 20/255.0), # 8 ground + Color(143/255.0, 71/255.0, 112/255.0), # 9 ground + Color(106/255.0, 62/255.0, 57/255.0), # 10 ground + Color(69/255.0, 42/255.0, 31/255.0), # 11 fallout + Color(53/255.0, 46/255.0, 26/255.0), # 12 fallout +] + +# Original wall slots ordered light→dark (by luminance). Tile art uses these for highlight/mid/shadow. +# We must assign scheme colors so light→dark is preserved, or shading inverts and looks wrong. +const _WALL_LIGHT_TO_DARK_ORDER: Array = [0, 1, 2, 4, 3, 5] + +func _luminance(c: Color) -> float: + return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b + +func _reorder_wall_colors_by_luminance(six_colors: Array) -> Array: + assert(six_colors.size() == 6) + var with_lum: Array = [] + for i in range(6): + var c = six_colors[i] as Color + with_lum.append({"color": c, "lum": _luminance(c)}) + with_lum.sort_custom(func(a, b): return (a["lum"] as float) > (b["lum"] as float)) + var sorted_colors: Array = [] + for e in with_lum: + sorted_colors.append(e["color"]) + var out: Array = [] + for i in range(6): + out.append(Color.BLACK) + out[0] = sorted_colors[0] + out[1] = sorted_colors[1] + out[2] = sorted_colors[2] + out[3] = sorted_colors[4] + out[4] = sorted_colors[3] + out[5] = sorted_colors[5] + return out + +func _get_dungeon_color_scheme(scheme_index: int) -> Array: + var o = _DUNGEON_ORIGINALS + var walls: Array + var ground_fallout: Array + match scheme_index: + 0: # 1️⃣ Arcane Blue (magic / night / mana) + walls = [ + Color(24/255.0, 59/255.0, 255/255.0), Color(80/255.0, 120/255.0, 255/255.0), Color(140/255.0, 180/255.0, 255/255.0), + Color(10/255.0, 30/255.0, 120/255.0), Color(180/255.0, 200/255.0, 255/255.0), Color(220/255.0, 230/255.0, 255/255.0), + ] + ground_fallout = [ + Color(0.78, 0.48, 0.24), Color(0.84, 0.54, 0.30), Color(0.58, 0.36, 0.16), + Color(0.72, 0.44, 0.22), Color(0.66, 0.40, 0.20), Color(0.38, 0.30, 0.22), + Color(0.32, 0.28, 0.20), + ] + 1: # 2️⃣ Crimson Void (blood / corruption / danger) + walls = [ + Color(120/255.0, 20/255.0, 40/255.0), Color(180/255.0, 40/255.0, 60/255.0), Color(220/255.0, 90/255.0, 110/255.0), + Color(60/255.0, 5/255.0, 20/255.0), Color(255/255.0, 140/255.0, 160/255.0), Color(90/255.0, 10/255.0, 30/255.0), + ] + ground_fallout = [ + Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52), + Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34), + Color(0.22, 0.34, 0.30), + ] + 2: # 3️⃣ Toxic Green (poison / nature / alchemy) + walls = [ + Color(20/255.0, 120/255.0, 40/255.0), Color(60/255.0, 180/255.0, 90/255.0), Color(120/255.0, 220/255.0, 160/255.0), + Color(10/255.0, 60/255.0, 25/255.0), Color(180/255.0, 255/255.0, 210/255.0), Color(40/255.0, 90/255.0, 55/255.0), + ] + ground_fallout = [ + Color(0.64, 0.36, 0.72), Color(0.70, 0.42, 0.78), Color(0.48, 0.26, 0.56), + Color(0.58, 0.32, 0.66), Color(0.54, 0.30, 0.62), Color(0.34, 0.26, 0.38), + Color(0.28, 0.22, 0.32), + ] + 3: # 4️⃣ Stone Grey (industrial / ruins / UI neutral) + walls = [ + Color(40/255.0, 40/255.0, 45/255.0), Color(80/255.0, 80/255.0, 85/255.0), Color(130/255.0, 130/255.0, 135/255.0), + Color(20/255.0, 20/255.0, 25/255.0), Color(180/255.0, 180/255.0, 185/255.0), Color(220/255.0, 220/255.0, 225/255.0), + ] + ground_fallout = [ + Color(0.50, 0.50, 0.52), Color(0.55, 0.55, 0.57), Color(0.35, 0.35, 0.38), + Color(0.48, 0.48, 0.50), Color(0.42, 0.42, 0.45), Color(0.28, 0.28, 0.30), + Color(0.24, 0.24, 0.26), + ] + 4: # 5️⃣ Royal Purple (arcane royalty / bosses) + walls = [ + Color(80/255.0, 30/255.0, 130/255.0), Color(130/255.0, 70/255.0, 180/255.0), Color(180/255.0, 130/255.0, 220/255.0), + Color(40/255.0, 10/255.0, 80/255.0), Color(220/255.0, 180/255.0, 255/255.0), Color(100/255.0, 60/255.0, 150/255.0), + ] + ground_fallout = [ + Color(0.90, 0.68, 0.22), Color(0.94, 0.74, 0.28), Color(0.72, 0.52, 0.14), + Color(0.84, 0.60, 0.18), Color(0.78, 0.56, 0.16), Color(0.46, 0.36, 0.20), + Color(0.38, 0.30, 0.18), + ] + 5: # 6️⃣ Desert Gold (sand / temples / sunlight) + walls = [ + Color(150/255.0, 110/255.0, 40/255.0), Color(200/255.0, 160/255.0, 80/255.0), Color(240/255.0, 210/255.0, 140/255.0), + Color(90/255.0, 60/255.0, 15/255.0), Color(255/255.0, 230/255.0, 170/255.0), Color(170/255.0, 130/255.0, 60/255.0), + ] + ground_fallout = [ + Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52), + Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34), + Color(0.22, 0.34, 0.30), + ] + 6: # 7️⃣ Neon Cyber (sci-fi / UI / hacking) + walls = [ + Color(20/255.0, 240/255.0, 220/255.0), Color(240/255.0, 60/255.0, 220/255.0), Color(120/255.0, 120/255.0, 255/255.0), + Color(10/255.0, 20/255.0, 40/255.0), Color(255/255.0, 255/255.0, 255/255.0), Color(80/255.0, 255/255.0, 180/255.0), + ] + ground_fallout = [ + Color(0.45, 0.28, 0.55), Color(0.52, 0.35, 0.62), Color(0.38, 0.22, 0.48), + Color(0.48, 0.32, 0.58), Color(0.42, 0.26, 0.52), Color(0.28, 0.18, 0.38), + Color(0.22, 0.14, 0.32), + ] + 7: # 8️⃣ Infernal Lava (hell / bosses / damage) + walls = [ + Color(180/255.0, 40/255.0, 20/255.0), Color(240/255.0, 90/255.0, 30/255.0), Color(255/255.0, 160/255.0, 80/255.0), + Color(90/255.0, 10/255.0, 5/255.0), Color(255/255.0, 210/255.0, 160/255.0), Color(140/255.0, 30/255.0, 15/255.0), + ] + ground_fallout = [ + Color(0.32, 0.68, 0.48), Color(0.38, 0.74, 0.54), Color(0.22, 0.52, 0.36), + Color(0.28, 0.62, 0.44), Color(0.26, 0.58, 0.40), Color(0.26, 0.34, 0.28), + Color(0.22, 0.30, 0.24), + ] + _: + return o.duplicate() + if walls.size() == 6 and ground_fallout.size() == 7: + walls = _reorder_wall_colors_by_luminance(walls) + var out: Array = [] + out.append_array(walls) + out.append_array(ground_fallout) + return out + return o.duplicate() + +func _apply_dungeon_color_scheme() -> void: + var scheme_idx = (abs(dungeon_seed) + current_level) % 8 + var replace_colors = _get_dungeon_color_scheme(scheme_idx) + var shader_res = load("res://shaders/game_world.gdshader") as Shader + if not shader_res: + return + for layer in [dungeon_tilemap_layer, dungeon_tilemap_layer_above]: + if not layer or not is_instance_valid(layer): + continue + var mat = layer.material + if not mat or not (mat is ShaderMaterial): + mat = ShaderMaterial.new() + mat.shader = shader_res + layer.material = mat + var sm = mat as ShaderMaterial + for i in range(13): + var orig = _DUNGEON_ORIGINALS[i] as Color + var rpl = replace_colors[i] as Color + sm.set_shader_parameter("original_" + str(i), orig) + sm.set_shader_parameter("replace_" + str(i), rpl) + # Index 13 unused; set to no-op (original same as replace, distinct from tile colors) + var neutral = Color(0.0, 0.0, 0.0, 1.0) + sm.set_shader_parameter("original_13", neutral) + sm.set_shader_parameter("replace_13", neutral) + # TileMapLayerAbove: tint ffffff77 for slight transparency + if layer == dungeon_tilemap_layer_above: + sm.set_shader_parameter("tint", Color(1.0, 1.0, 1.0, 0x77 / 255.0)) + else: + sm.set_shader_parameter("tint", Color(1.0, 1.0, 1.0, 1.0)) + # Apply CanvasModulate-style darkening via ambient (unshaded shader bypasses CanvasModulate) + var cm = get_node_or_null("CanvasModulate") + var ambient_color = Color(1.0, 1.0, 1.0, 1.0) + if cm and is_instance_valid(cm): + ambient_color = cm.color + sm.set_shader_parameter("ambient", ambient_color) + LogManager.log("GameWorld: Applied dungeon color scheme " + str(scheme_idx) + " (seed " + str(dungeon_seed) + ", level " + str(current_level) + ")", LogManager.CATEGORY_DUNGEON) + func _render_dungeon(): if dungeon_data.is_empty(): push_error("ERROR: Cannot render dungeon - no dungeon data!") @@ -2879,6 +3078,9 @@ func _render_dungeon(): # Create stairs Area2D if stairs data exists _create_stairs_area() + # Randomize dungeon color scheme (seed-based) + _apply_dungeon_color_scheme() + func _update_spawn_points(target_room: Dictionary = {}, clear_existing: bool = true): # Update player manager spawn points based on a room # If target_room is empty, use start room (for initial spawn) diff --git a/src/scripts/interactable_object.gd b/src/scripts/interactable_object.gd index 832a7ba..e9cf39e 100644 --- a/src/scripts/interactable_object.gd +++ b/src/scripts/interactable_object.gd @@ -430,6 +430,25 @@ func take_fire_damage(amount: float, _attacker_position: Vector2) -> void: else: _break_into_pieces() +func take_damage(amount: float, _from_position: Vector2) -> void: + """Generic damage from bomb, frost spike, etc. Any destroyable object.""" + if not is_destroyable or is_broken: + return + health -= amount + if health > 0: + return + var game_world = get_tree().get_first_node_in_group("game_world") + if multiplayer.has_multiplayer_peer(): + if multiplayer.is_server(): + if game_world and game_world.has_method("_rpc_to_ready_peers"): + game_world._rpc_to_ready_peers("_sync_object_break", [name]) + _break_into_pieces() + else: + if game_world and game_world.has_method("_sync_object_break"): + game_world._sync_object_break.rpc_id(1, name) + else: + _break_into_pieces() + func on_grabbed(by_player): # Special handling for chests - open instead of grab if object_type == "Chest" and not is_chest_opened: diff --git a/src/scripts/inventory_ui.gd b/src/scripts/inventory_ui.gd index 7c42529..de9e363 100644 --- a/src/scripts/inventory_ui.gd +++ b/src/scripts/inventory_ui.gd @@ -38,11 +38,40 @@ var equipment_selection_index: int = 0 # Current equipment slot index (0-5: main @onready var sfx_food: AudioStreamPlayer2D = $SfxFood @onready var sfx_armour: AudioStreamPlayer2D = $SfxArmour +# Bar layout constants (align X/Y + bar across rows) +const _BAR_WIDTH: int = 100 +const _BAR_VALUE_MIN_WIDTH: int = 44 # "999/999" +const _BAR_LABEL_MIN_WIDTH: int = 52 # "Weight:", "Exp:", "HP:", "MP:", "Coin:" + # Weight UI elements (created programmatically) var weight_container: HBoxContainer = null var weight_label: Label = null +var weight_value_label: Label = null var weight_progress_bar: ProgressBar = null +# Exp UI elements (like weight) +var exp_container: HBoxContainer = null +var exp_label: Label = null +var exp_value_label: Label = null +var exp_progress_bar: ProgressBar = null + +# HP / MP bar elements +var hp_container: HBoxContainer = null +var hp_label: Label = null +var hp_value_label: Label = null +var hp_progress_bar: ProgressBar = null +var mp_container: HBoxContainer = null +var mp_label: Label = null +var mp_value_label: Label = null +var mp_progress_bar: ProgressBar = null + +# Coin UI elements ("Coin:" + 6-frame sprite + "X N") +var coin_container: HBoxContainer = null +var coin_label: Label = null +var coin_sprite: Sprite2D = null +var coin_value_label: Label = null +var coin_anim_time: float = 0.0 + # Store button/item mappings for selection highlighting var inventory_buttons: Dictionary = {} # item -> button var equipment_buttons: Dictionary = {} # slot_name -> button @@ -80,8 +109,12 @@ func _ready(): # Create equipment slot buttons (dynamically) _create_equipment_slots() - # Create weight progress bar + # Create HP/MP bars, then weight, exp, coin (order in stats panel) + _create_hp_ui() + _create_mp_ui() _create_weight_ui() + _create_exp_ui() + _create_coin_ui() # Setup selection rectangle (already in scene, just configure it) _setup_selection_rectangle() @@ -175,43 +208,75 @@ func _update_stats(): var race_text = char_stats.race stats_label.text = "Stats - " + race_text - # Update base stats - label_base_stats_value.text = str(char_stats.level) + "\n\n" + \ - str(int(char_stats.hp)) + "/" + str(int(char_stats.maxhp)) + "\n" + \ - str(int(char_stats.mp)) + "/" + str(int(char_stats.maxmp)) + "\n\n" + \ - str(char_stats.baseStats.str) + "\n" + \ - str(char_stats.baseStats.dex) + "\n" + \ - str(char_stats.baseStats.end) + "\n" + \ - str(char_stats.baseStats.int) + "\n" + \ - str(char_stats.baseStats.wis) + "\n" + \ - str(char_stats.baseStats.lck) + # Base stats: Level, STR, DEX, END, INT, WIS, LCK, PER (HP/MP are bars below) + if label_base_stats: + label_base_stats.text = "Level\n\nSTR\nDEX\nEND\nINT\nWIS\nLCK\nPER" + if label_base_stats_value: + label_base_stats_value.text = str(char_stats.level) + "\n\n" + \ + str(char_stats.baseStats.str) + "\n" + \ + str(char_stats.baseStats.dex) + "\n" + \ + str(char_stats.baseStats.end) + "\n" + \ + str(char_stats.baseStats.int) + "\n" + \ + str(char_stats.baseStats.wis) + "\n" + \ + str(char_stats.baseStats.lck) + "\n" + \ + str(char_stats.baseStats.get("per", 10)) - # Update derived stats - label_derived_stats_value.text = str(int(char_stats.xp)) + "/" + str(int(char_stats.xp_to_next_level)) + "\n" + \ - str(char_stats.coin) + "\n\n\n\n" + \ - str(char_stats.damage) + "\n" + \ - str(char_stats.defense) + "\n" + \ - str(char_stats.move_speed) + "\n" + \ - str(char_stats.attack_speed) + "\n" + \ - str(char_stats.sight) + # Derived stats: DMG, DEF, MovSpd, AtkSpd, Sight, SpellAmp, Crit% (XP/Coin moved to exp meter & coin UI) + if label_derived_stats: + label_derived_stats.text = "DMG\nDEF\nMovSpd\nAtkSpd\nSight\nSpellAmp\nCrit%" + if label_derived_stats_value: + label_derived_stats_value.text = "%.1f\n%.1f\n%.2f\n%.2f\n%.1f\n%.1f\n%.1f%%" % [ + char_stats.damage, + char_stats.defense, + char_stats.move_speed, + char_stats.attack_speed, + char_stats.sight, + char_stats.spell_amp, + char_stats.crit_chance + ] - # Update weight progress bar - if weight_progress_bar and weight_label: + # HP bar + if hp_progress_bar and hp_value_label: + hp_progress_bar.max_value = max(1.0, char_stats.maxhp) + hp_progress_bar.value = char_stats.hp + hp_value_label.text = str(int(char_stats.hp)) + "/" + str(int(char_stats.maxhp)) + + # MP bar + if mp_progress_bar and mp_value_label: + mp_progress_bar.max_value = max(1.0, char_stats.maxmp) + mp_progress_bar.value = char_stats.mp + mp_value_label.text = str(int(char_stats.mp)) + "/" + str(int(char_stats.maxmp)) + + # Exp meter (like weight) + if exp_progress_bar and exp_value_label: + var xp = char_stats.xp + var xp_next = char_stats.xp_to_next_level + exp_progress_bar.max_value = max(1.0, xp_next) + exp_progress_bar.value = xp + exp_value_label.text = str(int(xp)) + "/" + str(int(xp_next)) + var fill_exp = StyleBoxFlat.new() + fill_exp.bg_color = Color(0.55, 0.35, 0.95) + exp_progress_bar.add_theme_stylebox_override("fill", fill_exp) + + # Coin: "Coin:" + 6-frame sprite + "X " + if coin_value_label: + coin_value_label.text = "X " + str(char_stats.coin) + + # Weight progress bar + if weight_progress_bar and weight_value_label: var current_weight = char_stats.get_total_weight() var max_weight = char_stats.get_carrying_capacity() weight_progress_bar.max_value = max_weight weight_progress_bar.value = current_weight - weight_label.text = "Weight: " + str(int(current_weight)) + "/" + str(int(max_weight)) - - # Change color based on weight (green -> yellow -> red) + weight_value_label.text = str(int(current_weight)) + "/" + str(int(max_weight)) var weight_ratio = current_weight / max_weight var fill_style = StyleBoxFlat.new() if weight_ratio < 0.7: - fill_style.bg_color = Color(0.6, 0.8, 0.3) # Green + fill_style.bg_color = Color(0.6, 0.8, 0.3) elif weight_ratio < 0.9: - fill_style.bg_color = Color(0.9, 0.8, 0.2) # Yellow + fill_style.bg_color = Color(0.9, 0.8, 0.2) else: - fill_style.bg_color = Color(0.9, 0.3, 0.2) # Red + fill_style.bg_color = Color(0.9, 0.3, 0.2) weight_progress_bar.add_theme_stylebox_override("fill", fill_style) func _create_equipment_slots(): @@ -269,45 +334,134 @@ func _create_equipment_slots(): equipment_slots[slot_name] = button equipment_buttons[slot_name] = button -func _create_weight_ui(): - # Create weight display (label + progress bar) +func _style_bar_font(lbl: Label) -> void: + lbl.add_theme_font_size_override("font_size", 10) + if ResourceLoader.exists("res://assets/fonts/standard_font.png"): + var fr = load("res://assets/fonts/standard_font.png") + if fr: + lbl.add_theme_font_override("font", fr) + +func _make_progress_bar_background() -> StyleBoxFlat: + var bg = StyleBoxFlat.new() + bg.bg_color = Color(0.2, 0.2, 0.2, 0.8) + bg.border_color = Color(0.4, 0.4, 0.4) + bg.set_border_width_all(1) + return bg + +func _create_bar_row(p_name: String, p_label_text: String) -> Dictionary: + var row = HBoxContainer.new() + row.name = p_name + row.add_theme_constant_override("separation", 4) + var left = Label.new() + left.text = p_label_text + left.custom_minimum_size.x = _BAR_LABEL_MIN_WIDTH + _style_bar_font(left) + var spacer = Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var value_lbl = Label.new() + value_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + value_lbl.custom_minimum_size.x = _BAR_VALUE_MIN_WIDTH + _style_bar_font(value_lbl) + var bar = ProgressBar.new() + bar.custom_minimum_size = Vector2(_BAR_WIDTH, 12) + bar.show_percentage = false + bar.add_theme_stylebox_override("background", _make_progress_bar_background()) + row.add_child(left) + row.add_child(spacer) + row.add_child(value_lbl) + row.add_child(bar) + stats_panel.add_child(row) + return {"container": row, "label": left, "value_label": value_lbl, "progress_bar": bar} + +func _create_hp_ui(): if not stats_panel: return - - # Create container for weight UI - weight_container = HBoxContainer.new() - weight_container.name = "WeightContainer" - weight_container.add_theme_constant_override("separation", 4) - - # Create label - weight_label = Label.new() - weight_label.text = "Weight:" - weight_label.add_theme_font_size_override("font_size", 10) - if ResourceLoader.exists("res://assets/fonts/standard_font.png"): - var font_resource = load("res://assets/fonts/standard_font.png") - if font_resource: - weight_label.add_theme_font_override("font", font_resource) - weight_container.add_child(weight_label) - - # Create progress bar - weight_progress_bar = ProgressBar.new() - weight_progress_bar.custom_minimum_size = Vector2(100, 12) - weight_progress_bar.show_percentage = false - # Style the progress bar - var progress_style = StyleBoxFlat.new() - progress_style.bg_color = Color(0.2, 0.2, 0.2, 0.8) - progress_style.border_color = Color(0.4, 0.4, 0.4) - progress_style.set_border_width_all(1) - weight_progress_bar.add_theme_stylebox_override("background", progress_style) - - var fill_style = StyleBoxFlat.new() - fill_style.bg_color = Color(0.6, 0.8, 0.3) # Green color - weight_progress_bar.add_theme_stylebox_override("fill", fill_style) - - weight_container.add_child(weight_progress_bar) - - # Add to stats panel (after stats labels) - stats_panel.add_child(weight_container) + var d = _create_bar_row("HPContainer", "HP:") + hp_container = d.container + hp_label = d.label + hp_value_label = d.value_label + hp_progress_bar = d.progress_bar + var fill = StyleBoxFlat.new() + fill.bg_color = Color(0.85, 0.2, 0.2) + hp_progress_bar.add_theme_stylebox_override("fill", fill) + +func _create_mp_ui(): + if not stats_panel: + return + var d = _create_bar_row("MPContainer", "MP:") + mp_container = d.container + mp_label = d.label + mp_value_label = d.value_label + mp_progress_bar = d.progress_bar + var fill = StyleBoxFlat.new() + fill.bg_color = Color(0.25, 0.45, 0.9) + mp_progress_bar.add_theme_stylebox_override("fill", fill) + +func _create_weight_ui(): + if not stats_panel: + return + var d = _create_bar_row("WeightContainer", "Weight:") + weight_container = d.container + weight_label = d.label + weight_value_label = d.value_label + weight_progress_bar = d.progress_bar + var fill = StyleBoxFlat.new() + fill.bg_color = Color(0.6, 0.8, 0.3) + weight_progress_bar.add_theme_stylebox_override("fill", fill) + +func _create_exp_ui(): + if not stats_panel: + return + var d = _create_bar_row("ExpContainer", "Exp:") + exp_container = d.container + exp_label = d.label + exp_value_label = d.value_label + exp_progress_bar = d.progress_bar + var fill = StyleBoxFlat.new() + fill.bg_color = Color(0.55, 0.35, 0.95) + exp_progress_bar.add_theme_stylebox_override("fill", fill) + +func _create_coin_ui(): + if not stats_panel: + return + coin_container = HBoxContainer.new() + coin_container.name = "CoinContainer" + coin_container.add_theme_constant_override("separation", 4) + coin_label = Label.new() + coin_label.name = "CoinLabel" + coin_label.text = "Coin:" + coin_label.custom_minimum_size.x = _BAR_LABEL_MIN_WIDTH + _style_bar_font(coin_label) + coin_container.add_child(coin_label) + var coin_spacer = Control.new() + coin_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + coin_container.add_child(coin_spacer) + var coin_wrap = Control.new() + coin_wrap.custom_minimum_size = Vector2(16, 16) + coin_sprite = Sprite2D.new() + coin_sprite.name = "CoinSprite" + var tex = load("res://assets/gfx/pickups/gold_coin.png") as Texture2D + if tex: + coin_sprite.texture = tex + coin_sprite.hframes = 6 + coin_sprite.vframes = 1 + coin_sprite.frame = 0 + coin_sprite.centered = false + # Scale down to fit; texture may be multi-frame + var tw = tex.get_width() / 6.0 + var th = tex.get_height() + if tw > 0 and th > 0: + var s = min(16.0 / tw, 16.0 / th) + coin_sprite.scale = Vector2(s, s) + coin_wrap.add_child(coin_sprite) + coin_container.add_child(coin_wrap) + coin_value_label = Label.new() + coin_value_label.name = "CoinValueLabel" + coin_value_label.text = "X 0" + coin_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + _style_bar_font(coin_value_label) + coin_container.add_child(coin_value_label) + stats_panel.add_child(coin_container) func _has_equipment_in_slot(slot_name: String) -> bool: # Check if there's an item equipped in this slot @@ -338,11 +492,11 @@ func _on_equipment_slot_pressed(slot_name: String): if is_updating_ui: return - # Only select if there's an item equipped + # Only select if there's an item equipped (same as arrow-key navigation) if not _has_equipment_in_slot(slot_name): return - # Select this slot + # Select this slot (equivalent to arrow-key selecting this equipment) selected_slot = slot_name selected_item = local_player.character_stats.equipment[slot_name] selected_type = "equipment" if selected_item else "" @@ -353,6 +507,7 @@ func _on_equipment_slot_pressed(slot_name: String): _update_selection_highlight() _update_selection_rectangle() + _update_info_panel() func _on_equipment_slot_gui_input(event: InputEvent, slot_name: String): # Handle double-click to unequip @@ -452,6 +607,11 @@ func _process(delta): var stylebox = selected_button.get_meta("highlight_stylebox") as StyleBoxFlat if stylebox: stylebox.border_color = animated_color + + # Animate 6-frame coin sprite + if coin_sprite and coin_sprite.hframes >= 6: + coin_anim_time += delta * 10.0 + coin_sprite.frame = int(coin_anim_time) % 6 func _update_ui(): if not local_player or not local_player.character_stats: diff --git a/src/scripts/player.gd b/src/scripts/player.gd index 1087f3e..4f358b2 100644 --- a/src/scripts/player.gd +++ b/src/scripts/player.gd @@ -42,6 +42,9 @@ var grab_released_while_lifting = false # Track if grab was released while lifti var grab_start_time = 0.0 # Time when we first grabbed (to detect quick tap) var grab_tap_threshold = 0.2 # Seconds to distinguish tap from hold var is_shielding: bool = false # True when holding grab with shield in offhand and nothing to grab/lift +var is_reviving: bool = false # True when holding grab on a corpse and charging revive +var revive_charge: float = 0.0 +const REVIVE_DURATION: float = 2.0 # Seconds holding grab to revive var last_movement_direction = Vector2.DOWN # Track last direction for placing objects var push_axis = Vector2.ZERO # Locked axis for pushing/pulling var push_direction_locked: int = Direction.DOWN # Locked facing direction when pushing @@ -160,6 +163,7 @@ var current_health: float: character_stats.hp = value var is_dead: bool = false var is_processing_death: bool = false # Prevent multiple death sequences +var was_revived: bool = false # Set by reviver; aborts _die() wait-for-all-dead var respawn_point: Vector2 = Vector2.ZERO var coins: int: get: @@ -299,7 +303,7 @@ const ANIMATIONS = { "nextAnimation": null }, "RUN_PULL": { - "frames": [32, 32, 32, 33], + "frames": [33, 32, 33, 34], "frameDurations": [260, 260, 260, 260], "loop": true, "nextAnimation": null @@ -2255,8 +2259,8 @@ func _handle_input(): if character_stats and character_stats.is_over_encumbered(): current_speed = base_speed * 0.25 - # Lock movement if movement_lock_timer is active - if movement_lock_timer > 0.0: + # Lock movement if movement_lock_timer is active or reviving a corpse + if movement_lock_timer > 0.0 or is_reviving: velocity = Vector2.ZERO else: velocity = input_vector * current_speed @@ -2614,6 +2618,10 @@ func _handle_interactions(): # 1. We just grabbed this frame (prevents immediate release bug) - THIS IS THE MOST IMPORTANT CHECK # 2. Button is still down (shouldn't happen, but safety check) # 3. grab_just_pressed is also true (same frame tap) + if grab_just_released: + is_reviving = false + revive_charge = 0.0 + if grab_just_released and held_object: # For bombs that are already lifted, skip the "just grabbed" logic # and go straight to the normal release handling (drop-on-second-press) @@ -2675,7 +2683,23 @@ func _handle_interactions(): # Update object position based on mode (only if button is still held) if held_object and grab_button_down: if is_lifting: - _update_lifted_object() + var holding_dead_player = _is_player(held_object) and "is_dead" in held_object and held_object.is_dead + var reviver_hp = character_stats.hp if character_stats else 1.0 + if holding_dead_player and reviver_hp > 1.0: + is_reviving = true + revive_charge += get_process_delta_time() + if revive_charge >= REVIVE_DURATION: + _do_revive(held_object) + _place_down_object() + is_reviving = false + revive_charge = 0.0 + else: + _update_lifted_object() + else: + if holding_dead_player: + is_reviving = false + revive_charge = 0.0 + _update_lifted_object() # Clear the "released while lifting" flag if button is held again if grab_released_while_lifting: grab_released_while_lifting = false @@ -4007,28 +4031,53 @@ func _cast_heal_spell(target: Node): return if not character_stats: return + var gw = get_tree().get_first_node_in_group("game_world") + var dungeon_seed: int = 0 + if gw and "dungeon_seed" in gw: + dungeon_seed = gw.dungeon_seed + var seed_val = dungeon_seed + hash(target.name) + int(global_position.x) * 31 + int(global_position.y) * 17 + int(Time.get_ticks_msec() / 50) + var rng = RandomNumberGenerator.new() + rng.seed = seed_val + var int_val = character_stats.baseStats.int + character_stats.get_pass("int") - var base_heal = 10.0 - var amount = base_heal + int_val * 0.5 + var lck_val = character_stats.baseStats.lck + character_stats.get_pass("lck") + var crit_chance_pct = (character_stats.baseStats.lck + character_stats.get_pass("lck")) * 1.2 + + var base_heal = 10.0 + int_val * 0.5 + var variance = 0.2 + var amount = base_heal * (1.0 + (rng.randf() * 2.0 - 1.0) * variance) amount = max(1.0, floor(amount)) - var cap = 0.0 - if target.character_stats: - cap = target.character_stats.maxhp - target.character_stats.hp - amount = min(amount, max(0.0, cap)) - if amount <= 0: - return + + var is_crit = rng.randf() * 100.0 < crit_chance_pct + if is_crit: + amount = floor(amount * 2.0) + + var overheal_chance_pct = 1.0 + lck_val * 0.3 + var can_overheal = target.character_stats and target.character_stats.hp <= target.character_stats.maxhp + var is_overheal = can_overheal and rng.randf() * 100.0 < overheal_chance_pct + + var display_amount = int(amount) + var actual_heal = amount + var allow_overheal = false + if is_overheal: + allow_overheal = true + else: + var cap = 0.0 + if target.character_stats: + cap = target.character_stats.maxhp - target.character_stats.hp + actual_heal = min(amount, max(0.0, cap)) + var me = multiplayer.get_unique_id() var tid = target.get_multiplayer_authority() - if me == tid: - target.heal(amount) - _spawn_heal_effect_and_text(target, amount) + if me == tid and actual_heal > 0: + target.heal(actual_heal, allow_overheal) + _spawn_heal_effect_and_text(target, display_amount, is_crit, is_overheal) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): - var gw = get_tree().get_first_node_in_group("game_world") - if gw and gw.has_method("_sync_heal_spell"): - _rpc_to_ready_peers("_sync_heal_spell_via_gw", [target.name, amount]) - print(name, " cast heal on ", target.name, " for ", int(amount), " HP") + if gw and gw.has_method("_apply_heal_spell_sync"): + _rpc_to_ready_peers("_sync_heal_spell_via_gw", [target.name, actual_heal, display_amount, is_crit, is_overheal, allow_overheal]) + print(name, " cast heal on ", target.name, " for ", display_amount, " HP", " (actual: ", actual_heal, ", crit: ", is_crit, ", overheal: ", is_overheal, ")") -func _spawn_heal_effect_and_text(target: Node, amount: float): +func _spawn_heal_effect_and_text(target: Node, display_amount: int, is_crit: bool, is_overheal: bool): if not target or not is_instance_valid(target): return var game_world = get_tree().get_first_node_in_group("game_world") @@ -4042,20 +4091,28 @@ func _spawn_heal_effect_and_text(target: Node, amount: float): eff.global_position = target.global_position if eff.has_method("setup"): eff.setup(target) + var prefix = "" + if is_crit and is_overheal: + prefix = "CRIT OVERHEAL! " + elif is_crit: + prefix = "CRIT! " + elif is_overheal: + prefix = "OVERHEAL! " + var heal_text = prefix + "+" + str(display_amount) + " HP" var floating_text_scene = preload("res://scenes/floating_text.tscn") if floating_text_scene: var ft = floating_text_scene.instantiate() parent.add_child(ft) ft.global_position = Vector2(target.global_position.x, target.global_position.y - 20) - ft.setup("+" + str(int(amount)) + " HP", Color.GREEN, 0.5, 0.5, null, 1, 1, 0) + ft.setup(heal_text, Color.GREEN, 0.5, 0.5, null, 1, 1, 0) @rpc("any_peer", "reliable") -func _sync_heal_spell_via_gw(target_name: String, amount: float): +func _sync_heal_spell_via_gw(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool): if is_multiplayer_authority(): return var gw = get_tree().get_first_node_in_group("game_world") if gw and gw.has_method("_apply_heal_spell_sync"): - gw._apply_heal_spell_sync(target_name, amount) + gw._apply_heal_spell_sync(target_name, amount_to_apply, display_amount, is_crit, is_overheal, allow_overheal) func _is_healing_spell() -> bool: if not character_stats or not character_stats.equipment.has("offhand"): @@ -5737,6 +5794,11 @@ func _die(): print(name, " died!") + # Show concussion status effect above head + var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") + if status_anim and status_anim.has_animation("concussion"): + status_anim.play("concussion") + # Play death sound effect if sfx_die: for i in 12: @@ -5808,14 +5870,29 @@ func _die(): being_held_by = null - # Wait 0.5 seconds after fade before respawning + # If another player is alive, lie dead until ALL players are dead (or we get revived) + while not _are_all_players_dead(): + await get_tree().create_timer(0.2).timeout + if was_revived: + return + + # Brief delay after last death before respawning await get_tree().create_timer(0.5).timeout + if was_revived: + return # Respawn (this will reset is_processing_death) _respawn() +func _are_all_players_dead() -> bool: + for p in get_tree().get_nodes_in_group("player"): + if "is_dead" in p and not p.is_dead: + return false + return true + func _respawn(): print(name, " respawning!") + was_revived = false # being_held_by already cleared in _die() before this # Holder already dropped us 0.2 seconds ago @@ -5846,6 +5923,10 @@ func _respawn(): if sprite_layer: sprite_layer.modulate.a = 1.0 + var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") + if status_anim and status_anim.has_animation("idle"): + status_anim.play("idle") + # Get respawn position - use spawn room (start room) for respawning var new_respawn_pos = respawn_point var game_world = get_tree().get_first_node_in_group("game_world") @@ -5958,6 +6039,42 @@ func _force_holder_to_drop_local(holder_name: String): else: print(" ✗ Holder not found or invalid") +func _do_revive(corpse: Node): + if not _is_player(corpse) or not "is_dead" in corpse or not corpse.is_dead: + return + var reviver_hp = character_stats.hp if character_stats else 1.0 + if reviver_hp <= 1.0: + return + var half_hp = max(1, int(reviver_hp * 0.5)) + if character_stats: + character_stats.hp = max(1, character_stats.hp - half_hp) + character_stats.character_changed.emit(character_stats) + if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): + corpse._revive_from_player.rpc_id(corpse.get_multiplayer_authority(), half_hp) + else: + corpse._revive_from_player(half_hp) + +@rpc("any_peer", "reliable") +func _revive_from_player(hp_amount: int): + if not is_dead: + return + was_revived = true + is_dead = false + is_processing_death = false + if character_stats: + character_stats.hp = float(hp_amount) + else: + current_health = float(hp_amount) + for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, + sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, + sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]: + if sprite_layer: + sprite_layer.modulate.a = 1.0 + var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") + if status_anim and status_anim.has_animation("idle"): + status_anim.play("idle") + _set_animation("IDLE") + @rpc("any_peer", "reliable") func _sync_death(): if not is_multiplayer_authority(): @@ -6258,16 +6375,17 @@ func _apply_inventory_and_equipment_from_server(inventory_data: Array, equipment character_stats.character_changed.emit(character_stats) print(name, " inventory+equipment applied from server: ", character_stats.inventory.size(), " items") -func heal(amount: float): +func heal(amount: float, allow_overheal: bool = false): if is_dead: return if character_stats: - character_stats.heal(amount) + character_stats.heal(amount, allow_overheal) print(name, " healed for ", amount, " HP! Health: ", character_stats.hp, "/", character_stats.maxhp) else: # Fallback for legacy - current_health = min(current_health + amount, max_health) + var new_hp = current_health + amount + current_health = max(0.0, new_hp) if allow_overheal else clamp(new_hp, 0.0, max_health) print(name, " healed for ", amount, " HP! Health: ", current_health, "/", max_health) func add_key(amount: int = 1): diff --git a/src/shaders/cloth.gdshader b/src/shaders/cloth.gdshader index 8338c19..1e537d2 100644 --- a/src/shaders/cloth.gdshader +++ b/src/shaders/cloth.gdshader @@ -39,6 +39,11 @@ void fragment() { COLOR = col * tint; } +void light() { + // Called for every pixel for every light affecting the CanvasItem. + float cNdotL = max(1.0, dot(NORMAL, LIGHT_DIRECTION)); + LIGHT = vec4(LIGHT_COLOR.rgb * COLOR.rgb * LIGHT_ENERGY * cNdotL, LIGHT_COLOR.a); +} diff --git a/src/shaders/game_world.gdshader b/src/shaders/game_world.gdshader new file mode 100644 index 0000000..24c2b61 --- /dev/null +++ b/src/shaders/game_world.gdshader @@ -0,0 +1,105 @@ +shader_type canvas_item; + +uniform vec4 original_0: source_color; +uniform vec4 original_1: source_color; +uniform vec4 original_2: source_color; +uniform vec4 original_3: source_color; +uniform vec4 original_4: source_color; +uniform vec4 original_5: source_color; +uniform vec4 original_6: source_color; +uniform vec4 original_7: source_color; +uniform vec4 original_8: source_color; +uniform vec4 original_9: source_color; +uniform vec4 original_10: source_color; +uniform vec4 original_11: source_color; +uniform vec4 original_12: source_color; +uniform vec4 original_13: source_color; +uniform vec4 replace_0: source_color; +uniform vec4 replace_1: source_color; +uniform vec4 replace_2: source_color; +uniform vec4 replace_3: source_color; +uniform vec4 replace_4: source_color; +uniform vec4 replace_5: source_color; +uniform vec4 replace_6: source_color; +uniform vec4 replace_7: source_color; +uniform vec4 replace_8: source_color; +uniform vec4 replace_9: source_color; +uniform vec4 replace_10: source_color; +uniform vec4 replace_11: source_color; +uniform vec4 replace_12: source_color; +uniform vec4 replace_13: source_color; + +uniform vec4 tint: source_color = vec4(1.0); +uniform vec4 ambient: source_color = vec4(1.0, 1.0, 1.0, 1.0); + +const float precision = 0.1; +const int Colz = 14; + +vec4 swap_color(vec4 color){ + vec4 original_colors[Colz] = vec4[Colz] ( + original_0, + original_1, + original_2, + original_3, + original_4, + original_5, + original_6, + original_7, + original_8, + original_9, + original_10, + original_11, + original_12, + original_13 + ); + vec4 replace_colors[Colz] = vec4[Colz] ( + replace_0, + replace_1, + replace_2, + replace_3, + replace_4, + replace_5, + replace_6, + replace_7, + replace_8, + replace_9, + replace_10, + replace_11, + replace_12, + replace_13 + ); + for (int i = 0; i < Colz; i ++) { + if (distance(color, original_colors[i]) <= precision){ + return replace_colors[i]; + } + } + return color; +} + + +void fragment() { + vec4 col = swap_color(texture(TEXTURE, UV)); + COLOR = col * tint * ambient; +} + +void light() { + // Called for every pixel for every light affecting the CanvasItem. + float cNdotL = max(1.0, dot(NORMAL, LIGHT_DIRECTION)); + LIGHT = vec4(LIGHT_COLOR.rgb * COLOR.rgb * LIGHT_ENERGY * cNdotL, LIGHT_COLOR.a); +} + + + + + + + + + + + + + + + + diff --git a/src/shaders/game_world.gdshader.uid b/src/shaders/game_world.gdshader.uid new file mode 100644 index 0000000..05b92a5 --- /dev/null +++ b/src/shaders/game_world.gdshader.uid @@ -0,0 +1 @@ +uid://dob36l1rwi2en