From b60e0e84374a9fcde9e4e1589dd995bb1891db9a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 22:26:34 -0700 Subject: [PATCH] Grab and throw in the park, toss empty guns, ink outlines, SDF heads, clean audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X+Y (Q+E) in the park grabs: in the air on a board it holds the deck; near someone it lifts them in front of you, unable to act, and letting go throws them. Held bodies are named by kind and pad so rollback snapshots stay plain data, and a holder who drops out releases what it held. A gun that runs dry leaves the hand a beat after its last round as a tumbling, arcing throw that stuns instead of wounds. Metal renders the world offscreen and draws one ink line wherever depth breaks (a second difference, so slopes stay clean): SDF figures, meshes, props and the skyline share a single outline. The loose cartoon's head is now a shaded SDF sphere with its drawn face and hairline on top. The park's supplies (karts, axes, chalk) draw with the world instead of from inside the HUD tray. Keycap verbs carry a shadow. Mac audio: sounds take an idle voice instead of queueing behind a 2.2 s pad (late hits), every buffer fades in and out over 5 ms, and a peak limiter sits after the mix (clipping) — the pops. A fighter stripped of every part no longer crashes its ragdoll's impulse. Co-Authored-By: Claude Opus 5.5 --- apple/oskiewar-mac/project.yml | 1 + .../Oskiewar.xcodeproj/project.pbxproj | 4 + apple/oskiewar/Sources/InkOutlines.swift | 71 ++++++++++ apple/oskiewar/Sources/MetalSceneView.swift | 33 ++++- xbox/live/oskiewar.js | 124 ++++++++++++++++-- xbox/live/tests/pool-playground.test.mjs | 26 +++- xbox/macos-native/main.swift | 35 ++++- xbox/tools/build-macos-app.sh | 1 + 8 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 apple/oskiewar/Sources/InkOutlines.swift diff --git a/apple/oskiewar-mac/project.yml b/apple/oskiewar-mac/project.yml index 6d28ae54a1..52abbf27d1 100644 --- a/apple/oskiewar-mac/project.yml +++ b/apple/oskiewar-mac/project.yml @@ -61,6 +61,7 @@ targets: # come along and collide with main.swift. - path: ../oskiewar/Sources/MetalSceneView.swift - path: ../oskiewar/Sources/SdfFigures.swift + - path: ../oskiewar/Sources/InkOutlines.swift - path: ../oskiewar/Sources/GlyphAtlas.swift # NativeGameHost.start() reads these back as "live/oskiewar.js" and diff --git a/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj b/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj index f37964136b..af64299f85 100644 --- a/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj +++ b/apple/oskiewar/Oskiewar.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0DAD924019F446F88876E94A /* InkOutlines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE2644F54ED6192F647BA1B /* InkOutlines.swift */; }; 1B43BAF5E41E93E3D9208197 /* product-analytics.mjs in Resources */ = {isa = PBXBuildFile; fileRef = A5CDAFE515D4323680797051 /* product-analytics.mjs */; }; 1BB115B2B2EC50D3FFBD88D4 /* scene3d-webgl.mjs in Resources */ = {isa = PBXBuildFile; fileRef = BF7515E8A95A1944C5424BD3 /* scene3d-webgl.mjs */; }; 21F3367D32F88CB3B2A52859 /* oskiewar-sfx.mjs in Resources */ = {isa = PBXBuildFile; fileRef = 2E725A92F239CBD5114A9A1E /* oskiewar-sfx.mjs */; }; @@ -34,6 +35,7 @@ 179EE75D06881E64B8C2C164 /* ComicRelief-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ComicRelief-Regular.ttf"; sourceTree = ""; }; 26C7F4ADC37CB30558AAE696 /* oskiewar-midi.mjs */ = {isa = PBXFileReference; path = "oskiewar-midi.mjs"; sourceTree = ""; }; 2E725A92F239CBD5114A9A1E /* oskiewar-sfx.mjs */ = {isa = PBXFileReference; path = "oskiewar-sfx.mjs"; sourceTree = ""; }; + 3EE2644F54ED6192F647BA1B /* InkOutlines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InkOutlines.swift; sourceTree = ""; }; 474C7E2A33BAAEDAEED939DF /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 4C7C4D62E6C706C3AB30BB3B /* frame-driver.mjs */ = {isa = PBXFileReference; path = "frame-driver.mjs"; sourceTree = ""; }; 4E537A6D78CBFE2DD45EEA57 /* SdfFigures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SdfFigures.swift; sourceTree = ""; }; @@ -58,6 +60,7 @@ isa = PBXGroup; children = ( 59BD48D8076F347B7FF9A774 /* GlyphAtlas.swift */, + 3EE2644F54ED6192F647BA1B /* InkOutlines.swift */, 812FA65365747604E58B98E7 /* MetalSceneView.swift */, C43902C050551AD2D6DA0EF9 /* OskiewarApp.swift */, 4E537A6D78CBFE2DD45EEA57 /* SdfFigures.swift */, @@ -220,6 +223,7 @@ buildActionMask = 2147483647; files = ( B93964796DBF64A171DD9439 /* GlyphAtlas.swift in Sources */, + 0DAD924019F446F88876E94A /* InkOutlines.swift in Sources */, 7D438ADCB6BECAD5D0E6C8F7 /* MetalSceneView.swift in Sources */, C0C46FBB6805F1A1E055FED7 /* OskiewarApp.swift in Sources */, B2694D04A86822B4F7712ABD /* SdfFigures.swift in Sources */, diff --git a/apple/oskiewar/Sources/InkOutlines.swift b/apple/oskiewar/Sources/InkOutlines.swift new file mode 100644 index 0000000000..f51b0610d2 --- /dev/null +++ b/apple/oskiewar/Sources/InkOutlines.swift @@ -0,0 +1,71 @@ +import MetalKit + +/// One ink line for the whole picture. The scene renders offscreen; this pass +/// copies it to the drawable and draws ink wherever depth jumps — a body in +/// front of a wall, a ramp's lip against the floor, the park against the sky — +/// so SDF figures, triangle meshes and props share a single outline instead of +/// each carrying its own. A second difference (left + right − 2·centre) marks +/// discontinuities and ignores smooth slopes, which a plain gradient would +/// shade in as false lines at grazing angles. +final class InkOutlines { + let pipeline: MTLRenderPipelineState + private(set) var color: MTLTexture? + private(set) var depth: MTLTexture? + + init(device: MTLDevice) throws { + let library = try device.makeLibrary(source: Self.source, options: nil) + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "ink_vertex") + descriptor.fragmentFunction = library.makeFunction(name: "ink_fragment") + descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm + descriptor.depthAttachmentPixelFormat = .depth32Float + pipeline = try device.makeRenderPipelineState(descriptor: descriptor) + } + + /// Offscreen targets matching the drawable; rebuilt only on resize. + func targets(device: MTLDevice, size: CGSize) -> (MTLTexture, MTLTexture)? { + let width = max(1, Int(size.width)), height = max(1, Int(size.height)) + if let color, let depth, color.width == width, color.height == height { return (color, depth) } + let colorDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .bgra8Unorm, + width: width, height: height, mipmapped: false) + colorDescriptor.usage = [.renderTarget, .shaderRead] + colorDescriptor.storageMode = .private + let depthDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .depth32Float, + width: width, height: height, mipmapped: false) + depthDescriptor.usage = [.renderTarget, .shaderRead] + depthDescriptor.storageMode = .private + guard let newColor = device.makeTexture(descriptor: colorDescriptor), + let newDepth = device.makeTexture(descriptor: depthDescriptor) else { return nil } + color = newColor; depth = newDepth + return (newColor, newDepth) + } + + static let source = """ + #include + using namespace metal; + struct InkRaster { float4 position [[position]]; }; + vertex InkRaster ink_vertex(uint id [[vertex_id]]) { + float2 p = float2(float((id << 1) & 2), float(id & 2)); + InkRaster out; out.position = float4(p * 2.0 - 1.0, 0.0, 1.0); return out; + } + fragment float4 ink_fragment(InkRaster in [[stage_in]], + texture2d scene [[texture(0)]], + depth2d depth [[texture(1)]], + constant float& scale [[buffer(0)]]) { + uint2 size = uint2(scene.get_width(), scene.get_height()); + int2 p = int2(in.position.xy); + int w = max(1, int(round(scale))); + auto at = [&](int2 q) { return depth.read(uint2(clamp(q, int2(0), int2(size) - 1))); }; + float c = at(p); + float3 color = scene.read(uint2(p)).rgb; + // HUD furniture sits in front of everything (depth near 0): leave it be. + if (c < 0.02) return float4(color, 1.0); + float horizontal = abs(at(p + int2(w, 0)) + at(p - int2(w, 0)) - 2.0 * c); + float vertical = abs(at(p + int2(0, w)) + at(p - int2(0, w)) - 2.0 * c); + float edge = max(horizontal, vertical); + // ~40 world units of depth break at the game's depth scale. + float ink = smoothstep(0.0012, 0.0030, edge); + return float4(mix(color, float3(20.0, 17.0, 28.0) / 255.0, ink), 1.0); + } + """ +} diff --git a/apple/oskiewar/Sources/MetalSceneView.swift b/apple/oskiewar/Sources/MetalSceneView.swift index 2cacdafe70..cb7d603d16 100644 --- a/apple/oskiewar/Sources/MetalSceneView.swift +++ b/apple/oskiewar/Sources/MetalSceneView.swift @@ -57,6 +57,9 @@ final class MetalSceneView: MTKView, MTKViewDelegate { /// Raymarched figures; nil when the device refused the pipeline, and the /// piece then draws triangles (sdfFigure returns false). private let sdf: SdfFigures? + /// The shared ink line; nil falls back to drawing straight to the screen. + private let ink: InkOutlines? + var inkOutlines = true private let uploads = (0..<3).map { _ in Uploads() } private let inFlight = DispatchSemaphore(value: 3) private var uploadIndex = 0 @@ -143,6 +146,7 @@ final class MetalSceneView: MTKView, MTKViewDelegate { fatalError("Metal scene pipeline failed: \(error)") } sdf = try? SdfFigures(device: device) + ink = try? InkOutlines(device: device) glyphAtlas = GlyphAtlas(device: device) do { glyphPipeline = try GlyphAtlas.pipeline(device: device) @@ -346,7 +350,22 @@ final class MetalSceneView: MTKView, MTKViewDelegate { pass.colorAttachments[0].loadAction = .clear pass.depthAttachment.clearDepth = 1 pass.depthAttachment.loadAction = .clear - guard let encoder = command.makeRenderCommandEncoder(descriptor: pass) else { return } + // With ink outlines the world renders offscreen first; the drawable + // then receives it through the outline pass, and text goes on last. + let offscreen = inkOutlines ? ink?.targets(device: device!, size: drawableSize) : nil + var scenePass = pass + if let (color, depth) = offscreen { + scenePass = MTLRenderPassDescriptor() + scenePass.colorAttachments[0].texture = color + scenePass.colorAttachments[0].loadAction = .clear + scenePass.colorAttachments[0].storeAction = .store + scenePass.colorAttachments[0].clearColor = clear + scenePass.depthAttachment.texture = depth + scenePass.depthAttachment.loadAction = .clear + scenePass.depthAttachment.storeAction = .store + scenePass.depthAttachment.clearDepth = 1 + } + guard var encoder = command.makeRenderCommandEncoder(descriptor: scenePass) else { return } if !vertices.isEmpty { _ = vertices.withUnsafeBytes { bytes in memcpy(vertexBuffer.contents(), bytes.baseAddress!, bytes.count) @@ -400,6 +419,18 @@ final class MetalSceneView: MTKView, MTKViewDelegate { instanceCount: run.count) } } + if let ink, let (color, depth) = offscreen { + encoder.endEncoding() + guard let screen = command.makeRenderCommandEncoder(descriptor: pass) else { return } + encoder = screen + var scale = Float(drawableSize.width / max(1, stage.width)) + encoder.setRenderPipelineState(ink.pipeline) + encoder.setDepthStencilState(nil) + encoder.setFragmentTexture(color, index: 0) + encoder.setFragmentTexture(depth, index: 1) + encoder.setFragmentBytes(&scale, length: MemoryLayout.stride, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) + } // Text last and depth-free: the HUD is not in the world, it is over it. if !glyphs.isEmpty { let capacity = glyphBuffer.length / MemoryLayout.stride diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 56cbf6c03e..80e6bbcc18 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -2225,7 +2225,7 @@ const commandFadeUs = 1900000; // PunchL rides last: it marks a punch thrown with the left hand (X, or Q), // so the striking arm — which is also the hitbox — is part of the input. const replayButtons = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", - "A", "B", "X", "Y", "PunchL"]; + "A", "B", "X", "Y", "PunchL", "Grab"]; let cameraCenter = (worldLeft + worldRight) / 2; let cameraWidth = worldRight - worldLeft; let cameraCenterY = floorY - cameraWidth / cameraAspect / 2; @@ -5859,8 +5859,8 @@ function translateButtons(index, down, seat = index) { if (s.holdX > 0) s.holdX--; if (s.holdY > 0) s.holdY--; if (s.latch === "bubble" && A && X) add(park ? "RightShoulder" : "X"); - // The 2D fight's grab is its internal A+B; the park has no hold yet. - if (s.latch === "grab" && X && Y && !park) { add("A"); add("B"); } + // The 2D fight's grab is its internal A+B; the park reads Grab (parkHold). + if (s.latch === "grab" && X && Y) { if (park) add("Grab"); else { add("A"); add("B"); } } if (kick) add(park ? (vehicle ? "ArrowUp" : "Y") : "A"); if (left || right) { add(gun || (right && !park && heldItem(p)) ? "Y" : "B"); @@ -8277,7 +8277,7 @@ function blendRoundViewerStates(from, to, t, seconds) { // vocabulary, no system keys. View and Menu stay local: a rival must never // toggle the host's debug overlay or send their game back to the title. const versusInputButtons = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", - "A", "B", "X", "Y", "LeftShoulder", "RightShoulder", "PunchL"]; + "A", "B", "X", "Y", "LeftShoulder", "RightShoulder", "PunchL", "Grab"]; // The buttons of the last frame the wire actually took, held apart from the // whole frame so a press can be told from a stick twitch. let versusInputLastDown = "[]"; @@ -9947,6 +9947,50 @@ function parkFightAxis(p, rival) { } return p.poolFightAxis; } +// Grab and hold (X+Y, Q+E): in the air on a board it grabs the deck; near +// someone it lifts them in front of you, where they cannot act, and letting +// go throws them. Held bodies are addressed by kind and pad, never by +// reference, so rollback snapshots stay plain data. +function parkHeldBody(p){ + if(p.holdPad===undefined)return null; + const body=p.holdKid?parkKids.find(k=>k.pad===p.holdPad):players[p.holdPad]; + return body?.alive&&body.heldBy===p.pad?body:null; +} +function parkHold(p,grab,now){ + const held=parkHeldBody(p); + if(p.holdPad!==undefined&&!held){p.holdPad=undefined;p.holdKid=false;} + const yaw=p.poolYaw||0,fx=Math.cos(yaw),fz=Math.sin(yaw); + if(grab){ + if(p.skateboard&&!p.grounded){p.skateGrab=true;return;} + if(held){ + held.x=p.x+fx*72;held.z=(p.z||0)+fz*72;held.y=p.y-18; + held.vx=held.vz=held.vy=0;held.grounded=true;held.attackKind=''; + held.hitStunUntil=Math.max(held.hitStunUntil||0,now+120000);held.poolYaw=yaw+Math.PI; + return; + } + if(p.holdTried)return; + p.holdTried=true; + let best=null,bestD=150; + for(const o of [...parkKids,...players]){ + if(o===p||!o.alive||o.headless||o.dummy||o.heldBy!==undefined)continue; + const d=Math.hypot(o.x-p.x,(o.z||0)-(p.z||0)); + if(dk.pad===p.sparringPartner); if(rival?.sparringPartner===p.pad){rival.sparringPartner=undefined;rival.poolFightAxis=null;rival.attackKind='';} @@ -10581,6 +10625,7 @@ function fireGun(player, input) { hitPlayers: 0, rocket: true }); while (grenades.length > 12) grenades.shift(); player.gunAmmo -= 1; + if (player.gunAmmo <= 0) player.gunThrowAt = now + 250000; player.nextGunShotAt = now + 650000; player.itemAction = "FIRE"; player.itemActionStartedAt = now; @@ -10606,6 +10651,7 @@ function fireGun(player, input) { } while (bullets.length > 24) bullets.shift(); player.gunAmmo -= shots; + if (player.gunAmmo <= 0) player.gunThrowAt = now + 250000; player.nextGunShotAt = now + (laser ? 340000 : smg ? 85000 : 220000); player.itemAction = "FIRE"; player.itemActionStartedAt = now; @@ -10971,7 +11017,12 @@ function updatePoolBullets(dt,now,combat){ const hit=sweptProjectileContact([{x1:from.x,y1:from.y,z1:from.z,x2:b.x,y2:b.y,z2:b.z,width:8,role:'bullet',part:'bullet'}],target,t); if(!hit||hit.separation>3)continue; b.life=0; - if(target.blocking)breakShield(target,now); + if(b.thrownGun){ + target.hitStunUntil=now+600000;target.attackKind=''; + target.vx=(target.vx||0)+b.vx*.25;target.vz=(target.vz||0)+(b.vz||0)*.25; + spawnImpact({x:b.x,y:b.y,z:b.z,life:.2,duration:.2,death:false,explosion:false}); + playDrum('snare',1,panAt(b.x,b.z)); + }else if(target.blocking)breakShield(target,now); else { const owner=players[b.owner]||players[0],pose=runnerWorldGeometry(target,t),segment=pose.segments[hit.segmentIndex]; spawnBlood(b.x,b.y,b.z,Math.sign(b.vx),14,1);addDecal({kind:'blood',x:b.x,z:b.z,size:12,stretch:1.4,angle:Math.atan2(b.vz,b.vx)}); @@ -10990,7 +11041,23 @@ function updatePoolBullets(dt,now,combat){ } for(let i=bullets.length-1;i>=0;i--)if(bullets[i].life<=0)bullets.splice(i,1); } +// An emptied gun leaves the hand a beat after its last round: a tumbling, +// arcing throw that stuns what it hits. It rides the bullet list, so netplay +// and ricochets treat it like any other projectile. +function throwEmptyGuns(now){ + for(const p of [...activePlayers(),...parkKids]){ + if(!p.gunThrowAt||now0)continue; + const yaw=poolOnly()?(p.poolYaw||0):p.facing>0?0:Math.PI,fx=Math.cos(yaw),fz=poolOnly()?Math.sin(yaw):0; + bullets.push({x:p.x+fx*40,y:p.y-110,z:(p.z||0)+fz*40,vx:fx*1500+(p.vx||0)*.5,vy:-420,vz:fz*1500, + owner:p.pad,life:1,thrownGun:true,spin:0,safeUntil:now+200000,spit:!poolOnly()}); + while(bullets.length>24)bullets.shift(); + p.pendingMoveLabel='TOSS';playDrum('block',.8,panPlayer(p)); + } +} function updateBullets(dt, now, combat = true) { + throwEmptyGuns(now); + for(const b of bullets)if(b.thrownGun&&b.life>0){b.spin=(b.spin||0)+dt*14;if(poolOnly()){b.vy+=1500*dt;b.life-=dt*.35;}} if(poolOnly()){updatePoolBullets(dt,now,combat);return;} for (const bullet of bullets) { if (bullet.life <= 0) continue; @@ -12898,6 +12965,12 @@ function updateTurn(player, now) { function updatePoolPlayer(p, pad, dt, now) { if(updateParkEntrance(p,pad,dt,now))return; + if(p.heldBy!==undefined){ + const holder=players[p.heldBy]; + if(holder?.alive&&holder.holdPad===p.pad&&!holder.holdKid)return; + p.heldBy=undefined; + } + parkHold(p,pad.down.includes('Grab'),now); if(p.sparringPartner!==undefined&&!parkFightRival(p))endParkSparring(p); const racePreviousX=p.x; const combatTarget=parkFightRival(p); @@ -15729,7 +15802,7 @@ class OskiewarRagdoll { distance(a,b){return Math.hypot(this.p[a*3]-this.p[b*3],this.p[a*3+1]-this.p[b*3+1],this.p[a*3+2]-this.p[b*3+2]);} link(a,b,min=this.distance(a,b),max=min){if(a===b)return;for(let j=0;j 0 ? "right-arm" : "left-arm"); const rearArm = actionArm === "right-arm" ? "left-arm" : "right-arm"; const actionLeg = player.facing > 0 ? "right-leg" : "left-leg"; @@ -18107,8 +18181,11 @@ function drawKeycapRun(entries, x, y, size, held, ink, revealAction = null) { : drawPadButton(label, cursor, y, size, held.includes(button)); } cursor += 8; - if (!revealAction || revealAction([cap, action, button])) + if (!revealAction || revealAction([cap, action, button])) { + // A soft shadow keeps the verb legible over a bright floor or sky. + typeWrite(action, cursor + 2, y + Math.round(size * .25) + 2, size, 12, 14, 26); typeWrite(action, cursor, y + Math.round(size * .25), size, ...ink); + } cursor += handleWidth(action, size) + 26; } return cursor; @@ -19279,14 +19356,15 @@ function drawLooseRunner(player,world,t,lod){ drawRunnerSymbol(player,c,(center,f,u,r)=>point(center,f+48,u,r),scale,disc); } } - const pony=ponytailStates.get(player); + const pony=!sdfBody&&ponytailStates.get(player); if(pony)for(let i=1;i=-.2){ drawHairline(player,h); const faceYaw=Math.atan2(forward.x*view.right.x+forward.z*view.right.z,front); @@ -19342,8 +19420,10 @@ function drawSdfRunner(player,world,t,loose=false){ } } if(loose){ - const pony=ponytailStates.get(player),hair=player.hairColor||[35,27,34],r=world.head.radius; + const pony=ponytailStates.get(player),hair=player.hairColor||[35,27,34],h=world.head,r=h.radius; if(pony)for(let i=1;i=0;i--)if(now>=crowdNotes[i].at){playSine(crowdNotes[i].hz,.045);crowdNotes.splice(i,1);} for(const kid of parkKids){ if(ragdollBodies.has(kid)&&!kid.alive&&!kid.headless)continue; + if(kid.heldBy!==undefined){const holder=players[kid.heldBy];if(!holder?.alive||holder.holdPad!==kid.pad)kid.heldBy=undefined;else continue;} + if(kid.thrownUntil>now&&!kid.grounded){ + const previous={x:kid.x,z:kid.z};kid.vy+=1600*dt;kid.x+=kid.vx*dt;kid.y+=kid.vy*dt;kid.z+=kid.vz*dt;boundParkBody(kid,previous); + const floor=poolFloorAt(kid.x,kid.z);if(kid.y>=floor){kid.y=floor;kid.grounded=true;kid.vx*=.3;kid.vz*=.3;kid.vy=0;playDrum('snare',.5,panAt(kid.x,kid.z));} + continue; + } if(kid.alive&&kid.sparringPartner!==undefined){ const rival=players[kid.sparringPartner],distance=rival?Math.hypot(rival.x-kid.x,(rival.z||0)-(kid.z||0)):Infinity; if(!rival?.alive||rival.sparringPartner!==kid.pad||rival.poolPipeEscapeUntil>now){endParkSparring(kid);} diff --git a/xbox/live/tests/pool-playground.test.mjs b/xbox/live/tests/pool-playground.test.mjs index 059b807789..ec157c8e43 100644 --- a/xbox/live/tests/pool-playground.test.mjs +++ b/xbox/live/tests/pool-playground.test.mjs @@ -13,7 +13,7 @@ function playground(legacyAudio=false){ bullets,updateBullets,gunPose,drawPoolGeometry,captureQuadMesh,drawRunner, boundParkBody,parkWindowWalls,brokenParkWindows,parkWindowShards,resetParkWindows, breakParkWindow,updateParkWindowShards,insidePark,parkLotMargin,updateCameraDoll,clearPoolCamera,updateMotorAudio,updateSkateAudio,balls, - updateSeatHeartbeat,updatePonytail,ponytailAnchor,ponytailStates,drawSpatialRunner,sampleCombatBoxes,sweptProjectileContact,damageParkCivilian,drawAeselFairy,sceneBoundsVisible,buildParkScene,parkActorVisible,figureLod,spectatorQrBox,returnToTitle,beginFreeskate,translateButtons,sessionName:()=>sessionName, + updateSeatHeartbeat,updatePonytail,ponytailAnchor,ponytailStates,drawSpatialRunner,sampleCombatBoxes,sweptProjectileContact,damageParkCivilian,drawAeselFairy,sceneBoundsVisible,buildParkScene,parkActorVisible,figureLod,spectatorQrBox,returnToTitle,beginFreeskate,translateButtons,parkHold,throwEmptyGuns,fireGun,sessionName:()=>sessionName, state:()=>({halfpipe:parkHalfPipe3D})};`)(()=>({monotonicUs:now}),()=>({platform:'web'}),noop,(event,seat,value,value2)=>{assert.ok(seat>=-1&&seat<=3);assert.ok(Number.isFinite(value)&&Number.isFinite(value2));},(name,gain,pan)=>{if(legacyAudio&&!['kick','snare','hat','block'].includes(name))throw new RangeError('unknown drum');drums.push({name,gain,pan});},noop,noop,noop,noop,noop,noop,(hz,gain)=>audio.push({hz,gain}),()=>audio.push({stop:true})); const p=api.players[0];Object.assign(p,{x:1080,z:0,y:api.poolFloorAt(1080,0),grounded:true,alive:true,dummy:false,skateboard:false,poolYaw:0,previous:[],spin:null,directionChanges:[],poolLastSteer:0}); api.step=(down=[],dt=1/60)=>{now+=dt*1e6;api.updatePlayer(p,{down,leftX:0,leftY:0},dt,api.clock());}; @@ -821,3 +821,27 @@ test('a punch strikes with the hand the input names',()=>{ for(const s of striking)assert.equal(s.part,arm,`${down} punches with ${arm}`); } }); +test('X+Y lifts the nearest body in front of you and letting go throws it',()=>{ + const a=playground(),p=a.players[0]; + a.resetParkKids?.(); + const kid=a.parkKids.find(k=>k.alive); + assert.ok(kid,'the park has kids'); + Object.assign(kid,{x:p.x+80,z:p.z||0,y:p.y,sparringPartner:undefined}); + p.poolYaw=0; + a.parkHold(p,true,a.now()); + assert.equal(kid.heldBy,p.pad); + a.parkHold(p,true,a.now()); + assert.ok(Math.abs(kid.x-(p.x+72))<1,'held in front'); + a.parkHold(p,false,a.now()); + assert.equal(kid.heldBy,undefined); + assert.ok(kid.vx>500&&kid.vy<0,'thrown forward and up'); +}); +test('an emptied gun leaves the hand as a thrown projectile',()=>{ + const a=playground(),p=a.players[0]; + Object.assign(p,{gunAmmo:1,gunMode:'PISTOL',poolYaw:0}); + a.fireGun(p,{down:[],leftX:0,leftY:0}); + assert.equal(p.gunAmmo,0);assert.ok(p.gunThrowAt>0); + a.step();for(let i=0;i<20;i++)a.step(); + a.throwEmptyGuns(a.now()); + assert.ok(a.bullets.some(b=>b.thrownGun),'the empty gun is in the air'); +}); diff --git a/xbox/macos-native/main.swift b/xbox/macos-native/main.swift index 2c49700a03..5e39949593 100644 --- a/xbox/macos-native/main.swift +++ b/xbox/macos-native/main.swift @@ -248,6 +248,9 @@ private final class NativeInput { private final class NativeAudio { private let engine = AVAudioEngine() private var players: [AVAudioPlayerNode] = [] + /// When each voice's scheduled audio runs out. A new sound takes a voice + /// that has gone quiet; queueing behind a 2.2 s chord pad made hits late. + private var busyUntil: [Double] = [] private var chordSamples: [[Double]] = [] private var cursor = 0 private let sampleRate = 48_000.0 @@ -269,6 +272,7 @@ private final class NativeAudio { engine.attach(player) engine.connect(player, to: engine.mainMixerNode, format: format) players.append(player) + busyUntil.append(0) } let voicings = [[45,60,64,67,71],[41,60,64,69,72],[48,59,62,64,67],[43,59,62,64,69]] chordSamples = voicings.map { notes in @@ -301,6 +305,16 @@ private final class NativeAudio { engine.connect(oscillator, to: engine.mainMixerNode, format: format) oscillatorNode = oscillator engine.mainMixerNode.outputVolume = 0.72 + // A peak limiter after the mix: chord pads, drums and the motor drone + // overlapping used to sum past full scale and clip, which popped. + let limiter = AVAudioUnitEffect(audioComponentDescription: AudioComponentDescription( + componentType: kAudioUnitType_Effect, componentSubType: kAudioUnitSubType_PeakLimiter, + componentManufacturer: kAudioUnitManufacturer_Apple, componentFlags: 0, componentFlagsMask: 0)) + engine.attach(limiter) + engine.disconnectNodeOutput(engine.mainMixerNode) + let output = engine.outputNode.inputFormat(forBus: 0) + engine.connect(engine.mainMixerNode, to: limiter, format: output) + engine.connect(limiter, to: engine.outputNode, format: output) try? engine.start() } @@ -367,15 +381,28 @@ private final class NativeAudio { buffer.frameLength = buffer.frameCapacity let leftGain = sqrt((1 - max(-1, min(1, pan))) * 0.5) * gain let rightGain = sqrt((1 + max(-1, min(1, pan))) * 0.5) * gain - for frame in 0..