From a6edbaa8e7b3678cb3d7051ec5f635663194fa8f Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:56:09 -0300 Subject: [PATCH 01/10] feat: extend ContentDB to field structures and status effects --- scripts/core/ContentDB.gd | 142 +++++++++++++++++++++----------------- 1 file changed, 80 insertions(+), 62 deletions(-) diff --git a/scripts/core/ContentDB.gd b/scripts/core/ContentDB.gd index 47110e7..2dea93a 100644 --- a/scripts/core/ContentDB.gd +++ b/scripts/core/ContentDB.gd @@ -2,139 +2,157 @@ extends Node signal content_changed(category:String,id:String) -const FILES:Dictionary={ +const FILES:Dictionary = { "attacks":"res://data/content/attacks.json", "projectiles":"res://data/content/projectiles.json", "items":"res://data/content/items.json", "monsters":"res://data/content/monsters.json", - "tiles":"res://data/content/tiles.json" + "tiles":"res://data/content/tiles.json", + "statuses":"res://data/content/statuses.json", + "buildings":"res://data/content/buildings.json" } -const OVERRIDE_PATH:="user://marchbound_content_overrides.json" +const OVERRIDE_PATH:String = "user://marchbound_content_overrides.json" -var catalogs:Dictionary={} -var overrides:Dictionary={} +var catalogs:Dictionary = {} +var overrides:Dictionary = {} -func _ready()->void: +func _ready() -> void: reload_all() -func reload_all()->void: +func reload_all() -> void: catalogs.clear() - for category in FILES: - catalogs[category]=_load_json(String(FILES[category])) - overrides=_load_json(OVERRIDE_PATH) if FileAccess.file_exists(OVERRIDE_PATH) else {} + for category:Variant in FILES.keys(): + catalogs[String(category)] = _load_json(String(FILES[category])) + overrides = _load_json(OVERRIDE_PATH) if FileAccess.file_exists(OVERRIDE_PATH) else {} _apply_overrides() -func _load_json(path:String)->Dictionary: +func _load_json(path:String) -> Dictionary: if not FileAccess.file_exists(path): push_warning("ContentDB missing %s"%path) return {} - var file:=FileAccess.open(path,FileAccess.READ) - if file==null: + var file:FileAccess = FileAccess.open(path,FileAccess.READ) + if file == null: return {} - var parsed:Variant=JSON.parse_string(file.get_as_text()) + var parsed:Variant = JSON.parse_string(file.get_as_text()) if parsed is Dictionary: return Dictionary(parsed) push_error("ContentDB invalid JSON: %s"%path) return {} -func _apply_overrides()->void: - for category in overrides: - if not catalogs.has(category) or not overrides[category] is Dictionary: +func _apply_overrides() -> void: + for category:Variant in overrides.keys(): + var category_name:String = String(category) + if not catalogs.has(category_name) or not overrides[category] is Dictionary: continue - for id in overrides[category]: - catalogs[category][id]=Dictionary(overrides[category][id]).duplicate(true) + var category_overrides:Dictionary = Dictionary(overrides[category]) + for id:Variant in category_overrides.keys(): + catalogs[category_name][String(id)] = Dictionary(category_overrides[id]).duplicate(true) -func get_entry(category:String,id:String)->Dictionary: +func get_entry(category:String,id:String) -> Dictionary: if not catalogs.has(category): return {} - var raw:Variant=Dictionary(catalogs[category]).get(id,{}) + var raw:Variant = Dictionary(catalogs[category]).get(id,{}) return Dictionary(raw).duplicate(true) if raw is Dictionary else {} -func all(category:String)->Dictionary: - var raw:Variant=catalogs.get(category,{}) +func all(category:String) -> Dictionary: + var raw:Variant = catalogs.get(category,{}) return Dictionary(raw).duplicate(true) if raw is Dictionary else {} -func ids(category:String)->Array[String]: - var result:Array[String]=[] +func ids(category:String) -> Array[String]: + var result:Array[String] = [] if not catalogs.has(category): return result - for id in Dictionary(catalogs[category]).keys(): + for id:Variant in Dictionary(catalogs[category]).keys(): result.append(String(id)) result.sort() return result -func update_entry(category:String,id:String,data:Dictionary,persist:=true)->void: +func update_entry(category:String,id:String,data:Dictionary,persist:bool=true) -> void: if not catalogs.has(category): - catalogs[category]={} - catalogs[category][id]=data.duplicate(true) + catalogs[category] = {} + catalogs[category][id] = data.duplicate(true) if persist: if not overrides.has(category): - overrides[category]={} - overrides[category][id]=data.duplicate(true) + overrides[category] = {} + overrides[category][id] = data.duplicate(true) _save_overrides() content_changed.emit(category,id) -func delete_override(category:String,id:String)->void: +func delete_override(category:String,id:String) -> void: if overrides.has(category) and overrides[category] is Dictionary: - overrides[category].erase(id) + Dictionary(overrides[category]).erase(id) _save_overrides() reload_all() content_changed.emit(category,id) -func reset_all_overrides()->void: +func reset_all_overrides() -> void: overrides.clear() if FileAccess.file_exists(OVERRIDE_PATH): DirAccess.remove_absolute(ProjectSettings.globalize_path(OVERRIDE_PATH)) reload_all() content_changed.emit("*","*") -func _save_overrides()->void: - var file:=FileAccess.open(OVERRIDE_PATH,FileAccess.WRITE) +func _save_overrides() -> void: + var file:FileAccess = FileAccess.open(OVERRIDE_PATH,FileAccess.WRITE) if file: file.store_string(JSON.stringify(overrides," ")) -func weapon(id:String)->Dictionary: - var d:=get_entry("items",id) - return d if String(d.get("kind",""))=="weapon" else {} +func weapon(id:String) -> Dictionary: + var definition:Dictionary = get_entry("items",id) + return definition if String(definition.get("kind","")) == "weapon" else {} -func attack(id:String)->Dictionary: +func attack(id:String) -> Dictionary: return get_entry("attacks",id) -func projectile(id:String)->Dictionary: +func projectile(id:String) -> Dictionary: return get_entry("projectiles",id) -func monster(id:String)->Dictionary: +func monster(id:String) -> Dictionary: return get_entry("monsters",id) -func tile_def(id:String)->Dictionary: +func tile_def(id:String) -> Dictionary: return get_entry("tiles",id) -func monsters_for_biome(biome:String)->Array[String]: - var result:Array[String]=[] - for id in ids("monsters"): - var d:=monster(id) - if biome in Array(d.get("biomes",[])): +func status(id:String) -> Dictionary: + return get_entry("statuses",id) + +func building(id:String) -> Dictionary: + return get_entry("buildings",id) + +func monsters_for_biome(biome:String) -> Array[String]: + var result:Array[String] = [] + for id:String in ids("monsters"): + var definition:Dictionary = monster(id) + if biome in Array(definition.get("biomes",[])): result.append(id) return result -func drops_for_monster(id:String)->Array: +func drops_for_monster(id:String) -> Array: return Array(monster(id).get("drops",[])).duplicate(true) -func validate_references()->Array[String]: - var errors:Array[String]=[] - for id in ids("items"): - var item:=get_entry("items",id) - if String(item.get("kind",""))!="weapon": +func validate_references() -> Array[String]: + var errors:Array[String] = [] + for id:String in ids("items"): + var item:Dictionary = get_entry("items",id) + if String(item.get("kind","")) != "weapon": continue - var attack_id:=String(item.get("attack_id","")) - if attack_id!="" and attack(attack_id).is_empty(): + var attack_id:String = String(item.get("attack_id","")) + if attack_id != "" and attack(attack_id).is_empty(): errors.append("item %s -> missing attack %s"%[id,attack_id]) - var projectile_id:=String(item.get("projectile_id","")) - if projectile_id!="" and projectile(projectile_id).is_empty(): + var projectile_id:String = String(item.get("projectile_id","")) + if projectile_id != "" and projectile(projectile_id).is_empty(): errors.append("item %s -> missing projectile %s"%[id,projectile_id]) - for id in ids("monsters"): - var monster_data:=monster(id) - var attack_id:=String(monster_data.get("attack_id","")) - if attack_id!="" and attack(attack_id).is_empty(): + for id:String in ids("monsters"): + var monster_data:Dictionary = monster(id) + var attack_id:String = String(monster_data.get("attack_id","")) + if attack_id != "" and attack(attack_id).is_empty(): errors.append("monster %s -> missing attack %s"%[id,attack_id]) + var projectile_id:String = String(monster_data.get("projectile_id","")) + if projectile_id != "" and projectile(projectile_id).is_empty(): + errors.append("monster %s -> missing projectile %s"%[id,projectile_id]) + for id:String in ids("projectiles"): + var projectile_data:Dictionary = projectile(id) + var status_id:String = String(projectile_data.get("status_id","")) + if status_id != "" and status(status_id).is_empty(): + errors.append("projectile %s -> missing status %s"%[id,status_id]) return errors From 582bb279a9f8bb0e26280e340586c84b9a359df3 Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:56:20 -0300 Subject: [PATCH 02/10] content: add reusable combat status effects --- data/content/statuses.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 data/content/statuses.json diff --git a/data/content/statuses.json b/data/content/statuses.json new file mode 100644 index 0000000..e3113c7 --- /dev/null +++ b/data/content/statuses.json @@ -0,0 +1,8 @@ +{ + "burn": {"name":"Burning","kind":"dot","duration":3.5,"tick_rate":0.5,"damage_per_tick_mult":0.18,"max_stacks":3,"color":"#ff8a55"}, + "poison": {"name":"Poisoned","kind":"dot","duration":5.0,"tick_rate":0.75,"damage_per_tick_mult":0.12,"max_stacks":5,"color":"#87c866"}, + "slow": {"name":"Chilled","kind":"slow","duration":2.8,"speed_mult":0.62,"max_stacks":1,"color":"#9de9ff"}, + "stun": {"name":"Stunned","kind":"stun","duration":0.8,"max_stacks":1,"color":"#ffe28a"}, + "armor_break": {"name":"Sundered","kind":"vulnerability","duration":4.0,"damage_taken_mult":1.22,"max_stacks":1,"color":"#e3b084"}, + "hex": {"name":"Hexed","kind":"vulnerability","duration":4.5,"damage_taken_mult":1.16,"speed_mult":0.86,"max_stacks":1,"color":"#c48bea"} +} \ No newline at end of file From 177c5f38d42eb593a7e40171237d2549ea30e0db Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:56:35 -0300 Subject: [PATCH 03/10] content: add first multi-structure field building catalog --- data/content/buildings.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 data/content/buildings.json diff --git a/data/content/buildings.json b/data/content/buildings.json new file mode 100644 index 0000000..bcb6d09 --- /dev/null +++ b/data/content/buildings.json @@ -0,0 +1,7 @@ +{ + "watchtower": {"name":"Field Watchtower","role":"turret","cost":{"wood":6},"range":260,"damage":18,"cadence":0.72,"knockback":0.45,"sprite_id":"building_watchtower","description":"Reliable ranged pressure. The first field-defense baseline."}, + "spike_trap": {"name":"Spike Trap","role":"trap","cost":{"wood":3,"iron":2},"trigger_radius":42,"damage":34,"cooldown":1.25,"knockback":1.35,"sprite_id":"fx_trap","description":"Cheap burst damage and strong knockback when enemies cross it."}, + "field_camp": {"name":"Field Camp","role":"support","cost":{"wood":8,"food":4},"radius":115,"heal_per_second":4.0,"sprite_id":"building_camp","description":"Creates a small recovery pocket for the Warden during long expeditions."}, + "arcane_pylon": {"name":"Arcane Pylon","role":"turret","cost":{"stone":5,"mana":2},"range":225,"damage":13,"cadence":0.48,"knockback":0.25,"chain":2,"sprite_id":"building_research","description":"Fast magical turret whose shots can jump to nearby enemies."}, + "palisade": {"name":"Palisade","role":"barrier","cost":{"wood":4,"stone":1},"radius":34,"slow_radius":54,"enemy_speed_mult":0.58,"sprite_id":"building_wall_gate","description":"A simple temporary obstacle zone that slows enemies crossing its footprint."} +} \ No newline at end of file From 4b8aa9469f6ead4b7f002a9d6924c732c24d92f9 Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:56:55 -0300 Subject: [PATCH 04/10] feat: add field-building cycle input --- project.godot | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/project.godot b/project.godot index 68b3b8c..5802bc7 100644 --- a/project.godot +++ b/project.godot @@ -62,3 +62,7 @@ build_outpost={ "deadzone": 0.5, "events": [Object(InputEventKey,"physical_keycode":66)] } +build_cycle={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"physical_keycode":67)] +} From a073e99b5b6dd39ac9464a5fa904db879f81fffa Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:57:51 -0300 Subject: [PATCH 05/10] feat: expand field building into five data-driven structures --- scripts/combat/QualityCombatArena.gd | 284 +++++++++++++++++++-------- 1 file changed, 204 insertions(+), 80 deletions(-) diff --git a/scripts/combat/QualityCombatArena.gd b/scripts/combat/QualityCombatArena.gd index dc9424d..0fa0ad5 100644 --- a/scripts/combat/QualityCombatArena.gd +++ b/scripts/combat/QualityCombatArena.gd @@ -1,74 +1,165 @@ class_name QualityCombatArena extends VisualCombatArena -const FIELD_WATCHTOWER_WOOD_COST := 6.0 -const FIELD_WATCHTOWER_RANGE := 390.0 -const FIELD_WATCHTOWER_COOLDOWN := 0.72 -const FIELD_WATCHTOWER_LIMIT := 12 +const FIELD_BUILD_LIMIT:int = 24 +const BUILD_ORDER:Array[String] = ["watchtower","spike_trap","field_camp","arcane_pylon","palisade"] var field_towers:Array = [] +var selected_building_index:int = 0 func begin(data:Dictionary) -> void: UnitRoster.ensure_schema() field_towers.clear() + selected_building_index = 0 super.begin(data) func _process(delta:float) -> void: super._process(delta) if ended or paused_for_upgrade: return + if Input.is_action_just_pressed("build_cycle"): + _cycle_field_building() if Input.is_action_just_pressed("build_outpost"): - _try_build_field_watchtower() - _update_field_towers(delta) + _try_build_selected() + _update_field_structures(delta) queue_redraw() +func _cycle_field_building() -> void: + selected_building_index = (selected_building_index+1)%BUILD_ORDER.size() + var id:String = BUILD_ORDER[selected_building_index] + var definition:Dictionary = ContentDB.building(id) + _float_text(player_pos+Vector2(0,-56),"BUILD · %s"%String(definition.get("name",id)),Color("#efd896"),1.0) + +func _selected_building_id() -> String: + return BUILD_ORDER[clampi(selected_building_index,0,BUILD_ORDER.size()-1)] + func _try_build_field_watchtower() -> void: - if field_towers.size() >= FIELD_WATCHTOWER_LIMIT: - _float_text(player_pos+Vector2(0,-52),"FIELD LIMIT REACHED",Color("#d4b28d"),0.9) + selected_building_index = 0 + _try_build_selected() + +func _try_build_selected() -> void: + if field_towers.size() >= FIELD_BUILD_LIMIT: + _float_text(player_pos+Vector2(0,-52),"FIELD STRUCTURE LIMIT REACHED",Color("#d4b28d"),0.9) return - if float(loot.get("wood",0.0)) < FIELD_WATCHTOWER_WOOD_COST: - _float_text(player_pos+Vector2(0,-52),"NEED 6 HARVESTED WOOD",Color("#d7a48b"),0.9) + var id:String = _selected_building_id() + var definition:Dictionary = ContentDB.building(id) + if definition.is_empty(): + _float_text(player_pos+Vector2(0,-52),"UNKNOWN BUILDING",Color("#d7a48b"),0.9) return - var snapped := Vector2( + var cost:Dictionary = Dictionary(definition.get("cost",{})) + for resource:Variant in cost.keys(): + var key:String = String(resource) + var required:float = float(cost[resource]) + if float(loot.get(key,0.0)) < required: + _float_text(player_pos+Vector2(0,-52),"NEED %d %s"%[int(ceil(required)),key.to_upper()],Color("#d7a48b"),0.9) + return + var snapped:Vector2 = Vector2( round(player_pos.x/float(LOCAL_TILE_PX))*LOCAL_TILE_PX, round(player_pos.y/float(LOCAL_TILE_PX))*LOCAL_TILE_PX ) - for tower in field_towers: - if Vector2(tower.get("pos",Vector2.ZERO)).distance_to(snapped) < 78.0: - _float_text(player_pos+Vector2(0,-52),"TOO CLOSE TO ANOTHER TOWER",Color("#d7a48b"),0.9) + for structure:Dictionary in field_towers: + if Vector2(structure.get("pos",Vector2.ZERO)).distance_to(snapped) < 66.0: + _float_text(player_pos+Vector2(0,-52),"BUILD SPACE OCCUPIED",Color("#d7a48b"),0.9) return - for building in local_structures: - if Vector2(building.get("pos",Vector2.ZERO)).distance_to(snapped) < 110.0: + for building:Dictionary in local_structures: + if Vector2(building.get("pos",Vector2.ZERO)).distance_to(snapped) < 100.0: _float_text(player_pos+Vector2(0,-52),"BUILD SPACE BLOCKED",Color("#d7a48b"),0.9) return - loot["wood"] = float(loot.get("wood",0.0)) - FIELD_WATCHTOWER_WOOD_COST - field_towers.append({"pos":snapped,"cooldown":0.18,"level":1}) + for resource:Variant in cost.keys(): + var key:String = String(resource) + loot[key] = float(loot.get(key,0.0))-float(cost[resource]) + field_towers.append({"id":id,"pos":snapped,"cooldown":0.15,"pulse":0.0}) _spawn_ring(snapped,Color("#e1c278")) - _float_text(snapped+Vector2(0,-56),"FIELD WATCHTOWER BUILT",Color("#f0d68d"),1.0) + _float_text(snapped+Vector2(0,-56),"%s BUILT"%String(definition.get("name",id)).to_upper(),Color("#f0d68d"),1.0) _shake(0.08,2.2) -func _update_field_towers(delta:float) -> void: - if field_towers.is_empty(): +func _update_field_structures(delta:float) -> void: + for i:int in range(field_towers.size()): + var structure:Dictionary = field_towers[i] + var id:String = String(structure.get("id","watchtower")) + var definition:Dictionary = ContentDB.building(id) + var role:String = String(definition.get("role","turret")) + structure["cooldown"] = maxf(0.0,float(structure.get("cooldown",0.0))-delta) + structure["pulse"] = float(structure.get("pulse",0.0))+delta + match role: + "turret": + _update_field_turret(structure,definition) + "trap": + _update_field_trap(structure,definition) + "support": + _update_field_support(structure,definition,delta) + "barrier": + _update_field_barrier(structure,definition,delta) + field_towers[i] = structure + +func _update_field_turret(structure:Dictionary,definition:Dictionary) -> void: + if float(structure.get("cooldown",0.0)) > 0.0 or enemies.is_empty(): + return + var origin:Vector2 = Vector2(structure.get("pos",Vector2.ZERO)) + var target:Dictionary = nearest_enemy(origin) + var attack_range:float = float(definition.get("range",260.0)) + if target.is_empty() or origin.distance_to(Vector2(target.get("pos",Vector2.ZERO))) > attack_range: return - for i in field_towers.size(): - var tower:Dictionary = field_towers[i] - tower["cooldown"] = max(0.0,float(tower.get("cooldown",0.0))-delta) - if float(tower.cooldown) <= 0.0 and not enemies.is_empty(): - var target := nearest_enemy(Vector2(tower.pos)) - if not target.is_empty() and Vector2(tower.pos).distance_to(Vector2(target.pos)) <= FIELD_WATCHTOWER_RANGE: - var tower_damage := 9.0 + float(GameState.player.level)*0.55 + float(tile.get("threat",1))*0.9 - _damage_enemy(target,tower_damage,false) - particles.append({"pos":Vector2(target.pos),"life":0.20,"max":0.20,"color":Color("#e5c578")}) - tower["cooldown"] = FIELD_WATCHTOWER_COOLDOWN - field_towers[i] = tower + var structure_damage:float = float(definition.get("damage",12.0))+float(GameState.player.level)*0.35+float(tile.get("threat",1))*0.45 + _damage_enemy(target,structure_damage,false) + var push_dir:Vector2 = (Vector2(target.get("pos",Vector2.ZERO))-origin).normalized() + target["pos"] = Vector2(target.get("pos",Vector2.ZERO))+push_dir*float(definition.get("knockback",0.25))*14.0 + particles.append({"pos":Vector2(target.get("pos",Vector2.ZERO)),"life":0.20,"max":0.20,"color":Color("#e5c578")}) + var chains:int = int(definition.get("chain",0)) + if chains > 0: + var chained:int = 0 + for other:Dictionary in enemies: + if other == target: + continue + if Vector2(other.get("pos",Vector2.ZERO)).distance_to(Vector2(target.get("pos",Vector2.ZERO))) <= 125.0: + _damage_enemy(other,structure_damage*0.55,false) + chained += 1 + if chained >= chains: + break + structure["cooldown"] = float(definition.get("cadence",0.72)) + +func _update_field_trap(structure:Dictionary,definition:Dictionary) -> void: + if float(structure.get("cooldown",0.0)) > 0.0: + return + var origin:Vector2 = Vector2(structure.get("pos",Vector2.ZERO)) + var radius:float = float(definition.get("trigger_radius",42.0)) + for enemy:Dictionary in enemies: + var enemy_pos:Vector2 = Vector2(enemy.get("pos",Vector2.ZERO)) + if enemy_pos.distance_to(origin) <= radius+float(enemy.get("radius",14.0)): + var trap_damage:float = float(definition.get("damage",30.0))+float(tile.get("threat",1))*0.75 + _damage_enemy(enemy,trap_damage,false) + var push:Vector2 = (enemy_pos-origin).normalized() + if push.length_squared() < 0.1: + push = Vector2.RIGHT + enemy["pos"] = enemy_pos+push*float(definition.get("knockback",1.0))*22.0 + structure["cooldown"] = float(definition.get("cooldown",1.25)) + _spawn_ring(origin,Color("#d8a26f")) + break + +func _update_field_support(structure:Dictionary,definition:Dictionary,delta:float) -> void: + var origin:Vector2 = Vector2(structure.get("pos",Vector2.ZERO)) + if player_pos.distance_to(origin) <= float(definition.get("radius",115.0)): + player_hp = minf(player_hp_max,player_hp+float(definition.get("heal_per_second",3.0))*delta) + +func _update_field_barrier(structure:Dictionary,definition:Dictionary,delta:float) -> void: + var origin:Vector2 = Vector2(structure.get("pos",Vector2.ZERO)) + var radius:float = float(definition.get("slow_radius",54.0)) + var resistance:float = 1.0-float(definition.get("enemy_speed_mult",0.6)) + for enemy:Dictionary in enemies: + var enemy_pos:Vector2 = Vector2(enemy.get("pos",Vector2.ZERO)) + if enemy_pos.distance_to(origin) <= radius+float(enemy.get("radius",14.0)): + var push:Vector2 = (enemy_pos-origin).normalized() + if push.length_squared() < 0.1: + push = Vector2.RIGHT + enemy["pos"] = enemy_pos+push*resistance*55.0*delta func _spawn_allies() -> void: allies.clear() UnitRoster.ensure_schema() - var offsets=[Vector2(-45,20),Vector2(45,20),Vector2(-70,55),Vector2(70,55),Vector2(0,65),Vector2(-95,75),Vector2(95,75)] - var idx:=0 - for record in UnitRoster.battle_instances(3): - var family:String=String(record.get("family","militia")) + var offsets:Array[Vector2] = [Vector2(-45,20),Vector2(45,20),Vector2(-70,55),Vector2(70,55),Vector2(0,65),Vector2(-95,75),Vector2(95,75)] + var idx:int = 0 + for record:Dictionary in UnitRoster.battle_instances(3): + var family:String = String(record.get("family","militia")) allies.append({ "type":family, "pos":player_pos+offsets[idx%offsets.size()], @@ -77,57 +168,90 @@ func _spawn_allies() -> void: "prefix":String(record.get("prefix","")), "elite":bool(record.get("elite",false)) }) - idx+=1 + idx += 1 func _update_allies(delta:float) -> void: - var index:=0 - for ally in allies: - var ally_type:String=String(ally.type) - var angle=elapsed*0.45+index*TAU/max(1,allies.size()) - var desired=player_pos+Vector2(cos(angle),sin(angle))*(68+(index%3)*20) - ally.pos=ally.pos.lerp(desired,min(1.0,delta*4.0)) - ally.cooldown-=delta - if ally.cooldown<=0 and not enemies.is_empty(): - var target=nearest_enemy(ally.pos) - var record={"family":ally_type,"prefix":String(ally.get("prefix","")),"elite":bool(ally.get("elite",false))} - var effective_range=_unit_range(ally_type)*UnitRoster.range_mult(record) - if not target.is_empty() and ally.pos.distance_to(target.pos)0 else 1.0 - var momentum=1.0+min(combo,30)*0.008 - var hit=unit_damage(ally_type)*army_damage_mult*rally_mult*momentum*UnitRoster.damage_mult(record) - var branch=UnitProgression.evolution(ally_type) - if ally_type=="militia" and branch=="vanguard" and bool(target.get("boss",false)):hit*=1.55 - if ally_type=="wolf" and branch=="dire" and float(target.hp)/max(1.0,float(target.max_hp))<0.45:hit*=1.50 - if ally_type=="ridgeback" and float(target.hp)/max(1.0,float(target.max_hp))<0.45:hit*=1.30 - if ally_type=="stone_golem" and bool(target.get("boss",false)):hit*=1.25 - hit*=UnitRoster.execute_mult(record,float(target.hp)/max(1.0,float(target.max_hp))) + var index:int = 0 + for ally:Dictionary in allies: + var ally_type:String = String(ally.get("type","militia")) + var angle:float = elapsed*0.45+float(index)*TAU/float(maxi(1,allies.size())) + var desired:Vector2 = player_pos+Vector2(cos(angle),sin(angle))*float(68+(index%3)*20) + ally["pos"] = Vector2(ally.get("pos",player_pos)).lerp(desired,minf(1.0,delta*4.0)) + ally["cooldown"] = float(ally.get("cooldown",0.0))-delta + if float(ally.get("cooldown",0.0)) <= 0.0 and not enemies.is_empty(): + var target:Dictionary = nearest_enemy(Vector2(ally.get("pos",player_pos))) + var record:Dictionary = {"family":ally_type,"prefix":String(ally.get("prefix","")),"elite":bool(ally.get("elite",false))} + var effective_range:float = _unit_range(ally_type)*UnitRoster.range_mult(record) + if not target.is_empty() and Vector2(ally.get("pos",player_pos)).distance_to(Vector2(target.get("pos",Vector2.ZERO))) < effective_range: + var rally_mult:float = 1.45 if rally_time > 0.0 else 1.0 + var momentum:float = 1.0+float(mini(combo,30))*0.008 + var hit:float = unit_damage(ally_type)*army_damage_mult*rally_mult*momentum*UnitRoster.damage_mult(record) + var branch:String = UnitProgression.evolution(ally_type) + if ally_type == "militia" and branch == "vanguard" and bool(target.get("boss",false)): + hit *= 1.55 + if ally_type == "wolf" and branch == "dire" and float(target.get("hp",1.0))/maxf(1.0,float(target.get("max_hp",1.0))) < 0.45: + hit *= 1.50 + if ally_type == "ridgeback" and float(target.get("hp",1.0))/maxf(1.0,float(target.get("max_hp",1.0))) < 0.45: + hit *= 1.30 + if ally_type == "stone_golem" and bool(target.get("boss",false)): + hit *= 1.25 + hit *= UnitRoster.execute_mult(record,float(target.get("hp",1.0))/maxf(1.0,float(target.get("max_hp",1.0)))) _damage_enemy(target,hit,false) - var quality_heal=UnitRoster.heal_on_hit(record) - if quality_heal>0.0:player_hp=min(player_hp_max,player_hp+quality_heal) - var chain=UnitRoster.chain_ratio(record) - if chain>0.0:_quality_chain(target,hit*chain) - if ally_type=="mage": - if branch=="stormcaller":_storm_chain(target,hit) - elif branch=="lifebinder":player_hp=min(player_hp_max,player_hp+2.2+float(GameState.unit_levels.get("mage",1))*0.38) - elif ally_type=="mire_leech": - player_hp=min(player_hp_max,player_hp+1.5+float(GameState.unit_levels.get(ally_type,1))*0.30) - elif ally_type=="ember_imp": + var quality_heal:float = UnitRoster.heal_on_hit(record) + if quality_heal > 0.0: + player_hp = minf(player_hp_max,player_hp+quality_heal) + var chain:float = UnitRoster.chain_ratio(record) + if chain > 0.0: + _quality_chain(target,hit*chain) + if ally_type == "mage": + if branch == "stormcaller": + _storm_chain(target,hit) + elif branch == "lifebinder": + player_hp = minf(player_hp_max,player_hp+2.2+float(GameState.unit_levels.get("mage",1))*0.38) + elif ally_type == "mire_leech": + player_hp = minf(player_hp_max,player_hp+1.5+float(GameState.unit_levels.get(ally_type,1))*0.30) + elif ally_type == "ember_imp": _wild_splash(target,hit*0.30) - ally.cooldown=unit_cooldown(ally_type)*army_haste_mult*UnitRoster.cooldown_mult(record) - index+=1 + ally["cooldown"] = unit_cooldown(ally_type)*army_haste_mult*UnitRoster.cooldown_mult(record) + index += 1 -func _quality_chain(origin:Dictionary,amount:float)->void: - for enemy in enemies: - if enemy==origin:continue - if origin.pos.distance_to(enemy.pos)<=125.0: +func _quality_chain(origin:Dictionary,amount:float) -> void: + for enemy:Dictionary in enemies: + if enemy == origin: + continue + if Vector2(origin.get("pos",Vector2.ZERO)).distance_to(Vector2(enemy.get("pos",Vector2.ZERO))) <= 125.0: _damage_enemy(enemy,amount,false) - _spawn_ring(enemy.pos,Color("#8ebdff")) + _spawn_ring(Vector2(enemy.get("pos",Vector2.ZERO)),Color("#8ebdff")) break func _draw_ground() -> void: super._draw_ground() - for tower in field_towers: - var p:Vector2=Vector2(tower.get("pos",Vector2.ZERO)) - if _visible_world_rect(140.0).has_point(p): - _draw_atlas("building_watchtower",p,Vector2(78,72),true) - draw_circle(p+Vector2(0,18),30,Color(0.85,0.73,0.38,0.10),false,2.0) + var visible:Rect2 = _visible_world_rect(140.0) + for structure:Dictionary in field_towers: + var pos:Vector2 = Vector2(structure.get("pos",Vector2.ZERO)) + if not visible.has_point(pos): + continue + var id:String = String(structure.get("id","watchtower")) + var definition:Dictionary = ContentDB.building(id) + var sprite_id:String = String(definition.get("sprite_id","building_watchtower")) + if VisualAtlas.has(sprite_id): + _draw_atlas(sprite_id,pos,Vector2(72,68),true) + else: + var role:String = String(definition.get("role","turret")) + var color:Color = {"turret":Color("#c79f62"),"trap":Color("#b86f54"),"support":Color("#72a77d"),"barrier":Color("#8d765e")}.get(role,Color("#b8a06d")) + draw_rect(Rect2(pos-Vector2(24,18),Vector2(48,36)),color) + if String(definition.get("role","")) == "support": + draw_circle(pos,float(definition.get("radius",115.0)),Color(0.3,0.8,0.48,0.08),false,2.0) + +func _draw() -> void: + super._draw() + if ended: + return + var id:String = _selected_building_id() + var definition:Dictionary = ContentDB.building(id) + var cost:Dictionary = Dictionary(definition.get("cost",{})) + var cost_parts:Array[String] = [] + for resource:Variant in cost.keys(): + cost_parts.append("%d %s"%[int(cost[resource]),String(resource).capitalize()]) + var label:String = "C: %s · B: Build (%s)"%[String(definition.get("name",id)),", ".join(cost_parts)] + draw_string(ThemeDB.fallback_font,player_pos+Vector2(-92,-70),label,HORIZONTAL_ALIGNMENT_LEFT,240,12,Color("#ead9a7")) From 7ab02f47baaf60166ef5ee462f6b9d115a0650ce Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:58:15 -0300 Subject: [PATCH 06/10] content: wire elemental and hostile projectiles to reusable statuses --- data/content/projectiles.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/data/content/projectiles.json b/data/content/projectiles.json index 795f022..578bb28 100644 --- a/data/content/projectiles.json +++ b/data/content/projectiles.json @@ -1,15 +1,15 @@ { "arrow_wood": {"name":"Wooden Arrow","speed":620,"life":1.8,"radius":5,"pierce":0,"damage_mult":1.0,"knockback_mult":1.0,"visual_id":"fx_arrow","color":"#d8c49a"}, - "arrow_iron": {"name":"Iron Arrow","speed":650,"life":1.9,"radius":5,"pierce":1,"damage_mult":1.12,"knockback_mult":1.15,"visual_id":"fx_arrow","color":"#c9d1da"}, - "arrow_fire": {"name":"Ember Arrow","speed":610,"life":1.8,"radius":6,"pierce":0,"damage_mult":1.18,"knockback_mult":0.9,"splash_radius":38,"visual_id":"fx_fireball","color":"#ff9b55"}, - "arrow_frost": {"name":"Rime Arrow","speed":600,"life":2.0,"radius":6,"pierce":0,"damage_mult":1.08,"knockback_mult":0.85,"slow":0.28,"visual_id":"fx_arcane","color":"#9ee8ff"}, - "bolt_iron": {"name":"Iron Bolt","speed":760,"life":1.7,"radius":5,"pierce":2,"damage_mult":1.22,"knockback_mult":1.35,"visual_id":"fx_arrow","color":"#d8dde6"}, + "arrow_iron": {"name":"Iron Arrow","speed":650,"life":1.9,"radius":5,"pierce":1,"damage_mult":1.12,"knockback_mult":1.15,"status_id":"armor_break","status_chance":0.18,"visual_id":"fx_arrow","color":"#c9d1da"}, + "arrow_fire": {"name":"Ember Arrow","speed":610,"life":1.8,"radius":6,"pierce":0,"damage_mult":1.18,"knockback_mult":0.9,"splash_radius":38,"status_id":"burn","status_chance":0.72,"visual_id":"fx_fireball","color":"#ff9b55"}, + "arrow_frost": {"name":"Rime Arrow","speed":600,"life":2.0,"radius":6,"pierce":0,"damage_mult":1.08,"knockback_mult":0.85,"status_id":"slow","status_chance":0.82,"visual_id":"fx_arcane","color":"#9ee8ff"}, + "bolt_iron": {"name":"Iron Bolt","speed":760,"life":1.7,"radius":5,"pierce":2,"damage_mult":1.22,"knockback_mult":1.35,"status_id":"armor_break","status_chance":0.34,"visual_id":"fx_arrow","color":"#d8dde6"}, "arcane_bolt": {"name":"Arcane Bolt","speed":520,"life":2.2,"radius":7,"pierce":0,"damage_mult":1.0,"knockback_mult":0.7,"visual_id":"fx_arcane","color":"#7fcfff"}, - "ember_orb": {"name":"Ember Orb","speed":440,"life":2.3,"radius":9,"pierce":0,"damage_mult":1.14,"knockback_mult":0.9,"splash_radius":52,"visual_id":"fx_fireball","color":"#ff7f4f"}, - "frost_shard": {"name":"Frost Shard","speed":560,"life":2.0,"radius":7,"pierce":1,"damage_mult":1.02,"knockback_mult":0.75,"slow":0.35,"visual_id":"fx_arcane","color":"#a7efff"}, - "void_orb": {"name":"Void Orb","speed":360,"life":2.8,"radius":12,"pierce":1,"damage_mult":1.3,"knockback_mult":1.15,"splash_radius":64,"visual_id":"fx_void","color":"#b16de6"}, - "holy_spark": {"name":"Holy Spark","speed":500,"life":2.1,"radius":8,"pierce":1,"damage_mult":1.12,"knockback_mult":0.9,"visual_id":"fx_holy","color":"#ffe58b"}, - "thorn_spine": {"name":"Thorn Spine","speed":540,"life":2.0,"radius":6,"pierce":1,"damage_mult":1.04,"knockback_mult":1.0,"visual_id":"fx_arrow","color":"#74b85a"}, - "acid_spit": {"name":"Acid Glob","speed":320,"life":2.5,"radius":8,"pierce":0,"damage_mult":1.0,"knockback_mult":0.45,"splash_radius":36,"visual_id":"fx_arcane","color":"#8dcc62"}, - "enemy_hex": {"name":"Hex Bolt","speed":285,"life":3.0,"radius":7,"pierce":0,"damage_mult":1.0,"knockback_mult":0.5,"visual_id":"fx_void","color":"#c68ae8"} + "ember_orb": {"name":"Ember Orb","speed":440,"life":2.3,"radius":9,"pierce":0,"damage_mult":1.14,"knockback_mult":0.9,"splash_radius":52,"status_id":"burn","status_chance":0.9,"visual_id":"fx_fireball","color":"#ff7f4f"}, + "frost_shard": {"name":"Frost Shard","speed":560,"life":2.0,"radius":7,"pierce":1,"damage_mult":1.02,"knockback_mult":0.75,"status_id":"slow","status_chance":1.0,"visual_id":"fx_arcane","color":"#a7efff"}, + "void_orb": {"name":"Void Orb","speed":360,"life":2.8,"radius":12,"pierce":1,"damage_mult":1.3,"knockback_mult":1.15,"splash_radius":64,"status_id":"hex","status_chance":0.75,"visual_id":"fx_void","color":"#b16de6"}, + "holy_spark": {"name":"Holy Spark","speed":500,"life":2.1,"radius":8,"pierce":1,"damage_mult":1.12,"knockback_mult":0.9,"status_id":"stun","status_chance":0.18,"visual_id":"fx_holy","color":"#ffe58b"}, + "thorn_spine": {"name":"Thorn Spine","speed":540,"life":2.0,"radius":6,"pierce":1,"damage_mult":1.04,"knockback_mult":1.0,"status_id":"poison","status_chance":0.48,"visual_id":"fx_arrow","color":"#74b85a"}, + "acid_spit": {"name":"Acid Glob","speed":320,"life":2.5,"radius":8,"pierce":0,"damage_mult":1.0,"knockback_mult":0.45,"splash_radius":36,"status_id":"poison","status_chance":0.8,"visual_id":"fx_arcane","color":"#8dcc62"}, + "enemy_hex": {"name":"Hex Bolt","speed":285,"life":3.0,"radius":7,"pierce":0,"damage_mult":1.0,"knockback_mult":0.5,"status_id":"hex","status_chance":0.65,"visual_id":"fx_void","color":"#c68ae8"} } \ No newline at end of file From 58915487a84f31cf80c5645711f574121ee1ffcf Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:58:52 -0300 Subject: [PATCH 07/10] feat: add reusable enemy status runtime for elemental projectiles --- scripts/combat/StatusCombatArena.gd | 151 ++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 scripts/combat/StatusCombatArena.gd diff --git a/scripts/combat/StatusCombatArena.gd b/scripts/combat/StatusCombatArena.gd new file mode 100644 index 0000000..622bf29 --- /dev/null +++ b/scripts/combat/StatusCombatArena.gd @@ -0,0 +1,151 @@ +class_name StatusCombatArena +extends DepthCombatArena + +func _spawn_enemy(boss:bool) -> void: + super._spawn_enemy(boss) + if enemies.is_empty(): + return + var index:int = enemies.size()-1 + var enemy:Dictionary = enemies[index] + if not enemy.has("base_speed"): + enemy["base_speed"] = float(enemy.get("speed",60.0)) + if not enemy.has("statuses"): + enemy["statuses"] = {} + enemies[index] = enemy + +func _update_enemies(delta:float) -> void: + for enemy:Dictionary in enemies: + _prepare_enemy_statuses(enemy,delta) + super._update_enemies(delta) + +func _prepare_enemy_statuses(enemy:Dictionary,delta:float) -> void: + if not enemy.has("base_speed"): + enemy["base_speed"] = float(enemy.get("speed",60.0)) + var status_map:Dictionary = Dictionary(enemy.get("statuses",{})) + var speed_mult:float = 1.0 + var stunned:bool = false + var remove_ids:Array[String] = [] + for raw_id:Variant in status_map.keys(): + var status_id:String = String(raw_id) + var active:Dictionary = Dictionary(status_map[raw_id]) + var definition:Dictionary = ContentDB.status(status_id) + active["time"] = float(active.get("time",0.0))-delta + var kind:String = String(definition.get("kind","")) + if definition.has("speed_mult"): + speed_mult *= float(definition.get("speed_mult",1.0)) + if kind == "stun": + stunned = true + if kind == "dot": + active["tick"] = float(active.get("tick",0.0))-delta + if float(active.get("tick",0.0)) <= 0.0: + var stacks:int = maxi(1,int(active.get("stacks",1))) + var dot_damage:float = float(active.get("power",4.0))*float(definition.get("damage_per_tick_mult",0.1))*float(stacks) + enemy["hp"] = float(enemy.get("hp",0.0))-dot_damage + active["tick"] = float(definition.get("tick_rate",0.75)) + particles.append({"pos":Vector2(enemy.get("pos",Vector2.ZERO)),"life":0.18,"max":0.18,"color":Color(String(definition.get("color","#ffffff")))}) + if float(active.get("time",0.0)) <= 0.0: + remove_ids.append(status_id) + else: + status_map[status_id] = active + for status_id:String in remove_ids: + status_map.erase(status_id) + enemy["statuses"] = status_map + enemy["speed"] = 0.0 if stunned else float(enemy.get("base_speed",60.0))*speed_mult + +func _damage_enemy(enemy:Dictionary,amount:float,is_crit:bool) -> void: + var multiplier:float = 1.0 + var status_map:Dictionary = Dictionary(enemy.get("statuses",{})) + for raw_id:Variant in status_map.keys(): + var definition:Dictionary = ContentDB.status(String(raw_id)) + if definition.has("damage_taken_mult"): + multiplier *= float(definition.get("damage_taken_mult",1.0)) + super._damage_enemy(enemy,amount*multiplier,is_crit) + +func _apply_status(enemy:Dictionary,status_id:String,source_power:float) -> void: + if status_id == "": + return + var definition:Dictionary = ContentDB.status(status_id) + if definition.is_empty(): + return + var status_map:Dictionary = Dictionary(enemy.get("statuses",{})) + var active:Dictionary = Dictionary(status_map.get(status_id,{})) + var max_stacks:int = maxi(1,int(definition.get("max_stacks",1))) + active["stacks"] = mini(max_stacks,int(active.get("stacks",0))+1) + active["time"] = float(definition.get("duration",2.0)) + active["tick"] = minf(float(active.get("tick",999.0)),float(definition.get("tick_rate",0.75))) + active["power"] = maxf(float(active.get("power",0.0)),source_power) + status_map[status_id] = active + enemy["statuses"] = status_map + _float_text(Vector2(enemy.get("pos",Vector2.ZERO))+Vector2(0,-36),String(definition.get("name",status_id)).to_upper(),Color(String(definition.get("color","#ffffff"))),0.7) + +func _update_projectiles(delta:float) -> void: + for i:int in range(projectiles.size()-1,-1,-1): + var projectile:Dictionary = projectiles[i] + projectile["pos"] = Vector2(projectile.get("pos",Vector2.ZERO))+Vector2(projectile.get("vel",Vector2.ZERO))*delta + projectile["life"] = float(projectile.get("life",0.0))-delta + var remove_projectile:bool = false + for enemy:Dictionary in enemies: + if Vector2(projectile.get("pos",Vector2.ZERO)).distance_to(Vector2(enemy.get("pos",Vector2.ZERO))) >= float(enemy.get("radius",14.0))+float(projectile.get("radius",5.0)): + continue + var direction:Vector2 = Vector2(projectile.get("vel",Vector2.RIGHT)).normalized() + var projectile_damage:float = float(projectile.get("damage",0.0)) + _hit_with_knockback(enemy,projectile_damage,direction,float(projectile.get("knockback",0.4)),bool(projectile.get("crit",false))) + var status_id:String = String(projectile.get("status_id","")) + if status_id != "" and rng.randf() <= float(projectile.get("status_chance",1.0)): + _apply_status(enemy,status_id,projectile_damage) + var splash:float = float(projectile.get("splash_radius",0.0)) + if splash > 0.0: + for other:Dictionary in enemies: + if other == enemy: + continue + if Vector2(other.get("pos",Vector2.ZERO)).distance_to(Vector2(enemy.get("pos",Vector2.ZERO))) <= splash: + _damage_enemy(other,projectile_damage*0.45,false) + if status_id != "" and rng.randf() <= float(projectile.get("status_chance",1.0))*0.55: + _apply_status(other,status_id,projectile_damage*0.65) + var pierce:int = int(projectile.get("pierce",0)) + if pierce > 0: + projectile["pierce"] = pierce-1 + projectile["pos"] = Vector2(projectile.get("pos",Vector2.ZERO))+direction*12.0 + else: + remove_projectile = true + break + if remove_projectile or float(projectile.get("life",0.0)) <= 0.0 or not bounds.grow(30.0).has_point(Vector2(projectile.get("pos",Vector2.ZERO))): + projectiles.remove_at(i) + else: + projectiles[i] = projectile + +func _perform_ranged_attack(direction:Vector2,hit_damage:float,knockback_amount:float,is_crit:bool) -> void: + var projectile_id:String = String(weapon_profile.get("projectile_id","")) + if projectile_id == "": + projectile_id = String(attack_profile.get("default_projectile","arcane_bolt")) + var definition:Dictionary = ContentDB.projectile(projectile_id) + for i:int in range(projectile_count): + var spread:float = (float(i)-float(projectile_count-1)/2.0)*0.13 + var shot_dir:Vector2 = direction.rotated(spread) + projectiles.append({ + "pos":player_pos+shot_dir*18.0, + "vel":shot_dir*float(definition.get("speed",430.0)), + "damage":hit_damage*float(definition.get("damage_mult",1.0)), + "life":float(definition.get("life",1.8)), + "crit":is_crit, + "radius":float(definition.get("radius",5.0)), + "pierce":int(definition.get("pierce",0)), + "splash_radius":float(definition.get("splash_radius",0.0)), + "knockback":knockback_amount*float(definition.get("knockback_mult",1.0)), + "status_id":String(definition.get("status_id","")), + "status_chance":float(definition.get("status_chance",0.0)), + "visual_id":String(definition.get("visual_id","fx_arrow")), + "color":String(definition.get("color","#bfe4ff")) + }) + +func _draw_enemy(enemy:Dictionary) -> void: + super._draw_enemy(enemy) + var status_map:Dictionary = Dictionary(enemy.get("statuses",{})) + if status_map.is_empty(): + return + var start:Vector2 = Vector2(enemy.get("pos",Vector2.ZERO))+Vector2(-float(status_map.size()-1)*6.0,-54) + var index:int = 0 + for raw_id:Variant in status_map.keys(): + var definition:Dictionary = ContentDB.status(String(raw_id)) + draw_circle(start+Vector2(index*12,0),4.0,Color(String(definition.get("color","#ffffff")))) + index += 1 From 8d0f4f158c4671703a05875376ba907f61ffd96e Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:59:40 -0300 Subject: [PATCH 08/10] feat: enable status-aware expedition combat --- scripts/screens/ExpeditionScreenVisual.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/screens/ExpeditionScreenVisual.gd b/scripts/screens/ExpeditionScreenVisual.gd index 526e490..fac7bc3 100644 --- a/scripts/screens/ExpeditionScreenVisual.gd +++ b/scripts/screens/ExpeditionScreenVisual.gd @@ -10,7 +10,7 @@ func _build() -> void: add_child(layout_scene) var holder:Control = layout_scene.get_node("Margin/MainRow/ArenaPanel/ArenaHolder") - arena = DepthCombatArena.new() + arena = StatusCombatArena.new() holder.add_child(arena) arena.set_view_size(Vector2(1400,840)) arena.hud_changed.connect(_hud) From 8351df24c917e70e6a655f13569cd7833319b78c Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:00:14 -0300 Subject: [PATCH 09/10] feat: expose statuses and field structures in Content Lab --- scripts/screens/ContentEditorScreen.gd | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/screens/ContentEditorScreen.gd b/scripts/screens/ContentEditorScreen.gd index c6f9e2d..a7e6630 100644 --- a/scripts/screens/ContentEditorScreen.gd +++ b/scripts/screens/ContentEditorScreen.gd @@ -1,6 +1,6 @@ extends Control -const CATEGORIES:Array[String] = ["items","monsters","attacks","projectiles","tiles"] +const CATEGORIES:Array[String] = ["items","monsters","attacks","projectiles","statuses","buildings","tiles"] @onready var category:OptionButton = $Margin/Root/Header/Category @onready var list:ItemList = $Margin/Root/Body/Left/List @@ -91,6 +91,20 @@ func _update_summary(definition:Dictionary) -> void: "projectiles": lines.append("Speed %d · Radius %.1f · Pierce %d"%[int(definition.get("speed",0)),float(definition.get("radius",0.0)),int(definition.get("pierce",0))]) lines.append("Damage ×%.2f · Knockback ×%.2f"%[float(definition.get("damage_mult",1.0)),float(definition.get("knockback_mult",1.0))]) + if String(definition.get("status_id","")) != "": + lines.append("Status: %s · %.0f%%"%[String(definition.get("status_id","")),float(definition.get("status_chance",0.0))*100.0]) + "statuses": + lines.append("Kind: %s · Duration %.2fs · max stacks %d"%[String(definition.get("kind","?")),float(definition.get("duration",0.0)),int(definition.get("max_stacks",1))]) + if definition.has("damage_per_tick_mult"): + lines.append("DOT ×%.2f every %.2fs"%[float(definition.get("damage_per_tick_mult",0.0)),float(definition.get("tick_rate",1.0))]) + if definition.has("speed_mult"): + lines.append("Speed ×%.2f"%float(definition.get("speed_mult",1.0))) + if definition.has("damage_taken_mult"): + lines.append("Damage taken ×%.2f"%float(definition.get("damage_taken_mult",1.0))) + "buildings": + lines.append("Role: %s"%String(definition.get("role","?"))) + lines.append("Cost: "+_dictionary_summary(Dictionary(definition.get("cost",{})))) + lines.append(String(definition.get("description",""))) "tiles": lines.append("Biome: %s · Kind: %s"%[String(definition.get("biome","?")),String(definition.get("kind","?"))]) lines.append("Tags: "+_join_values(Array(definition.get("tags",[])))) @@ -102,6 +116,12 @@ func _join_values(values:Array) -> String: strings.append(String(value)) return ", ".join(strings) +func _dictionary_summary(values:Dictionary) -> String: + var parts:Array[String] = [] + for key:Variant in values.keys(): + parts.append("%s %s"%[String(values[key]),String(key).capitalize()]) + return ", ".join(parts) + func _save() -> void: if current_id == "": return @@ -126,7 +146,7 @@ func _reset_entry() -> void: func _validate() -> void: var errors:Array[String] = ContentDB.validate_references() if errors.is_empty(): - footer.text = "All item/monster attack and projectile links are valid" + footer.text = "All content references are valid" footer.modulate = Color("#8dd5a6") else: footer.text = "Reference errors: "+" | ".join(errors) From 893b7bd1f05ba4c62a35480c9c0a0d1a30d3d9c9 Mon Sep 17 00:00:00 2001 From: ramonsamagaio <70339182+ramonsamagaio@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:43:47 -0300 Subject: [PATCH 10/10] Fix status arena override signature for Godot 4.7 --- scripts/combat/StatusCombatArena.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/combat/StatusCombatArena.gd b/scripts/combat/StatusCombatArena.gd index 622bf29..66d50a3 100644 --- a/scripts/combat/StatusCombatArena.gd +++ b/scripts/combat/StatusCombatArena.gd @@ -52,7 +52,7 @@ func _prepare_enemy_statuses(enemy:Dictionary,delta:float) -> void: enemy["statuses"] = status_map enemy["speed"] = 0.0 if stunned else float(enemy.get("base_speed",60.0))*speed_mult -func _damage_enemy(enemy:Dictionary,amount:float,is_crit:bool) -> void: +func _damage_enemy(enemy:Dictionary,amount:float,is_crit:bool=false) -> void: var multiplier:float = 1.0 var status_map:Dictionary = Dictionary(enemy.get("statuses",{})) for raw_id:Variant in status_map.keys():