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

+9 -26
View File
@@ -1,32 +1,15 @@
# Cinder Hammer
# Iron Coast Cinder Hammer
An independent boss mod for Iron Coast mission 5, **Cinder Docks**. The furnace
gunship patrols persistently at the top of the combat area. A pressure-warning
sound precedes three molten projectiles fired six simulation ticks apart. The
center mortar travels vertically; the two plasma rounds spread outward.
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_5:unit`; health 160, reward 1,800, logical size 96×64.
- Mortar and plasma: damage 3, speed 4; a 54-tick attack cycle.
- Death objective: `iron_coast_boss_5:defeated`, incremented only on actual death.
- Manifest API 1, package version 1.0.0, no dependencies or overrides.
Enemy `iron_coast_boss_5:unit` is an airborne boss with **400** nominal health and a **96×64** 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.
The package never completes a mission itself. A mission must spawn the enemy,
declare this package as a dependency, and require its death objective. Each
enemy instance stores its own attack clock and patrol direction in serializable
Rhai state. Dead or removed enemies cannot issue attacks or movement commands.
Three staggered furnaces / converging outer pair / locked mortar. 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.
SD PNGs are derived from the same transparent generated masters as the 8× HD
PNGs. Both modes retain the same logical canvas and collision dimensions.
`art/preview.png` shows the HD hull and exact SD silhouette enlarged for review.
The generation prompts are in `prompts/`; `provenance.json` records tool mode
and source hashes. All artwork and audio belong in Git with this mod.
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.
Run `python3 build_assets.py` to reproduce the resized art and synthesized mono
22,050 Hz, 16-bit PCM warning, shot and destruction sounds. Python and Pillow
are required. `audio-recipe.json` and `art-qa.json` record file hashes and checks.
Only actual death increments `iron_coast_boss_5: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.
Run `cargo test --locked --manifest-path validation/Cargo.toml` from this folder
for five asset-independent checks of attack timing, multiple-instance state,
save serialization, dead-entity guards, patrol turns, actual simulation
persistence, rewards and the single death objective. The simulation test also
validates the real package-local PNGs and WAVs.
## Validation and history
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.
+2 -2
View File
@@ -35,12 +35,12 @@
{
"id": "iron_coast_boss_5:unit",
"name": "Cinder Hammer",
"health": 160,
"health": 400,
"speed": 0,
"reward": 1800,
"ground": false,
"boss": true,
"cooldown": 54,
"cooldown": 96,
"art": {
"png": "art/unit-sd.png",
"hd_png": "art/unit-hd.png",
+2 -2
View File
@@ -1,7 +1,7 @@
id = "iron_coast_boss_5"
name = "Iron Coast Cinder Hammer"
version = "1.0.0"
version = "1.0.1"
api_version = 1
description = "A furnace gunship boss for Cinder Docks: persistent patrol, audible heat warning and staggered mortar/plasma fire. Includes matching SD/HD sprites and synthesized sound effects."
description = "A three-phase Cinder Hammer encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
content = "content.json"
scripts = ["scripts/ai.rhai"]
+115 -67
View File
@@ -1,82 +1,130 @@
// Persistent furnace gunship. Each instance has its own serializable clock.
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 initialize(state, key) {
if !state.contains("units") { state.units = #{}; }
if !state.units.contains(key) {
state.units[key] = #{ age: 0, direction: 1, shots: 0 };
}
state
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
}
fn on_spawn(event, state, random) {
if !is_alive(event) { return #{ commands: [], state: state }; }
let key = event.entity.to_string();
state = initialize(state, key);
// A pilot save contains previous script state; this is a fresh enemy instance.
state.units[key] = #{ age: 0, direction: 1, shots: 0 };
#{ commands: [#{ op: "velocity", entity: event.entity, vx: 0, vy: 2 }], state: state }
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) {
// A lethal hit earlier in this tick must never create a ghost attack.
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 actor=live_actor(event);
if actor.health <= 0 { return #{commands:[],state:state}; }
let key = event.entity.to_string();
state = initialize(state, key);
let unit = state.units[key];
unit.age += 1;
if event.x >= 144 { unit.direction = -1; }
if event.x <= 80 { unit.direction = 1; }
let commands = [
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
#{ op: "velocity", entity: event.entity, vx: unit.direction, vy: 0 }
];
// 12 ticks of warning before the first muzzle fires, then three shells six
// ticks apart. The right/center/left lanes leave space to dodge the fan.
let phase = unit.age % 54;
if phase == 36 {
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_5:warning" });
if !state.contains("units") { state.units = #{}; }
let unit = cursor(event,state,key);
if actor.y < 18 {
state.units[key] = unit;
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
}
if phase == 48 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_5:plasma", x: event.x + 24, y: event.y + 55, vx: -2, vy: 4, hostile: true });
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_5:shot" });
unit.shots += 1;
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=96;
let left=actor.x+31;
let center=actor.x+width/2;
let right=actor.x+65;
let muzzle_y=actor.y+54;
let center_y=actor.y+58;
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_5: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};
}
if phase == 0 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_5:mortar", x: event.x + 44, y: event.y + 58, vx: 0, vy: 4, hostile: true });
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_5:shot" });
unit.shots += 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 == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:plasma",x:left,y:muzzle_y,vx:1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:plasma",x:right,y:muzzle_y,vx:-1,vy:speed,hostile:true}); }
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:plasma",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); }
}
if phase == 6 && unit.age > 6 {
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_5:plasma", x: event.x + 64, y: event.y + 55, vx: 2, vy: 4, hostile: true });
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_5:shot" });
unit.shots += 1;
if unit.clock == lead+6 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:mortar",x:center,y:center_y,vx:if unit.pattern == 2 {aim} else {0},vy:speed-1,hostile:true}); }
if unit.clock == lead+12 && unit.pattern != 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_5:plasma",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
if unit.clock == lead {
commands.push(#{op:"play_sound",sound:"iron_coast_boss_5:shot"});
state.shots=if state.contains("shots") {state.shots+1} else {1};
}
state.units[key] = unit;
#{ commands: commands, state: state }
}
// 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+12;
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 {18+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});
fn on_hit(event, state, random) {
state.hits = if state.contains("hits") { state.hits + 1 } else { 1 };
#{ commands: [], state: state }
if unit.clock == lead+12 {unit.volley+=1;}
unit.age+=1;
unit.clock=(unit.clock+1)%period;
state.units[key]=unit;
#{commands:commands,state:state}
}
fn on_death(event, state, random) {
if state.contains("units") { state.units.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_5:defeated") { event.objectives["iron_coast_boss_5:defeated"] + 1 } else { 1 };
#{ commands: [
#{ op: "objective", key: "iron_coast_boss_5:defeated", value: defeated },
#{ op: "effect", x: event.x + 24, y: event.y + 40 },
#{ op: "effect", x: event.x + 64, y: event.y + 40 },
#{ op: "play_sound", sound: "iron_coast_boss_5:death" }
], 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_5:defeated") {event.objectives["iron_coast_boss_5:defeated"]+1} else {1};
#{commands:[#{op:"objective",key:"iron_coast_boss_5:defeated",value:defeated},
#{op:"effect",x:event.x+96/3,y:event.y+64/2},
#{op:"effect",x:event.x+96*2/3,y:event.y+64/2},
#{op:"play_sound",sound:"iron_coast_boss_5:death"}],state:state}
}