Retune Iron Coast waves, enemy attacks and weapon presentation
This commit is contained in:
1 parent
00cfcc2031
commit
c4a4d821d5
145 files changed
+9242
-2951
No files matched your search
@@ -1,11 +1,15 @@
|
||||
# Saltbreaker
|
||||
# Iron Coast Saltbreaker
|
||||
|
||||
Standalone API 1 mod, version 1.0.0, with no dependencies. Saltworks Convoy requires this package and references `iron_coast_boss_3:unit` and the death objective `iron_coast_boss_3:defeated`.
|
||||
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.
|
||||
|
||||
The armored twin-fan escort enters vertically, then patrols between x=80 and x=144 at y=24. Its logical canvas remains 96×64 in SD and HD. It has 160 health and pays 1,800 credits when destroyed. Every 54 simulation steps after arrival, one nose cannon fires two three-damage shells. The cannons alternate and each warning sounds nine steps before firing. Shells move four pixels vertically, with a second shell angled one pixel toward the center. The boss never completes a mission or pays a separate scripted victory reward.
|
||||
Enemy `iron_coast_boss_3:unit` is an airborne boss with **320** 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.
|
||||
|
||||
Every entity retains its own direction, clock and cannon side in serializable script state. Dead or absent entities cannot move or fire. A duplicate death callback cannot count twice. A new spawn resets its lifecycle even when a lethal collision precedes its queued callback, so restored state and reused entity IDs cannot suppress replay objectives. Mission-start objective reset and victory gating belong to the mission mod.
|
||||
Alternating single battery / delayed inward fork / side-switching pair. 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.
|
||||
|
||||
Generated master images, aligned SD/HD PNGs and synthesized mono 16-bit 22,050 Hz WAVs are included. `prompts/` contains the exact built-in image generation prompts, `provenance.json` records the sources, and `build_assets.py` reproduces derived artwork and audio. Use `--audio-only` to regenerate sounds without Pillow. `art-qa.json` records logical dimensions and clipping checks; `audio-recipe.json` records non-silent PCM peaks and hashes.
|
||||
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.
|
||||
|
||||
Validate content with `raptor-rust --data data --mods RustRaptor/mods --validate-mods iron_coast_boss_3` from the repository root. `validate.rs` is a package-local acceptance probe compiled against the RustRaptor library and serde_json. It checks cannon alternation, warning timing, independent entities, restoration, patrol bounds and death objectives. A real World regression kills the boss through collision before queued callbacks, restores script checkpoints and repeats three lifecycles with the same entity ID; each must count and pay exactly once without firing after death.
|
||||
Only actual death increments `iron_coast_boss_3: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.
|
||||
|
||||
## 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.
|
||||
@@ -1,18 +1,68 @@
|
||||
{
|
||||
"weapons": [{
|
||||
"id": "iron_coast_boss_3:projectile", "name": "Saltbreaker incendiary shell",
|
||||
"damage": 3, "speed": 4, "cooldown": 54, "behavior": "straight", "target": "all",
|
||||
"art": { "png": "art/projectile-sd.png", "hd_png": "art/projectile-hd.png", "logical_width": 8, "logical_height": 8 }
|
||||
}],
|
||||
"enemies": [{
|
||||
"id": "iron_coast_boss_3:unit", "name": "Saltbreaker armored escort VTOL",
|
||||
"health": 160, "speed": 0, "reward": 1800, "ground": false, "boss": true, "cooldown": 54,
|
||||
"art": { "png": "art/unit-sd.png", "hd_png": "art/unit-hd.png", "logical_width": 96, "logical_height": 64, "frames": 1 },
|
||||
"script": "scripts/ai.rhai"
|
||||
}],
|
||||
"weapons": [
|
||||
{
|
||||
"id": "iron_coast_boss_3:projectile",
|
||||
"name": "Saltbreaker incendiary shell",
|
||||
"damage": 3,
|
||||
"speed": 4,
|
||||
"cooldown": 54,
|
||||
"behavior": "straight",
|
||||
"target": "all",
|
||||
"art": {
|
||||
"png": "art/projectile-sd.png",
|
||||
"hd_png": "art/projectile-hd.png",
|
||||
"logical_width": 8,
|
||||
"logical_height": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"enemies": [
|
||||
{
|
||||
"id": "iron_coast_boss_3:unit",
|
||||
"name": "Saltbreaker armored escort VTOL",
|
||||
"health": 320,
|
||||
"speed": 0,
|
||||
"reward": 1800,
|
||||
"ground": false,
|
||||
"boss": true,
|
||||
"cooldown": 90,
|
||||
"art": {
|
||||
"png": "art/unit-sd.png",
|
||||
"hd_png": "art/unit-hd.png",
|
||||
"logical_width": 84,
|
||||
"logical_height": 56,
|
||||
"frames": 1
|
||||
},
|
||||
"script": "scripts/ai.rhai"
|
||||
}
|
||||
],
|
||||
"sounds": [
|
||||
{ "id": "iron_coast_boss_3:shot", "name": "Saltbreaker cannon report", "file": "audio/shot.wav", "volume": 95, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_3:warning", "name": "Saltbreaker cannon charging warning", "file": "audio/warning.wav", "volume": 83, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_3:death", "name": "Saltbreaker turbine destruction", "file": "audio/death.wav", "volume": 120, "pan": 127, "pitch": 128, "priority": 4 }
|
||||
{
|
||||
"id": "iron_coast_boss_3:shot",
|
||||
"name": "Saltbreaker cannon report",
|
||||
"file": "audio/shot.wav",
|
||||
"volume": 95,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"id": "iron_coast_boss_3:warning",
|
||||
"name": "Saltbreaker cannon charging warning",
|
||||
"file": "audio/warning.wav",
|
||||
"volume": 83,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"id": "iron_coast_boss_3:death",
|
||||
"name": "Saltbreaker turbine destruction",
|
||||
"file": "audio/death.wav",
|
||||
"volume": 120,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "iron_coast_boss_3"
|
||||
name = "Iron Coast Saltbreaker"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
api_version = 1
|
||||
description = "An armored convoy-escort VTOL boss with alternating, audible side-cannon warnings, SD and HD artwork, and deterministic serializable AI."
|
||||
description = "A three-phase Saltbreaker encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/ai.rhai"]
|
||||
@@ -1,73 +1,132 @@
|
||||
// Each boss has its own direction, cannon clock and alternating barrel.
|
||||
// No wall-clock time or global tick modulo affects restored behavior.
|
||||
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
|
||||
}
|
||||
|
||||
fn on_spawn(event, state, random) {
|
||||
let key = event.entity.to_string();
|
||||
if !state.contains("units") { state.units = #{}; }
|
||||
if !state.contains("dead") { state.dead = #{}; }
|
||||
let key = event.entity.to_string();
|
||||
state.units[key] = #{direction: 1, clock: 0, side: 0};
|
||||
state.dead.remove(key);
|
||||
// IDs restart on replay. Even a spawn killed during advance must clear
|
||||
// its previous life's death guard before the queued death callback runs.
|
||||
if !is_alive(event) { return #{commands: [], state: state}; }
|
||||
#{commands: [#{op: "velocity", entity: event.entity, vx: 0, vy: 2}], state: state}
|
||||
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}; }
|
||||
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();
|
||||
if !state.contains("units") { state.units = #{}; }
|
||||
let unit = if state.units.contains(key) { state.units[key] } else {#{direction: 1, clock: 0, side: 0}};
|
||||
if event.x >= 144 { unit.direction = -1; }
|
||||
if event.x <= 80 { unit.direction = 1; }
|
||||
unit.clock = (unit.clock + 1) % 54;
|
||||
let commands = [
|
||||
#{op: "move", entity: event.entity, x: event.x, y: 24},
|
||||
#{op: "velocity", entity: event.entity, vx: unit.direction, vy: 0}
|
||||
];
|
||||
// Audible warning lasts nine simulation steps before each side fires.
|
||||
if unit.clock == 45 {
|
||||
commands.push(#{op: "play_sound", sound: "iron_coast_boss_3:warning"});
|
||||
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};
|
||||
}
|
||||
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=84;
|
||||
let left=actor.x+30;
|
||||
let center=actor.x+width/2;
|
||||
let right=actor.x+54;
|
||||
let muzzle_y=actor.y+47;
|
||||
let center_y=actor.y+52;
|
||||
let commands=[];
|
||||
if unit.clock == 0 {
|
||||
let muzzle = if unit.side == 0 {35} else {61};
|
||||
let inward = if unit.side == 0 {1} else {-1};
|
||||
commands.push(#{op: "projectile", weapon: "iron_coast_boss_3:projectile", x: event.x + muzzle, y: event.y + 54, vx: 0, vy: 4, hostile: true});
|
||||
commands.push(#{op: "projectile", weapon: "iron_coast_boss_3:projectile", x: event.x + muzzle, y: event.y + 54, vx: inward, vy: 4, hostile: true});
|
||||
commands.push(#{op: "play_sound", sound: "iron_coast_boss_3:shot"});
|
||||
unit.side = 1 - unit.side;
|
||||
state.shots = if state.contains("shots") {state.shots + 1} else {1};
|
||||
unit.lock_x=event.player.x+16;
|
||||
unit.pattern=(unit.volley+phase)%3;
|
||||
commands.push(#{op:"play_sound",sound:"iron_coast_boss_3: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};
|
||||
}
|
||||
state.units[key] = unit;
|
||||
#{commands: commands, state: state}
|
||||
}
|
||||
let aim=if unit.lock_x<center-24 {-1} else if unit.lock_x>center+24 {1} else {0};
|
||||
let active = if unit.volley % 2 == 0 {left} else {right};
|
||||
let inward = if unit.volley % 2 == 0 {1} else {-1};
|
||||
if unit.clock == lead { commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:active,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
|
||||
if unit.clock == lead+6 {
|
||||
if unit.pattern == 0 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:active,y:muzzle_y,vx:inward,vy:speed,hostile:true}); }
|
||||
else if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
|
||||
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_3:projectile",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
|
||||
}
|
||||
if unit.clock == lead {
|
||||
commands.push(#{op:"play_sound",sound:"iron_coast_boss_3: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+6;
|
||||
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*12/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+6 {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) {
|
||||
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("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_3:defeated") {event.objectives["iron_coast_boss_3:defeated"] + 1} else {1};
|
||||
#{commands: [
|
||||
#{op: "objective", key: "iron_coast_boss_3:defeated", value: defeated},
|
||||
#{op: "effect", x: event.x + 35, y: event.y + 48},
|
||||
#{op: "effect", x: event.x + 61, y: event.y + 48},
|
||||
#{op: "play_sound", sound: "iron_coast_boss_3: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_3:defeated") {event.objectives["iron_coast_boss_3:defeated"]+1} else {1};
|
||||
#{commands:[#{op:"objective",key:"iron_coast_boss_3: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_3:death"}],state:state}
|
||||
}
|
||||
Reference in new issue
Block a user