From 7e0400e77fcb01322382dc4b3c27c272b132af9d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 19:39:29 -0700 Subject: [PATCH] Restore half-pipe flow, add neon chalk, spatial stereo and park combat --- xbox/live/oskiewar.js | 303 ++++++++++++++---- xbox/live/tests/pool-playground.test.mjs | 80 ++++- xbox/macos-native/PoolDecals.cpp | 4 + xbox/macos-native/PoolDecals.h | 1 + xbox/macos-native/PoolDecals.swift | 4 +- xbox/macos-native/main.swift | 6 + xbox/macos-native/tests/pool-decals.cpp | 4 + xbox/native-bios/App.cpp | 39 ++- xbox/native-bios/Package.appxmanifest | 2 +- xbox/native-bios/QuickJsEngine.cpp | 9 + .../tests/quickjs_engine_smoke.cpp | 2 + xbox/runtime/include/ac/decal_surface.hpp | 5 +- xbox/runtime/include/ac/runtime.hpp | 1 + 13 files changed, 373 insertions(+), 87 deletions(-) diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 9390e195f8..a35777ac3a 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -102,7 +102,7 @@ if (hostAnalytics) const hostAc = typeof ac === "function" ? ac : null; // Monotonic count of committed revisions to this piece (next revision included). -const buildVersion = 190; +const buildVersion = 192; const floorY = 1800; // Oskiewar now opens as a versus game. An ordinary web visit hosts a room — // the URL becomes the invitation — and until a friend opens it, all you can @@ -845,7 +845,7 @@ function drawIndoorHallGeometry(ground) { } function rasterDecalPatches(decal) { - const skid = decal.kind === "skid", arc = decal.kind === "arc"; + const skid = decal.kind === "skid" || decal.kind === "chalk", arc = decal.kind === "arc"; const cell = skid || arc || decal.kind === "wheelmark" ? 0 : decal.kind === "chip" || decal.kind === "ding" ? 2 : decal.kind === "blood" ? 3 : 1; const angle = decal.angle || 0, c = Math.cos(angle), s = Math.sin(angle); @@ -1610,7 +1610,7 @@ function parkHalfPipeHeight(x,z){ return height*taper*taper*(3-2*taper); } const parkDeckY=floorY-pool.depth; -const parkPalette={pool:[172,212,211],ground:[220,185,151], +const parkPalette={pool:[172,212,211],ground:[99,104,113], hills:[[167,188,207],[185,172,209],[171,200,170]],ramp:[218,155,138], wall:[210,202,190],base:[143,165,167],frame:[68,91,106],ceiling:[188,191,187], glass:[174,218,233],light:[255,244,196]}; @@ -1735,7 +1735,7 @@ function boundParkBody(p,previous={x:p.x,z:p.z||0}){ const radius=p.pad===undefined?18:25,height=p.pad===undefined?40:180; for(const {w,bay,u,old,dist} of parkWallContact(previous,{x:p.x,z:p.z||0},radius)){ const horizontal=u>bay*w.width+109+radius&&u<(bay+1)*w.width-109-radius; - const wt=parkDeckY-parkBuildingHeight+269,wb=parkDeckY-479; + const wt=parkDeckY-parkBuildingHeight+269,wb=parkDeckY-99; const impactY=p.y-height*.5,impact=horizontal&&impactY>wt&&impactY450||p.attackKind||p.spin?.rate>5))breakParkWindow(w,bay,{x:p.x,y:impactY,z:p.z},p); @@ -3234,6 +3234,7 @@ function clearPoolDecals(){ poolDecalCount=0; poolDecalsNative=typeof decalClear==='function'&&typeof decalStamp==='function'&& typeof decalMesh==='function'&&(nativeRetainedMeshes||typeof decalMeshUpload==='function')?decalClear():false; + if(poolOnly())seedStreetTexture(); } let decalSeed = 7; function decalRandom() { @@ -3245,16 +3246,17 @@ function addDecal(decal) { decal.at = runtime().monotonicUs; // The ground under a mark never moves: its height and slope are read once // here, not every frame for every corner. - decal.ground = terrainFloorAt(decal.x) - .9; + decal.ground = terrainFloorAt(decal.x,decal.z) - .9; const slope = terrainTangentAt(decal.x, 8), norm = Math.hypot(1, slope); decal.tx = 1 / norm; decal.ty = slope / norm; decal.shape = decalShape(decal); if(poolOnly()){ if(poolDecalsNative===null)clearPoolDecals(); if(poolDecalsNative){ - for(const patch of rasterDecalPatches(decal)) - decalStamp(...patch.uv,...patch.points.flatMap(p=>[ - (p.x-gridLeft+parkLotMargin)/(gridWidth+parkLotMargin*2)*2048,(p.z-worldNear+parkLotMargin)/(roomDepth+parkLotMargin*2)*2048])); + for(const patch of rasterDecalPatches(decal)){ + const args=[...patch.uv,...patch.points.flatMap(p=>[(p.x-gridLeft+parkLotMargin)/(gridWidth+parkLotMargin*2)*2048,(p.z-worldNear+parkLotMargin)/(roomDepth+parkLotMargin*2)*2048])]; + if(decal.color&&typeof decalTint==='function')decalTint(...args,...decal.color);else decalStamp(...args); + } poolDecalCount++; return decal; } @@ -3326,7 +3328,7 @@ function updateBlood(dt) { drop.vy += fallGravity() * dt; drop.x += drop.vx * dt; drop.y += drop.vy * dt; drop.z += drop.vz * dt; drop.z = clamp(drop.z, worldNear + 10, worldFar - 10); - const ground = terrainFloorAt(drop.x); + const ground = terrainFloorAt(drop.x,drop.z); if (drop.y < ground) continue; const sideways = Math.hypot(drop.vx, drop.vz); if (drop.kind === "wood") { @@ -5978,10 +5980,10 @@ const unitsPerMetre = 100; const mph = (unitsPerSecond) => unitsPerSecond / unitsPerMetre * 2.23694; // What the character is doing right now, as a verb, with its measurement: // mph for moving, rpm for spinning. -function statusVerb(rider, speed) { +function statusVerb(rider, speed, includeSpin=true) { const mphText = Math.round(mph(speed)) + " mph"; if (wallPressActive(rider)) return [rider.ducking ? "crouching against wall" : "pressing into wall", ""]; - if (rider.spin) return ["spinning", Math.round(spinRpm(rider)) + " rpm"]; + if (includeSpin && rider.spin) return ["spinning", Math.round(spinRpm(rider)) + " rpm"]; if (rider.swimming) return ["swimming", mphText]; if (rider.skateStall) return ["holding", ""]; if (rider.strafe) return ["strafing", ""]; @@ -5989,9 +5991,9 @@ function statusVerb(rider, speed) { if (isHeadOnly(rider)) return [speed < 30 ? "resting" : "rolling", speed < 30 ? "" : mphText]; if (rider.onewheel) return [speed < 30 ? "balancing" : "riding electric", speed < 30 ? "" : mphText]; if (rider.skateboard) return [speed < 30 ? "standing on the board" : "skating", speed < 30 ? "" : mphText]; - if (rider.ducking) return [Math.abs(rider.vx) > 40 ? "crawling" : "crouching", Math.abs(rider.vx) > 40 ? mphText : ""]; + if (rider.ducking) return [speed > 40 ? "crawling" : "crouching", speed > 40 ? mphText : ""]; if (speed < 30) return ["standing", ""]; - if (Math.abs(rider.vx) > walkSpeed + 120) return ["running", mphText]; + if (speed > walkSpeed + 120) return ["running", mphText]; return ["walking", mphText]; } function drawStatusVerb(rider, speed, ink) { @@ -9690,8 +9692,28 @@ function clearPoolCamera(position, subject) { function updateCameraDoll(dt, now) { if (poolOnly() && freeskateActive()) { const p=players[0],heading=p.poolYaw||0,speed=Math.hypot(p.vx,p.vz||0); - const idle=p.alive&&p.grounded&&speed<35&&Math.abs(p.vy||0)<40&& - !p.spin&&!p.attackKind&&Math.abs(p.inputX||0)<.1&&Math.abs(p.inputY||0)<.1; + const rival=parkKids.find(k=>k.pad===p.sparringPartner&&k.alive); + if(rival&&p.alive&&now>=(p.poolPipeEscapeUntil||0)){ + const dx=rival.x-p.x,dz=(rival.z||0)-(p.z||0),distance=Math.hypot(dx,dz)||1; + const target={x:(p.x+rival.x)/2,y:(p.y+rival.y)/2-90,z:((p.z||0)+(rival.z||0))/2}; + const gap=clamp(750+distance,900,1700),position=clearPoolCamera({x:target.x-dz/distance*gap,y:target.y-180,z:target.z+dx/distance*gap},target); + cameraDoll.track({target,position,width:Math.max(700,distance+450),perspective:.25,fov:55,roll:0},dt,5);return; + } + if(p.poolPipeLocked){ + const pipe=parkHalfPipe3D,air=Math.max(0,parkDeckY-pipe.radius-p.y); + let yaw=heading; + if(p.poolVert?.pipe){const progress=clamp((p.poolVert.speed+p.vy)/(2*p.poolVert.speed),0,1);yaw=p.poolVert.heading+Math.PI*(progress*progress*(3-2*progress));} + const error=Math.atan2(Math.sin(yaw-poolCameraYaw),Math.cos(yaw-poolCameraYaw));poolCameraYaw+=error*(1-Math.exp(-dt*2)); + const angle=poolCameraYaw+Math.PI*.6,radius=2400+air*.4; + const target={x:pipe.x,y:parkDeckY-pipe.radius*.5-air*.2,z:pipe.z}; + const position={x:pipe.x-Math.cos(angle)*radius,y:parkDeckY-1800-air*.6,z:pipe.z-Math.sin(angle)*radius}; + cameraCenter=target.x;cameraCenterY=target.y;cameraWidth=3200; + cameraDoll.track({target,position,width:3200,perspective:1,fov:65,roll:0},dt,5); + cameraDoll.position.y=Math.min(cameraDoll.position.y,parkDeckY-700); + cameraDoll.dirty=true;poolIdleSeconds=poolIdleClose=0;return; + } + + const idle=p.alive&&p.grounded&&speed<35&&Math.abs(p.vy||0)<40; poolIdleSeconds=idle?poolIdleSeconds+dt:0; poolIdleClose+=((poolIdleSeconds>2?1:0)-poolIdleClose)*(1-Math.exp(-dt*(idle?1.2:7))); const wall=poolDistance(p.x,p.z||0),vert=p.poolVert; @@ -10264,16 +10286,9 @@ function fireGun(player, input) { player.itemActionUntil = now + (laser ? 210000 : 170000); player.pendingMoveLabel = (laser ? "LASER " : smg ? "RUBBER SMG " : "FIRE ") + player.gunAmmo; - for (let shot = 0; shot < shots; shot++) { - playDrum(laser ? "whoosh" : smg ? "block" : "hat", - laser ? 1.35 : smg ? .62 + shot * .08 : 1.05, panPlayer(player)); - if (poolOnly() && !laser && !smg) { - playDrum("snare", .95, panPlayer(player)); - playDrum("kick", .65, panPlayer(player)); - } - playSine(laser ? 1180 : (smg ? 310 : 760) + shot * 55, - laser ? .13 : smg ? .055 : .08); - } + if(laser){playDrum('whoosh',1.1,panPlayer(player));playSine(1180,.13);} + else playDrum(smg?'smg-shot':'gunshot',smg?.72:1,panPlayer(player)); + player.lastButton=smg?'FIRING SMG':'FIRING PISTOL';player.lastButtonAt=now; emitSignal(laser ? "laser" : "bullet", player.pad, pose.dx, pose.dy); } @@ -10603,7 +10618,7 @@ function updatePoolBullets(dt,now,combat){ const hitY=lerp(from.y,b.y,fraction); if(hitYparkDeckY)continue; const inGlass=u>bay*w.width+109&&u<(bay+1)*w.width-109&& - hitY>parkDeckY-parkBuildingHeight+269&&hitYparkDeckY-parkBuildingHeight+269&&hitY=0?1:-1; @@ -10630,11 +10645,18 @@ function updatePoolBullets(dt,now,combat){ if(!hit||hit.separation>3)continue; b.life=0; if(target.blocking)breakShield(target,now); - else if(target.civilian){ - if(hit.headshot)popCivilianHead(target,players[b.owner]||players[0],runnerWorldGeometry(target,t),now); - else damageParkCivilian(target,players[b.owner]||players[0],b,now,2); - }else if(hit.headshot)killPlayer(target,b.owner,now,'SHOT'); - else applyBodyHit(target,hit.segmentIndex,b.x-b.vx,b.owner,now,1180,125); + 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)}); + if(hit.headshot){ + if(target.civilian)popCivilianHead(target,owner,pose,now);else killPlayer(target,b.owner,now,'SHOT'); + }else if(segment){ + bleed(target,segment,b.x-b.vx,2);addScar(target,segment); + detachPart(target,segment.part,pose,b.x-b.vx,now); + if(target.civilian){startRagdoll(target,b,[b.vx*.1,-150,b.vz*.1]);target.alive=false;target.attackKind='';} + else if(segment.part==='torso')killPlayer(target,b.owner,now,'SHOT'); + } + } break; } } @@ -11845,7 +11867,7 @@ function killPlayer(target, killerPad, now, cause = "KO") { target.stance = "HIT"; target.lastButton = cause; target.lastButtonAt = now; - if (killerPad !== target.pad) players[killerPad].score += 1; + if (killerPad !== target.pad && players[killerPad]) players[killerPad].score += 1; emitSignal(cause === "BALLED" ? "balled" : "ko", killerPad, target.pad, players[killerPad]?.score || 0); roundCause = cause; @@ -11951,6 +11973,19 @@ function couldStrike(player, now) { !player.attackHit && !player.blocking && now >= player.hitStunUntil; } +function strikeParkWindow(attacker,attacking){ + if(!poolOnly())return false; + for(const hit of attacking.hit){const c=hit.capsule;if(!c)continue; + const from={x:c.x1,y:c.y1,z:c.z1},to={x:c.x2,y:c.y2,z:c.z2}; + for(const {w,bay,u,fraction} of parkWallContact(from,to,(c.width||10)/2+4)){ + const y=lerp(from.y,to.y,fraction); + if(u>bay*w.width+109&&u<(bay+1)*w.width-109&&y>parkDeckY-parkBuildingHeight+269&&y samples[player.pad] ||= sampleCombatBoxes(player, now); - for (const attacker of players) { + for (const attacker of poolOnly()?[...players,...parkKids.filter(k=>k.sparringPartner!==undefined)]:players) { if (!couldStrike(attacker, now)) continue; const attacking = sampleOf(attacker); if (!attacking.hit.length) continue; @@ -11988,9 +12023,10 @@ function resolveMelee(now) { break; } if (attacker.attackHit) continue; + if(strikeParkWindow(attacker,attacking))continue; const targets=poolOnly()?[...activePlayers(),...parkKids]:[players[attacker.pad===0?1:0]]; for(const target of targets){ - if (target===attacker||!target.alive) continue; + if (target===attacker||!target.alive||(attacker.civilian&&target.pad!==attacker.sparringPartner)) continue; const contact = combatBoxContact(attacking, sampleOf(target)); if (contact?.separation <= 3) { attacker.attackHit = true; @@ -12518,6 +12554,20 @@ function updatePoolPlayer(p, pad, dt, now) { const axis=value=>Math.abs(value)>.18?Math.sign(value)*(Math.abs(value)-.18)/.82:0; const turn=Number(held.includes('ArrowRight'))-Number(held.includes('ArrowLeft')) || axis(pad.leftX||0); const throttle=Number(held.includes('ArrowUp'))-Number(held.includes('ArrowDown')) || axis(pad.leftY||0); + const tapKey=['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].find(pressed); + let directionDash=false; + if(tapKey){directionDash=p.poolTap?.key===tapKey&&now-p.poolTap.at<280000;p.poolTap=directionDash?null:{key:tapKey,at:now};} + const analogKey=Math.abs(turn)>.6?(turn>0?'right':'left'):Math.abs(throttle)>.6?(throttle>0?'forward':'back'):''; + if(analogKey&&analogKey!==p.poolAnalogKey){if(p.poolAnalogTap?.key===analogKey&&now-p.poolAnalogTap.at<280000){directionDash=true;p.poolAnalogTap=null;}else p.poolAnalogTap={key:analogKey,at:now};} + p.poolAnalogKey=analogKey; + if(directionDash){ + p.poolPipeLocked=false;p.poolPipeEscapeUntil=now+900000;p.poolVert=null;p.dashUntil=now+180000;p.poolRunTime=1.5; + const side=tapKey==='ArrowLeft'||analogKey==='left'?-1:tapKey==='ArrowRight'||analogKey==='right'?1:0; + if(side)p.poolYaw+=(side*Math.PI/2); + const sign=tapKey==='ArrowDown'||analogKey==='back'?-1:1; + p.vx=Math.cos(p.poolYaw||0)*2200*sign;p.vz=Math.sin(p.poolYaw||0)*2200*sign;p.vy=Math.min(p.vy,-480);p.grounded=false; + p.lastButton='DASH OUT';p.lastButtonAt=now;playDrum('whoosh',.5,panPlayer(p)); + } const steer=Math.sign(turn); if(steer&&steer!==p.poolLastSteer){ noteDirectionChange(p,now); @@ -12537,6 +12587,13 @@ function updatePoolPlayer(p, pad, dt, now) { const vx=p.vx,vz=p.vz||0,c=Math.cos(headingDelta),sn=Math.sin(headingDelta); p.vx=vx*c-vz*sn;p.vz=vx*sn+vz*c; } + const pipe=parkHalfPipe3D,pipeInside=Math.abs(p.x-pipe.x)=(p.poolPipeEscapeUntil||0)&&(p.poolPipeLocked||p.grounded&&speedBefore>160&&Math.abs(Math.cos(p.poolYaw))>.7)){ + if(!p.poolPipeLocked)p.poolPipeZ=p.z||0; + p.poolPipeLocked=true;p.poolYaw=Math.cos(p.poolYaw)>=0?0:Math.PI; + p.vz=0;p.z=p.poolPipeZ; + } const driveX=Math.cos(p.poolYaw),driveZ=Math.sin(p.poolYaw); p.previousY=p.y; p.inputX=turn; p.inputZ=throttle; p.inputY=held.includes('X')?-1:held.includes('A')?1:0; p.ducking=held.includes('X'); p.crouchBlend+=(Number(p.ducking)-(p.crouchBlend||0))*(1-Math.exp(-dt*14)); @@ -12549,17 +12606,18 @@ function updatePoolPlayer(p, pad, dt, now) { if(now>=p.attackUntil)p.attackKind=''; if(p.itemAction&&now>=p.itemActionUntil)p.itemAction=''; if(!blocking){ - if(pressed('B'))startMelee(p,'PUNCH',now); - if(pressed('Y')){ + if(pressed('B')&&!p.chalkColor)startMelee(p,'PUNCH',now); + if(pressed('Y')||(held.includes('Y')&&p.gunMode==='RUBBER SMG')){ if(p.gunAmmo>0&&now>=(p.nextGunShotAt||0)&&availableArm(p))fireGun(p,{horizontal:1,vertical:0}); else if(!p.gunAmmo)startMelee(p,'KICK',now); } } + if(throttle&&!p.ducking)p.poolRunTime=(p.poolRunTime||0)+dt;else p.poolRunTime=0; const mounted=p.skateboard; const dash=pressed('LeftShoulder')&&now>=(p.poolDashReadyAt||0); if(dash){ if(mounted)dismountSkateboard(p,now); - p.poolVert=null;p.poolDashReadyAt=now+450000;p.dashUntil=now+180000; + p.poolVert=null;p.poolPipeLocked=false;p.poolPipeEscapeUntil=now+900000;p.poolRunTime=1.5;p.poolDashReadyAt=now+450000;p.dashUntil=now+180000; p.vx=driveX*2200;p.vz=driveZ*2200;p.vy=Math.min(p.vy,-420);p.grounded=false; p.lastButton='DASH';p.lastButtonAt=now;emitSignal('dash',p.pad,1,0); }else if(pressed('A')&&(p.grounded||mounted)){ @@ -12578,7 +12636,7 @@ function updatePoolPlayer(p, pad, dt, now) { const grade=slope.x*driveX+slope.z*driveZ, tangent=Math.hypot(1,grade); let rolling=(p.vx*driveX+(p.vz||0)*driveZ)*tangent; rolling+=1800*grade/tangent*step; - const top=p.onewheel?(p.wheelTurbo?4800:3100):p.skateboard?2700:walkSpeed*(p.ducking?.38:1); + const top=p.onewheel?(p.wheelTurbo?4800:3100):p.skateboard?2700:p.ducking?walkSpeed*.38:walkSpeed*lerp(.55,1.65,clamp(((p.poolRunTime||0)-.3)/1.1,0,1)); if(throttle){ const target=top*throttle,carving=Math.sign(target)!==Math.sign(rolling); if(carving||Math.abs(rolling)350&&(before.x-lipX)*side<0&&(p.x-lipX)*side>=0){ + p.x=lipX;p.z=p.poolPipeZ;p.y=Math.min(previousY,poolFloorAt(p.x,p.z)); + p.grounded=false;p.vy=-Math.min(speed,skateTop);p.vx=p.vz=0;p.skateAirAt=now; + p.poolVert={x:p.x,z:p.z,nx:side,nz:0,speed:-p.vy,heading:p.poolYaw,pipe:true}; + emitSignal('skate-air',p.pad,Math.round(-p.vy),0);continue; + } const nextFloor=poolFloorAt(p.x,p.z); // Rolling over the rim leaves the deck. Do not teleport down the wall. if(nextFloor-previousY>Math.max(12,speed*step*1.5)){ @@ -12602,7 +12667,9 @@ function updatePoolPlayer(p, pad, dt, now) { slope=poolSlopeAt(p.x,p.z); const nextVy=slope.x*p.vx+slope.z*p.vz; if(p.skateboard&&speed>650&&previousVy < -100&&nextVy-previousVy>1800*step*1.5+3){ - p.grounded=false;p.y=Math.min(nextFloor,previousY+previousVy*step);p.vy=previousVy+1800*step;continue; + p.grounded=false;p.y=Math.min(nextFloor,previousY+previousVy*step);p.vy=previousVy+1800*step; + if(p.poolPipeLocked){p.poolVert={x:p.x,z:p.z,nx:Math.sign(p.x-pipe.x),nz:0,speed:-p.vy,heading:p.poolYaw,pipe:true};p.vx=p.vz=0;} + continue; } const bent=Math.hypot(p.vx,slope.x*p.vx+slope.z*p.vz,p.vz); if(bent>0){p.vx*=speed/bent;p.vz*=speed/bent;} @@ -12631,6 +12698,7 @@ function updatePoolPlayer(p, pad, dt, now) { const inward=(p.vy-slope.x*p.vx-slope.z*p.vz)/(1+slope.x*slope.x+slope.z*slope.z); if(inward>0){p.vx+=slope.x*inward;p.vz+=slope.z*inward;} p.landingSpeed=p.vy;p.y=floor;p.vy=slope.x*p.vx+slope.z*p.vz; + if(p.poolVert?.pipe){p.poolYaw=p.poolVert.nx>0?Math.PI:0;p.poolPipeLocked=true;} p.grounded=true;p.poolVert=null;p.landPoseUntil=now+110000; } } @@ -12640,6 +12708,7 @@ function updatePoolPlayer(p, pad, dt, now) { playDrum('clap',clamp((p.landingSpeed||0)/2400,.25,.8),panPlayer(p)); emitSignal('skate-land',p.pad,p.landingSpeed||0,0); } + updateChalk(p,held,now); const horizontal=Math.hypot(p.vx,p.vz||0); if(p.grounded&&!p.skateboard){ const forward=p.vx*driveX+(p.vz||0)*driveZ; @@ -15302,7 +15371,7 @@ function ragdollGeometry(body){ } function startRagdoll(player,point,impulse){ if(!poolOnly())return; - const pose=runnerWorldGeometry(player,(runtime().monotonicUs-startedAt)/1e6); + const source=runnerWorldGeometry(player,(runtime().monotonicUs-startedAt)/1e6),pose={...source,segments:source.segments.filter(s=>hasPart(player,s.part))}; const body=new OskiewarRagdoll(pose,[player.vx||0,player.vy||0,player.vz||0]); if(point&&impulse)body.impulse([point.x,point.y,point.z||0],impulse); // The park has eight civilians and at most two player bodies: bounded actor ownership. @@ -16602,6 +16671,7 @@ function sampleCombatBoxes(player, now, world = null) { { part: 'head', segmentIndex: -1, capsule:{x1:head.x,y1:head.y,z1:head.z,x2:head.x,y2:head.y,z2:head.z,width:headRadius*2} })); for (let i=0; i0){const gun=gunPose(player,runtime().monotonicUs);stroke3(gun.hand,gun.muzzle,7,[35,35,43]);} - drawHeldAxe(player,t);triangleDepth=saved; + drawHeldAxe(player,t);drawHeldChalk(player,t);triangleDepth=saved; } function drawSpatialRunner(player,world,t,lod=0){ @@ -18865,7 +18935,7 @@ function drawSpatialRunner(player,world,t,lod=0){ tube(pose.hand,pose.muzzle,4*scale,4*scale,[45,48,55],4); tube(pose.hand,{...pose.hand,y:pose.hand.y+14*scale},4*scale,3*scale,[30,29,34],4); } - drawHeldAxe(player,t);triangleDepth=saved; + drawHeldAxe(player,t);drawHeldChalk(player,t);triangleDepth=saved; } function drawRunner(player,t,showLabel=true){ @@ -22663,7 +22733,7 @@ function gamePaint() { drawRunner(renderable.item, t, showRunnerLabels); } } - if(poolOnly()){drawCerealMilk(t);drawAeselFairy(t);} + if(poolOnly()){drawCerealMilk(t);drawParkStereo();drawAeselFairy(t);} // Debug geometry shares the unfiltered overlay pass, behind screen UI. triangleDepth = -1.465; for(const p of activePlayers()){drawDebugHitboxes(p,t);if(debugHitboxes)drawAxeHitbox(p,t);} @@ -23029,11 +23099,47 @@ function drawMonowheel(p){ worldQuad(at(b,-8,-14),at(b,-8,14),at(b,-4,14),at(b,-4,-14),side>0?[237,247,251]:[249,62,62]); } } +const chalkColors=[{name:'PINK',rgb:[255,55,190]},{name:'CYAN',rgb:[40,244,255]},{name:'LIME',rgb:[145,255,45]},{name:'YELLOW',rgb:[255,242,55]},{name:'ORANGE',rgb:[255,133,35]},{name:'VIOLET',rgb:[192,99,255]}]; +const chalkPickups=[],parkAxes=[]; +function chalkTip(p){const a=(p.poolYaw||0)+(p.spin?.angle||0),x=p.x+Math.cos(a)*45-Math.sin(a)*30,z=(p.z||0)+Math.sin(a)*45+Math.cos(a)*30;return {x,z,y:poolFloorAt(x,z)-2};} +function updateChalk(p,held,now){ + p.chalkDrawing=!!p.chalkColor&&held.includes('B')&&p.grounded&&!p.swimming; + if(!p.chalkDrawing){p.chalkPrevious=null;return;} + const tip=chalkTip(p),previous=p.chalkPrevious; + if(previous){const distance=Math.hypot(tip.x-previous.x,tip.z-previous.z); + if(distance>=2&&distance<180)addDecal({kind:'chalk',x:previous.x,z:previous.z,x2:tip.x,z2:tip.z,size:4,color:p.chalkColor.rgb}); + } + p.chalkPrevious=tip; +} +function drawHeldChalk(p,t){ + if(!p.chalkColor||!p.alive)return; + const world=runnerWorldGeometry(p,t),arm=world.segments.find(s=>s.part===itemHand(p)&&/forearm$/.test(s.role));if(!arm)return; + const hand={x:arm.x2,y:arm.y2,z:arm.z2},tip=p.chalkDrawing?chalkTip(p):{x:hand.x+8,y:hand.y+16,z:hand.z}; + worldCapsule(hand.x,hand.y,hand.z,tip.x,tip.y,tip.z,6*projectionScaleAt(tip),p.chalkColor.rgb); +} +function seedStreetTexture(){ + if(typeof decalTint!=='function'||!poolDecalsNative)return; + const bounds={x:gridLeft-parkLotMargin,z:worldNear-parkLotMargin,w:gridWidth+parkLotMargin*2,h:roomDepth+parkLotMargin*2}; + const pix=(x,z)=>[(x-bounds.x)/bounds.w*2048,(z-bounds.z)/bounds.h*2048]; + for(let x=gridLeft-parkLotMargin;x0)continue; + const n=Math.abs(Math.sin(x*.173+z*.617)),gray=45+Math.round(n*33); + decalTint(0,0,128,128,...pix(x,z),...pix(x+180,z),...pix(x+180,z+180),...pix(x,z+180),gray,gray+2,gray+6);poolDecalCount++; + } +} function resetParkSupply(now){ + chalkPickups.length=0;parkAxes.length=0;for(const p of players){p.chalkColor=null;p.chalkPrevious=null;p.chalkDrawing=false;} + if(poolOnly()){ + chalkColors.forEach((color,i)=>{const x=pool.x-240+i*95,z=260;chalkPickups.push({x,z,y:poolFloorAt(x,z)-12,color,active:true});}); + for(let i=gunPickups.length-1;i>=0;i--)if(gunPickups[i].parkScatter)gunPickups.splice(i,1); + for(const [x,z,kind] of [[2400,-1100,'RUBBER SMG'],[4500,1300,'HANDGUN'],[7100,-800,'RUBBER SMG']])gunPickups.push({parkScatter:true,kind,x,z,y:poolFloorAt(x,z)-65,amount:kind==='RUBBER SMG'?90:24,active:true,startsActive:false,respawnAt:Infinity}); + for(const [x,z] of [[3900,-200],[6500,700]])parkAxes.push({x,z,y:poolFloorAt(x,z)-65,active:true}); + } if(halfpipeOnly()){ for(const p of [...gunPickups,...saberPickups,...grenadePickups])p.active=false; axePickup.active=false;for(const p of players){p.axeHeld=false;p.underPipe=false;} if(poolOnly()){ + for(const pickup of gunPickups)if(pickup.parkScatter)pickup.active=true; const pistol=gunPickups.find(p=>p.kind==='HANDGUN'); Object.assign(pistol,{active:true,amount:12,x:pool.x+160,z:100,y:poolFloorAt(pool.x+160,100)-65}); Object.assign(axePickup,{active:true,x:pool.x-170,z:-100,y:poolFloorAt(pool.x-170,-100)-65}); @@ -23052,6 +23158,8 @@ function resetParkSupply(now){ function updateParkSupply(dt,now){ if(!freeskateActive())return; if(poolOnly()){ + for(const chalk of chalkPickups)if(chalk.active)for(const p of activePlayers())if(p.alive&&availableArm(p)&&Math.hypot(p.x-chalk.x,(p.z||0)-chalk.z)<35&&Math.abs(p.y-chalk.y)<70){p.chalkColor=chalk.color;p.itemArm=availableArm(p);chalk.active=false;playDrum('hat',.25,panPlayer(p));break;} + for(const axe of parkAxes)if(axe.active)for(const p of activePlayers())if(p.alive&&availableArm(p)&&Math.hypot(p.x-axe.x,(p.z||0)-axe.z)<55&&Math.abs(p.y-axe.y-65)<100){p.axeHeld=true;p.itemArm=availableArm(p);axe.active=false;break;} if(axePickup.active)for(const p of activePlayers())if(p.alive&&availableArm(p)&& Math.hypot(p.x-axePickup.x,(p.z||0)-(axePickup.z||0))<65&&Math.abs(p.y-axePickup.y-65)<100){ p.axeHeld=true;p.itemArm=availableArm(p);axePickup.active=false;break; @@ -23084,6 +23192,8 @@ function updateParkSupply(dt,now){ function drawParkSupply(){ if(!freeskateActive() || (halfpipeOnly()&&!poolOnly()))return; if(axePickup.active){const p=projectPoint(axePickup.x,axePickup.y,axePickup.z||0),t=projectPoint(axePickup.x+30,axePickup.y-130,axePickup.z||0);const old=triangleDepth;triangleDepth=p.z;drawBigAxe(p.x,p.y,t.x,t.y,cameraScale());triangleDepth=old;} + for(const chalk of chalkPickups)if(chalk.active)worldCapsule(chalk.x-10,chalk.y,chalk.z,chalk.x+10,chalk.y,chalk.z,8*projectionScaleAt(chalk),chalk.color.rgb); + for(const axe of parkAxes)if(axe.active){const p=projectPoint(axe.x,axe.y,axe.z),top=projectPoint(axe.x+30,axe.y-130,axe.z),old=triangleDepth;triangleDepth=p.z;drawBigAxe(p.x,p.y,top.x,top.y,projectionScaleAt(axe));triangleDepth=old;} const d=parkSupply.drone; if(d){ worldCapsule(d.x-65,d.y,0,d.x+65,d.y,0,Math.max(2,12*cameraScale()),[63,70,87]); @@ -23312,8 +23422,11 @@ function seatHudReadout(p,now){ if(age>=0 && age<100000)return held; const speed=Math.hypot(p.vx,p.vy,p.vz||0)*gameSpeed; const idle=speed<30 && p.grounded && !p.spin; - const readout={at:now,idle, - measure:idle?Math.round(p.heartRate||68)+' bpm':p.spin?Math.round(spinRpm(p))+' rpm':Math.round(mph(speed))+' mph'}; + const metrics=[{text:Math.round(p.heartRate||68)+' bpm',color:[255,144,188]}]; + const speedValue=Math.round(mph(speed)),rpm=Math.round(p.spin?spinRpm(p):0); + if(speedValue)metrics.push({text:speedValue+' mph',color:[110,220,255]}); + if(rpm)metrics.push({text:rpm+' rpm',color:[255,215,80]}); + const readout={at:now,idle,metrics,measure:metrics.map(m=>m.text).join(' ')}; seatHudReadouts.set(p,readout); return readout; } @@ -23322,41 +23435,63 @@ function drawSeatPlayerHud(ink){ const safe=hudSafeRect(),old=triangleDepth,now=runtime().monotonicUs;triangleDepth=hudDepth; for(const p of activePlayers()){ if(p.dummy)continue; - const i=p.pad,{idle,measure}=seatHudReadout(p,now); + const i=p.pad,{idle,measure,metrics}=seatHudReadout(p,now); const accent=p.wheelTurbo?[209,129,255]:i?[174,161,255]:[255,144,188]; const size=42; - const heartRadius=idle?size*.23:0,heartSpace=idle?heartRadius*3+18:0; - const width=handleWidth(measure,size)+heartSpace; + const heartRadius=size*.23,heartSpace=heartRadius*3+18; + const gap=24,width=metrics.reduce((sum,m)=>sum+handleWidth(m.text,size),0)+gap*(metrics.length-1)+heartSpace; const solo=activePlayers().filter(p=>!p.dummy).length===1; const center=solo?viewCenterX():lerp(safe.left,safe.right,i?.72:.28); const x=clamp(center-width/2,safe.left,safe.right-width),y=safe.bottom-size-70; - seatHudText(measure,x,y,size,accent); - if(idle){ - const hx=x+handleWidth(measure,size)+heartSpace/2,hy=y+size*.75; + let cursor=x+heartSpace;for(const metric of metrics){seatHudText(metric.text,cursor,y,size,metric.color);cursor+=handleWidth(metric.text,size)+gap;} + { + const hx=x+heartSpace/2,hy=y+size*.75; drawSeatHeart(hx+5,hy+5,p,[9,12,24],heartRadius); - drawSeatHeart(hx,hy,p,accent,heartRadius); + drawSeatHeart(hx,hy,p,[255,144,188],heartRadius); } } triangleDepth=old; } function seatActionText(p,now){ + const speed=Math.hypot(p.vx,p.vz||0),states=[p.poolPipeLocked?'HALFPIPING':statusVerb(p,speed,false)[0].toUpperCase()]; + if(p.poolPipeLocked)states.push('LOCKED IN'); + if(p.chalkDrawing)states.push('DRAWING'); + if(p.sparringPartner!==undefined)states.push('SPARRING'); + if(now<(p.dashUntil||0))states.push(now<(p.poolPipeEscapeUntil||0)&&p.skateboard?'DASHING OUT':'DASHING'); + if(Math.abs(p.inputX||0)>.18)states.push(p.inputX>0?'TURNING RIGHT':'TURNING LEFT'); + if(p.spin)states.push('SPINNING'); + if(p.wheelTurbo)states.push('SUPER TURBO'); const recent=now-(p.lastButtonAt||0)<850000?p.lastButton:''; - const action=p.wheelTurbo?'SUPER TURBO':p.spin?'SPIN':recent&&!['NONE','LEFT','RIGHT','UP','DOWN'].includes(recent)?recent:statusVerb(p,Math.hypot(p.vx,p.vz||0))[0].toUpperCase(); + if(recent&&!['NONE','LEFT','RIGHT','UP','DOWN','SPIN','SUPER TURBO','SKATEBOARD','MONOWHEEL','DASH','DASH OUT'].includes(recent)&&!states.includes(recent))states.push(recent); const held=[]; - if(p.axeHeld)held.push('AXE');if(p.swordHeld)held.push('SWORD');if(p.gunAmmo>0)held.push('PISTOL'); + if(p.axeHeld)held.push('AXE');if(p.swordHeld)held.push('SWORD');if(p.gunAmmo>0)held.push(p.gunMode==='RUBBER SMG'?'SMG':'PISTOL'); + if(p.chalkColor)held.push(p.chalkColor.name+' CHALK'); if(p.heldBall>=0)held.push('BALL');if(p.heldPlayer>=0)held.push('PARTNER'); - return action+(held.length?' W/ '+held.join(' + '):''); + return states.join(' + ')+(held.length?' W/ '+held.join(' + '):''); +} +const actionColors={ + 'STANDING':[237,202,255],'WALKING':[145,232,179],'RUNNING':[71,236,147], + 'CRAWLING':[240,172,98],'CROUCHING':[220,167,255],'SPINNING':[255,215,80], + 'IN THE AIR':[110,220,255],'SWIMMING':[130,238,231],'SKATING':[167,197,255], + 'BALANCING':[199,167,255],'RIDING ELECTRIC':[203,155,255],'SUPER TURBO':[244,133,245], + 'TURNING LEFT':[179,174,255],'TURNING RIGHT':[143,207,255], + 'HALFPIPING':[132,222,255],'LOCKED IN':[164,255,134],'DASH OUT':[255,162,235],'DASHING':[98,255,170],'DASHING OUT':[255,162,235],'DRAWING':[255,161,244],'SPARRING':[255,144,123],'SMG':[143,208,244], + 'AXE':[255,160,100],'PISTOL':[115,208,255],'SWORD':[220,230,255],'BALL':[248,227,112], + 'PARTNER':[153,230,155],'PUNCH':[255,134,147],'KICK':[247,170,106],'DASH':[98,255,170], +}; +function seatActionRuns(p,now){ + return seatActionText(p,now).split(/( \+ | W\/ )/).map(text=>({text,color:chalkColors.find(c=>text===c.name+' CHALK')?.rgb||actionColors[text]||(/^[ +]| W\//.test(text)?[213,207,219]:[255,175,199])})); } function drawSeatAction(p){ if(shellMode!=="GAME"||parkSupply.ko||!p.alive||p.dummy)return; - const text=seatActionText(p,runtime().monotonicUs),size=42,safe=hudSafeRect(); + const runs=seatActionRuns(p,runtime().monotonicUs),size=42,safe=hudSafeRect(); const old=triangleDepth;triangleDepth=hudDepth; - const width=handleWidth(text,size),x=viewCenterX()-width/2; + const width=runs.reduce((sum,r)=>sum+handleWidth(r.text,size),0); + let x=viewCenterX()-width/2; const seats=activePlayers().filter(p=>!p.dummy),row=Math.max(0,seats.indexOf(p)); const y=safe.top+18+row*50; - const ink=p.wheelTurbo?[238,130,238]:p.spin?[255,215,0]:/DASH/.test(text)?[0,255,127]:/JUMP/.test(text)?[0,191,255]:p.pad===0?[255,105,180]:[135,206,250]; - typeWrite(text,x+3,y+3,size,9,12,24); - typeWrite(text,x,y,size,...ink);triangleDepth=old; + for(const run of runs){seatHudText(run.text,x,y,size,run.color);x+=handleWidth(run.text,size);} + triangleDepth=old; } function drawAxeHitbox(p,t){ @@ -23458,7 +23593,7 @@ function parkSightlineClear(from,to){ for(const {w,bay,u,fraction} of parkWallContact(from,to,0)){ const y=lerp(from.y,to.y,fraction); if(!brokenParkWindows.has(w.id+':'+bay)||u<=bay*w.width+109||u>=(bay+1)*w.width-109|| - y<=parkDeckY-parkBuildingHeight+269||y>=parkDeckY-479)return false; + y<=parkDeckY-parkBuildingHeight+269||y>=parkDeckY-99)return false; } for(let i=1;i<12;i++){ const t=i/12,x=lerp(from.x,to.x,t),z=lerp(from.z,to.z,t); @@ -23599,6 +23734,19 @@ function updateParkKids(dt,now){ for(let i=crowdNotes.length-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.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||distance>1300||rival.poolPipeEscapeUntil>now){if(rival)rival.sparringPartner=undefined;kid.sparringPartner=undefined;kid.attackKind='';} + else{ + if(now>=kid.attackUntil)kid.attackKind=''; + const dx=rival.x-kid.x,dz=(rival.z||0)-(kid.z||0),yaw=Math.atan2(dz,dx);kid.poolYaw=yaw;kid.facing=1;kid.grounded=true;kid.ducking=false; + const speed=distance>95?Math.min(380,(distance-95)*3):0,previous={x:kid.x,z:kid.z}; + kid.vx=Math.cos(yaw)*speed;kid.vz=Math.sin(yaw)*speed;kid.x+=kid.vx*dt;kid.z+=kid.vz*dt;kid.y=poolFloorAt(kid.x,kid.z); + kid.poolStridePhase=(kid.poolStridePhase||0)+speed*dt/240;boundParkBody(kid,previous); + if(distance<155&&now>=(kid.nextSparAt||0)&&now>=(kid.hitStunUntil||0)){kid.sparCount=(kid.sparCount||0)+1;startMelee(kid,kid.sparCount%3===0?'KICK':'PUNCH',now);kid.nextSparAt=now+1100000;} + continue; + } + } if(kid.headless){ const h=kid.looseHead;h.vy+=1100*dt;h.x+=h.vx*dt;h.y+=h.vy*dt;h.z=clamp(h.z+h.vz*dt,worldNear+30,worldFar-30); const floor=terrainFloorAt(h.x,h.z)-h.radius; @@ -23658,7 +23806,7 @@ function drawTurboParticles(){ function damageParkCivilian(kid,owner,at,now,damage){ kid.health=(kid.health??4)-damage;kid.hit=1;kid.startledUntil=now+900000; - kid.sparringPartner=owner.pad;kid.lastHitAt=now; + kid.sparringPartner=owner.pad;owner.sparringPartner=kid.pad;kid.lastHitAt=now; const dx=kid.x-owner.x,dz=(kid.z||0)-(owner.z||0),length=Math.hypot(dx,dz)||1; kid.x+=dx/length*20;kid.z+=dz/length*20;kid.patrolZ=kid.z; spawnImpact({x:at.x,y:at.y,z:at.z,life:.18,duration:.18,death:false,explosion:false}); @@ -23803,6 +23951,32 @@ function mainNativeCamera(){ return mainNativeCameraBuffer; } +const parkStereo={x:parkHalfPipe3D.x,z:-parkHalfPipe3D.run-130,beat:0}; +function stereoGain(p){return Math.pow(clamp(1-Math.hypot(p.x-parkStereo.x,(p.z||0)-parkStereo.z)/3600,0,1),2);} +function updateParkMusic(p,previous){ + if(previous===undefined||!poolOnly())return; + const gain=stereoGain(p),pan=panAt(parkStereo.x,parkStereo.z); + if(p.heartPhase.005){playDrum('kick',gain*.65,pan);if(parkStereo.beat%2===0)playDrum('bass',gain*.45,pan);if(parkStereo.beat%4===2)playDrum('snare',gain*.22,pan);} + } + if(previous<.5&&p.heartPhase>=.5&&gain>.005)playDrum('hat',gain*.22,pan); +} +function drawParkStereo(){ + if(!poolOnly())return; + const phase=players[0].heartPhase||0,pulse=Math.max(0,1-phase/.17); + for(const side of [-1,1]){ + const x=parkStereo.x+side*155,z=parkStereo.z,y=poolFloorAt(x,z),w=55,h=220; + const a={x:x-w,y,z:z-45},b={x:x+w,y,z:z-45},c={x:x+w,y:y-h,z:z-45},d={x:x-w,y:y-h,z:z-45}; + worldQuad(a,b,c,d,[32,34,42]);worldQuad(d,c,{...c,z:z+45},{...d,z:z+45},[52,56,65]); + for(const xx of [x-w,x+w])worldQuad({x:xx,y,z:z-45},{x:xx,y,z:z+45},{x:xx,y:y-h,z:z+45},{x:xx,y:y-h,z:z-45},[44,46,56]); + for(const [height,radius] of [[58,39],[150,30]])for(let i=0;i<16;i++){ + const p=j=>({x:x+Math.cos(j*Math.PI/8)*radius,y:y-height+Math.sin(j*Math.PI/8)*radius,z:z-47-pulse*3}); + worldTriangle({x,y:y-height,z:z-52-pulse*5},p(i),p(i+1),i%2?[64,68,78]:[54,58,70]); + } + worldCapsule(x-38,y-h+15,z-47,x+38,y-h+15,z-47,3*projectionScaleAt(d),[140,255,170]); + } +} function updateSeatHeartbeat(dt,now){ const realDt=dt/Math.max(.1,gameSpeed); for(const p of [...activePlayers(),...parkKids]){ @@ -23820,7 +23994,9 @@ function updateSeatHeartbeat(dt,now){ } for(const b of bullets)if(b.owner!==p.pad){const distance=Math.hypot(b.x-p.x,b.y-(p.y-90));danger=Math.max(danger,clamp(1-distance/950,0,1));} danger=Math.max(danger,Math.min(1,(p.hit||0)*2)); - const rest=62+(p.pad*7%19),effort=Math.min(1,Math.hypot(p.vx,p.vy)/1800),target=rest+danger*85+effort*25; + const rest=62+(p.pad*7%19),effort=Math.min(1,Math.hypot(p.vx,p.vy,p.vz||0)/1800); + const excitement=Math.max(p.spin?Math.min(1,spinRpm(p)/180):0,p.attackKind?.length?1:0,!p.grounded?.55:0,p.wheelTurbo?1:0,now-(p.lastButtonAt||0)<600000&&/PUNCH|KICK|DASH|JUMP|SHOT|WACK/.test(p.lastButton||'')?.8:0); + const target=Math.min(210,rest+danger*65+effort*55+excitement*35); p.heartRate=(p.heartRate||rest)+(target-(p.heartRate||rest))*(1-Math.exp(-realDt*(target>(p.heartRate||rest)?1.8+(p.pad%3)*.2:.23+(p.pad%4)*.04))); const previousHeartPhase=p.heartPhase; p.heartDanger=danger;p.heartPhase=((p.heartPhase ?? (p.pad*.173)%1)+realDt*p.heartRate/60)%1; @@ -23828,6 +24004,7 @@ function updateSeatHeartbeat(dt,now){ playDrum('kick',.23,0); if(p===players[0]&&previousHeartPhase!==undefined&&p.heartPhase>=.23&&previousHeartPhase<.23) playDrum('kick',.12,0); + if(p===players[0])updateParkMusic(p,previousHeartPhase); p.breathRate=11+(p.pad%5)+danger*13+effort*12; p.breathPhase=((p.breathPhase ?? (p.pad*.317)%1)+realDt*p.breathRate/60)%1; p.motionClock=(p.motionClock ?? now/1e6+p.pad*1.7)+realDt*(.8+p.heartRate/300); diff --git a/xbox/live/tests/pool-playground.test.mjs b/xbox/live/tests/pool-playground.test.mjs index e084f77ba8..0502a937af 100644 --- a/xbox/live/tests/pool-playground.test.mjs +++ b/xbox/live/tests/pool-playground.test.mjs @@ -7,7 +7,7 @@ function playground(){ let now=1e6;const noop=()=>{},audio=[],drums=[]; const api=new Function('runtime','capabilities','telemetry','gameSignal','drum','wipe','box','line','triangle','write','systemWrite','oscillator','oscillatorStop',`${source} configureWorldMap('skatepark','pool');fightOpponent='freeskate';gameMode='fight'; - return {milkAt,swimMilk,parkPools,drawCerealMilk,ragdollBodies,updateRagdolls,ragdollGeometry,OskiewarRagdoll,seatActionText,looseRunnerGeometry,drawLooseRunner,mainNativeCamera,characterLocalCamera,spectatorState,netDrainHostInbox,players,updatePlayer,runnerWorldGeometry,projectRunnerWorldGeometry,cameraDoll, + return {strikeParkWindow,updateChalk,chalkTip,chalkColors,chalkPickups,decals,stereoGain,parkStereo,seatActionRuns,seatHudReadout,milkAt,swimMilk,parkPools,drawCerealMilk,ragdollBodies,updateRagdolls,ragdollGeometry,OskiewarRagdoll,seatActionText,looseRunnerGeometry,drawLooseRunner,mainNativeCamera,characterLocalCamera,spectatorState,netDrainHostInbox,players,updatePlayer,runnerWorldGeometry,projectRunnerWorldGeometry,cameraDoll, parkHalfPipe3D,parkHalfPipeHeight,parkDeckY,poolFloorAt,poolSlopeAt,gunPickups,axePickup, resetParkSupply,updateParkSupply,updateGunPickups,resetParkKids,updateParkKids,parkKids, bullets,updateBullets,gunPose,drawPoolGeometry,captureQuadMesh,drawRunner, @@ -176,7 +176,7 @@ test('figure LOD follows projected size and holds its tier at a boundary',()=>{ test('3D rounds reflect off solid walls in depth and retain their lifetime',()=>{ const a=playground(),w=a.parkWindowWalls()[0]; - const b={x:w.ax+w.width*.5,y:a.parkDeckY-150,z:w.az+10,vx:300,vy:0,vz:-4200,life:1,owner:0,safeUntil:Infinity}; + const b={x:w.ax+w.width*.5,y:a.parkDeckY-50,z:w.az+10,vx:300,vy:0,vz:-4200,life:1,owner:0,safeUntil:Infinity}; a.bullets.push(b);const speed=Math.hypot(b.vx,b.vy,b.vz); a.updateBullets(1/60,a.now(),false); assert.ok(b.vz>0);assert.equal(b.vx,300);assert.ok(b.z>w.az); @@ -185,12 +185,12 @@ test('3D rounds reflect off solid walls in depth and retain their lifetime',()=> }); test('3D laser rounds are absorbed by solid walls',()=>{ const a=playground(),w=a.parkWindowWalls()[0]; - a.bullets.push({x:w.ax+w.width*.5,y:a.parkDeckY-150,z:w.az+10,vx:0,vy:0,vz:-4200,life:1,laser:true}); + a.bullets.push({x:w.ax+w.width*.5,y:a.parkDeckY-50,z:w.az+10,vx:0,vy:0,vz:-4200,life:1,laser:true}); a.updateBullets(1/60,a.now(),false);assert.equal(a.bullets.length,0); }); test('3D pistol layers a crack and low report onto its shot',()=>{ const a=playground(),p=a.players[0];p.gunAmmo=3;p.gunMode='HANDGUN';a.step(['Y']); - assert.ok(a.drums.some(d=>d.name==='snare'));assert.ok(a.drums.some(d=>d.name==='kick')); + assert.ok(a.drums.some(d=>d.name==='gunshot'));assert.equal(a.drums.some(d=>d.name==='hat'),false); }); test('skateboard rumble stays low, follows horizontal movement, and fades at rest',()=>{ @@ -443,3 +443,75 @@ test('a head behind the near plane does not hide visible limbs',()=>{ const g=a.projectRunnerWorldGeometry({head:{x:0,y:0,z:-40,radius:22},segments:[{x1:0,y1:30,z1:-10,x2:0,y2:100,z2:200,width:10}]}); assert.equal(g.behind,false);assert.equal(g.head.behind,true);assert.equal(g.segments[0].hidden,undefined); }); + +test('HUD keeps BPM MPH and RPM together and depth travel raises heart rate',()=>{ + const a=playground(),p=a.players[0];p.heartRate=62; + const idle=a.seatHudReadout(p,a.now()).measure;assert.equal(idle,'62 bpm'); + p.vz=1800;for(let i=0;i<180;i++)a.updateSeatHeartbeat(1/60,a.now()); + assert.ok(p.heartRate>95,'depth speed is exertion too'); + const moving=p.heartRate;p.attackKind='PUNCH';for(let i=0;i<180;i++)a.updateSeatHeartbeat(1/60,a.now()); + assert.ok(p.heartRate>moving+15,'action excitement adds to speed'); +}); + +test('crawling and spinning remain combined with held equipment',()=>{ + const a=playground(),p=a.players[0];Object.assign(p,{vx:0,vz:100,ducking:true,spin:{rate:8,angle:0},axeHeld:true,lastButton:'NONE',lastButtonAt:0}); + assert.equal(a.seatActionText(p,a.now()),'CRAWLING + SPINNING W/ AXE'); +}); + +test('action and item runs keep distinct colors',()=>{ + const a=playground(),p=a.players[0];Object.assign(p,{vx:100,ducking:true,spin:{rate:8,angle:0},axeHeld:true,lastButton:'NONE',lastButtonAt:0}); + const runs=a.seatActionRuns(p,a.now()),color=text=>runs.find(r=>r.text===text).color; + assert.notDeepEqual(color('CRAWLING'),color('SPINNING'));assert.notDeepEqual(color('AXE'),color('SPINNING')); +}); + +test('half-pipe automatically locks the board plane and returns vert airs into the transition',()=>{ + const a=playground(),p=a.players[0],pipe=a.parkHalfPipe3D; + Object.assign(p,{x:pipe.x,z:80,y:a.poolFloorAt(pipe.x,80),vx:2000,vz:120,vy:0,skateboard:true,onewheel:false,poolYaw:.08,grounded:true}); + a.step(['ArrowUp']);assert.equal(p.poolPipeLocked,true);const lane=p.z;let airborne=false,returned=false; + for(let i=0;i<600;i++){ + a.step(['ArrowUp']);assert.ok(Math.abs(p.z-lane)<1e-6,'locked lane is stable'); + if(p.poolVert?.pipe)airborne=true; + if(airborne&&p.grounded){returned=true;break;} + } + assert.ok(airborne,'reaches a locked vert air');assert.ok(returned,'lands back on the pipe'); + a.step(['LeftShoulder']);assert.equal(p.skateboard,false,'dash releases rider from board'); +}); + +test('turning combines with movement and equipment',()=>{ + const a=playground(),p=a.players[0];Object.assign(p,{vx:100,inputX:-1,lastButton:'NONE',lastButtonAt:0,axeHeld:true}); + assert.equal(a.seatActionText(p,a.now()),'WALKING + TURNING LEFT W/ AXE'); +}); + +test('walking builds into running and a held-forward dash settles into a run',()=>{ + const a=playground(),p=a.players[0];p.x=500;p.z=a.parkWindowWalls()[0].az-600;p.y=a.poolFloorAt(p.x,p.z); + for(let i=0;i<15;i++)a.step(['ArrowUp']);assert.ok(Math.hypot(p.vx,p.vz)<1000); + for(let i=0;i<120;i++)a.step(['ArrowUp']);assert.ok(Math.hypot(p.vx,p.vz)>1000); + a.step(['ArrowUp','LeftShoulder']);assert.ok(a.seatActionText(p,a.now()).includes('DASHING')); + for(let i=0;i<120;i++)a.step(['ArrowUp']);assert.ok(Math.hypot(p.vx,p.vz)>1000); + assert.ok(!a.seatActionText(p,a.now()).includes('DASHING')); +}); + +test('held punch draws neon chalk through movement and spins, without an attack',()=>{ + const a=playground(),p=a.players[0];p.chalkColor=a.chalkColors[0];p.attackKind=''; + a.updateChalk(p,['B'],a.now());p.x+=20;a.updateChalk(p,['B'],a.now()); + p.spin={angle:1,rate:8};a.updateChalk(p,['B'],a.now()); + const marks=a.decals.filter(d=>d.kind==='chalk');assert.ok(marks.length>=2);assert.deepEqual(marks[0].color,[255,55,190]); + assert.equal(p.attackKind,'');a.updateChalk(p,[],a.now());assert.equal(p.chalkDrawing,false);assert.equal(p.chalkPrevious,null); +}); +test('park scatters SMGs and held fire shoots repeated individual rounds',()=>{ + const a=playground(),p=a.players[0];a.resetParkSupply(a.now());assert.ok(a.gunPickups.filter(g=>g.active&&g.kind==='RUBBER SMG').length>=2); + p.gunMode='RUBBER SMG';p.gunAmmo=30;for(let i=0;i<60;i++)a.step(['Y']); + assert.ok(p.gunAmmo<22);assert.ok(p.gunAmmo>15);assert.ok(a.drums.some(d=>d.name==='smg-shot')); +}); +test('an attacked civilian approaches and starts sparring, then disengages after escape',()=>{ + const a=playground(),p=a.players[0];a.resetParkKids();const kid=a.parkKids[0]; + Object.assign(p,{x:kid.x-100,z:kid.z,y:kid.y});a.damageParkCivilian(kid,p,{x:kid.x,y:kid.y-100,z:kid.z},a.now(),1); + a.updateParkKids(1/60,a.now()+1000000);assert.equal(p.sparringPartner,kid.pad);assert.ok(kid.attackKind); + p.poolPipeEscapeUntil=a.now()+3000000;a.updateParkKids(1/60,a.now()+1100000);assert.equal(p.sparringPartner,undefined); +}); +test('stereo fades by distance and strike capsules can break low windows',()=>{ + const a=playground(),p=a.players[0];assert.equal(a.stereoGain({x:a.parkStereo.x,z:a.parkStereo.z}),1);assert.equal(a.stereoGain({x:a.parkStereo.x+4000,z:a.parkStereo.z}),0); + const w=a.parkWindowWalls()[0],x=w.ax+w.width*.5,y=a.parkDeckY-130; + assert.equal(a.strikeParkWindow(p,{hit:[{capsule:{x1:x,y1:y,z1:w.az+40,x2:x,y2:y,z2:w.az-10,width:12}}]}),true); + assert.equal(a.brokenParkWindows.size,1); +}); diff --git a/xbox/macos-native/PoolDecals.cpp b/xbox/macos-native/PoolDecals.cpp index 999cb9e427..9dd0cb59b2 100644 --- a/xbox/macos-native/PoolDecals.cpp +++ b/xbox/macos-native/PoolDecals.cpp @@ -32,6 +32,10 @@ bool ac_pool_stamp(void* p,const float* values) { std::array q;std::copy_n(values,12,q.begin()); return static_cast(p)->surface.stamp(q); } +bool ac_pool_tint(void* p,const float* values) { + std::array q;std::copy_n(values,12,q.begin());std::array tint;std::copy_n(values+12,3,tint.begin()); + return static_cast(p)->surface.stamp(q,&tint); +} // Return the dirty rectangle without consuming it; Swift copies it into each // frame slot's pending bounds before acknowledging it with ac_pool_clean. const uint8_t* ac_pool_pixels(void* p,int* bounds) { diff --git a/xbox/macos-native/PoolDecals.h b/xbox/macos-native/PoolDecals.h index 4b9838fbc1..81b44753aa 100644 --- a/xbox/macos-native/PoolDecals.h +++ b/xbox/macos-native/PoolDecals.h @@ -8,6 +8,7 @@ void* ac_pool_create(void); void ac_pool_destroy(void*); void ac_pool_clear(void*); bool ac_pool_stamp(void*, const float*); +bool ac_pool_tint(void*, const float*); const uint8_t* ac_pool_pixels(void*, int*); const uint8_t* ac_pool_all_pixels(void*); void ac_pool_clean(void*); diff --git a/xbox/macos-native/PoolDecals.swift b/xbox/macos-native/PoolDecals.swift index a3d2e09c0e..423f805cb6 100644 --- a/xbox/macos-native/PoolDecals.swift +++ b/xbox/macos-native/PoolDecals.swift @@ -28,8 +28,8 @@ final class PoolDecals { return true } func stamp(_ values: [Float]) -> Bool { - guard values.count == 12 else { return false } - return values.withUnsafeBufferPointer { ac_pool_stamp(surface, $0.baseAddress!) } + guard values.count == 12 || values.count == 15 else { return false } + return values.withUnsafeBufferPointer { values.count == 15 ? ac_pool_tint(surface, $0.baseAddress!) : ac_pool_stamp(surface, $0.baseAddress!) } } func upload(_ vertices: [Float], _ faces: [Float]) -> Int { guard !vertices.isEmpty, !faces.isEmpty else { return -1 } diff --git a/xbox/macos-native/main.swift b/xbox/macos-native/main.swift index 790d8b04a6..eae160d294 100644 --- a/xbox/macos-native/main.swift +++ b/xbox/macos-native/main.swift @@ -295,6 +295,8 @@ private final class NativeAudio { func drum(_ name: String, velocity: Double, pan: Double) { let duration: Double switch name { + case "gunshot", "smg-shot": duration = 0.18 + case "bass": duration = 0.20 case "kick": duration = 0.22 case "clap", "punch", "block": duration = 0.14 case "whoosh": duration = 0.30 @@ -306,6 +308,9 @@ private final class NativeAudio { let envelope = pow(max(0, 1 - progress), name == "bell" ? 1.7 : 3.1) let noise = self.noise() switch name { + case "gunshot", "smg-shot": + return (noise * exp(-time * 38) + sin(2 * .pi * 80 * time) * exp(-time * 65) * 0.4) * 0.9 + case "bass": return sin(2 * .pi * 55 * time) * envelope * 0.65 case "kick": let frequency = 145 - 104 * progress return sin(2 * .pi * frequency * time) * envelope * 0.86 @@ -995,6 +1000,7 @@ private final class NativeGameHost { } javascript.setObject(clearDecals, forKeyedSubscript: "decalClear" as NSString) javascript.setObject(stampDecal, forKeyedSubscript: "decalStamp" as NSString) + javascript.setObject(stampDecal, forKeyedSubscript: "decalTint" as NSString) javascript.setObject(uploadDecalMesh, forKeyedSubscript: "decalMeshUpload" as NSString) javascript.setObject(drawDecalMesh, forKeyedSubscript: "decalMesh" as NSString) // The retained-texture contract. Arguments arrive loose (the sprite's diff --git a/xbox/macos-native/tests/pool-decals.cpp b/xbox/macos-native/tests/pool-decals.cpp index 950d155a47..abb5977de1 100644 --- a/xbox/macos-native/tests/pool-decals.cpp +++ b/xbox/macos-native/tests/pool-decals.cpp @@ -15,6 +15,10 @@ int main() { assert(dirty[0]==100&&dirty[1]==100&&dirty[2]==132&&dirty[3]==132); unsigned alpha=0;for(int y=100;y<132;y++)for(int x=100;x<132;x++)alpha+=pixels[(y*2048+x)*4+3]; assert(alpha>0); + float neon[]={0,0,128,128,200,200,240,200,240,240,200,240,255,30,180}; + assert(ac_pool_tint(pool,neon));pixels=ac_pool_pixels(pool,dirty);bool found=false; + for(int y=200;y<240;y++)for(int x=200;x<240;x++){const auto* p=pixels+(y*2048+x)*4;if(p[3]){assert(p[0]==255&&p[1]==30&&p[2]==180);found=true;}} + assert(found);neon[12]=300;assert(!ac_pool_tint(pool,neon)); const auto* originalPixels=ac_pool_all_pixels(pool); ac_pool_clean(pool); stamp[0]=250;assert(!ac_pool_stamp(pool,stamp));assert(!ac_pool_pixels(pool,dirty));stamp[0]=0; diff --git a/xbox/native-bios/App.cpp b/xbox/native-bios/App.cpp index 9803b1acbe..d80a0808af 100644 --- a/xbox/native-bios/App.cpp +++ b/xbox/native-bios/App.cpp @@ -256,6 +256,7 @@ class HostGraphics final : public Graphics { std::function on_theme_quad; std::function on_decal_clear; std::function&)> on_decal_stamp; + std::function&,const std::array&)> on_decal_tint; std::function on_decal_triangle; std::function on_write; std::function on_system_write; @@ -281,6 +282,7 @@ class HostGraphics final : public Graphics { } bool decal_clear() override { return on_decal_clear && on_decal_clear(); } bool decal_stamp(const std::array& stamp) override { return on_decal_stamp && on_decal_stamp(stamp); } + bool decal_tint(const std::array& stamp,const std::array& tint) override { return on_decal_tint && on_decal_tint(stamp,tint); } void decal_triangle(const ac::xbox::TexturedTriangle& triangle) override { if(on_decal_triangle)on_decal_triangle(triangle); } void theme_sprite(const ac::xbox::ThemeSprite& sprite) override { const float c = std::cos(sprite.angle), s = std::sin(sprite.angle); @@ -419,6 +421,7 @@ public: m_graphics->on_decal_stamp = [this](const std::array& stamp) { return m_decalView && m_decalSurface.stamp(stamp); }; + m_graphics->on_decal_tint = [this](const std::array& stamp,const std::array& tint) { return m_decalView && m_decalSurface.stamp(stamp,&tint); }; m_graphics->on_decal_triangle = [this](const ac::xbox::TexturedTriangle& triangle) { if(m_frameDecalTriangles.size()on_stop = [this]() { if (m_skateVoice) m_skateVoice->SetVolume(0); if (m_voice) { m_voice->Stop(0); m_voice->FlushSourceBuffers(); } + for(auto* voice:m_drumVoices)if(voice){voice->Stop(0);voice->FlushSourceBuffers();} for (auto* voice : m_glassVoices) if (voice) { voice->Stop(0); voice->FlushSourceBuffers(); } @@ -1288,6 +1292,7 @@ private: Check(m_audio->CreateSourceVoice(&m_oscVoice, &format, 0, 64.0f)); for (auto& voice : m_glassVoices) Check(m_audio->CreateSourceVoice(&voice, &format, 0, XAUDIO2_DEFAULT_FREQ_RATIO)); + for(auto& voice:m_drumVoices)Check(m_audio->CreateSourceVoice(&voice,&format,0,XAUDIO2_DEFAULT_FREQ_RATIO)); Check(m_audio->CreateSourceVoice(&m_skateVoice, &format, 0, 2.0f)); m_skateSamples = synthesize_skate_roll(sampleRate); XAUDIO2_BUFFER skateBuffer{}; @@ -1337,6 +1342,7 @@ private: if (voice) { voice->DestroyVoice(); voice = nullptr; } } m_nextGlassVoice = 0; + for(auto& voice:m_drumVoices)if(voice){voice->DestroyVoice();voice=nullptr;} if (m_oscVoice) { m_oscVoice->DestroyVoice(); m_oscVoice = nullptr; } if (m_voice) { m_voice->DestroyVoice(); m_voice = nullptr; } if (m_master) { m_master->DestroyVoice(); m_master = nullptr; } @@ -1387,14 +1393,19 @@ private: PlayGlass(name == "glass-shard", velocity); return; } if (!m_voice || m_sampleRate == 0) return; - (void)pan; // The current game-effects voice is mono; preserve the API for a stereo pool. + enum class Wave { Sine, Triangle, Square, Noise }; struct Layer { Wave wave; double frequency, duration, volume, attack, decay; }; std::vector layers; - if (name == "kick") { + if (name == "gunshot" || name == "smg-shot") { + layers = {{Wave::Noise,11000,.006,1.25,.0001,.0059},{Wave::Noise,3800,.075,.9,.0002,.074},{Wave::Noise,900,.18,.46,.001,.179},{Wave::Sine,80,.055,.45,.0003,.054}}; + if(name=="smg-shot")for(auto& layer:layers)layer.duration*=.7; + } else if (name == "bass") { + layers = {{Wave::Triangle,55,.18,.6,.003,.17},{Wave::Sine,55,.2,.7,.002,.19}}; + } else if (name == "kick") { layers = { {Wave::Noise, 2500, .0025, .50, .0002, .0022}, {Wave::Sine, 200, .012, 1.10, .0005, .011}, @@ -1447,7 +1458,7 @@ private: }; } - const double hit = (std::max)(.1, (std::min)(1.5, static_cast(velocity))); + const double hit = (std::max)(0.0, (std::min)(1.5, static_cast(velocity))); double duration = 0; for (const auto& layer : layers) duration = (std::max)(duration, layer.duration); const auto frames = static_cast(m_sampleRate * duration); @@ -1484,18 +1495,13 @@ private: } } - m_voice->Stop(0); - m_voice->FlushSourceBuffers(); - m_samples.resize(frames); - for (uint32_t index = 0; index < frames; ++index) { - const double sample = (std::max)(-1.0, (std::min)(1.0, mixed[index] * hit * .28)); - m_samples[index] = static_cast(sample * 32767.0); - } - m_buffer = {}; - m_buffer.AudioBytes = static_cast(m_samples.size() * sizeof(int16_t)); - m_buffer.pAudioData = reinterpret_cast(m_samples.data()); - m_buffer.Flags = XAUDIO2_END_OF_STREAM; - TriggerAudio(0); + const auto slot=m_nextDrumVoice++%m_drumVoices.size();auto* voice=m_drumVoices[slot];if(!voice)return; + voice->Stop(0);voice->FlushSourceBuffers();auto& samples=m_drumSamples[slot];samples.resize(frames); + for(uint32_t i=0;i((std::max)(-1.0,(std::min)(1.0,mixed[i]*hit*.28))*32767.0); + XAUDIO2_VOICE_DETAILS details{};m_master->GetVoiceDetails(&details); + if(details.InputChannels>=2){std::vector matrix(details.InputChannels,0);const float p=(std::max)(-1.f,(std::min)(1.f,pan));matrix[0]=std::sqrt((1-p)*.5f);matrix[1]=std::sqrt((1+p)*.5f);voice->SetOutputMatrix(m_master,1,details.InputChannels,matrix.data());} + XAUDIO2_BUFFER buffer{};buffer.AudioBytes=static_cast(samples.size()*sizeof(int16_t));buffer.pAudioData=reinterpret_cast(samples.data());buffer.Flags=XAUDIO2_END_OF_STREAM; + Check(voice->SubmitSourceBuffer(&buffer));Check(voice->Start(0)); } void SetOscillator(float frequency, float volume) { @@ -3076,6 +3082,9 @@ private: std::atomic_bool m_clientErrorWriteInFlight{false}; std::atomic_uint64_t m_clientErrorSequence{0}; std::array m_glassVoices{}; + std::array m_drumVoices{}; + std::array,12> m_drumSamples; + std::size_t m_nextDrumVoice=0; std::size_t m_nextGlassVoice = 0; std::vector m_glassSamples, m_glassShardSamples; IXAudio2SourceVoice* m_skateVoice = nullptr; diff --git a/xbox/native-bios/Package.appxmanifest b/xbox/native-bios/Package.appxmanifest index 17626a1ad3..467af28aa6 100644 --- a/xbox/native-bios/Package.appxmanifest +++ b/xbox/native-bios/Package.appxmanifest @@ -6,7 +6,7 @@ IgnorableNamespaces="uap mp rescap"> + Version="1.0.0.57" /> diff --git a/xbox/native-bios/QuickJsEngine.cpp b/xbox/native-bios/QuickJsEngine.cpp index 8d93947103..0477e114f0 100644 --- a/xbox/native-bios/QuickJsEngine.cpp +++ b/xbox/native-bios/QuickJsEngine.cpp @@ -371,6 +371,14 @@ JSValue DecalStamp(JSContext* context, JSValueConst, int argc, JSValueConst* arg return JS_NewBool(context,scope->api->graphics.decal_stamp(q)); } +JSValue DecalTint(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope=static_cast(JS_GetContextOpaque(context)); + if(!scope||!scope->api||argc!=15)return JS_ThrowTypeError(context,"decalTint requires 12 coordinates and RGB"); + std::array q{};std::array tint{}; + for(int i=0;i<15;i++){double n=0;if(JS_ToFloat64(context,&n,argv[i])||!std::isfinite(n)||std::abs(n)>32768)return JS_ThrowRangeError(context,"invalid tinted stamp");if(i<12)q[i]=float(n);else {if(n<0||n>255)return JS_ThrowRangeError(context,"invalid tint");tint[i-12]=float(n);}} + return JS_NewBool(context,scope->api->graphics.decal_tint(q,tint)); +} + JSValue ThemeAssetReady(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { auto* scope = static_cast(JS_GetContextOpaque(context)); int32_t asset = -1; @@ -1020,6 +1028,7 @@ class QuickJsPiece final : public JsPiece { JS_SetPropertyStr(context_, global, "themeSprite", JS_NewCFunction(context_, ThemeSpriteDraw, "themeSprite", 12)); JS_SetPropertyStr(context_, global, "themeQuad", JS_NewCFunction(context_, ThemeQuadDraw, "themeQuad", 17)); JS_SetPropertyStr(context_, global, "decalClear", JS_NewCFunction(context_, DecalClear, "decalClear", 0)); + JS_SetPropertyStr(context_, global, "decalTint", JS_NewCFunction(context_, DecalTint, "decalTint", 15)); JS_SetPropertyStr(context_, global, "decalStamp", JS_NewCFunction(context_, DecalStamp, "decalStamp", 12)); JS_SetPropertyStr(context_, global, "decalMesh", JS_NewCFunction(context_, DecalMesh, "decalMesh", 6)); JS_SetPropertyStr(context_, global, "systemWrite", JS_NewCFunction(context_, SystemWrite, "systemWrite", 7)); diff --git a/xbox/native-bios/tests/quickjs_engine_smoke.cpp b/xbox/native-bios/tests/quickjs_engine_smoke.cpp index ab686c9769..d4c4d993c4 100644 --- a/xbox/native-bios/tests/quickjs_engine_smoke.cpp +++ b/xbox/native-bios/tests/quickjs_engine_smoke.cpp @@ -11,6 +11,7 @@ class GraphicsProbe final : public Graphics { public: DecalSurface surface; int decalTriangles=0; TexturedTriangle lastDecal{}; bool decal_clear() override { surface.clear();return true; } bool decal_stamp(const std::array& q) override { return surface.stamp(q); } + bool decal_tint(const std::array& q,const std::array& tint) override { return surface.stamp(q,&tint); } void decal_triangle(const TexturedTriangle& t) override { ++decalTriangles;lastDecal=t; } bool themeAvailable = true; int themeSprites = 0, themeQuads = 0; ThemeSprite lastTheme{}; ThemeQuad lastQuad{}; bool theme_ready() const override { return themeAvailable; } bool theme_asset_ready(int asset) const override { return themeAvailable && asset >= 0 && asset < 6; } void theme_sprite(const ThemeSprite& value) override { ++themeSprites; lastTheme = value; } void theme_quad(const ThemeQuad& value) override { ++themeQuads; lastQuad=value; } Color color{}; int boxes = 0; int lines = 0; int triangles = 0; int textured = 0; int sprites = 0; int writes = 0; int systemWrites = 0; int glyphs = 0; int images = 0; int blurs = 0; ImageDraw lastImage{}; void wipe(Color value) override { color = value; } void box(const Rect&) override { ++boxes; } void line(const Line&) override { ++lines; } void triangle(const Triangle&) override { ++triangles; } void textured_triangle(const TexturedTriangle&) override { ++textured; } void sprite(const Sprite&) override { ++sprites; } void write(const Text&) override { ++writes; } void system_write(const SystemText&) override { ++systemWrites; } void system_glyph(const SystemGlyph&) override { ++glyphs; } void image(const ImageDraw& draw) override { ++images; lastImage = draw; } void blur(unsigned) override { ++blurs; } }; class SoundProbe final : public Sound { public: int calls = 0; int skateCalls = 0; void skate_audio(float speed, float volume) override { assert(speed == .4f && volume == .2f); ++skateCalls; } int oscillators = 0; int stops = 0; int drums = 0; void synth(const SynthVoice&) override { ++calls; } void stop_all() override {} int sample_rate() const override { return 48000; } void oscillator(float, float) override { ++oscillators; } void oscillator_stop() override { ++stops; } void drum(std::string_view, float, float) override { ++drums; } }; @@ -187,6 +188,7 @@ int main() { auto retainedDecals=engine.compile({"retained-decals","test",R"JS( function paint(){ if(!decalClear()||!decalStamp(0,0,128,128,20,20,44,20,44,44,20,44))throw Error('retained stamp'); + if(!decalTint(0,0,128,128,50,50,70,50,70,70,50,70,255,30,180))throw Error('tinted stamp'); let rejected=false;try{decalStamp(250,0,128,128,0,0,1,0,1,1,0,1);}catch(_){rejected=true;} if(!rejected)throw Error('invalid atlas accepted'); const vertices=new Float32Array([-10,0,20,10,0,20,10,0,40,-10,0,40]); diff --git a/xbox/runtime/include/ac/decal_surface.hpp b/xbox/runtime/include/ac/decal_surface.hpp index 56ea87f429..6648939244 100644 --- a/xbox/runtime/include/ac/decal_surface.hpp +++ b/xbox/runtime/include/ac/decal_surface.hpp @@ -16,7 +16,8 @@ class DecalSurface { } void clean() { dirty=false;left=top=side;right=bottom=0; } // Source rectangle in the 256px atlas, then four destination pixel points. - bool stamp(const std::array& q) { + bool stamp(const std::array& q, const std::array* tint=nullptr) { + if(tint)for(float v:*tint)if(!std::isfinite(v)||v<0||v>255)return false; for(float v:q)if(!std::isfinite(v)||std::abs(v)>32768)return false; if(q[0]<0||q[1]<0||q[2]<=0||q[3]<=0||q[0]+q[2]>256||q[1]+q[3]>256)return false; float loX=q[4],hiX=q[4],loY=q[5],hiY=q[5]; @@ -41,7 +42,7 @@ class DecalSurface { const auto* src=atlas.data()+(sy*256+sx)*4;auto* dst=pixels.data()+(y*side+x)*4; const unsigned a=src[3],old=dst[3],out=a*255+old*(255-a); if(!a)continue; - for(int k=0;k<3;k++)dst[k]=uint8_t((src[k]*a*255+dst[k]*old*(255-a)+out/2)/out); + for(int k=0;k<3;k++)dst[k]=uint8_t(((tint?unsigned((*tint)[k]):src[k])*a*255+dst[k]*old*(255-a)+out/2)/out); dst[3]=uint8_t((out+127)/255); } left=(std::min)(left,unsigned(x0));top=(std::min)(top,unsigned(y0)); diff --git a/xbox/runtime/include/ac/runtime.hpp b/xbox/runtime/include/ac/runtime.hpp index 6c5f9ef04e..db042c8cd1 100644 --- a/xbox/runtime/include/ac/runtime.hpp +++ b/xbox/runtime/include/ac/runtime.hpp @@ -107,6 +107,7 @@ class Graphics { virtual void theme_quad(const ThemeQuad&) {} virtual bool decal_clear() { return false; } virtual bool decal_stamp(const std::array&) { return false; } + virtual bool decal_tint(const std::array&,const std::array&) { return false; } virtual void decal_triangle(const TexturedTriangle&) {} virtual void write(const Text&) = 0; virtual void system_write(const SystemText&) {} -- 2.51.2