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,33 +1,15 @@
|
||||
# Iron Coast: Relay Warden
|
||||
|
||||
Standalone API 1 boss package used by the Tidal Relay mission. It has no dependencies and never decides when a mission finishes. Enable the mission to select this package automatically through its dependency.
|
||||
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_2:unit`, health 160, reward 1,800 credits, airborne boss.
|
||||
- Projectile: `iron_coast_boss_2:projectile`, damage 3, speed 4, 8×8 logical canvas.
|
||||
- Completion observation: `iron_coast_boss_2:defeated` increases once for each actual death. Duplicate notifications are harmless; despawning or time passing does not count.
|
||||
- Art: one complete static frame on a 96×64 logical canvas. SD and HD preserve the same origin, full silhouette and true alpha. HD is 768×512; gameplay collision dimensions remain 96×64.
|
||||
- Audio: `shot`, `warning` and `death` namespaced sound IDs, each a generated, non-silent 22,050 Hz, mono, 16-bit PCM WAV.
|
||||
Enemy `iron_coast_boss_2:unit` is an airborne boss with **280** 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.
|
||||
|
||||
The Warden enters at two logical pixels per tick, stops at y=24, and patrols x=80…144. Each entity tracks its own arrival timer and direction. An audible radar charge starts 12 ticks before each 54-tick volley. Odd volleys fire two parallel outer lanes; even volleys fire two diverging inner lanes. The opening below its central radar dish remains readable. Each queued spawn registers its life even if a projectile has already killed it before callback dispatch; that early kill still counts when the engine delivers its actual death. Timers and directions are removed when the aircraft dies, and dead units cannot emit movement, warnings or shots. Difficulty scaling comes from the engine, including its existing half-health rule on the two lower difficulties.
|
||||
Wide radar gate / outward fork / warned locked doublet. 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.
|
||||
|
||||
## Asset sources
|
||||
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 two masters were generated using the built-in imagegen tool from the saved prompts, with the existing generated Iron Coast cruiser inspected for material and color style. The Warden and cyan radar pulse have new silhouettes; no original game art or recordings are copied into this package. `provenance.json`, `art-qa.json` and `audio-recipe.json` record inputs, processing, alpha bounds and hashes. All PNGs, WAVs and prompts are intended for Git.
|
||||
Only actual death increments `iron_coast_boss_2: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.
|
||||
|
||||
Recreate the SD/HD output and audio from the saved masters with:
|
||||
## Validation and history
|
||||
|
||||
```sh
|
||||
python3 RustRaptor/mods/iron_coast_boss_2/build_assets.py
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
The package contains a small Rust validation program that uses the game's public modding and deterministic simulation API. It validates package artwork and WAV decoding, both telegraphs and alternating volleys, separate entity timers, actual death rewards/objectives, repeated and dead callbacks, cleanup, a real lethal projectile during the spawn step, and reuse of an entity ID for a new life. It requires no original game assets.
|
||||
|
||||
```sh
|
||||
CARGO_TARGET_DIR=RustRaptor/target cargo run --offline \
|
||||
--manifest-path RustRaptor/mods/iron_coast_boss_2/validation/Cargo.toml \
|
||||
--bin validate-boss
|
||||
```
|
||||
|
||||
Its saved result is `validation/report.json`. Tidal Relay's validation also exercises this boss in the complete mission with the real original content registry.
|
||||
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.
|
||||
@@ -20,25 +20,49 @@
|
||||
{
|
||||
"id": "iron_coast_boss_2:unit",
|
||||
"name": "Relay Warden",
|
||||
"health": 160,
|
||||
"health": 280,
|
||||
"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": 84,
|
||||
"logical_height": 56,
|
||||
"frames": 1
|
||||
},
|
||||
"script": "scripts/ai.rhai"
|
||||
}
|
||||
],
|
||||
"sounds": [
|
||||
{ "id": "iron_coast_boss_2:shot", "name": "Relay Warden pulse volley", "file": "audio/shot.wav", "volume": 95, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_2:warning", "name": "Relay Warden radar charge", "file": "audio/warning.wav", "volume": 86, "pan": 127, "pitch": 128, "priority": 2 },
|
||||
{ "id": "iron_coast_boss_2:death", "name": "Relay Warden destruction", "file": "audio/death.wav", "volume": 120, "pan": 127, "pitch": 128, "priority": 4 }
|
||||
{
|
||||
"id": "iron_coast_boss_2:shot",
|
||||
"name": "Relay Warden pulse volley",
|
||||
"file": "audio/shot.wav",
|
||||
"volume": 95,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"id": "iron_coast_boss_2:warning",
|
||||
"name": "Relay Warden radar charge",
|
||||
"file": "audio/warning.wav",
|
||||
"volume": 86,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 2
|
||||
},
|
||||
{
|
||||
"id": "iron_coast_boss_2:death",
|
||||
"name": "Relay Warden destruction",
|
||||
"file": "audio/death.wav",
|
||||
"volume": 120,
|
||||
"pan": 127,
|
||||
"pitch": 128,
|
||||
"priority": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "iron_coast_boss_2"
|
||||
name = "Iron Coast: Relay Warden"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
api_version = 1
|
||||
description = "A broad radar-aircraft boss for Tidal Relay, with an audible warning and alternating two-lane pulse volleys. Includes transparent SD/HD art and generated sounds."
|
||||
description = "A three-phase Relay Warden encounter with warned, locked attack lanes and bounded recovery. Includes unchanged SD/HD artwork and audio."
|
||||
content = "content.json"
|
||||
scripts = ["scripts/ai.rhai"]
|
||||
@@ -1,84 +1,130 @@
|
||||
// All cursors are per entity. Arrival and attack timers use simulation callbacks.
|
||||
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) {
|
||||
// Register the life before checking current liveness: the engine can kill a
|
||||
// queued spawn during advance, before it dispatches this callback. Its later
|
||||
// actual death still needs one objective update, without movement or audio.
|
||||
if !state.contains("directions") { state.directions = #{}; }
|
||||
if !state.contains("ages") { state.ages = #{}; }
|
||||
let key = event.entity.to_string();
|
||||
state.directions[key] = 1;
|
||||
state.ages[key] = 0;
|
||||
if !is_alive(event) { return #{ commands: [], state: state }; }
|
||||
#{ 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) {
|
||||
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();
|
||||
// An untracked unit cannot acquire another entity's attack timer.
|
||||
if !state.contains("ages") || !state.ages.contains(key) {
|
||||
return #{ commands: [], state: state };
|
||||
if !state.contains("units") { state.units = #{}; }
|
||||
let unit = cursor(event,state,key);
|
||||
if actor.y < 20 {
|
||||
state.units[key] = unit;
|
||||
return #{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:2}],state:state};
|
||||
}
|
||||
let direction = state.directions[key];
|
||||
if event.x >= 144 { direction = -1; }
|
||||
if event.x <= 80 { direction = 1; }
|
||||
state.directions[key] = direction;
|
||||
let age = state.ages[key] + 1;
|
||||
state.ages[key] = age;
|
||||
let commands = [
|
||||
#{ op: "move", entity: event.entity, x: event.x, y: 24 },
|
||||
#{ op: "velocity", entity: event.entity, vx: direction, vy: 0 }
|
||||
];
|
||||
// Twelve ticks of warning precede the shot: about half a second at 70/3 Hz.
|
||||
if age % 54 == 42 {
|
||||
commands.push(#{ op: "play_sound", sound: "iron_coast_boss_2:warning" });
|
||||
}
|
||||
if age % 54 == 0 {
|
||||
// Alternate parallel outer lanes with a diverging inner fork.
|
||||
// Both leave a readable opening directly under the central radar dish.
|
||||
if (age / 54) % 2 == 1 {
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 24, y: 75, vx: 0, vy: 4, hostile: true });
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 72, y: 75, vx: 0, vy: 4, hostile: true });
|
||||
} else {
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 36, y: 75, vx: -1, vy: 4, hostile: true });
|
||||
commands.push(#{ op: "projectile", weapon: "iron_coast_boss_2:projectile", x: event.x + 60, y: 75, vx: 1, 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=84;
|
||||
let left=actor.x+33;
|
||||
let center=actor.x+width/2;
|
||||
let right=actor.x+50;
|
||||
let muzzle_y=actor.y+44;
|
||||
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_2: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_2:shot" });
|
||||
state.shots = if state.contains("shots") { state.shots + 1 } else { 1 };
|
||||
state.warnings=if state.contains("warnings") {state.warnings+1} else {1};
|
||||
}
|
||||
#{ 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();
|
||||
// A death callback must refer to a spawned unit that is absent from live queries.
|
||||
// Removing its cursor also makes repeated death notifications harmless.
|
||||
if is_alive(event) || !state.contains("ages") || !state.ages.contains(key) {
|
||||
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_2:projectile",x:left,y:muzzle_y,vx:0,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:right,y:muzzle_y,vx:0,vy:speed,hostile:true}); }
|
||||
else if unit.pattern == 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:left,y:muzzle_y,vx:-1,vy:speed,hostile:true}); commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:right,y:muzzle_y,vx:1,vy:speed,hostile:true}); }
|
||||
else { commands.push(#{op:"projectile",weapon:"iron_coast_boss_2:projectile",x:center,y:center_y,vx:aim,vy:speed,hostile:true}); }
|
||||
}
|
||||
state.ages.remove(key);
|
||||
state.directions.remove(key);
|
||||
state.destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
|
||||
let defeated = if event.objectives.contains("iron_coast_boss_2:defeated") { event.objectives["iron_coast_boss_2:defeated"] + 1 } else { 1 };
|
||||
#{ commands: [
|
||||
#{ op: "objective", key: "iron_coast_boss_2:defeated", value: defeated },
|
||||
#{ op: "effect", x: event.x + 32, y: event.y + 38 },
|
||||
#{ op: "effect", x: event.x + 64, y: event.y + 38 },
|
||||
#{ op: "play_sound", sound: "iron_coast_boss_2:death" }
|
||||
], state: state }
|
||||
if unit.clock == lead+6 && unit.pattern == 2 && difficulty >= 1 { commands.push(#{op:"projectile",weapon:"iron_coast_boss_2: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_2: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 {20+if phase>0 {triangle*6/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+6 {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_2:defeated") {event.objectives["iron_coast_boss_2:defeated"]+1} else {1};
|
||||
#{commands:[#{op:"objective",key:"iron_coast_boss_2: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_2:death"}],state:state}
|
||||
}
|
||||
Reference in new issue
Block a user