From 7abadb92a9338308c82786aff3a220e477a80473 Mon Sep 17 00:00:00 2001 From: Elrinth Date: Sun, 25 Jan 2026 16:27:19 +0100 Subject: [PATCH] lots of changeru --- src/assets/gfx/enemies/hand_monster.png | Bin 0 -> 8975 bytes .../gfx/enemies/hand_monster.png.import | 40 +++ src/scenes/game_world.tscn | 69 +++++ src/scripts/attack_bomb.gd | 6 + src/scripts/attack_spell_frostspike.gd | 18 +- src/scripts/character_stats.gd | 12 +- src/scripts/door.gd | 10 + src/scripts/game_world.gd | 212 ++++++++++++- src/scripts/interactable_object.gd | 19 ++ src/scripts/inventory_ui.gd | 292 ++++++++++++++---- src/scripts/player.gd | 172 +++++++++-- src/shaders/cloth.gdshader | 5 + src/shaders/game_world.gdshader | 105 +++++++ src/shaders/game_world.gdshader.uid | 1 + 14 files changed, 855 insertions(+), 106 deletions(-) create mode 100644 src/assets/gfx/enemies/hand_monster.png create mode 100644 src/assets/gfx/enemies/hand_monster.png.import create mode 100644 src/shaders/game_world.gdshader create mode 100644 src/shaders/game_world.gdshader.uid diff --git a/src/assets/gfx/enemies/hand_monster.png b/src/assets/gfx/enemies/hand_monster.png new file mode 100644 index 0000000000000000000000000000000000000000..e5f3d7382235e996251f94c55c490c00f4b6d9df GIT binary patch literal 8975 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 z 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