Retune Iron Coast waves, enemy attacks and weapon presentation

This commit is contained in:
raptorreplica committed 2026-10-17 18:13:20 +11:00
1 parent 00cfcc2031
commit c4a4d821d5
145 files changed
+9242 -2951

No files matched your search

+8 -47
View File
@@ -1,54 +1,15 @@
# Iron Crown boss
# Iron Coast: Iron Crown Boss
A standalone API 1 mod for the Iron Coast finale. It adds a new command
Dreadnought with broad armored wings and three forward cannon pods, matching
the industrial overhead style of the original game. The package has no mod
dependencies and does not replace any original or Iron Coast enemy definition.
API 1 package, version 1.0.1. Enable it in Mods at the main menu and apply the selection. This package has no mod dependencies.
- Enemy: `iron_coast_boss_6:unit`; health 160, reward 1800, air boss, logical 96×64.
- Weapon: `iron_coast_boss_6:projectile`; damage 3, speed 4, logical 8×8.
- Objective: every actual boss death increments `iron_coast_boss_6:defeated`.
- Entry: fly downward to y 24, then patrol between x 80 and x 144.
- Attack: every 54 simulation ticks, alternate a three-shell fan with two
straight cannon lanes. A rising charging sound begins nine ticks before
each volley. At 70/3 Hz this gives 0.386 seconds warning before a 2.314-second
volley cycle. Charging creates no explosion effects.
- State: per-entity patrol direction, age and volley cursor; removed on death.
Supports the finale's two staged bosses and simultaneous spawns. It never
completes a mission directly or fires from retired entities.
Enemy `iron_coast_boss_6:unit` is an airborne boss with **420** nominal health and a **108×72** logical canvas. Actual destruction awards 1,800 credits. The existing World rule halves boss health, rounded up, on Training and Rookie. SD and HD use the same logical body.
Difficulty uses the engine's original boss health rule: definitions retain 160
health, and the two lower difficulty levels spawn bosses with 80 health.
Cannon fan / open-center twin gate / alternating-side follow-up. Each life has three health phases (above two-thirds, at/below two-thirds, at/below one-third of its actual spawn health). Every phase change restarts a full warning. Brief 8-pixel, 8-tick anchored charge arcs and existing quiet audio precede the salvo; the warning and short burst hold position, then the boss recovers into a bounded lateral patrol. Gun positions follow the existing artwork. No homing retarget occurs after aim is locked.
`art/unit-sd.png` and `art/unit-hd.png` share one centered 96×64 logical canvas.
The HD texture is 768×512 (8×) and the SD version is derived from the same
transparent generated master. The cannon shell likewise has matching 8×8 and
64×64 versions. `art-qa.json` records bounds and source hashes. The supplied
alpha is preserved; runtime colors with alpha below 128 remain transparent.
Warning leads are 18 / 16 / 14 / 12 ticks for Training / Rookie / Veteran / Elite. Recovery leaves a new approach opportunity; watch the charge cue and move into the opening before the volley.
The built-in imagegen tool generated both masters. Exact prompts live in
`prompts/`; `provenance.json` records their outputs. All authored images and
sounds belong in Git. Original sprite references remain outside this package.
Only actual death increments `iron_coast_boss_6:defeated`; escape or time passing grants no objective credit. This enemy package does not decide mission completion. Its existing art, audio, generation provenance and asset recipes are retained.
Reproduce the deterministic sounds and SD/HD reductions with:
## Validation and history
```sh
python3 RustRaptor/mods/iron_coast_boss_6/build_assets.py
```
`--audio-only` needs only Python's standard library. `audio-recipe.json`
records seeds, PCM properties and hashes. The sounds are non-silent PCM16
mono 22050 Hz charging, firing and destruction cues; no recordings are used.
Validate activation and lifecycle behavior using the production simulation:
```sh
CARGO_TARGET_DIR=RustRaptor/target cargo run --offline \
--manifest-path RustRaptor/mods/iron_coast_boss_6/validation/Cargo.toml
```
The validation checks alternating patterns, warning timing, independent
entity state, serialized state restoration, death counter/reward behavior,
dead-enemy firing guards and two staged encounters on all four difficulties.
`validation-results.json` records the controlled outcomes. The complete
mission 6 mod runs separate campaign and balance checks with its other enemies.
See the [current Iron Coast retune results and limits](../../docs/iron-coast-retune.md). Package-local acceptance reports, validation results and retained test sources describe pre-retune 1.0 behavior unless a result explicitly identifies the newer package and runner. They remain historical material, not current playthrough guarantees.
+4 -4
View File
@@ -20,17 +20,17 @@
{
"id": "iron_coast_boss_6:unit",
"name": "Iron Crown Command Dreadnought",
"health": 160,
"health": 420,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 54,
"cooldown": 90,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
"logical_width": 96,
"logical_height": 64
"logical_width": 108,
"logical_height": 72
},
"script": "scripts/ai.rhai"
}
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss_6"
name = "Iron Coast: Iron Crown Boss"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "The Iron Crown command dreadnought: a persistent finale boss with telegraphed alternating cannon fans and twin lanes. Includes matching SD/HD artwork and synthesized sound."
description = "A three-phase Iron Crown encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+120 -68
View File
@@ -1,81 +1,133 @@
fn is_alive(event) {
for unit in event.entities {
if unit.entity == event.entity && unit.health > 0 { return true; }
// Private 1.0.1 retune: per-entity telegraph, locked lanes and recovery.
// No legacy selectors. Old persisted counters and cursor fields are retained.
fn live_actor(event) {
// Current API refreshes actor after earlier committed callbacks. An explicit
// absent/dead actor must not fall back to a stale queued event payload.
if event.contains("actor") {
if type_of(event.actor) == "map" { return event.actor; }
return #{health:0,x:0,y:0};
}
false
// Historical API fallback only: its entities snapshot is also current.
for enemy in event.entities { if enemy.entity == event.entity { return enemy; } }
#{health:0,x:0,y:0}
}
fn settings(event) {
// API v1 fallback is Training until the centrally supplied field is present.
if event.contains("difficulty") { return event.difficulty; }
0
}
fn cursor(event, state, key) {
let unit = if state.contains("units") && state.units.contains(key) {state.units[key]} else {#{}};
if !unit.contains("direction") { unit.direction = if state.contains("directions") && state.directions.contains(key) {state.directions[key]} else {1}; }
if !unit.contains("age") { unit.age = if state.contains("ages") && state.ages.contains(key) {state.ages[key]} else {0}; }
if !unit.contains("clock") { unit.clock = 0; }
if !unit.contains("volley") { unit.volley = if unit.contains("shots") {unit.shots} else {0}; }
if !unit.contains("phase") { unit.phase = 0; }
if !unit.contains("max_health") { unit.max_health = live_actor(event).health; }
if unit.max_health < 1 { unit.max_health = 1; }
if !unit.contains("lock_x") { unit.lock_x = event.player.x + 16; }
if !unit.contains("pattern") { unit.pattern = unit.volley % 3; }
unit
}
// State is shared by a script, so each staged or simultaneous boss has its own
// patrol direction, charge timer and alternating attack cursor.
fn on_spawn(event, state, random) {
if state.contains("last_death") && state.last_death == event.entity { state.remove("last_death"); }
if !is_alive(event) { return #{ commands: [], state: state }; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
state.units[event.entity.to_string()] = #{direction: 1, age: 0, volley: 0};
#{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state }
if !state.contains("dead") { state.dead = #{}; }
state.dead.remove(key);
if state.contains("dead_order") {let keep=[];for old in state.dead_order {if old!=key {keep.push(old);}}state.dead_order=keep;}
// Register even a queued spawn killed before this callback: its death counts.
state.units[key] = #{direction:1,age:0,clock:0,volley:0,phase:0,max_health:live_actor(event).health,lock_x:event.player.x+16,pattern:0};
if live_actor(event).health <= 0 { return #{commands:[],state:state}; }
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state}
}
fn on_tick(event, state, random) {
if event.health <= 0 || !is_alive(event) { return #{ commands: [], state: state }; }
let actor=live_actor(event);
if actor.health <= 0 { return #{commands:[],state:state}; }
let key = event.entity.to_string();
if !state.contains("units") { state.units = #{}; }
let cursor = if state.units.contains(key) {
state.units[key]
} else { #{direction: 1, age: 0, volley: 0} };
if event.y < 24 {
state.units[key] = cursor;
return #{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state };
let unit = cursor(event,state,key);
if actor.y < 12 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
if event.x >= 144 { cursor.direction = -1; }
if event.x <= 80 { cursor.direction = 1; }
cursor.age += 1;
let commands = [
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
#{ op: "velocity", entity: event.entity, vx: cursor.direction, vy: 0 }
];
// Nine simulation ticks (0.386 seconds) of audible warning precede each
// volley. Charging does not create an explosion in front of the aircraft.
if cursor.age % 54 == 45 {
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_6:warning" });
}
if cursor.age % 54 == 0 {
if cursor.volley % 2 == 0 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 28, y: event.y + 60, vx: -2, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 48, y: event.y + 60, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 68, y: event.y + 60, vx: 2, vy: 4, hostile: true });
} else {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 28, y: event.y + 60, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_6:projectile", x: event.x + 68, y: event.y + 60, vx: 0, vy: 4, hostile: true });
let difficulty = settings(event);
unit.difficulty=difficulty;
let phase = if actor.health*3 <= unit.max_health {2} else if actor.health*3 <= unit.max_health*2 {1} else {0};
// A health transition always restarts the full warning; it cannot skip ahead
// to an untelegraphed salvo when the period changes.
if phase != unit.phase { unit.phase=phase; unit.clock=0; }
let period = 90 - difficulty*6 - phase*6;
let lead = 18-difficulty*2;
let speed = 3 + if difficulty >= 2 {1} else {0};
if unit.clock >= period { unit.clock=0; }
let width=108;
let left=actor.x+37;
let center=actor.x+width/2;
let right=actor.x+71;
let muzzle_y=actor.y+61;
let center_y=actor.y+67;
let commands=[];
if unit.clock == 0 {
unit.lock_x=event.player.x+16;
unit.pattern=(unit.volley+phase)%3;
commands.push(#{op:"play_sound",sound:"iron_coast_boss_6:warning"});
if unit.pattern == 2 {commands.push(#{op:"arc",x:center-4,y:center_y,to_x:center+4,to_y:center_y,ticks:8,anchor:event.entity,to_anchor:event.entity});}
else {
commands.push(#{op:"arc",x:left-4,y:muzzle_y,to_x:left+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
commands.push(#{op:"arc",x:right-4,y:muzzle_y,to_x:right+4,to_y:muzzle_y,ticks:8,anchor:event.entity,to_anchor:event.entity});
}
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_6:shot" });
cursor.volley += 1;
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
}
state.units[key] = cursor;
#{ commands: commands, state: state }
}
fn on_hit(event, state, random) {
state.hits = if state.contains("hits") { state.hits + 1 } else { 1 };
#{ commands: [], state: state }
}
fn on_death(event, state, random) {
let key = event.entity.to_string();
// Retired entities cannot fire. Preserve other live bosses' cursors, and
// still count an aircraft destroyed before its queued spawn hook runs.
if state.contains("last_death") && state.last_death == event.entity {
return #{ commands: [], state: state };
let aim=if unit.lock_x<center-24 {-1} else if unit.lock_x>center+24 {1} else {0};
if unit.clock == lead {
if unit.pattern == 0 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:center,y:center_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
else if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:left,y:muzzle_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:right,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
}
state.last_death = event.entity;
if state.contains("units") { state.units.remove(key); }
state.destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
let defeated = if event.objectives.contains("iron_coast_boss_6:defeated") {
event.objectives["iron_coast_boss_6:defeated"] + 1
} else { 1 };
#{ commands: [
#{ op: "objective", key: "iron_coast_boss_6:defeated", value: defeated },
#{ op: "effect", x: event.x + 28, y: event.y + 44 },
#{ op: "effect", x: event.x + 68, y: event.y + 44 },
#{ op: "play_sound", sound: "iron_coast_boss_6:death" }
], state: state }
if unit.clock == lead+8 && unit.pattern == 2 && difficulty >= 1 {
let active = if unit.volley % 2 == 0 {left} else {right};
commands.push(#{op:"projectile",weapon:"iron_coast_boss_6:projectile",x:active,y:muzzle_y,vx:0,vy:speed,hostile:true});
}
if unit.clock == lead {
commands.push(#{op:"play_sound",sound:"iron_coast_boss_6:shot"});
state.shots=if state.contains("shots") {state.shots+1} else {1};
}
// Warning and short burst are stationary hit windows; reposition only in
// recovery. The ship never leaves the forward-gun reach or dives into player.
let resting=unit.clock <= lead+8;
if actor.x >= 320-width-32 {unit.direction=-1;}
if actor.x <= 32 {unit.direction=1;}
let vx=if resting {0} else {unit.direction*(1+if difficulty>=2 {1} else {0})};
let triangle=if (unit.age/12)%8<4 {(unit.age/12)%8} else {8-(unit.age/12)%8};
let y=if resting {actor.y} else {12+if phase>0 {triangle*10/4} else {0}};
commands.push(#{op:"move",entity:event.entity,x:actor.x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
if unit.clock == lead+8 {unit.volley+=1;}
unit.age+=1;
unit.clock=(unit.clock+1)%period;
state.units[key]=unit;
#{commands:commands,state:state}
}
fn on_hit(event,state,random) {
state.hits=if state.contains("hits") {state.hits+1} else {1};
#{commands:[],state:state}
}
fn on_death(event,state,random) {
let key=event.entity.to_string();
if !state.contains("dead") {state.dead=#{};}
if state.dead.contains(key) {return #{commands:[],state:state};}
state.dead[key]=true;
if !state.contains("dead_order") {state.dead_order=[];}
state.dead_order.push(key);
if state.dead_order.len()>32 {let old=state.dead_order.remove(0);state.dead.remove(old);}
if state.contains("units") {state.units.remove(key);}
if state.contains("directions") {state.directions.remove(key);}
if state.contains("ages") {state.ages.remove(key);}
state.destroyed=if state.contains("destroyed") {state.destroyed+1} else {1};
let defeated=if event.objectives.contains("iron_coast_boss_6:defeated") {event.objectives["iron_coast_boss_6:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_boss_6:defeated",value:defeated},
#{op:"effect",x:event.x+108/3,y:event.y+72/2},
#{op:"effect",x:event.x+108*2/3,y:event.y+72/2},
#{op:"play_sound",sound:"iron_coast_boss_6:death"}],state:state}
}