From 94c6616cdcc78a83eb3bc7f5f19d0c728f1bcc8b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 14:37:44 -0700 Subject: [PATCH] oskiewar v177: pool returns, vehicle exits and persistent trails --- xbox/live/oskiewar.js | 228 ++++++++++++++++++++----- xbox/live/tests/xbox-feedback.test.mjs | 144 ++++++++++++++-- 2 files changed, 312 insertions(+), 60 deletions(-) diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index a36cddf346..5c398e921d 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 = 176; +const buildVersion = 177; 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 @@ -868,7 +868,11 @@ function rasterDecalPatches(decal) { // would float over the curve or cut into it at its edges. return {x,y:terrainFloorAt(x,z)-.9,z}; }; - const patches = [], across = arc ? 8 : 4, deep = arc || skid ? 1 : 2; + const patches = []; + // Flat stamps need one quad; retain subdivisions around the curved walls. + const corners = [at(0,0),at(1,0),at(1,1),at(0,1)]; + const flat = !arc && corners.every(p=>Math.abs(p.y-corners[0].y)<.001); + const across = flat ? 1 : arc ? 8 : 4, deep = flat || arc || skid ? 1 : 2; for (let i=0;iprojectPoint(point.x,point.y,point.z)); if (p.some(point=>point.behind || ![point.x,point.y,point.z].every(Number.isFinite) || Math.abs(point.x)>30000 || Math.abs(point.y)>30000)) continue; + if (p.every(point=>point.xpoint.x>stageRight) || + p.every(point=>point.y<0) || p.every(point=>point.y>viewHeight)) continue; themeQuad(5,...patch.uv,...p.flatMap(point=>[point.x,point.y,point.z-.002]),false); } } @@ -892,14 +898,17 @@ function drawDecals(surface) { const lod = sceneLod(); const previous = triangleDepth; const raster = typeof themeQuad === "function" && hostThemeAssetReady && hostThemeAssetReady(5); + const persistent = poolOnly(); let drawn = 0; - for (let index = decals.length - 1; index >= 0 && drawn < 45; index--) { + for (let index = decals.length - 1; index >= 0 && (persistent || drawn < 45); index--) { const decal = decals[index]; - if (Math.abs(decal.x - cameraCenter) > reach) continue; - if (lod > 1 && decal.size < 5 && decal.kind !== "skid") continue; + // The pool camera can turn through 360 degrees, so horizontal distance + // from its target is not a visibility test. Cull projected patches instead. + if (!persistent && Math.abs(decal.x - cameraCenter) > reach) continue; + if (!persistent && lod > 1 && decal.size < 5 && decal.kind !== "skid") continue; drawn++; if (raster) { drawRasterDecal(decal); continue; } - const age = (now - decal.at) / 1e6; + const age = persistent ? 0 : (now - decal.at) / 1e6; decal.shape ||= decalShape(decal); if (decal.kind === "arc") { const color = mixColor(surface, [34, 30, 30], .85 * Math.max(.65, decal.strength) * clamp(1 - age / 180, .7, 1)); @@ -3097,7 +3106,9 @@ function addDecal(decal) { } } decals.push(decal); - if (decals.length > decalCap) decals.splice(0, decals.length - decalCap); + // Pool marks belong to the surface for the entire run; new marks never + // evict old ones. Other maps retain their existing combat budget. + if (!poolOnly() && decals.length > decalCap) decals.splice(0, decals.length - decalCap); return decal; } // A mark's outline, fixed when it lands: corners as (along, across) on the @@ -3262,7 +3273,7 @@ function seedHalfpipeScuffs() { addDecal({kind:"scuff",x,z,size,angle,stretch:1.7,strength:.5}); } function trackMonowheelSurface(player) { - if (!player.onewheel || !player.grounded) { player.wheelMarkAt=null; return; } + if (!player.skateboard || !player.grounded) { player.wheelMarkAt=null; return; } const at={x:player.x,z:player.z||0},previous=player.wheelMarkAt; if (!previous) { player.wheelMarkAt=at; return; } const distance=Math.hypot(at.x-previous.x,at.z-previous.z); @@ -6189,6 +6200,11 @@ function boardForRider(player, loose = false) { return nearest; } function ensureFreeskateBoards() { + if(poolOnly()){ + const board=balls.find(item=>item.type==='skateboard'); + if(board){board.seat=0;board.condition={health:100,cracks:[],brokenAt:0};placeFreeskateBoard(players[0],board);} + return; + } if (halfpipeOnly()) return; if (balls.filter(item=>item.type === "skateboard").length < 2) balls.push({...skateBoardKind, x:0,y:floorY,z:0,vx:0,vy:0,rotation:0, @@ -6202,19 +6218,19 @@ function ensureFreeskateBoards() { } } function placeFreeskateBoard(rider = players[0], chosen = null) { - if (!indoorSkate()) return; + if (!indoorSkate()&&!poolOnly()) return; const board = chosen || balls.find(item=>item.type === "skateboard" && (item.seat||0) === rider.pad); if (!rider || !board || rider.skateboard || board.condition?.brokenAt) return; board.active = true; board.heldBy = -1; board.releasedBy = -1; - board.x = rider.x + (rider.facing || 1) * 170; + board.x = rider.x + (poolOnly()?-170:(rider.facing || 1)*170); board.y = terrainFloorAt(board.x) - 18; board.z = rider.z || 0; board.vx = board.vy = 0; board.rotation = 0; board.flipSpin = 0; - board.spatialSpin=false;board.yaw=board.yawRate=board.vz=0; + board.spatialSpin=false;board.poolYaw=0;board.yaw=board.yawRate=board.vz=0; board.safeUntil = 0; } @@ -6304,7 +6320,7 @@ function beginFreeskate(now) { placeFreeskateBoard(); } ensureFreeskateBoards(); - if(poolOnly()){rider.poolYaw=0;poolCameraYaw=0;} + if(poolOnly()){rider.poolYaw=0;poolCameraYaw=0;poolCameraDip=0;poolCameraReturnYaw=null;} resetMonowheel(); resetParkSupply(now); resetParkKids(); @@ -6613,7 +6629,8 @@ function returnToTitle(now, reason = "back") { parkKids.length=0; resetRoofPanes();resetParkSupply(now);resetMonowheel(); waterSplashes.length=0;bubbleNotes.length=0;bottomGlass.fill(false); - decals.length=0;roofShards.length=0; + if(!poolOnly())decals.length=0; + roofShards.length=0; for(const p of players){resetSkate(p);p.underPipe=false;p.axeHeld=false;p.axeHitAt={};p.resultReaction='';} if(motorRunning && typeof oscillatorStop==='function')oscillatorStop();motorRunning=false; finishReplay(); @@ -9122,9 +9139,10 @@ function resetRound(now, resetMatch = false, keepMap = false) { if (halfpipeOnly()) { resetSkate(players[0]); players[0].wheelMarkAt = null; - if(poolOnly()){players[0].poolYaw=0;poolCameraYaw=0;} + if(poolOnly()){players[0].poolYaw=0;poolCameraYaw=0;poolCameraDip=0;poolCameraReturnYaw=null;} seedHalfpipeScuffs(); for (const item of balls) item.active = false; + if(poolOnly())ensureFreeskateBoards(); resetMonowheel(); resetParkSupply(now); resetParkKids(); @@ -9387,21 +9405,64 @@ function deathOrbitShot(target, width, progress) { perspective: .65, fov: 55, roll: Math.sin(phase) * .035 }; } -let poolCameraYaw = 0; +let poolCameraYaw = 0, poolCameraDip = 0, poolCameraReturnYaw = null; +// Keep the line from the lens to the rider above the coping and curved walls. +function clearPoolCamera(position, subject) { + // A lens outside the lip cannot see feet on its nearly vertical inner wall. + // Shorten the boom into the bowl before adjusting its height. + const edge=poolDistance(position.x,position.z),limit=pool.radius-20; + if(edge.d>limit)position={...position,x:position.x-edge.nx*(edge.d-limit),z:position.z-edge.nz*(edge.d-limit)}; + let y=position.y; + for(let i=1;i<20;i++){ + const t=i/20,x=lerp(position.x,subject.x,t),z=lerp(position.z,subject.z,t); + const surface=poolFloorAt(x,z); + y=Math.min(y,(surface-subject.y*t)/(1-t)-65); + } + return {...position,y}; +} function updateCameraDoll(dt, now) { if (poolOnly() && freeskateActive()) { - const p = players[0], heading = p.poolYaw || 0; - poolCameraYaw += Math.atan2(Math.sin(heading-poolCameraYaw), Math.cos(heading-poolCameraYaw)) * - (1-Math.exp(-dt*6)); - const angle = poolCameraYaw + .14 + playerCameraYaw; - const zoom = playerCameraZoom, distance = 1350 * zoom; - const target = { x:p.x+clamp(p.vx*.1,-160,160), y:p.y-85, - z:(p.z||0)+clamp((p.vz||0)*.1,-160,160) }; - const position = { x:target.x-Math.cos(angle)*distance, - y:Math.min(target.y-880*zoom-playerCameraPitch*600, floorY-pool.radius-300), - z:target.z-Math.sin(angle)*distance }; - cameraCenter=target.x; cameraCenterY=target.y; cameraWidth=1650*zoom; - cameraDoll.track({target,position,width:cameraWidth,perspective:1,fov:58,roll:0},dt,8); + const p=players[0],heading=p.poolYaw||0,speed=Math.hypot(p.vx,p.vz||0); + const wall=poolDistance(p.x,p.z||0),vert=p.poolVert; + let goal=heading; + if(vert){ + const inward=Math.atan2(-vert.nz,-vert.nx); + poolCameraReturnYaw=inward; + const progress=clamp((vert.speed+p.vy)/(2*vert.speed),0,1); + const amount=clamp((progress-.08)/.6,0,1),ease=amount*amount*(3-2*amount); + goal=vert.heading+Math.atan2(Math.sin(inward-vert.heading),Math.cos(inward-vert.heading))*ease; + }else{ + if(!p.grounded&&p.vy>0&&wall.d>pool.radius*.6) + poolCameraReturnYaw=Math.atan2(-wall.nz,-wall.nx); + if(poolCameraReturnYaw!==null){ + goal=speed>180?Math.atan2(p.vz,p.vx):poolCameraReturnYaw; + if(p.grounded&&speed>180&&Math.cos(goal-heading)>.7)poolCameraReturnYaw=null; + } + } + // Explicit steering takes the camera back; an unsteered vert returns itself. + if(Math.abs(p.inputX||0)>.18){goal=heading;poolCameraReturnYaw=null;} + const yawError=Math.atan2(Math.sin(goal-poolCameraYaw),Math.cos(goal-poolCameraYaw)); + poolCameraYaw+=clamp(yawError*(1-Math.exp(-dt*5)),-dt*2.8,dt*2.8); + const descent=clamp((p.vy+(vert?250:0))/1000,0,1); + poolCameraDip+=(descent-poolCameraDip)*(1-Math.exp(-dt*4.5)); + const zoom=playerCameraZoom,angle=poolCameraYaw+.14*(1-poolCameraDip)+playerCameraYaw; + const airHeight=Math.max(0,poolFloorAt(p.x,p.z||0)-p.y); + const distance=(1350+Math.min(850,airHeight*.35)-poolCameraDip*120)*zoom; + const look=140+Math.min(180,speed*.07)+(poolCameraReturnYaw!==null?lerp(140,420,clamp(airHeight/600,0,1)):0); + const lookX=p.x+Math.cos(goal)*look,lookZ=(p.z||0)+Math.sin(goal)*look; + const groundFocus=Math.max(poolCameraDip*.28,clamp(airHeight/650,0,1)*.32); + const target={x:lerp(p.x,lookX,.15+poolCameraDip*.4), + y:lerp(p.y-85+clamp(p.vy*.1,-160,220),poolFloorAt(lookX,lookZ)-40,groundFocus), + z:lerp(p.z||0,lookZ,.15+poolCameraDip*.4)}; + const subject={x:p.x,y:p.y-2,z:p.z||0}; + const position=clearPoolCamera({x:target.x-Math.cos(angle)*distance, + y:Math.min(p.y-85-(880+poolCameraDip*260+Math.min(500,airHeight*.2))*zoom-playerCameraPitch*600, + floorY-pool.radius-300),z:target.z-Math.sin(angle)*distance},subject); + cameraCenter=target.x;cameraCenterY=target.y;cameraWidth=1650*zoom; + cameraDoll.track({target,position,width:cameraWidth,perspective:1,fov:58+poolCameraDip*5,roll:0},dt,8); + // Also protect the eased path while orbiting around a corner of the pool. + Object.assign(cameraDoll.position,clearPoolCamera(cameraDoll.position,subject)); + cameraDoll.dirty=true; return; } if(freeskateActive() && parkSupply.ko){ @@ -10924,6 +10985,10 @@ function updateBoomerboard(dt, now) { } function updateBall(ball, dt, now) { + if(poolOnly()&&freeskateActive()&&ball.type==='skateboard'){ + if(ball.active)updatePoolLooseVehicle(ball,dt,now,true); + return; + } // A popped ball is gone, not retired — a crater should cost the round its // ball for a moment, not for good. It re-inflates over the middle once its // serve time comes round. @@ -12092,7 +12157,6 @@ 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 forward=Math.max(0,throttle),brake=Math.max(0,-throttle); const oldHeading=p.poolYaw||0; const speedBefore=Math.hypot(p.vx,p.vz||0); const turnRate=p.onewheel?lerp(2.8,1.9,clamp(speedBefore/3000,0,1)):3.2; @@ -12114,8 +12178,16 @@ function updatePoolPlayer(p, pad, dt, now) { p.blocking=blocking; p.shieldCrouched=p.ducking; if(now>=p.attackUntil)p.attackKind=''; if(!blocking){if(pressed('B'))startMelee(p,'PUNCH',now);if(pressed('Y'))startMelee(p,'KICK',now);} - if(pressed('A')&&p.grounded){ - p.vy=Math.min(0,p.vy)-jumpVelocity; p.grounded=false; + 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.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)){ + if(mounted)dismountSkateboard(p,now); + p.poolVert=null;p.vy=Math.min(0,p.vy)-jumpVelocity; p.grounded=false; p.jumpPoseUntil=now+240000; p.jumpLaunchAt=0; p.jumpHeld=true; emitSignal('jump',p.pad,0,0); } @@ -12127,14 +12199,27 @@ 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):walkSpeed*(p.ducking?.38:1); - if(forward)rolling+=clamp((top*forward-rolling)*(p.onewheel?3:12),0,p.onewheel?4200:9000)*step; - rolling*=Math.exp(-step*(p.onewheel?.16:forward?1:14)); - // Back spends speed and holds still. It never applies reverse thrust. - if(brake)rolling=Math.sign(rolling)*Math.max(0,Math.abs(rolling)-brake*(p.onewheel?5600:4800)*step); + const top=p.onewheel?(p.wheelTurbo?4800:3100):p.skateboard?2700:walkSpeed*(p.ducking?.38:1); + if(throttle){ + const target=top*throttle,carving=Math.sign(target)!==Math.sign(rolling); + if(carving||Math.abs(rolling).18) + rolling+=Math.sign(rolling)*Math.min(1,Math.abs(grade))*skatePumpAcceleration*step; + rolling*=Math.exp(-step*(p.skateboard?(p.ducking?.04:.16):throttle?1:14)); + rolling=clamp(rolling,-6000,6000); p.vx=driveX*rolling/tangent;p.vz=driveZ*rolling/tangent; const speed=Math.abs(rolling); + const previousY=p.y,previousVy=grade*rolling/tangent; p.x+=p.vx*step;p.z=(p.z||0)+p.vz*step; + 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)){ + p.grounded=false;p.y=previousY+previousVy*step;p.vy=previousVy; + continue; + } slope=poolSlopeAt(p.x,p.z); 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;} @@ -12143,20 +12228,27 @@ function updatePoolPlayer(p, pad, dt, now) { const lip=poolDistance(p.x,p.z); if(lip.d>=pool.radius-5&&lip.d=floor){ slope=poolSlopeAt(p.x,p.z); 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; - p.grounded=true;p.landPoseUntil=now+110000; + p.grounded=true;p.poolVert=null;p.landPoseUntil=now+110000; } } const edge=poolDistance(p.x,p.z),limit=pool.radius+pool.deck-25; @@ -12170,7 +12262,7 @@ function updatePoolPlayer(p, pad, dt, now) { const slope=poolSlopeAt(p.x,p.z||0),heading=p.poolYaw||0; const pitch=p.grounded?Math.atan(slope.x*Math.cos(heading)+slope.z*Math.sin(heading)):0; p.skatePitch+=(pitch-(p.skatePitch||0))*(1-Math.exp(-dt*16)); - p.skateVx=p.onewheel?Math.hypot(p.vx,p.vy,p.vz||0):0; + p.skateVx=p.skateboard?Math.hypot(p.vx,p.vy,p.vz||0):0; p.stance=p.attackKind?'ATTACK':!p.grounded?'JUMP':p.ducking?'CROUCH':horizontal>40?'WALK':'NEUTRAL'; p.walkSince=horizontal>40?(p.walkSince||now):0; p.hit=Math.max(0,(p.hit||0)-dt*2);p.previous=held.slice(); @@ -14036,7 +14128,7 @@ function gameSim() { resolvePlayerStanding(now); resolvePlayerPushboxes(); if (!depthControls()) for(const p of activePlayers()){p.z=0;p.strafe=null;} - for(const b of balls)if(!b.spatialSpin)b.z=0; + if(!poolOnly())for(const b of balls)if(!b.spatialSpin)b.z=0; wrapFreeskate(); updateMonowheel(dt, now); updateParkSupply(dt, now); @@ -16210,8 +16302,9 @@ function skateFrame(player) { }; }; } function spunSkateFrame(frame, player) { - if (!player.spin) return frame; - const c = Math.cos(player.spin.angle), s = Math.sin(player.spin.angle); + const angle=(player.spin?.angle||0)+(poolOnly()&&!player.onewheel?(player.poolYaw||0):0); + if (!angle) return frame; + const c = Math.cos(angle), s = Math.sin(angle); return (along, height = 0, across = 0) => { const point = frame(along, height, across); const dx = point.x - player.x, dz = point.z - player.z; @@ -16220,7 +16313,7 @@ function spunSkateFrame(frame, player) { } function resetSkate(player) { - player.wheelTurbo=false;player.wheelChargeAt=0; + player.wheelTurbo=false;player.wheelChargeAt=0;player.poolVert=null; if (player.onewheel) { Object.assign(monowheel,{active:true,x:player.x-(player.facing||1)*75,y:player.y,z:player.z||0, vx:player.vx*.3,vy:0,safeUntil:runtime().monotonicUs+500000}); @@ -16250,6 +16343,16 @@ function releaseSpinningBoard(p,now){ } function dismountSkateboard(target, now) { if (!target.skateboard) return false; + if(poolOnly()){ + const one=target.onewheel,vehicle=one?monowheel:boardForRider(target),yaw=target.poolYaw||0; + if(vehicle)Object.assign(vehicle,{active:true,heldBy:-1,releasedBy:target.pad,releasedAt:now, + x:target.x-Math.cos(yaw)*52,y:target.y-(one?0:skateAxleDrop+skateWheelRadius), + z:(target.z||0)-Math.sin(yaw)*52,vx:target.vx*.6,vy:Math.min(0,target.vy*.4),vz:(target.vz||0)*.6, + poolYaw:yaw,rotation:target.skatePitch||0,spatialSpin:false,safeUntil:now+700000,safePlayers:1<=floor-2&&v.vy>=-1; + if(supported){ + const den=1+slope.x*slope.x+slope.z*slope.z; + v.vx+=1800*slope.x/den*step;v.vz=(v.vz||0)+1800*slope.z/den*step; + v.vx*=Math.exp(-step*1.8);v.vz*=Math.exp(-step*1.8); + } + const oldY=v.y; + v.x+=v.vx*step;v.z=(v.z||0)+(v.vz||0)*step; + const edge=poolDistance(v.x,v.z),limit=pool.radius+pool.deck-25; + if(edge.d>limit){v.x-=edge.nx*(edge.d-limit);v.z-=edge.nz*(edge.d-limit);v.vx=v.vz=0;} + const nextFloor=poolFloorAt(v.x,v.z)-offset; + v.vy+=1800*step;v.y+=v.vy*step; + if(v.y>=nextFloor||(supported&&nextFloor-oldY<12)){v.y=nextFloor;v.vy=0;} + } + const slope=poolSlopeAt(v.x,v.z||0),yaw=v.poolYaw||0; + v.rotation=Math.atan(slope.x*Math.cos(yaw)+slope.z*Math.sin(yaw));v.skatePitch=v.rotation; + for(const p of activePlayers())if(p.alive&&!p.dummy&&!p.skateboard&&p.grounded&&!isHeadOnly(p)&& + now>=(v.safeUntil||0)&&Math.hypot(p.x-v.x,(p.z||0)-(v.z||0))<48&&Math.abs(p.y-v.y-offset)<42){ + resetSkate(p);p.skateboard=true;p.onewheel=!board; + if(board)p.skateBoardId=balls.indexOf(v); + if(Math.hypot(v.vx,v.vz||0)>Math.hypot(p.vx,p.vz||0)){p.vx=v.vx;p.vz=v.vz||0;} + p.skateVx=Math.hypot(p.vx,p.vz||0);p.y=v.y+offset;p.skatePitch=v.rotation; + v.active=false;p.lastButton=board?'SKATEBOARD':'MONOWHEEL';p.lastButtonAt=now; + emitSignal('skate-mount',p.pad,board?1:2,0);break; + } +} const monowheel={active:false,x:0,y:0,z:0,vx:0,vy:0,safeUntil:0}; const parkSupply={nextAt:0,drone:null,drop:null,ko:null}; function resetMonowheel(){ const x=halfpipeOnly()?clamp(players[0].x+170,gridLeft+60,gridLeft+gridWidth-60):tileCenterX(37); - Object.assign(monowheel,{active:true,x,y:terrainFloorAt(x),z:0,vx:0,vy:0,safeUntil:0}); + Object.assign(monowheel,{active:true,x,y:terrainFloorAt(x),z:0,vx:0,vy:0,vz:0,poolYaw:0,skatePitch:0,safeUntil:0}); } function monowheelFrame(p, local=false){ const pitch=p.skatePitch||0,c=Math.cos(pitch),s=Math.sin(pitch); @@ -21678,6 +21811,7 @@ function riderContact(p,y){ } function updateMonowheel(dt,now){ if(!freeskateActive()||!monowheel.active)return; + if(poolOnly()){updatePoolLooseVehicle(monowheel,dt,now,false);return;} const m=monowheel,oldY=m.y; m.vy+=1800*dt;m.x=clamp(m.x+m.vx*dt,worldLeft+60,worldRight-60);m.y+=m.vy*dt;m.vx*=Math.exp(-2*dt); const ledge=ledgeCrossed(m.x,oldY-1,m.y,0,0,m.z); diff --git a/xbox/live/tests/xbox-feedback.test.mjs b/xbox/live/tests/xbox-feedback.test.mjs index 1172831b41..a20d27dbf6 100644 --- a/xbox/live/tests/xbox-feedback.test.mjs +++ b/xbox/live/tests/xbox-feedback.test.mjs @@ -52,7 +52,7 @@ function game(course = "pool") { tick: () => { now += 16667; api.sim(); assert.equal(api.error(), ''); } }; } -test('default course stays one rider and one monowheel through ticks, joins and resets', () => { +test('pool keeps one rider, one monowheel and one skateboard through joins and resets', () => { const g = game(), a = g.api; const check = () => { assert.equal(a.map().course, 'pool'); @@ -63,7 +63,8 @@ test('default course stays one rider and one monowheel through ticks, joins and assert.equal(a.players[1].alive, false); for (const objects of [a.parkKids,a.roofPanes,a.skateLoops,a.skateBoosts,a.skateRopes]) assert.equal(objects.length, 0); - assert.ok(a.balls.every(b => !b.active), 'no skateboards or balls'); + assert.ok(a.balls.filter(b=>b.type!=='skateboard').every(b=>!b.active),'no loose balls'); + assert.equal(a.balls.filter(b=>b.type==='skateboard'&&b.active).length+Number(a.players[0].skateboard&&!a.players[0].onewheel),1); assert.ok(a.gunPickups.every(p => !p.active)); assert.equal(a.axePickup.active, false); assert.equal(Number(a.monowheel.active) + Number(!!a.players[0].onewheel), 1); @@ -236,6 +237,34 @@ test('raster stamps conform to the curved halfpipe and depth-test as transparent } }); +test('pool marks remain visible beyond both old limits, after time and revisiting the surface', () => { + const g=game(),a=g.api; + a.decals.length=0; + for(let row=0;row<12;row++)for(let col=0;col<20;col++) + a.addDecal({kind:'skid',x:780+col*30,x2:800+col*30,z:-165+row*30,size:4,strength:.6}); + const oldest=a.decals[0]; + assert.equal(a.decals.length,240,'new marks never evict older ones'); + const aim=x=>{ + vm.runInContext(`cameraCenter=${x};cameraWidth=2400`,g.context); + a.cameraDoll.snap({target:{x,y:1800,z:0},position:{x,y:500,z:-2200}, + width:2400,perspective:0,fov:55,roll:0}); + g.decalsDrawn.length=0; + a.drawDecals([180,140,100]); + }; + aim(1080); + assert.equal(g.decalsDrawn.length,240,'all visible marks draw, including the oldest'); + const initial=JSON.stringify(g.decalsDrawn); + for(const mark of a.decals)mark.at-=3600e6; + aim(50000); + assert.equal(g.decalsDrawn.length,0,'off-screen marks do not submit texture quads'); + aim(1080); + assert.equal(JSON.stringify(g.decalsDrawn),initial,'old marks return unchanged after an hour'); + a.reset(); + assert.ok(a.decals.includes(oldest),'restarting the rider preserves the surface'); + a.configureWorldMap('skatepark','indoor'); + assert.equal(a.decals.length,0,'changing the actual map starts a new surface'); +}); + test('a glass roof break is one composite cue and still works on the full hall', () => { const {api:a,sounds} = game(); a.configureWorldMap('skatepark','indoor'); a.resetRoofPanes(); @@ -296,8 +325,8 @@ test('pool camera follows depth and height with perspective', () => { Object.assign(p,{x:1400,y:1100,z:420,vx:0,vz:0,poolYaw:Math.PI/2}); for(let i=0;i<180;i++)a.updateCameraDoll(1/60,1000000+i*16667); assert.ok(Math.abs(a.cameraDoll.target.x-p.x)<1); - assert.ok(Math.abs(a.cameraDoll.target.z-p.z)<1); - assert.ok(Math.abs(a.cameraDoll.target.y-(p.y-85))<1); + assert.ok(a.cameraDoll.target.z>p.z&&a.cameraDoll.target.zp.y-85&&a.cameraDoll.target.y.999); assert.ok(a.cameraDoll.position.z { +test('forward pushes in the facing direction, and down brakes through zero into reverse', () => { for(const riding of [false,true])for(const heading of [0,Math.PI/2,Math.PI,-Math.PI/2]){ const g=game(),p=g.api.players[0],x=1080,z=0; Object.assign(p,{x,z,y:1800,onewheel:riding,skateboard:riding,poolYaw:heading,vx:0,vz:0}); @@ -341,14 +370,12 @@ test('forward pushes in the facing direction, and back brakes without reversing' assert.ok(forward>20);assert.ok(Math.abs(sideways)<.001); const peak=Math.hypot(p.vx,p.vz); g.pads[0].down=['ArrowDown']; - for(let i=0;i<30;i++){ - g.tick(); - assert.ok(p.vx*Math.cos(heading)+p.vz*Math.sin(heading)>=-.001,'back never reverses'); - } - assert.ok(peak>100);assert.ok(Math.hypot(p.vx,p.vz)<.001,'back stops'); - const stopped={x:p.x,z:p.z}; - for(let i=0;i<30;i++)g.tick(); - assert.ok(Math.hypot(p.x-stopped.x,p.z-stopped.z)<.001,'holding back stays stopped'); + g.tick(); + assert.ok(p.vx*Math.cos(heading)+p.vz*Math.sin(heading)100); + assert.ok(p.vx*Math.cos(heading)+p.vz*Math.sin(heading)<-100,'holding down drives backward'); + assert.ok(Math.abs(p.poolYaw-heading)<.001,'reverse preserves rider heading'); } }); @@ -360,3 +387,94 @@ test('monowheel coasts and carves while the stick turns without forward held', ( assert.ok(p.x>1080&&p.z<0);assert.ok(p.poolYaw<-.2); assert.ok(Math.hypot(p.vx,p.vz)>1200,'turning preserves rolling momentum'); }); + + +test('A and LB exit either vehicle, preserve it, and allow mounting again', () => { + for(const one of [true,false])for(const button of ['A','LeftShoulder']){ + const g=game(),p=g.api.players[0],board=g.api.balls.find(b=>b.type==='skateboard'); + const vehicle=one?g.api.monowheel:board; + Object.assign(p,{x:vehicle.x,y:vehicle.y+(one?0:18),z:vehicle.z,grounded:true}); + for(let i=0;i<3;i++)g.tick(); + assert.equal(p.skateboard,true);assert.equal(p.onewheel,one); + g.pads[0].down=[button];g.tick(); + assert.equal(p.skateboard,false);assert.equal(p.onewheel,false); + assert.equal(vehicle.active,true);assert.ok(!p.grounded&&p.vy<0); + if(button==='LeftShoulder')assert.ok(Math.hypot(p.vx,p.vz)>1900); + g.pads[0].down=[]; + for(let i=0;i<8;i++)g.tick(); + assert.equal(p.skateboard,false,'no immediate remount'); + for(let i=0;i<120;i++)g.tick(); + Object.assign(p,{x:vehicle.x,y:vehicle.y+(one?0:18),z:vehicle.z,vx:0,vy:0,vz:0,grounded:true}); + for(let i=0;i<3;i++)g.tick(); + assert.equal(p.skateboard,true);assert.equal(p.onewheel,one); + assert.equal(vehicle.active,false); + } +}); + +test('a vert air stays over the curve while forward is held and rolls back down on release', () => { + for(const heading of [0,Math.PI/2,Math.PI/4]){ + const g=game(),p=g.api.players[0]; + Object.assign(p,{onewheel:true,skateboard:true,poolYaw:heading});g.api.monowheel.active=false; + g.pads[0].down=['ArrowUp']; + let anchor=null,landed=false,speed=0,returned=false; + for(let i=0;i<650;i++){ + g.tick(); + if(p.poolVert){ + anchor ||= {x:p.x,z:p.z}; + assert.ok(Math.hypot(p.x-anchor.x,p.z-anchor.z)<.001,'no throttle drift onto deck'); + if(p.vy>0)g.pads[0].down=[]; + }else if(anchor&&p.grounded){ + landed=true;speed=Math.max(speed,p.skateVx); + if(g.api.poolDistance(p.x,p.z).d<25){returned=true;break;} + } + } + assert.ok(anchor&&landed&&returned,'air reconnects to the curved wall and reaches the flat'); + assert.ok(speed>900,'landing carries the fall into rolling speed'); + } +}); + +test('rolling off the coping falls into the pool without a downward teleport', () => { + const g=game(),p=g.api.players[0]; + Object.assign(p,{x:2044,z:0,y:1320,poolYaw:Math.PI,vx:-700,vz:0,vy:0,onewheel:true,skateboard:true,grounded:true}); + g.api.monowheel.active=false; + let falling=false,landed=false,maxStep=0; + for(let i=0;i<160;i++){ + const y=p.y;g.tick();maxStep=Math.max(maxStep,Math.abs(p.y-y)); + if(!p.grounded)falling=true;else if(falling)landed=true; + assert.ok(p.y<=g.api.terrainFloorAt(p.x,p.z)+.01); + } + assert.ok(falling&&landed);assert.ok(maxStep<45,`drop moved ${maxStep} units in one tick`); +}); + + +test('camera swings into a vert return, dips toward the landing, and clears the coping', () => { + for(const heading of [0,Math.PI/2,Math.PI/4]){ + const g=game(),a=g.api,p=a.players[0]; + Object.assign(p,{onewheel:true,skateboard:true,poolYaw:heading});a.monowheel.active=false; + for(let i=0;i<120;i++)g.tick(); + a.cameraDoll.prepare();const initialPitch=a.cameraDoll.view.forward.y; + let turned=false,dipped=false,previousYaw=null,maxYawStep=0; + g.pads[0].down=['ArrowUp']; + for(let i=0;i<350;i++){ + g.tick();a.cameraDoll.prepare();const c=a.cameraDoll,v=c.view.forward; + const yaw=Math.atan2(v.z,v.x); + if(previousYaw!==null)maxYawStep=Math.max(maxYawStep,Math.abs(Math.atan2(Math.sin(yaw-previousYaw),Math.cos(yaw-previousYaw)))); + previousYaw=yaw; + if(p.poolVert&&p.vy>0){ + g.pads[0].down=[]; + const dot=(-p.poolVert.nx*v.x-p.poolVert.nz*v.z)/Math.hypot(v.x,v.z); + turned ||= dot>.7;dipped ||= v.y>initialPitch+.04; + } + for(const y of [p.y,p.y-190]){ + const point=a.projectPoint(p.x,y,p.z); + assert.ok(!point.behind&&point.x>20&&point.x<1900&&point.y>20&&point.y<980,`rider frame heading=${heading} tick=${i} x=${point.x} y=${point.y}`); + } + for(let j=1;j<20;j++){ + const t=j/20,x=c.position.x+(p.x-c.position.x)*t,z=c.position.z+(p.z-c.position.z)*t; + const y=c.position.y+(p.y-2-c.position.y)*t; + assert.ok(y