diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index a103f2ae..f51c796d 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -16,6 +16,13 @@ struct BoneBinding { parent_bone: Option, } impl AnimationStatus { + pub(crate) fn entity_for_bone(&self, bone: usize) -> Option { + self.bindings + .iter() + .find(|binding| binding.bone == bone) + .map(|binding| binding.entity) + } + /// Prepare a hidden imported scene without disturbing the live bindings. pub(crate) fn for_scene( root: Entity, diff --git a/crates/skate-game/src/modding/mod.rs b/crates/skate-game/src/modding/mod.rs index fa3a7fd0..cfade063 100644 --- a/crates/skate-game/src/modding/mod.rs +++ b/crates/skate-game/src/modding/mod.rs @@ -25,6 +25,11 @@ struct Owned { entity: Entity, mesh: Option>, material: Option>, + attachment: Option, +} +#[derive(Component)] +struct HeldAttachment { + bone: usize, } pub(crate) struct ModdingPlugin; impl Plugin for ModdingPlugin { @@ -91,7 +96,12 @@ impl Plugin for ModdingPlugin { .after(crate::app::SimulationSet::Physics) .run_if(crate::graphics_menu::gameplay_active), ) - .add_systems(Update, update.after(crate::app::FrameSet::Animation)); + .add_systems( + Update, + (attach_held, update) + .chain() + .after(crate::app::FrameSet::Animation), + ); vehicles::install(app); network::install(app); menu::install(app); @@ -210,6 +220,22 @@ fn update(world: &mut World) { apply(world, &mut mods); }); } +fn attach_held( + mut commands: Commands, + animation: Res, + held: Query<(Entity, &HeldAttachment), Without>, +) { + if !animation.ready { + return; + } + for (entity, attachment) in &held { + if let Some(parent) = animation.entity_for_bone(attachment.bone) { + commands + .entity(entity) + .insert((ChildOf(parent), Visibility::Inherited)); + } + } +} fn retire(world: &mut World, mods: &mut Mods, key: &(String, String)) { if let Some(owned) = mods.owned.remove(key) { world.despawn(owned.entity); @@ -271,7 +297,9 @@ fn apply(world: &mut World, mods: &mut Mods) { Command::Remove { key } => { keys.remove(key); } - Command::Overlay { key, .. } | Command::Cube { key, .. } => { + Command::Overlay { key, .. } + | Command::Cube { key, .. } + | Command::HeldCube { key, .. } => { keys.insert(key.clone()); if keys.len() > 64 { return Err("64 owned objects per mod maximum".into()); @@ -352,7 +380,10 @@ fn teleport_ready(world: &World) -> Result<(), String> { } fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> Result<(), String> { let key = match &command { - Command::Cube { key, .. } | Command::Overlay { key, .. } | Command::Remove { key } => { + Command::Cube { key, .. } + | Command::HeldCube { key, .. } + | Command::Overlay { key, .. } + | Command::Remove { key } => { Some((id.to_owned(), key.clone())) } _ => None, @@ -421,6 +452,7 @@ fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> entity, mesh: None, material: None, + attachment: None, }, ); } @@ -432,7 +464,7 @@ fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> } => { let key = key.unwrap(); if let Some(owned) = mods.owned.get(&key) { - if owned.mesh.is_some() { + if owned.mesh.is_some() && owned.attachment.is_none() { *world.get_mut::(owned.entity).unwrap() = Transform::from_translation(Vec3::from_array(position)) .with_scale(Vec3::from_array(size)); @@ -468,9 +500,72 @@ fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> .id(), mesh: Some(mesh.id()), material: Some(material.id()), + attachment: None, }; mods.owned.insert(key, owned); } + Command::HeldCube { + bone, + offset, + size, + color, + .. + } => { + let key = key.unwrap(); + let bone_index = world + .resource::() + .animation + .evaluator + .frames + .bone_names + .iter() + .position(|name| name.eq_ignore_ascii_case(&bone)) + .ok_or_else(|| format!("Unknown held-cube bone {bone}"))?; + let transform = Transform::from_translation(Vec3::from_array(offset)) + .with_scale(Vec3::from_array(size)); + if let Some(owned) = mods.owned.get(&key) { + if owned.mesh.is_some() && owned.attachment == Some(bone_index) { + *world.get_mut::(owned.entity).unwrap() = transform; + if let Some(material) = owned.material { + if let Some(m) = world + .resource_mut::>() + .get_mut(material) + { + m.base_color = Color::srgb(color[0], color[1], color[2]); + } + } + return Ok(()); + } + } + retire(world, mods, &key); + let mesh = world + .resource_mut::>() + .add(Cuboid::new(1., 1., 1.)); + let material = world + .resource_mut::>() + .add(StandardMaterial { + base_color: Color::srgb(color[0], color[1], color[2]), + ..default() + }); + let entity = world + .spawn(( + Mesh3d(mesh.clone()), + MeshMaterial3d(material.clone()), + transform, + Visibility::Hidden, + HeldAttachment { bone: bone_index }, + )) + .id(); + mods.owned.insert( + key, + Owned { + entity, + mesh: Some(mesh.id()), + material: Some(material.id()), + attachment: Some(bone_index), + }, + ); + } Command::Teleport { position, heading, diff --git a/crates/skate-mods/src/api.lua b/crates/skate-mods/src/api.lua index 314373d9..d4661622 100644 --- a/crates/skate-mods/src/api.lua +++ b/crates/skate-mods/src/api.lua @@ -7,6 +7,9 @@ sdk.scene = {} function sdk.scene.cube(key,position,size,color) submit{kind="cube",key=key,position=position,size=size,color=color} end +function sdk.scene.held_cube(key,bone,offset,size,color) + submit{kind="held_cube",key=key,bone=bone,offset=offset,size=size,color=color} +end function sdk.scene.remove(key) submit{kind="remove",key=key} end sdk.player = {} function sdk.player.read() return sdk.snapshot.player end diff --git a/crates/skate-mods/src/vm.rs b/crates/skate-mods/src/vm.rs index 36864e2a..7e8a5c9a 100644 --- a/crates/skate-mods/src/vm.rs +++ b/crates/skate-mods/src/vm.rs @@ -38,6 +38,13 @@ pub enum Command { size: [f32; 3], color: [f32; 3], }, + HeldCube { + key: String, + bone: String, + offset: [f32; 3], + size: [f32; 3], + color: [f32; 3], + }, Remove { key: String, }, @@ -74,6 +81,25 @@ impl Command { .iter() .all(|v| v.is_finite() && (0. ..=1.).contains(v)) } + Self::HeldCube { + key, + bone, + offset, + size, + color, + } => { + crate::schema::valid_id(key) + && !bone.is_empty() + && bone.len() <= 64 + && bone + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + && offset.iter().all(|v| v.is_finite() && v.abs() <= 10.) + && size.iter().all(|v| v.is_finite() && *v > 0. && *v <= 10.) + && color + .iter() + .all(|v| v.is_finite() && (0. ..=1.).contains(v)) + } Self::Remove { key } => crate::schema::valid_id(key), Self::Teleport { position, heading, .. diff --git a/crates/skate-mods/tests/runtime.rs b/crates/skate-mods/tests/runtime.rs index e97576af..7b7210c0 100644 --- a/crates/skate-mods/tests/runtime.rs +++ b/crates/skate-mods/tests/runtime.rs @@ -104,6 +104,26 @@ fn libraries_are_restricted() { ); } #[test] +fn held_cube_command_is_typed_validated_and_queued() { + let f = Fixture::new( + "return {on_load=function() sdk.scene.held_cube('gun','RIGHTHAND',{0,-0.1,0.05},{0.1,0.3,0.1},{0.2,0.2,0.2}) end}", + ); + let mut m = f.manager(); + m.enable("example", true).unwrap(); + assert!(matches!( + &m.commands[0].1, + Command::HeldCube { key, bone, .. } if key == "gun" && bone == "RIGHTHAND" + )); + + let f = Fixture::new( + "return {on_load=function() sdk.scene.held_cube('gun','RIGHT-HAND',{0,0,0},{0.1,0.3,0.1},{1,1,1}) end}", + ); + let mut m = f.manager(); + m.enable("example", true).unwrap(); + assert!(!m.packages["example"].running()); + assert!(m.commands.is_empty()); +} +#[test] fn immediate_settings_persist_and_validate() { let f = Fixture::new( "return {on_settings=function(e) sdk.log(tostring(sdk.settings.count)..':'..e.key) end}", @@ -152,7 +172,7 @@ fn examples_load_and_run() { m.scan(true); assert!(m.diagnostics.is_empty(), "{:?}", m.diagnostics); let ids: Vec<_> = m.packages.keys().cloned().collect(); - assert_eq!(ids, vec!["community.mario-kart", "community.native-trainer"]); + assert_eq!(ids, vec!["community.mario-kart", "community.native-trainer", "community.zombie-shooter"]); for id in ids { m.enable(&id, true).unwrap(); for _ in 0..5 { diff --git a/docs/lua-modding.md b/docs/lua-modding.md index fc22cd12..f4378f6a 100644 --- a/docs/lua-modding.md +++ b/docs/lua-modding.md @@ -124,7 +124,8 @@ All vectors are 1-based Lua arrays `{x,y,z}` in engine world coordinates, metres | `sdk.input.action(id:integer)` | number from published native gameplay action 64–81; errors outside range | | `sdk.ui.text(key:string,text:string)` | nil; create/update owned plain screen text, max 1024 bytes; stable top-left rows sorted by owner/key, 19px font, 28px row spacing | | `sdk.scene.cube(key:string,position:Vec3,size:Vec3,color:Vec3)` | nil; create/update owned visual cuboid; no collision, grind attachment or rigid body | -| `sdk.scene.remove(key:string)` | nil; remove owned cube/text; absent key is a no-op; cannot address another mod's objects | +| `sdk.scene.held_cube(key:string,bone:string,offset:Vec3,size:Vec3,color:Vec3)` | nil; create/update an owned visual cuboid parented to an exact displayed skeleton bone; local-only, non-colliding and removed with its owner | +| `sdk.scene.remove(key:string)` | nil; remove owned world cube, held cube or text; absent key is a no-op; cannot address another mod's objects | | `sdk.time.elapsed` | active Update seconds since state creation, advanced after `on_update` | | `sdk.time.after(key:string,seconds:number,callback:function)` | nil; replace keyed one-shot timer; delay 0–86400 finite seconds, key 1–64 bytes, at most 64 timers | | `sdk.time.cancel(key:string)` | nil; remove timer; absent key is a no-op | @@ -133,7 +134,7 @@ All vectors are 1-based Lua arrays `{x,y,z}` in engine world coordinates, metres Timers run after `on_update`, within its same instruction budget and transaction. Due timer keys sort lexicographically. A timer is removed before invocation. Newly scheduled timers normally wait for the next Update (replacing an already due, not-yet-delivered key can affect that key's pending delivery). Pausing stops the timer clock. Errors disable the owner. `sdk.time.elapsed` and snapshot fields should be treated as read-only by authors. -Cube positions and teleport positions must be finite, each component within ±100000. Size components must be finite in `(0,100]`. RGB components must be finite in `[0,1]`, interpreted as sRGB. Heading must be finite. Text and cubes share an owner-local key namespace: switching type replaces the old object. At most 64 visual objects per mod; update existing keys to avoid churn. At most 128 commands per Lua invocation. Invalid arguments raise Lua errors; exceeding limits stops the mod. Commands return nil on successful **queuing**, not proof of engine completion. Host rejection appears in the mod's error panel and log. Teleports reject map loading, replay and busy actor-reset conditions. This is a free-position command, not a terrain-safe spawn finder: authors must select valid destinations. It does not set velocities directly or bypass the native deferred reset/camera-cut sequence. +Cube positions and teleport positions must be finite, each component within ±100000. World-cube size components must be finite in `(0,100]`. Held-cube offsets are limited to ±10 metres, held-cube sizes to `(0,10]`, and the bone must be an exact installed name reported by `sdk.animation.info()`. A held cube remains hidden until that bone has a displayed binding, then follows its final animated transform. RGB components must be finite in `[0,1]`, interpreted as sRGB. Heading must be finite. Text and cubes share an owner-local key namespace: switching type replaces the old object. At most 64 visual objects per mod; update existing keys to avoid churn. At most 128 commands per Lua invocation. Invalid arguments raise Lua errors; exceeding limits stops the mod. Commands return nil on successful **queuing**, not proof of engine completion. Host rejection appears in the mod's error panel and log. Teleports reject map loading, replay and busy actor-reset conditions. This is a free-position command, not a terrain-safe spawn finder: authors must select valid destinations. It does not set velocities directly or bypass the native deferred reset/camera-cut sequence. ### Snapshot and event payloads diff --git a/docs/multiplayer-mods.md b/docs/multiplayer-mods.md index 34f94cf5..d9d2680d 100644 --- a/docs/multiplayer-mods.md +++ b/docs/multiplayer-mods.md @@ -11,6 +11,7 @@ available. `Build.ps1` builds and stages both executables without launching them | SDK feature | Multiplayer behavior | | --- | --- | | `scene.cube` / `scene.remove` | Owner-scoped shared visual objects; updates, removal and late joins supported. Cubes remain non-colliding. | +| `scene.held_cube` | Local visual attachment to the owning player's displayed skeleton. It is not replicated by SDK 1. | | `vehicle.spawn/remove/reset/tune/control` | Matching package supplies the model and definition. Owner sends chassis pose, velocities, wheel state, controls, tuning and occupancy. Other peers render and collide with replicas. | | Vehicle entry/exit/steering | The final displayed native skeleton, including stance blending and arm steering, is replicated. Seated riders follow the same car render sample. | | Vehicle engine audio | Each occupied car has a smoothed engine voice; other players' cars attenuate with distance. | diff --git a/scripts/Build-Release.ps1 b/scripts/Build-Release.ps1 index d7c6b295..3238760a 100644 --- a/scripts/Build-Release.ps1 +++ b/scripts/Build-Release.ps1 @@ -18,6 +18,12 @@ try { # Link this invocation directly into private staging; never copy a generic cache EXE. & cargo rustc --release --locked --target x86_64-pc-windows-msvc --target-dir $TargetDirectory -p skate-game --bin skate3rust --no-default-features -- -C extra-filename= -o "$stage/skate3rust.exe" -C "link-arg=/PDB:$symbols/skate3rust.pdb" if ($LASTEXITCODE -ne 0) { throw 'Release compilation failed' } + & cargo build --release --locked --target x86_64-pc-windows-msvc --target-dir $TargetDirectory -p skate-steam-relay + if ($LASTEXITCODE -ne 0) { throw 'Steam relay compilation failed' } + & (Join-Path $PSScriptRoot 'Stage-SteamRelay.ps1') ` + -TargetDirectory $TargetDirectory ` + -BinDirectory $stage ` + -RelayExecutable (Join-Path $TargetDirectory 'x86_64-pc-windows-msvc/release/skate-steam-relay.exe') # rustc also emits a dep-info file beside -o; it contains local source paths. $depInfo = Join-Path $stage 'skate3rust.d' if (Test-Path -LiteralPath $depInfo) { Remove-Item -LiteralPath $depInfo } @@ -70,6 +76,9 @@ try { foreach ($name in @('skate3rust.exe', 'support/skate3setup.exe', 'support/skate3update.exe')) { $files[$name] = (Get-FileHash -LiteralPath (Join-Path $stage $name) -Algorithm SHA256).Hash.ToLower() } + foreach ($name in @('steam-relay/skate-steam-relay.exe', 'steam-relay/steam_api64.dll')) { + $files[$name] = (Get-FileHash -LiteralPath (Join-Path $stage $name) -Algorithm SHA256).Hash.ToLower() + } @{ schema = 1; repository = 'SK8-ENGINE/skate-3-rust-engine'; target = 'windows-x64' build = $build; tag = $tag; revision = (& git rev-parse HEAD).Trim(); files = $files diff --git a/scripts/Build.ps1 b/scripts/Build.ps1 index f8bc82c4..9bcce4aa 100644 --- a/scripts/Build.ps1 +++ b/scripts/Build.ps1 @@ -4,7 +4,7 @@ $ErrorActionPreference = 'Stop' Push-Location $ProjectRoot try { if (-not $StageOnly) { - & cargo build -p skate-game --locked --target-dir $TargetDirectory + & cargo build -p skate-game -p skate-steam-relay --locked --target-dir $TargetDirectory if ($LASTEXITCODE -ne 0) { throw 'Build failed; see the compiler output above.' } } $debugDirectory = Join-Path $TargetDirectory 'debug' @@ -56,6 +56,10 @@ try { } } } + & (Join-Path $PSScriptRoot 'Stage-SteamRelay.ps1') -TargetDirectory $TargetDirectory -BinDirectory $binDirectory + foreach ($name in @('steam-relay/skate-steam-relay.exe', 'steam-relay/steam_api64.dll')) { + $staged += @{name = $name; sha256 = (Get-FileHash -LiteralPath (Join-Path $binDirectory $name) -Algorithm SHA256).Hash} + } $staged | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $binDirectory 'manifest.json') -Encoding UTF8 Write-Host "Ready: $binDirectory/skate3rust.exe" } finally { Pop-Location } diff --git a/scripts/Stage-SteamRelay.ps1 b/scripts/Stage-SteamRelay.ps1 new file mode 100644 index 00000000..46e399e1 --- /dev/null +++ b/scripts/Stage-SteamRelay.ps1 @@ -0,0 +1,20 @@ +param( + [string]$TargetDirectory = (Join-Path (Split-Path $PSScriptRoot -Parent) 'target'), + [string]$BinDirectory = (Join-Path (Split-Path $PSScriptRoot -Parent) 'bin'), + [string]$RelayExecutable = '' +) +$ErrorActionPreference = 'Stop' +$relayExecutable = if ($RelayExecutable) { $RelayExecutable } else { Join-Path $TargetDirectory 'debug/skate-steam-relay.exe' } +if (-not (Test-Path -LiteralPath $relayExecutable)) { throw 'Build skate-steam-relay before staging.' } +$relayMetadata = (& cargo metadata --format-version 1 --locked --filter-platform x86_64-pc-windows-msvc) -join "`n" +if ($LASTEXITCODE -ne 0) { throw 'Could not locate the locked Steam SDK redistributable.' } +$sdk = [regex]::Match($relayMetadata, '"name":"steamworks-sys","version":"0\.13\.0".*?"manifest_path":("(?:[^"\\]|\\.)*")') +if (-not $sdk.Success) { throw 'Expected steamworks-sys 0.13.0 in Cargo metadata.' } +$sdkManifest = ConvertFrom-Json -InputObject $sdk.Groups[1].Value +$steamDll = Join-Path (Split-Path -Parent $sdkManifest) 'lib/steam/redistributable_bin/win64/steam_api64.dll' +if (-not (Test-Path -LiteralPath $steamDll)) { throw "Missing Steam SDK DLL: $steamDll" } +$relayDirectory = Join-Path $BinDirectory 'steam-relay' +New-Item -ItemType Directory -Path $relayDirectory -Force | Out-Null +Copy-Item -LiteralPath $relayExecutable -Destination (Join-Path $relayDirectory 'skate-steam-relay.exe') -Force +Copy-Item -LiteralPath $steamDll -Destination (Join-Path $relayDirectory 'steam_api64.dll') -Force +Write-Host "Steam relay ready: $relayDirectory" diff --git a/sdk/examples/zombie-shooter/README.md b/sdk/examples/zombie-shooter/README.md new file mode 100644 index 00000000..a0dd1398 --- /dev/null +++ b/sdk/examples/zombie-shooter/README.md @@ -0,0 +1,9 @@ +# Block Zombie Shooter + +Enable the mod from Escape > Mods. The skater carries a small block pistol. +Press controller RB or keyboard F to fire in the direction the skater faces. +Zombies are non-physical block targets that approach the player. Press R to restart. + +The gun is a local visual attached to the displayed RIGHTHAND bone. Zombie movement, +hits, health, score and waves are Lua-owned arcade rules; they do not alter native +Skate collision, ragdoll, scoring or animation state. diff --git a/sdk/examples/zombie-shooter/main.lua b/sdk/examples/zombie-shooter/main.lua new file mode 100644 index 00000000..beec847c --- /dev/null +++ b/sdk/examples/zombie-shooter/main.lua @@ -0,0 +1,141 @@ +local zombies = {} +local wave, score, health = 0, 0, 100 +local fire_was_down, restart_was_down = false, false + +local function remove_zombie(i) + sdk.scene.remove('zombie-body-' .. i) + sdk.scene.remove('zombie-head-' .. i) +end + +local function clear_zombies() + for i = 1, 12 do remove_zombie(i) end + zombies = {} +end + +local function equip() + -- These are bone-local primitives: body, barrel and grip form a tiny pistol. + sdk.scene.held_cube('gun-body', 'RIGHTHAND', {0.00, -0.10, 0.06}, {0.10, 0.28, 0.12}, {0.12, 0.12, 0.14}) + sdk.scene.held_cube('gun-barrel', 'RIGHTHAND', {0.00, -0.24, 0.06}, {0.07, 0.30, 0.08}, {0.20, 0.20, 0.23}) + sdk.scene.held_cube('gun-grip', 'RIGHTHAND', {0.00, -0.08, -0.02}, {0.08, 0.11, 0.18}, {0.08, 0.08, 0.09}) +end + +local function draw_zombie(i, zombie) + local hurt = zombie.hp < math.floor(sdk.settings.hits) + local body_color = hurt and {0.65, 0.18, 0.12} or {0.18, 0.55, 0.16} + sdk.scene.cube('zombie-body-' .. i, {zombie.x, zombie.y + 0.85, zombie.z}, {0.7, 1.3, 0.5}, body_color) + sdk.scene.cube('zombie-head-' .. i, {zombie.x, zombie.y + 1.75, zombie.z}, {0.55, 0.55, 0.55}, {0.35, 0.78, 0.28}) +end + +local function spawn_wave() + clear_zombies() + wave = wave + 1 + local p = sdk.player.read().position + local count = math.floor(sdk.settings.zombies) + for i = 1, count do + local angle = (i / count) * math.pi * 2 + wave * 0.37 + local radius = 12 + (i % 3) * 3 + zombies[i] = { + x = p[1] + math.sin(angle) * radius, + y = p[2], + z = p[3] + math.cos(angle) * radius, + hp = math.floor(sdk.settings.hits), + attack = 0, + } + draw_zombie(i, zombies[i]) + end +end + +local function hud(message) + sdk.ui.text('zombie-hud', string.format('ZOMBIES | health %d | score %d | wave %d', health, score, wave)) + sdk.ui.text('zombie-help', message or 'RB / F: shoot | R: restart | Shots travel where the skater faces') +end + +local function restart() + score, health, wave = 0, 100, 0 + spawn_wave() + hud() +end + +local function shoot() + if health <= 0 then return end + local player = sdk.player.read() + if player.bailing then return end + local dx, dz = math.sin(player.heading), math.cos(player.heading) + local best, best_distance + for i, zombie in pairs(zombies) do + local x, z = zombie.x - player.position[1], zombie.z - player.position[3] + local forward = x * dx + z * dz + if forward > 0 and forward < 35 then + local side_squared = math.max(0, x * x + z * z - forward * forward) + if side_squared < 0.75 * 0.75 and (not best_distance or forward < best_distance) then + best, best_distance = i, forward + end + end + end + sdk.scene.cube('muzzle-flash', { + player.position[1] + dx * 1.1, + player.position[2] + 1.25, + player.position[3] + dz * 1.1, + }, {0.13, 0.13, 0.13}, {1.0, 0.65, 0.1}) + sdk.time.after('muzzle-flash', 0.06, function() sdk.scene.remove('muzzle-flash') end) + if best then + local zombie = zombies[best] + zombie.hp = zombie.hp - 1 + if zombie.hp <= 0 then + remove_zombie(best) + zombies[best] = nil + score = score + 100 + else + draw_zombie(best, zombie) + end + end +end + +local function update() + local fire_down = sdk.input.down('KeyF') or sdk.input.action(73) > 0.5 + if fire_down and not fire_was_down then shoot() end + fire_was_down = fire_down + local restart_down = sdk.input.down('KeyR') + if restart_down and not restart_was_down then restart() end + restart_was_down = restart_down + if health <= 0 then hud('GAME OVER | Press R to restart') else hud() end +end + +local function fixed(event) + if health <= 0 then return end + local p = sdk.player.read().position + local alive = 0 + for i, zombie in pairs(zombies) do + alive = alive + 1 + local x, z = p[1] - zombie.x, p[3] - zombie.z + local distance = math.sqrt(x * x + z * z) + if distance > 0.001 then + local step = math.min(distance, sdk.settings.speed * event.dt) + zombie.x = zombie.x + x / distance * step + zombie.z = zombie.z + z / distance * step + end + zombie.y = p[2] + zombie.attack = math.max(0, zombie.attack - event.dt) + if distance < 1.25 and zombie.attack <= 0 then + health = math.max(0, health - 10) + zombie.attack = 0.75 + end + draw_zombie(i, zombie) + end + if alive == 0 then spawn_wave() end + if health <= 0 then clear_zombies() end +end + +return { + on_load = function() equip(); restart(); sdk.log('Block Zombie Shooter loaded') end, + on_update = update, + on_fixed_update = fixed, + on_settings = function() restart() end, + on_event = function(event) + if event.name == 'world_changed' then + fire_was_down, restart_was_down = false, false + equip() + restart() + end + end, +} diff --git a/sdk/examples/zombie-shooter/mod.json b/sdk/examples/zombie-shooter/mod.json new file mode 100644 index 00000000..b6a5ca2d --- /dev/null +++ b/sdk/examples/zombie-shooter/mod.json @@ -0,0 +1,38 @@ +{ + "id": "community.zombie-shooter", + "api": 1, + "name": "Block Zombie Shooter", + "version": "1.0.0", + "author": "Skate Rust Engine", + "description": "A tiny arcade shooter. The skater holds a block pistol; press RB or F to shoot approaching block zombies.", + "entry": "main.lua", + "settings": { + "zombies": { + "type": "number", + "label": "Zombies per wave", + "description": "Number of block zombies spawned in each wave.", + "default": 6, + "min": 1, + "max": 12, + "step": 1 + }, + "speed": { + "type": "number", + "label": "Zombie speed", + "description": "Movement speed in metres per second.", + "default": 1.5, + "min": 0.25, + "max": 5, + "step": 0.25 + }, + "hits": { + "type": "number", + "label": "Hits per zombie", + "description": "Shots required to remove one zombie.", + "default": 2, + "min": 1, + "max": 5, + "step": 1 + } + } +} diff --git a/sdk/skate.lua b/sdk/skate.lua index b8c238bc..e8ad03bd 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -81,7 +81,13 @@ function sdk.ui.text(key, text) end ---@param size Vec3 Each component >0 and <=100 metres. ---@param color Vec3 Each sRGB component in 0..1. function sdk.scene.cube(key, position, size, color) end ----@param key string Remove owner's text or cube; absent is a no-op. +---@param key string Owner-local stable visual key. +---@param bone string Exact installed skeleton bone name, e.g. RIGHTHAND. +---@param offset Vec3 Local metres in the selected bone frame, each component within ±10. +---@param size Vec3 Each component >0 and <=10 metres. +---@param color Vec3 Each sRGB component in 0..1. +function sdk.scene.held_cube(key, bone, offset, size, color) end +---@param key string Remove owner's text, world cube, or held cube; absent is a no-op. function sdk.scene.remove(key) end ---@param key string Timer key, 1..64 bytes. Existing timer is replaced. ---@param seconds number Finite delay 0..86400 active Update seconds.