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,17 +1,19 @@
|
||||
# Iron Coast Scout
|
||||
|
||||
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_scout: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 interceptor weaves left/right every 24 ticks, advances 1 pixel/tick, aims at the pilot and fires a 2 damage shell.
|
||||
Straight entry at 3 pixels/tick, one 2-pixel lateral firing pass, a brief break, then a committed 3-pixel/tick exit. No indefinite bounce or slow alternating wobble.
|
||||
|
||||
Logical collision canvas `24×24`, top-left anchor `(0,0)`, health `14`, reward `75` 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.
|
||||
The body remains 24×24, health 14, reward 75. SD and HD use the existing aligned PNGs. All PNG/WAV/master/prompt/provenance bytes are unchanged. Nonlegacy hostile projectile collision remains the engine's fixed `abs(dx)<10 && abs(dy)<10` point-to-player-center test, independent of artwork size. The following canvas change only changes visible sampling; it does not shrink that threshold. Projectile logical canvas is 6×6 in both modes; the complete existing square PNG canvas is sampled by the normal renderer, without crop or asset repaint. Its narrow visible core is checked in both modes.
|
||||
|
||||
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.
|
||||
All four difficulty profiles are authored in Rhai. Training/Rookie/Veteran/Elite (difficulty0/1/2/3) repeat periods are [64, 54, 46, 40] ticks, short-salvo counts [1, 1, 2, 2], windups [9, 8, 7, 7], projectile maximum-axis speeds [3, 4, 4, 5]; successive salvo shots are 5 ticks apart. These periods are script clocks, not the unused engine auto-fire cooldown. The enemy has no automatic weapon so it cannot fire a second implicit volley. Existing `:gun` definitions in Stage 2 remain compatibility content and do not drive this AI.
|
||||
|
||||
Enemy deaths increment the current mission objective `iron_coast_scout: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.
|
||||
Aim is frozen at the start of a short salvo. Units only fire when their complete body is visible and still safely above the player. Salvos cancel as soon as that condition fails. Shot audio plays once per actual projectile; siege/skimmer warning audio plays once per salvo. Existing large explosion/flare effects are not used as muzzle blooms. Native death feedback remains engine-owned.
|
||||
|
||||
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`.
|
||||
`event.difficulty` must be integer 0–3; absent or invalid data uses difficulty1 (Rookie). Optional `event.actor` uses the current central-dispatch enemy snapshot, or null for a retired actor. Older engines lacking both new fields use a compatibility lookup through `event.entities`. Current null actors have the integer difficulty field and skip immediately. No full-event native map methods or helper arguments are used in the current path. The dense-wave operation-budget acceptance requires the current actor API; a fallback trace does not prove that new API is installed.
|
||||
|
||||
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.
|
||||
Per-entity script state is bounded to 64 entries. Birth/death update the bounded lifecycle index; normal ticks never prune or scan the world when actor metadata is present. State, RNG and salvo timing are deterministic and save-continuable. Only actual engine death callbacks increment `iron_coast_scout:defeated` once. Air exits never award kill credit; required ground units never self-clear. The script never deals direct damage, grants equipment, spawns enemy reinforcements or completes a mission.
|
||||
|
||||
Validate from the repository root: `cargo run -- --mods mods --validate-mods iron_coast_scout`. 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`.
|
||||
## 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.
|
||||
@@ -4,15 +4,15 @@
|
||||
"id": "iron_coast_scout:projectile",
|
||||
"name": "Iron Coast Scout cannon shell",
|
||||
"damage": 2,
|
||||
"speed": 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
|
||||
"logical_width": 6,
|
||||
"logical_height": 6
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -33,8 +33,7 @@
|
||||
"logical_height": 24,
|
||||
"frames": 1
|
||||
},
|
||||
"script": "scripts/ai.rhai",
|
||||
"weapon": "iron_coast_scout:projectile"
|
||||
"script": "scripts/ai.rhai"
|
||||
}
|
||||
],
|
||||
"sounds": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
id = "iron_coast_scout"
|
||||
name = "Iron Coast Scout"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
api_version = 1
|
||||
description = "An agile weaving interceptor with amber cannon fire. Includes SD and HD artwork, deterministic Rhai AI and generated sound effects."
|
||||
content = "content.json"
|
||||
|
||||
@@ -1,40 +1,120 @@
|
||||
fn is_alive(event) {
|
||||
for unit in event.entities {
|
||||
if unit.entity == event.entity && unit.health > 0 { return true; }
|
||||
}
|
||||
false
|
||||
// Authored attack clocks, not a legacy selector. Art, sound and kill rewards are unchanged.
|
||||
// The central actor snapshot gives O(1) current-entity access. A legacy-only scan
|
||||
// remains for dispatchers lacking BOTH actor and difficulty; no full event helper.
|
||||
// Current dispatch supplies BOTH actor and difficulty. Read compact properties
|
||||
// directly: native map methods recursively validate the entire event payload.
|
||||
// Only an older dispatch with neither field enters the legacy array fallback.
|
||||
fn legacy_actor(entities, id) {
|
||||
for unit in entities { if unit.entity == id { return unit; } }
|
||||
()
|
||||
}
|
||||
fn difficulty(value) {
|
||||
if type_of(value) == "i64" && value >= 0 && value <= 3 { return value; }
|
||||
1
|
||||
}
|
||||
fn ready(state) {
|
||||
if !state.contains("units") { state.units = #{}; }
|
||||
if !state.contains("order") { state.order = []; }
|
||||
if !state.contains("dead") { state.dead = #{}; }
|
||||
if !state.contains("dead_order") { state.dead_order = []; }
|
||||
state
|
||||
}
|
||||
fn bump(state, name) {
|
||||
if !state.contains(name) { return 1; }
|
||||
if state[name] < 1000000000 { state[name] + 1 } else { state[name] }
|
||||
}
|
||||
fn visible(a, player) {
|
||||
a.x >= 0 && a.x + a.width <= 320 && a.y >= 4 && a.y + a.height <= 174 && a.y + a.height < player.y - 8
|
||||
}
|
||||
fn aimed(a, player, speed) {
|
||||
let dx = player.x + 16 - (a.x + a.width / 2);
|
||||
let dy = player.y + 16 - (a.y + a.height);
|
||||
let ax = if dx < 0 {-dx} else {dx};
|
||||
let norm = if ax > dy {ax} else {dy};
|
||||
if norm <= 0 { return [0,speed]; }
|
||||
let vx = dx * speed / norm;
|
||||
let vy = dy * speed / norm;
|
||||
[vx, if vy < 1 {1} else {vy}]
|
||||
}
|
||||
|
||||
// Velocity changes are committed after the callback completes.
|
||||
fn on_spawn(event, state, random) {
|
||||
if !is_alive(event) { return #{ commands: [], state: state }; }
|
||||
#{ commands: [#{ op: "velocity", entity: event.entity, vx: 1, vy: 1 }], state: state }
|
||||
state = ready(state);
|
||||
let key = event.entity.to_string();
|
||||
// IDs restart with missions. Replace this lifecycle even if the callback is stale.
|
||||
state.units.remove(key); state.dead.remove(key);
|
||||
let order = []; for old in state.order { if old != key { order.push(old); } }
|
||||
state.order = order;
|
||||
let dead_order = []; for old in state.dead_order { if old != key { dead_order.push(old); } }
|
||||
state.dead_order = dead_order;
|
||||
let a = event.actor;
|
||||
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
|
||||
if a == () || a.health <= 0 { return #{commands:[],state:state}; }
|
||||
// Constant bound; pruning is at birth/death, never an all-entity tick sweep.
|
||||
if state.order.len() >= 64 { let old = state.order.remove(0); state.units.remove(old); }
|
||||
state.order.push(key);
|
||||
state.units[key] = #{age:0, sign:if a.x < 148 {1} else {-1}, cooldown:18 + event.entity % 19, remaining:0, wait:0, aim_x:0, aim_y:3};
|
||||
#{commands:[#{op:"velocity",entity:event.entity,vx:0,vy:3}],state:state}
|
||||
}
|
||||
|
||||
fn on_tick(event, state, random) {
|
||||
if event.health <= 0 || !is_alive(event) { return #{ commands: [], state: state }; }
|
||||
let sideways = if ((event.tick + event.entity * 7) / 24) % 2 == 0 { 1 } else { -1 };
|
||||
if event.x <= 24 { sideways = 1; }
|
||||
if event.x >= 272 { sideways = -1; }
|
||||
#{ commands: [#{ op: "velocity", entity: event.entity, vx: sideways, vy: 1 }], state: state }
|
||||
state = ready(state);
|
||||
let key = event.entity.to_string();
|
||||
let a = event.actor;
|
||||
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
|
||||
if a == () || a.health <= 0 { state.units.remove(key); return #{commands:[],state:state}; }
|
||||
if !state.units.contains(key) { return #{commands:[],state:state}; }
|
||||
let unit = state.units[key];
|
||||
unit.age = if unit.age < 1000000000 {unit.age + 1} else {unit.age};
|
||||
let commands = [];
|
||||
if a.y > 224 || a.x < -32 || a.x > 328 {
|
||||
state.units.remove(key);
|
||||
return #{commands:[#{op:"despawn",entity:event.entity}],state:state};
|
||||
}
|
||||
// Straight entry, diagonal firing pass, brief level break, then a committed exit.
|
||||
let vx = if unit.age < 16 {0} else if unit.age < 56 {unit.sign * 2} else if unit.age < 76 {0} else {unit.sign};
|
||||
let vy = if unit.age < 16 {3} else if unit.age < 56 {1} else if unit.age < 76 {2} else {3};
|
||||
if unit.age < 76 && a.x < 8 {vx = 2;}
|
||||
if unit.age < 76 && a.x > 288 {vx = -2;}
|
||||
commands.push(#{op:"velocity",entity:event.entity,vx:vx,vy:vy});
|
||||
let d = difficulty(event.difficulty);
|
||||
let period = [64, 54, 46, 40][d];
|
||||
let burst = [1, 1, 2, 2][d];
|
||||
let windup = [9, 8, 7, 7][d];
|
||||
let speed = [3, 4, 4, 5][d];
|
||||
if unit.cooldown > 0 { unit.cooldown -= 1; }
|
||||
let can_fire = visible(a,event.player) && unit.age >= 12;
|
||||
if !can_fire { unit.remaining = 0; unit.wait = 0; }
|
||||
if can_fire && unit.remaining == 0 && unit.cooldown == 0 {
|
||||
let aim = aimed(a,event.player,speed);
|
||||
unit.aim_x = aim[0]; unit.aim_y = aim[1];
|
||||
// This tick consumes one counter step; retain the full documented warning interval.
|
||||
unit.remaining = burst; unit.wait = windup + 1; unit.cooldown = period;
|
||||
// No oversized flare: the visible approach/hold is the light-unit telegraph.
|
||||
}
|
||||
if unit.wait > 0 { unit.wait -= 1; }
|
||||
if can_fire && unit.remaining > 0 && unit.wait == 0 {
|
||||
// Aim is frozen at the start of this short salvo: no homing correction.
|
||||
commands.push(#{op:"projectile",weapon:"iron_coast_scout:projectile",x:a.x + a.width / 2 - 6 / 2,y:a.y + a.height - 6 / 2,vx:unit.aim_x,vy:unit.aim_y,hostile:true});
|
||||
commands.push(#{op:"play_sound",sound:"iron_coast_scout:shot"});
|
||||
unit.remaining -= 1; unit.wait = 5;
|
||||
state.shots = bump(state,"shots");
|
||||
}
|
||||
state.units[key] = unit;
|
||||
#{commands:commands,state:state}
|
||||
}
|
||||
|
||||
fn on_fire(event, state, random) {
|
||||
let shots = if state.contains("shots") { state.shots + 1 } else { 1 };
|
||||
state.shots = shots;
|
||||
#{ commands: [#{ op: "play_sound", sound: "iron_coast_scout:shot" }], state: state }
|
||||
}
|
||||
|
||||
fn on_hit(event, state, random) {
|
||||
state.hits = if state.contains("hits") { state.hits + 1 } else { 1 };
|
||||
#{ commands: [], state: state }
|
||||
state.hits = bump(state,"hits");
|
||||
#{commands:[],state:state}
|
||||
}
|
||||
|
||||
fn on_death(event, state, random) {
|
||||
state.destroyed = if state.contains("destroyed") { state.destroyed + 1 } else { 1 };
|
||||
let defeated = if event.objectives.contains("iron_coast_scout:defeated") { event.objectives["iron_coast_scout:defeated"] + 1 } else { 1 };
|
||||
#{ commands: [
|
||||
#{ op: "objective", key: "iron_coast_scout:defeated", value: defeated },
|
||||
#{ op: "play_sound", sound: "iron_coast_scout:death" }
|
||||
], state: state }
|
||||
state = ready(state);
|
||||
let key = event.entity.to_string();
|
||||
// Only the engine's actual death hook advances required-target counters.
|
||||
// Natural air exits never enter this path and never grant rewards/objectives.
|
||||
let a = event.actor;
|
||||
if a == () && event.difficulty == () { a = legacy_actor(event.entities,event.entity); }
|
||||
if (a != () && a.health > 0) || state.dead.contains(key) { return #{commands:[],state:state}; }
|
||||
if state.dead_order.len() >= 64 { let old = state.dead_order.remove(0); state.dead.remove(old); }
|
||||
state.dead[key] = true; state.dead_order.push(key); state.units.remove(key);
|
||||
state.destroyed = bump(state,"destroyed");
|
||||
let defeated = if event.objectives.contains("iron_coast_scout:defeated") {event.objectives["iron_coast_scout:defeated"] + 1} else {1};
|
||||
#{commands:[#{op:"objective",key:"iron_coast_scout:defeated",value:defeated},#{op:"play_sound",sound:"iron_coast_scout:death"}],state:state}
|
||||
}
|
||||
Reference in new issue
Block a user