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..