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

+7 -9
View File
@@ -1,17 +1,15 @@
# Iron Coast Command Cruiser
Independent version 1.0.0 enemy package for mod API 1. Enable/disable it from the in-game Mods screen and use it in any mission through `iron_coast_boss:unit`. Iron Coast mission 1 supplies placements and completion objectives; this package does not add a mission or override base content.
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.
Airborne command cruiser enters at 2 pixels/tick, settles at y24 and patrols x80..144 indefinitely. It fires a fan of three 3 damage shells every 54 ticks. A standard pilot can destroy it with starter guns; it stays in their firing corridor.
Enemy `iron_coast_boss:unit` is an airborne boss with **240** nominal health and a **84×56** 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.
Logical collision canvas `96×64`, top-left anchor `(0,0)`, health `160`, reward `1800` credits, firing interval `54` simulation ticks. The engine halves boss health on difficulties 0/1; difficulty 2/3 uses the declared health. SD mode uses `art/unit-sd.png`; enable HD art in Settings for `art/unit-hd.png`. The HD canvas is 8× logical resolution and bypasses automatic ScaleNx. Projectile artwork has an 8×8 logical canvas and an independent 64×64 HD version.
Three-way fan / open-center gate / locked single shot. 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.
The boss uses two registered frames (idle/amber charged); the scout and turret use stable single frames. All supplied variants stay on the same canvas and anchor. `art-qa.json` records transparency bounds and, for the boss, animation silhouette overlap.
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.
Enemy deaths increment the current mission objective `iron_coast_boss:defeated`. Persistent script `destroyed`, `hits` and `shots` counters are statistics; current-mission objectives reset when the mission restarts. Callbacks guard retired entities and use deterministic simulation data. No wall clock or unseeded random input enters the scripts.
Only actual death increments `iron_coast_boss: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.
Generated PNG masters, SD/HD strips, previews and WAV sounds are included in Git. Original reference images under `art/source/` are excluded. Exact built-in imagegen prompts are in `prompts/`, with source IDs and generated-image provenance in `provenance.json`.
## Validation and history
To rebuild derived artwork from supplied generated masters and regenerate audio, install Pillow and run `python3 build_assets.py`. To regenerate only audio with the Python standard library, run `python3 build_assets.py --audio-only`. WAVs are original procedural mono 16-bit PCM at 22050 Hz; `audio-recipe.json` records deterministic seeds, sizes and hashes. The art pipeline resizes and assembles supplied images; it does not redraw them.
Validate from the repository root: `cargo run -- --mods mods --validate-mods iron_coast_boss`. Synthetic behavior and save/replay tests: `cargo test --test enemies_mods`. Verify shipped artwork locally: `cargo test --test enemies_mods shipped_enemy_assets_are_valid_and_aligned`.
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.
+15 -6
View File
@@ -11,8 +11,8 @@
"art": {
"png": "art/projectile-sd.png",
"hd_png": "art/projectile-hd.png",
"logical_width": 8,
"logical_height": 8
"logical_width": 5,
"logical_height": 5
}
}
],
@@ -20,17 +20,17 @@
{
"id": "iron_coast_boss:unit",
"name": "Iron Coast Command Cruiser",
"health": 160,
"health": 240,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 54,
"cooldown": 96,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
"logical_width": 96,
"logical_height": 64,
"logical_width": 84,
"logical_height": 56,
"frames": 2
},
"script": "scripts/ai.rhai"
@@ -54,6 +54,15 @@
"pan": 127,
"pitch": 128,
"priority": 4
},
{
"id": "iron_coast_boss:warning",
"name": "Quiet charge cue",
"file": "audio/shot.wav",
"volume": 35,
"pan": 127,
"pitch": 170,
"priority": 2
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss"
name = "Iron Coast Command Cruiser"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "A persistent patrol boss with a three-shell cannon fan. Includes SD and HD artwork, deterministic Rhai AI and generated sound effects."
description = "A three-phase Command Cruiser encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+120 -48
View File
@@ -1,56 +1,128 @@
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
}
// Per-entity directions permit multiple cruisers without sharing a patrol cursor.
fn on_spawn(event, state, random) {
if !is_alive(event) { return #{ commands: [], state: state }; }
if !state.contains("directions") { state.directions = #{}; }
state.directions[event.entity.to_string()] = 1;
#{ 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 }; }
if event.y < 24 {
return #{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state };
}
let key = event.entity.to_string();
if !state.contains("directions") { state.directions = #{}; }
let direction = if state.directions.contains(key) { state.directions[key] } else { 1 };
if event.x >= 144 { direction = -1; }
if event.x <= 80 { direction = 1; }
state.directions[key] = direction;
let commands = [
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
#{ op: "velocity", entity: event.entity, vx: direction, vy: 0 }
];
if event.tick % 54 == 0 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 35, y: 64, vx: -2, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 48, y: 64, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "projectile", weapon: "iron_coast_boss:projectile", x: event.x + 61, y: 64, vx: 2, vy: 4, hostile: true });
commands.push(#{ op: "play_sound", sound: "iron_coast_boss:shot" });
state.shots = if state.contains("shots") { state.shots + 1 } else { 1 };
if !state.contains("units") { state.units = #{}; }
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) {
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 unit = cursor(event,state,key);
if actor.y < 24 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
#{ commands: commands, state: state }
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 = 96 - 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=84;
let left=actor.x+33;
let center=actor.x+width/2;
let right=actor.x+50;
let muzzle_y=actor.y+39;
let center_y=actor.y+52;
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: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});
}
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
}
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:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:center,y:center_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss: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:projectile",x:left,y:muzzle_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:right,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
}
if unit.clock == lead {
commands.push(#{op:"play_sound",sound:"iron_coast_boss: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;
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 y=if resting {actor.y} else {24};
commands.push(#{op:"move",entity:event.entity,x:actor.x,y:y});
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:0});
commands.push(#{op:"animation",entity:event.entity,frame:if unit.clock<=lead {1} else {0}});
if unit.clock == lead {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_hit(event,state,random) {
state.hits=if state.contains("hits") {state.hits+1} else {1};
#{commands:[],state:state}
}
fn on_death(event, state, random) {
if state.contains("directions") { state.directions.remove(event.entity.to_string()); }
state.destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
let defeated = if event.objectives.contains("iron_coast_boss:defeated") { event.objectives["iron_coast_boss:defeated"] + 1 } else { 1 };
#{ commands: [
#{ op: "objective", key: "iron_coast_boss:defeated", value: defeated },
#{ op: "effect", x: event.x + 35, y: event.y + 36 },
#{ op: "effect", x: event.x + 61, y: event.y + 36 },
#{ op: "play_sound", sound: "iron_coast_boss:death" }
], 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:defeated") {event.objectives["iron_coast_boss:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_boss:defeated",value:defeated},
#{op:"effect",x:event.x+84/3,y:event.y+56/2},
#{op:"effect",x:event.x+84*2/3,y:event.y+56/2},
#{op:"play_sound",sound:"iron_coast_boss:death"}],state:state}
}