From 2bf0869ffe02016cc1a182bcc332780f878ed635 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 27 Sep 2026 19:22:51 -0700 Subject: [PATCH] Animate Aesel artifacts and loosen Oskiewar characters, HUD and milk pool --- apple/aesel/Sources/AeselNotebook.swift | 2 +- apple/aesel/Sources/ContentView.swift | 65 ++-- easel/phone/notebook.css | 4 + easel/phone/notebook.js | 10 +- easel/phone/shim/revisions.mjs | 17 + easel/test/phone-session.test.mjs | 7 + xbox/live/oskiewar.js | 423 +++++++++++++++++++---- xbox/live/tests/pool-playground.test.mjs | 70 +++- 8 files changed, 488 insertions(+), 110 deletions(-) diff --git a/apple/aesel/Sources/AeselNotebook.swift b/apple/aesel/Sources/AeselNotebook.swift index 9db11e6f84..f2827be85d 100644 --- a/apple/aesel/Sources/AeselNotebook.swift +++ b/apple/aesel/Sources/AeselNotebook.swift @@ -76,7 +76,7 @@ struct AeselNotebook: AeselWebViewRepresentable { return [Int((rgb >> 16) & 255), Int((rgb >> 8) & 255), Int(rgb & 255)] } let payload: [String: Any] = ["entries": entries, "handle": session.handle, "colors": colors, "theme": paint.css, - "exclusion": exclusion, "busy": session.busy, "activity": session.busy ? session.status : ""] + "exclusion": exclusion, "busy": session.busy, "streamingCode": String(session.streamingCode.suffix(4000)), "activity": session.busy ? session.status : ""] if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), let json = String(data: data, encoding: .utf8) { context.coordinator.payload = json diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 012d52a174..9ef9261abe 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -331,6 +331,16 @@ struct ContentView: View { private var sheet: some View { VStack(spacing: 0) { notebookHeader + HStack(spacing: 12) { + Button { openSettings() } label: { + Text(session.braincells.map { $0.formatted(.number.precision(.fractionLength(0))) + " braincells" } ?? session.creditsStatus) + } + Spacer(minLength: 8) + Text(session.reportedModel.isEmpty ? (session.provider == "ac" ? "AC · automatic model" : session.model) : session.reportedModel) + .lineLimit(1).truncationMode(.middle) + } + .font(Paint.font(12)).foregroundStyle(paint.dim) + .buttonStyle(AeselButtonStyle()).padding(.horizontal, edgeInset).padding(.vertical, 4) GeometryReader { geometry in ScrollViewReader { proxy in ScrollView { @@ -484,7 +494,7 @@ struct ContentView: View { maximumWidth: max(0, availableWidth - 28), horizontalInset: 0) } .frame(height: row) - .help(oskiewar.status) + .help(oskiewar.status + (oskiewar.editStatus.isEmpty ? "" : "\n" + oskiewar.editStatus)) .accessibilityElement(children: .ignore) .accessibilityLabel("Oskiewar, " + oskiewar.room + ". " + oskiewar.status) .contextMenu { Button("Disconnect Oskiewar") { oskiewar.disconnect() } } @@ -513,11 +523,6 @@ struct ContentView: View { .frame(width: previewSize.width, height: previewSize.height, alignment: .topTrailing) .clipped() .overlay { AeselPreviewInset() } - .overlay { - if session.busy && !session.streamingCode.isEmpty { - StreamingCodePreview(source: session.streamingCode) - } - } } } @@ -535,13 +540,9 @@ struct ContentView: View { } private var artifactPreview: some View { - VStack(spacing: 4) { - OskiewarArtifactPreview(artifact: oskiewar.artifact) - .frame(maxWidth: .infinity, maxHeight: .infinity) - if !oskiewar.editStatus.isEmpty { - Text(oskiewar.editStatus).font(.system(size: 10)).lineLimit(2).minimumScaleFactor(0.7) - } - }.padding(8).background(Color(red: 0.16, green: 0.13, blue: 0.22)).foregroundStyle(.white) + TimelineView(.animation(minimumInterval: 1.0 / 30)) { clock in + OskiewarArtifactPreview(artifact: oskiewar.artifact, time: clock.date.timeIntervalSince1970) + }.padding(8).background(Color(red: 0.16, green: 0.13, blue: 0.22)) } private var expandedPiece: some View { @@ -835,7 +836,7 @@ struct ContentView: View { "overlays": ["settings": session.accountReady && showSettings, "home": session.accountReady && showHome, "help": session.accountReady && showHelp, "signin": session.showSignIn], "piece": ["route": session.route, "version": session.displayedRevision, "currentVersion": session.currentRevision, "readOnly": session.viewingHistory, "sourceBytes": session.displayedSource.utf8.count, "previewURL": session.previewURL?.absoluteString ?? "", "shareURL": session.shareURL?.absoluteString ?? ""], - "oskiewar": ["room": oskiewar.room, "status": oskiewar.status], + "oskiewar": ["room": oskiewar.room, "status": oskiewar.status, "update": oskiewar.editStatus, "artifact": oskiewar.artifact.wire], "composer": ["characters": draft.count, "placeholder": "", "focused": writing], "title": ["opacity": 1, "linked": oskiewar.room.isEmpty && titleURL != nil, "text": oskiewar.room.isEmpty ? session.route : "Oskiewar · " + oskiewar.room], "footer": "v\(session.displayedRevision)", "session": ["id": session.currentSessionID, "busy": session.busy, "status": session.status, "signedIn": session.signedIn, "accountReady": session.accountReady, @@ -954,28 +955,6 @@ private struct SignInCarrier: AeselWebViewRepresentable { } } -/// Incoming source occupies the canvas until a complete piece is saved. -private struct StreamingCodePreview: View { - let source: String - @Environment(\.paint) private var paint - var body: some View { - ScrollViewReader { proxy in - ScrollView([.vertical, .horizontal]) { - Text(String(source.suffix(4000))) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(paint.ink) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - .id("code-tail") - } - .background(paint.bg) - .onChange(of: source) { proxy.scrollTo("code-tail", anchor: .bottomLeading) } - } - .accessibilityLabel("Incoming piece code") - .clipped() - } -} - /// Matches prompt.mjs's curtain login palette and square TextButton outline. private struct CurtainAccountButtonStyle: ButtonStyle { var signUp = false @@ -1037,6 +1016,7 @@ private final class OskiewarAttachment: ObservableObject { For any new outline use shape "custom" plus "points":[[x,y],...] with 3 to 10 vertices, integer coordinates from -9 to 9, y up. Order them around a simple, non-self-intersecting polygon. The compact descriptor must fit 320 ASCII bytes. Example: {"version":1,"kind":"shirt-symbol","shape":"custom","color":"#ff6699","points":[[0,8],[7,-6],[-7,-6]]}. For custom drawings use shape "drawing" and "draw": an array of up to 12 commands. Circle [0,x,y,r,c]; round stroke [1,x1,y1,x2,y2,width,c]; filled triangle [2,x1,y1,x2,y2,x3,y3,c]. All values integers, positions -16 to 16, circle radius 1 to 9, stroke width 1 to 6. Color index c is 0=artifact color, 1=dark ink, 2=white. Later commands draw on top. Example smiley: {"version":1,"kind":"shirt-symbol","shape":"drawing","color":"#ffcc44","draw":[[0,0,0,8,0],[0,-3,2,1,1],[0,3,2,1,1],[1,-4,-2,0,-4,1,1],[1,0,-4,4,-2,1,1]]}. You CAN custom draw and make smileys using these primitives. Do not say custom shapes are unavailable. + Optional "animation":"spin", "pulse", or "float" animates the graphic in both TV and game on a shared two-second loop. Omit animation for a still image. Keep a minimal paint export for valid piece syntax. The native TV renders the descriptor alone, without a shirt or scene. On completion this descriptor is automatically sent to the game. Only the game's acknowledgment confirms application. Briefly describe your actual edit in ordinary text; the butterfly mirrors an excerpt of that response. @@ -1175,8 +1155,9 @@ private struct OskiewarArtifact: Codable, Equatable { var color: String var points: [[Int]]? = nil var draw: [[Int]]? = nil + var animation: String? = nil var valid: Bool { - version == 1 && kind == "shirt-symbol" && ["star", "heart", "flower", "rainbow", "custom", "drawing"].contains(shape) + (animation == nil || ["spin", "pulse", "float"].contains(animation!)) && version == 1 && kind == "shirt-symbol" && ["star", "heart", "flower", "rainbow", "custom", "drawing"].contains(shape) && color.range(of: "^#[0-9a-fA-F]{6}$", options: .regularExpression) != nil && (shape != "custom" || (points.map { (3...10).contains($0.count) && $0.allSatisfy { $0.count == 2 && $0.allSatisfy { (-9...9).contains($0) } } } ?? false)) && (shape != "drawing" || (draw.map { commands in @@ -1205,8 +1186,16 @@ private struct OskiewarArtifact: Codable, Equatable { private struct OskiewarArtifactPreview: View { let artifact: OskiewarArtifact + var time: Double = 0 var body: some View { - Canvas { context, size in + Canvas { input, size in + var context = input + let phase = time.truncatingRemainder(dividingBy: 2) * .pi + context.translateBy(x: size.width / 2, y: size.height / 2) + if artifact.animation == "spin" { context.rotate(by: .radians(-phase)) } + if artifact.animation == "pulse" { let k = 0.85 + sin(phase) * 0.15; context.scaleBy(x: k, y: k) } + if artifact.animation == "float" { context.translateBy(x: 0, y: -sin(phase) * min(size.width, size.height) / 10) } + context.translateBy(x: -size.width / 2, y: -size.height / 2) let scale = min(size.width, size.height) / 20 let center = CGPoint(x: size.width / 2, y: size.height / 2) func point(_ x: Double, _ y: Double) -> CGPoint { CGPoint(x: center.x + x * scale, y: center.y - y * scale) } diff --git a/easel/phone/notebook.css b/easel/phone/notebook.css index e6c8179302..295699db0e 100644 --- a/easel/phone/notebook.css +++ b/easel/phone/notebook.css @@ -33,3 +33,7 @@ button:disabled,[aria-disabled=true]{cursor:default} #notebook-preview-space{float:right;pointer-events:none} #notebook-preview-space[hidden]{display:none} + +#notebook-code-grass{display:block;max-width:100%;height:72px;overflow:auto;white-space:pre; color:#8ccf79;background:rgba(20,40,20,.12);font:11px/18px Menlo,monospace;mask-image:linear-gradient(transparent,#000 18px);padding:0 8px;} +#notebook-code-grass[hidden]{display:none} +#notebook-code-grass code{font:inherit;white-space:pre} diff --git a/easel/phone/notebook.js b/easel/phone/notebook.js index 651ea2f994..ad0025f52f 100644 --- a/easel/phone/notebook.js +++ b/easel/phone/notebook.js @@ -12,6 +12,9 @@ window.updatePhoneNotebook = value => { window.setNotebookHandle('', []); window.updateConversation({entries:value.entries}); placeActivity(!!value.busy, value.activity || ''); + grass.hidden = !value.busy || !value.streamingCode; + grassCode.textContent = String(value.streamingCode || '').slice(-4000); + grass.scrollTop = grass.scrollHeight; alignBaselines(); reportHeight(); donkey?.paint(); @@ -21,7 +24,11 @@ window.updatePhoneNotebook = value => { const feedback = document.createElement('span'); feedback.id = 'prompt-feedback'; feedback.hidden = true; feedback.setAttribute('role', 'img'); const caption = document.createElement('span'); caption.id = 'activity-caption'; caption.hidden = true; const activity = document.createElement('span'); activity.id = 'notebook-activity'; activity.hidden = true; activity.append(feedback, caption); +const grass=document.createElement('span'); grass.id='notebook-code-grass'; grass.hidden=true; +grass.setAttribute('aria-label','Generated source code'); +const grassCode=document.createElement('code');grass.append(grassCode);activity.append(grass); document.body.append(activity); + const donkey = window.installNotebookDonkey?.(feedback); const exclusion = document.createElement('div'); exclusion.id = 'notebook-preview-space'; @@ -83,7 +90,8 @@ const reportHeight = () => { baseline = marker.getBoundingClientRect().top - content.getBoundingClientRect().top; marker.remove(); } - const height = Math.max(24, Math.ceil(baseline / 24) * 24 + 24); + const activityBottom=activity.hidden?0:activity.getBoundingClientRect().bottom-content.getBoundingClientRect().top; + const height = Math.max(24, Math.ceil(Math.max(baseline,activityBottom) / 24) * 24 + 24); if (height === reported) return; reported = height; window.webkit?.messageHandlers?.notebook?.postMessage({height}); diff --git a/easel/phone/shim/revisions.mjs b/easel/phone/shim/revisions.mjs index e143a87af1..26f6ab08f1 100644 --- a/easel/phone/shim/revisions.mjs +++ b/easel/phone/shim/revisions.mjs @@ -3,6 +3,23 @@ import {parse} from '../../src/vendor/acorn.mjs'; export async function validatePieceSource(source, file) { if (typeof source !== 'string' || !source.trim()) throw new Error('The piece is empty.'); if (!String(file).endsWith('.mjs')) return; + if (source.includes('/* ac-artifact ')) { + const matches=[...source.matchAll(/\/\* ac-artifact ([\s\S]*?)\*\//g)]; + if(matches.length!==1)throw new Error('Use exactly one ac-artifact descriptor.'); + const json=matches[0][1].trim();let artifact; + try{artifact=JSON.parse(json);}catch{throw new Error('Artifact descriptor must be valid JSON.');} + if(json.length>320||/[^\x20-\x7e]/.test(json))throw new Error('Keep the compact artifact descriptor within 320 ASCII bytes.'); + if(artifact.version!==1||artifact.kind!=='shirt-symbol'||!['star','heart','flower','rainbow','custom','drawing'].includes(artifact.shape)||!/^#[0-9a-fA-F]{6}$/.test(artifact.color))throw new Error('Invalid shirt-symbol version, shape, or six-digit hex color.'); + if(artifact.animation!==undefined&&!['spin','pulse','float'].includes(artifact.animation))throw new Error('Animation must be spin, pulse, or float.'); + if(artifact.shape==='drawing'){ + if(!Array.isArray(artifact.draw)||artifact.draw.length<1||artifact.draw.length>12)throw new Error('Drawing needs 1 to 12 commands.'); + for(const [i,c] of artifact.draw.entries()){ + if(!Array.isArray(c)||![0,1,2].includes(c[0])||c.length!==[5,7,8][c[0]])throw new Error(`Drawing command ${i+1} has the wrong number of values. Circle exactly [0,x,y,r,c]; stroke [1,x1,y1,x2,y2,width,c]; triangle [2,x1,y1,x2,y2,x3,y3,c]. Fix the descriptor and write it again.`); + if(c.some(v=>!Number.isInteger(v)||Math.abs(v)>16)||c.at(-1)<0||c.at(-1)>2||c[0]===0&&(c[3]<1||c[3]>9)||c[0]===1&&(c[5]<1||c[5]>6))throw new Error(`Drawing command ${i+1} has out-of-range values.`); + } + } + if(artifact.shape==='custom'&&(!Array.isArray(artifact.points)||artifact.points.length<3||artifact.points.length>10||artifact.points.some(p=>!Array.isArray(p)||p.length!==2||p.some(v=>!Number.isInteger(v)||Math.abs(v)>9))))throw new Error('Custom outlines need 3 to 10 integer [x,y] points from -9 to 9.'); + } try { parse(source, {ecmaVersion:'latest',sourceType:'module'}); } catch(error) { throw new Error(`Invalid JavaScript; previous preview kept. ${error.message}`); } } diff --git a/easel/test/phone-session.test.mjs b/easel/test/phone-session.test.mjs index bd73c3032b..9f11bf50bb 100644 --- a/easel/test/phone-session.test.mjs +++ b/easel/test/phone-session.test.mjs @@ -465,3 +465,10 @@ test('artifact context reaches the model without replacing the visible user prom assert.equal(input,'Edit the isolated shirt-symbol descriptor.\n\nUser request: Make it a rainbow'); assert.equal(events.findLast(e=>e.type==='you').text,'Make it a rainbow'); }); + +test('malformed Fia circle commands are rejected before the model can save success',async()=>{ + const {validatePieceSource}=await import('../phone/shim/revisions.mjs'); + const source=draw=>'/* ac-artifact '+JSON.stringify({version:1,kind:'shirt-symbol',shape:'drawing',color:'#fac83c',draw})+' */\nexport function paint() {}'; + await assert.rejects(validatePieceSource(source([[0,0,9,1,2,1]]),'piece.mjs'),/Circle exactly/); + await assert.doesNotReject(validatePieceSource(source([[0,0,9,1,1],[1,-8,-6,-8,6,2,1]]),'piece.mjs')); +}); diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 6c38c01732..9390e195f8 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 = 188; +const buildVersion = 190; 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 @@ -1640,6 +1640,46 @@ function poolFloorAt(x,z=0){ const edge=poolDistance(x,z); return edge.dy?{y,bowl}:null; +} +function swimMilk(p,dt,throttle,driveX,driveZ,held,now){ + const milk=milkAt(p.x,p.z||0); + if(!milk||p.y<=milk.y+4){p.swimming=false;return false;} + if(!p.swimming){spawnWaterSplash(p,milk.y);playDrum('whoosh',.35,panPlayer(p));emitSignal('splash',p.pad,0,0);} + p.swimming=true;p.grounded=false;p.poolVert=null; + const depth=p.y-milk.y; + p.vy+=(1800-3240*Math.min(1,depth/90))*dt; + if(held.includes('A'))p.vy-=1700*dt; + if(held.includes('X'))p.vy+=2200*dt; + p.vy*=Math.exp(-dt*2.8); + p.vx=(p.vx+driveX*throttle*1100*dt)*Math.exp(-dt*1.6); + p.vz=((p.vz||0)+driveZ*throttle*1100*dt)*Math.exp(-dt*1.6); + const before={x:p.x,y:p.y,z:p.z};p.x+=p.vx*dt;p.z+=p.vz*dt;p.y+=p.vy*dt; + const floor=poolFloorAt(p.x,p.z);if(p.y>floor){p.y=floor;p.vy=Math.min(0,p.vy);} + boundParkBody(p,before); + p.swimVx=Math.hypot(p.vx,p.vz);p.swimPhase=(p.swimPhase||0)+dt*(2.2+p.swimVx/200); + return true; +} +function drawCerealMilk(t){ + const bowl=parkPools[2],y=parkDeckY+32; + if(!sceneBoundsVisible({minX:bowl.x-bowl.halfX-bowl.radius,maxX:bowl.x+bowl.halfX+bowl.radius,minY:y-10,maxY:y+10,minZ:bowl.z-bowl.halfZ-bowl.radius,maxZ:bowl.z+bowl.halfZ+bowl.radius}))return; + const radius=bowl.radius*Math.acos(2*32/bowl.depth-1)/Math.PI,ring=[]; + for(let corner=0;corner<4;corner++)for(let i=0;i<=8;i++){ + const a=(corner+i/8)*Math.PI/2,sx=corner===0||corner===3?1:-1,sz=corner<2?1:-1; + ring.push({x:bowl.x+sx*bowl.halfX+Math.cos(a)*radius,y,z:bowl.z+sz*bowl.halfZ+Math.sin(a)*radius}); + } + const center={x:bowl.x,y,z:bowl.z};for(let i=0;i({x:x+Math.cos(j*Math.PI/4)*r,y:h,z:z+Math.sin(j*Math.PI/4)*r}); + for(let j=0;j<8;j++)worldQuad(point(j,outer),point(j+1,outer),point(j+1,inner),point(j,inner),i%3?[219,159,68]:[183,113,47]); + } +} function poolSlopeAt(x,z){ const {d,nx,nz,bowl}=poolDistance(x,z); if(d12||value.draw.some(c=> !Array.isArray(c)||![0,1,2].includes(c[0])||c.length!==[5,7,8][c[0]]||c.some(v=>!Number.isInteger(v)||Math.abs(v)>16)|| c.at(-1)<0||c.at(-1)>2||c[0]===0&&(c[3]<1||c[3]>9)||c[0]===1&&(c[5]<1||c[5]>6))))return false; + if(value.animation!==undefined&&!['spin','pulse','float'].includes(value.animation))return false; const faces=value.shape==='custom'?triangulateAeselOutline(value.points):null; if(value.shape==='custom'&&!faces)return false; if(wire===aeselArtifactWire)return true; @@ -4099,24 +4140,26 @@ function drawAeselFairy(t){ const caption=globalThis.__oskiewarRenderFlags?.aeselCaption; const label=typeof caption==='string'?caption:intent===2?'PUTTING A STAR ON YOUR SHIRT':intent===1?'WORKING IN AESEL':''; if(label){ - const p=cameraDoll.project(c),saved=triangleDepth,size=18,w=handleWidth(label,size); + const p=cameraDoll.project(c),saved=triangleDepth,size=42; + const maxChars=Math.max(18,Math.floor((viewWidth()-80)/(size*.54))),shown=label.length>maxChars?label.slice(0,maxChars-1)+"…":label,w=handleWidth(shown,size); if(Number.isFinite(p.x)&&Number.isFinite(p.y)){ const x=Math.max(24,Math.min(viewWidth()-w-24,p.x-w/2)),y=Math.max(32,Math.min(viewHeight-48,p.y-42)); - triangleDepth=-1.49;screenRect(x-8,y-5,w+16,30,[31,24,41]);typeWrite(label,x,y,size,255,231,180);triangleDepth=saved; + triangleDepth=-1.49;screenRect(x-8,y-5,w+16,44,[31,24,41]);typeWrite(shown,x,y,size,255,231,180);triangleDepth=saved; } } } function drawAeselConnect(){ if(!poolOnly()||!sessionName)return; - const label=sessionName,size=32,safe=hudSafeRect(); + const label=sessionName,size=42,safe=hudSafeRect(); const saved=triangleDepth;triangleDepth=-1.49; const qr=shellMode==='MENU'?spectatorQrBox():null; - const x=safe.right-handleWidth(label,size),y=qr?qr.top+qr.size+20:safe.top+72; + const x=safe.right-handleWidth(label,size),y=qr?qr.top+qr.size+20:safe.top+18; const lit=runtime().monotonicUs-aeselSeenAt<3500000,ink=lit?[163,244,211]:[194,196,220]; - filledDisc(x-25,y+7,4,ink); - filledCapsule(x-25,y+14,x-25,y+28,4,ink); - screenTriangle(x-25,y+15,x-40,y+7,x-37,y+24,...ink); - screenTriangle(x-25,y+15,x-10,y+7,x-13,y+24,...ink); + const flutter=.35+.65*Math.abs(Math.sin(runtime().monotonicUs/100000)),bob=Math.sin(runtime().monotonicUs/380000)*2; + const cx=x-30,cy=y+22+bob,span=8+13*flutter; + filledDisc(cx,cy-12,4,ink);filledCapsule(cx,cy-5,cx,cy+10,4,ink); + screenTriangle(cx,cy,cx-span,cy-12,cx-span*.8,cy+10,...ink); + screenTriangle(cx,cy,cx+span,cy-12,cx+span*.8,cy+10,...ink); typeWrite(label,x+1,y+2,size,12,18,30);typeWrite(label,x,y,size,231,247,241); triangleDepth=saved; } @@ -9692,6 +9735,11 @@ function updateCameraDoll(dt, now) { 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); + // Position interpolation can cut across the subject during a front/back orbit. + // Keep the lens outside the body before clearing walls and terrain. + const dx=cameraDoll.position.x-p.x,dz=cameraDoll.position.z-(p.z||0),gap=Math.hypot(dx,dz),minGap=200; + if(gap.01?dx/gap:-Math.cos(angle),nz=gap>.01?dz/gap:-Math.sin(angle); + cameraDoll.position.x=p.x+nx*minGap;cameraDoll.position.z=(p.z||0)+nz*minGap;} // Also protect the eased path while orbiting around a corner of the pool. Object.assign(cameraDoll.position,clearPoolCamera(cameraDoll.position,subject)); cameraDoll.dirty=true; @@ -11788,6 +11836,7 @@ function killPlayer(target, killerPad, now, cause = "KO") { winnerPad: killerPad, cause }; freezeFinalFrame(now, deathCinematic?.winnerPad ?? -1); } + startRagdoll(target,{x:target.x,y:target.y-100,z:target.z},[Math.sign(target.x-(players[killerPad]?.x??target.x-1))*400,-180,0]); target.alive = false; target.headBustedAt = now; target.respawnAt = now + (parkSupply.ko ? 6000000 : 1200000); @@ -12522,6 +12571,7 @@ function updatePoolPlayer(p, pad, dt, now) { // Substep contacts in both horizontal axes, keeping speed tangent to the bowl. const steps=Math.max(1,Math.ceil(dt*120)), step=dt/steps; for(let n=0;n{ + for(let i=0;is.part===lower.part&&/(upper-arm|thigh)$/.test(s.role));if(!upper)continue; + if(upper.b!==lower.a)this.link(upper.b,lower.a); + const l1=this.distance(upper.a,upper.b),l2=this.distance(lower.a,lower.b); + this.link(upper.a,lower.b,Math.sqrt(l1*l1+l2*l2+2*l1*l2*Math.cos(2.55)),(l1+l2)*.999); + } + const torso=segments.find(s=>s.role==='torso'); + if(torso){const roots=[torso.a,torso.b];for(const s of segments)if(/(upper-arm|thigh)$/.test(s.role)&&!roots.includes(s.a))roots.push(s.a); + for(let i=0;i=0)this.link(this.head,nearest); + // Join disconnected authored neck/shoulder fragments with nearest attachments. + const visited=new Uint8Array(64);visited[this.head]=1; + for(let pass=0;pass=3&&maxV<12)?this.quiet+dt:0;if(this.quiet>.75){this.sleeping=true;this.v.fill(0);} + } +} + +const ragdollBodies=new Map(); +function ragdollGeometry(body){ + const p=body.p,at=i=>({x:p[i*3],y:p[i*3+1],z:p[i*3+2]}); + return {head:{...at(body.head),radius:body.r[body.head]},segments:body.bones.map(b=>({role:b.role,part:b.part,width:b.width,x1:p[b.a*3],y1:p[b.a*3+1],z1:p[b.a*3+2],x2:p[b.b*3],y2:p[b.b*3+1],z2:p[b.b*3+2]}))}; +} +function startRagdoll(player,point,impulse){ + if(!poolOnly())return; + const pose=runnerWorldGeometry(player,(runtime().monotonicUs-startedAt)/1e6); + 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. + ragdollBodies.set(player,{body,pose}); + player.frozenGeometry=null;player.replayGeometry=null; +} +function updateRagdolls(dt){ + for(const [player,state] of ragdollBodies){ + if(player.alive||(!players.includes(player)&&!parkKids.includes(player))){ragdollBodies.delete(player);continue;} + if(!state.body.sleeping){state.body.step(dt,poolFloorAt);state.pose=ragdollGeometry(state.body);} + const torso=state.pose.segments.find(s=>s.role==='torso'); + if(torso){player.x=torso.x2;player.y=torso.y2;player.z=torso.z2;} + } +} const renderPoses = new Map(); let sharingRenderPoses = false; function runnerWorldGeometry(player, t) { + const fallen=ragdollBodies.get(player);if(fallen&&!player.alive)return fallen.pose; if (wallPressActive(player)) return wallPressGeometry(player); const cached = sharingRenderPoses && renderPoses.get(player); if (cached && (!player.spiderDummy || cached.t === t)) return cached.pose; @@ -15308,6 +15449,7 @@ function runnerWorldGeometry(player, t) { rotate(pose.head,'x','z'); for(const bone of pose.segments){rotate(bone,'x1','z1');rotate(bone,'x2','z2');} } + if(poolOnly())pose.flow={yaw:(player.poolYaw||0)+(player.spin?.angle||0),phase:player.motionClock??t,swing:player.rig?.arms||0,scale:player.civilian?(player.bodyScale||1):1}; if (sharingRenderPoses) renderPoses.set(player, { t, pose }); return pose; } @@ -15358,7 +15500,7 @@ function updateRig(player, pad, dt) { const crouchTarget = (pumping ? .55 : poolOnly()&&board?.28:0) + (!player.grounded && board ? .3 : 0); springStep(rig, "crouch", crouchTarget, 90, 9, dt); rig.crouch = clamp(rig.crouch, 0, .95); - springStep(rig, "arms", clamp((rig.leanV || 0) * .02, -1.4, 1.4) * strength, 55, 4, dt); + springStep(rig, "arms", clamp((rig.leanV || 0) * .035 + Math.sin((player.motionClock||0)*2.1)*.18, -1.8, 1.8) * strength, 38, 3.6, dt); updateTruckSprings(player, rig, dt); if (!player.grounded || !player.skateboard) player.skidFrom = null; } @@ -16085,10 +16227,11 @@ function projectRunnerWorldGeometry(world) { const headView = cameraDoll.toView(world.head); const headPoint = cameraDoll.project(world.head); const scale = cameraScale(); - const behind = headView.z < cameraPin; + const headBehind=headView.z+world.head.radiuscameraDoll.toView({x:s.x1,y:s.y1,z:s.z1}).z { const cut = behind ? null : worldSegment(segment.x1, segment.y1, segment.z1, @@ -16461,7 +16604,7 @@ function sampleCombatBoxes(player, now, world = null) { const s = world.segments[i]; // Each independently damageable region has its own rectangle, including // limbs extended during recovery. Missing limbs never produce hurtboxes. - const radius = poolOnly()?(s.role==='torso'?21*(player.bodyScale||1):(s.width||10)/2):s.part === 'torso' ? 14 : s.hitboxOnly ? 16 : 10; + const radius = poolOnly()?(s.role==='torso'?(looseCartoonEnabled()?15:21)*(player.bodyScale||1):(s.width||10)/2):s.part === 'torso' ? 14 : s.hitboxOnly ? 16 : 10; hurt.push(combatRect(s.role+'-'+i, Math.min(s.x1,s.x2)-radius, Math.min(s.y1,s.y2)-radius, Math.max(s.x1,s.x2)+radius, Math.max(s.y1,s.y2)+radius, (s.z1+s.z2)/2, @@ -16480,7 +16623,7 @@ function sampleCombatBoxes(player, now, world = null) { const part = kick ? player.facing > 0 ? 'right-leg' : 'left-leg' : itemHand(player); if (part && hasPart(player, part)) { if(poolOnly()){ - const limb=world.segments.find(s=>s.part===part&&(kick?/shin$/:/forearm$/).test(s.role||'')); + const limb=world.segments.findLast(s=>s.part===part&&(kick?/shin$/:/forearm$/).test(s.role||'')); if(limb){const radius=(limb.width||10)/2+4; hit.push(combatRect('spatial-strike',Math.min(limb.x1,limb.x2)-radius,Math.min(limb.y1,limb.y2)-radius, Math.max(limb.x1,limb.x2)+radius,Math.max(limb.y1,limb.y2)+radius,(limb.z1+limb.z2)/2, @@ -17522,7 +17665,7 @@ function drawHandle(handle, x, y, size, colors, fallback) { // angled nose; a curved smile lifted at one corner; blush on the cheeks. It // blinks, and looks the way the skater is going. Laid out in the Trio's // face units (0..1, y up) on the head circle, turned toward the facing side. -function drawTrioFace(player, head, now) { +function drawTrioFace(player, head, now, cameraYaw = null) { if(player.civilian){ const r=head.radius; for(const side of [-1,1]){const x=head.x+side*r*.3,y=head.y+r*.1;filledDisc(x,y,r*.23,[252,250,245]);filledDisc(x+(player.facing||1)*r*.07,y,r*.11,[28,22,34]);} @@ -17535,7 +17678,7 @@ function drawTrioFace(player, head, now) { // projected from the sphere's surface — so features foreshorten as they // turn, crowd toward the rim and pass out of sight round the back. const facing = player.facing || 1; - const yaw = facing * .42 + (player.spin ? player.spin.angle : 0); + const yaw = cameraYaw ?? (facing * .42 + (player.spin ? player.spin.angle : 0)); // A head rolling on its own turns like a wheel, face and all (a head // riding a board keeps its eyes level). const roll = isHeadOnly(player) && !player.skateboard ? player.headRoll || 0 : 0; @@ -18434,6 +18577,168 @@ function updatePonytails(dt,now){ } } +function drawRunnerSymbol(player,c,point,scale,ellipsoid){ + if(player===players[0]&&player.alive&&projectionScaleAt(c)*22*scale>=36){ + const phase=player.heartPhase||0,pulse=Math.max(0,1-phase/.15)+.6*Math.max(0,1-Math.abs(phase-.23)/.1); + const radius=(.23+pulse*.07)*scale,center=point(c,14*scale,7*scale,-9*scale); + const vertex=i=>{const a=i*Math.PI/16;return point(center,0,(13*Math.cos(a)-5*Math.cos(2*a)-2*Math.cos(3*a)-Math.cos(4*a))*radius,16*Math.pow(Math.sin(a),3)*radius);}; + for(let i=0;i<32;i++)worldTriangle(center,vertex(i),vertex(i+1),[255,80,129]); + } + const artifact=player===players[0]?aeselArtifact:null; + if(artifact?.animation){ + const phase=((runtime().unixMs||runtime().monotonicUs/1000)/1000%2)*Math.PI,basePoint=point; + point=(c,f,u,r)=>{ + if(artifact.animation==='spin'){const co=Math.cos(phase),sn=Math.sin(phase),x=r;r=x*co-u*sn;u=x*sn+u*co;} + else if(artifact.animation==='pulse'){const k=.85+Math.sin(phase)*.15;u*=k;r*=k;} + else if(artifact.animation==='float')u+=Math.sin(phase)*2*scale; + return basePoint(c,f,u,r); + }; + } + const shape=artifact?.shape||(player===players[0]&&globalThis.__oskiewarRenderFlags?.shirtSymbol===2?'star':'flower'); + const symbolColor=artifact?.rgb||[250,200,60]; + if(shape==='drawing'){ + const colors=[symbolColor,[24,18,26],[255,255,250]]; + const vertex=(x,y,layer)=>point(c,(12+layer*.06)*scale,y*scale,x*scale); + const disc=(x,y,r,ink,layer)=>{for(let i=0;i<16;i++){const a=i*Math.PI/8,b=(i+1)*Math.PI/8;worldTriangle(vertex(x,y,layer),vertex(x+Math.cos(a)*r,y+Math.sin(a)*r,layer),vertex(x+Math.cos(b)*r,y+Math.sin(b)*r,layer),ink);}}; + artifact.draw.forEach((cmd,layer)=>{ + const ink=colors[cmd.at(-1)]; + if(cmd[0]===0)disc(cmd[1],cmd[2],cmd[3],ink,layer); + else if(cmd[0]===2)worldTriangle(vertex(cmd[1],cmd[2],layer),vertex(cmd[3],cmd[4],layer),vertex(cmd[5],cmd[6],layer),ink); + else{const [,x,y,xx,yy,w]=cmd,len=Math.hypot(xx-x,yy-y)||1,nx=-(yy-y)/len*w/2,ny=(xx-x)/len*w/2; + worldQuad(vertex(x+nx,y+ny,layer),vertex(xx+nx,yy+ny,layer),vertex(xx-nx,yy-ny,layer),vertex(x-nx,y-ny,layer),ink); + disc(x,y,w/2,ink,layer);disc(xx,yy,w/2,ink,layer);} + }); + }else if(shape==='custom'){ + for(const face of artifact.faces){const v=face.map(i=>point(c,12*scale,artifact.points[i][1]*scale,artifact.points[i][0]*scale));worldTriangle(v[0],v[1],v[2],symbolColor);} + }else if(shape==='star'||shape==='heart'){ + const center=point(c,12*scale,0,0),count=shape==='star'?10:32; + const vertex=i=>{const a=i*Math.PI*2/count; + if(shape==='heart')return point(c,12*scale,(13*Math.cos(a)-5*Math.cos(2*a)-2*Math.cos(3*a)-Math.cos(4*a))*.38*scale,16*Math.pow(Math.sin(a),3)*.38*scale); + const r=(i%2?2.6:6)*scale;return point(c,12*scale,Math.cos(a)*r,Math.sin(a)*r);}; + for(let i=0;ipoint(c,12*scale,(Math.sin(j*Math.PI/12)*r-3)*scale,Math.cos(j*Math.PI/12)*r*scale); + const r=8-band*.85;worldQuad(v(i,r),v(i+1,r),v(i+1,r-.85),v(i,r-.85),colors[band]); + } + }else{ + for(let i=0;i<6;i++){const a=i/6*Math.PI*2;ellipsoid(point(c,12*scale,Math.cos(a)*3*scale,Math.sin(a)*3*scale),.8*scale,2*scale,2*scale,[255,255,250],4,2);} + ellipsoid(point(c,13*scale,0,0),1*scale,2*scale,2*scale,symbolColor,4,2); + } +} + +// One sampled 3D curve supplies both the cartoon strokes and contact capsules. +const loosePoseCache=new WeakMap(); +function looseRunnerGeometry(world,steps=8){ + const cached=loosePoseCache.get(world);if(cached&&cached.steps===steps)return cached; + const segments=[],used=new Set(),end=(b,n)=>({x:b['x'+n],y:b['y'+n],z:b['z'+n]}); + for(const upper of world.segments){ + if(used.has(upper))continue; + const lower=/(thigh|upper-arm)$/.test(upper.role||'')?world.segments.find(b=>b.part===upper.part&&/(shin|forearm)$/.test(b.role||'')):null; + if(!lower){segments.push({...upper,width:upper.role==='torso'?30*(world.flow?.scale||1):upper.width});continue;} + used.add(upper);used.add(lower); + const a=end(upper,1),joint=end(upper,2),b=end(lower,2); + const control={};for(const k of ['x','y','z'])control[k]=2*joint[k]-(a[k]+b[k])*.5; + if(world.flow){ + const f=world.flow,side=upper.part.startsWith('left')?-1:1,arm=/upper-arm$/.test(upper.role); + const bow=(arm?8:3.5)+Math.sin(f.phase*2.1+side*.9)*(arm?4:2.3)+(arm?f.swing*5:0); + control.x-=Math.sin(f.yaw)*bow*side*f.scale;control.z+=Math.cos(f.yaw)*bow*side*f.scale; + } + let previous=a; + for(let i=1;i<=steps;i++){ + const u=i/steps,q=1-u,next={};for(const k of ['x','y','z'])next[k]=q*q*a[k]+2*q*u*control[k]+u*u*b[k]; + const original=i<=steps/2?upper:lower; + segments.push({...original,x1:previous.x,y1:previous.y,z1:previous.z,x2:next.x,y2:next.y,z2:next.z});previous=next; + } + } + const result={...world,segments,steps};loosePoseCache.set(world,result);return result; +} +const looseCartoonEnabled=()=>poolOnly()&&globalThis.__oskiewarRenderFlags?.solidCharacter!==true; +function drawLooseRunner(player,world,t,lod){ + const radius=world.head.radius*projectionScaleAt(world.head); + const pose=looseRunnerGeometry(world,radius<15?3:radius<40?5:8),geometry=projectRunnerWorldGeometry(pose); + if(geometry.behind)return; + const saved=triangleDepth,ink=[20,17,28],skin=player.color,hair=player.hairColor||[35,27,34]; + const scale=player.civilian?(player.bodyScale||1):1; + const stroke3=(a,b,width,color,edge=0,depth=null)=>{ + const cut=worldSegment(a.x,a.y,a.z,b.x,b.y,b.z);if(!cut)return; + const k=projectionScaleAt({x:(a.x+b.x)*.5,y:(a.y+b.y)*.5,z:(a.z+b.z)*.5}); + triangleDepth=depth??((cut.from.z+cut.to.z)*.5-.00002); + filledCapsule(cut.from.x,cut.from.y,cut.to.x,cut.to.y,width*k+edge,color); + }; + if(player.skateboard)drawSkateboard(player); + const end=(b,n)=>({x:b['x'+n],y:b['y'+n],z:b['z'+n]}); + const groups=new Map(); + for(const bone of geometry.segments){ + if(bone.hidden||bone.hitboxOnly||!hasPart(player,bone.part))continue; + const key=bone.part||bone.role;if(!groups.has(key))groups.set(key,[]);groups.get(key).push(bone); + } + const ordered=[...groups.values()].map(bones=>({bones,depth:bones.reduce((sum,b)=>sum+b.depth,0)/bones.length})).sort((a,b)=>b.depth-a.depth); + for(const {bones,depth} of ordered){ + const limb=/arm|leg/.test(bones[0].part||''); + if(!limb){ + triangleDepth=depth; + for(const b of bones)filledCapsule(b.x1,b.y1,b.x2,b.y2,b.width+3,ink); + triangleDepth=depth-.00001; + for(const b of bones)filledCapsule(b.x1,b.y1,b.x2,b.y2,b.width,b.role==='torso'?player.shirtColor||[252,252,250]:skin); + continue; + } + const points=[{x:bones[0].x1,y:bones[0].y1,width:bones[0].width}]; + for(const b of bones)points.push({x:b.x2,y:b.y2,width:b.width}); + for(let i=0;ib.role==='torso'); + const yaw=(player.poolYaw||0)+(player.spin?.angle||0),forward={x:Math.cos(yaw),y:0,z:Math.sin(yaw)}; + const view=cameraDoll.view,front=-(forward.x*view.forward.x+forward.z*view.forward.z); + const point=(c,f,u,r)=>({x:c.x-view.forward.x*f+view.right.x*r,y:c.y-u-view.forward.y*f+view.right.y*r,z:c.z-view.forward.z*f+view.right.z*r}); + if(torso){ + const hip=end(torso,2),neck=end(torso,1); + if(!player.pantsColor){ + const skirt=player.skirtColor||[188,164,226],sway=Math.sin((player.poolStridePhase||0)*Math.PI*2)*3; + const a=point(hip,1,1,-11*scale),b=point(hip,1,1,11*scale),c=point(hip,1,-34*scale,23*scale+sway),d=point(hip,1,-34*scale,-23*scale+sway); + worldQuad(a,b,c,d,skirt); + stroke3(a,d,1,ink);stroke3(d,c,1,ink);stroke3(c,b,1,ink); + for(const r of [-.45,0,.45])stroke3(point(hip,1.1,0,r*11*scale),point(hip,1.1,-34*scale,r*23*scale+sway),.6,mixColor(skirt,ink,.25)); + } + if(front>0&&lod===0){ + const c={x:lerp(hip.x,neck.x,.65),y:lerp(hip.y,neck.y,.65),z:lerp(hip.z,neck.z,.65)}; + const disc=(center,rx,ry,rz,color)=>{const p=cameraDoll.project(center);triangleDepth=p.z-.00002;filledDisc(p.x,p.y,ry*projectionScaleAt(center),color);}; + drawRunnerSymbol(player,c,point,scale,disc); + } + } + const pony=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); + drawTrioFace(player,h,runtime().monotonicUs,faceYaw); + } + } + if(player.gunAmmo>0){const gun=gunPose(player,runtime().monotonicUs);stroke3(gun.hand,gun.muzzle,7,[35,35,43]);} + drawHeldAxe(player,t);triangleDepth=saved; +} + function drawSpatialRunner(player,world,t,lod=0){ const scale=player.civilian?(player.bodyScale||1):1; // The ink silhouette stays smooth even when the shaded volume uses fewer @@ -18523,39 +18828,7 @@ function drawSpatialRunner(player,world,t,lod=0){ } if(dressed&&lod===0){ const c={x:lerp(hip.x,neck.x,.65),y:lerp(hip.y,neck.y,.65),z:lerp(hip.z,neck.z,.65)}; - const artifact=player===players[0]?aeselArtifact:null; - const shape=artifact?.shape||(player===players[0]&&globalThis.__oskiewarRenderFlags?.shirtSymbol===2?'star':'flower'); - const symbolColor=artifact?.rgb||[250,200,60]; - if(shape==='drawing'){ - const colors=[symbolColor,[24,18,26],[255,255,250]]; - const vertex=(x,y,layer)=>point(c,(12+layer*.06)*scale,y*scale,x*scale); - const disc=(x,y,r,ink,layer)=>{for(let i=0;i<16;i++){const a=i*Math.PI/8,b=(i+1)*Math.PI/8;worldTriangle(vertex(x,y,layer),vertex(x+Math.cos(a)*r,y+Math.sin(a)*r,layer),vertex(x+Math.cos(b)*r,y+Math.sin(b)*r,layer),ink);}}; - artifact.draw.forEach((cmd,layer)=>{ - const ink=colors[cmd.at(-1)]; - if(cmd[0]===0)disc(cmd[1],cmd[2],cmd[3],ink,layer); - else if(cmd[0]===2)worldTriangle(vertex(cmd[1],cmd[2],layer),vertex(cmd[3],cmd[4],layer),vertex(cmd[5],cmd[6],layer),ink); - else{const [,x,y,xx,yy,w]=cmd,len=Math.hypot(xx-x,yy-y)||1,nx=-(yy-y)/len*w/2,ny=(xx-x)/len*w/2; - worldQuad(vertex(x+nx,y+ny,layer),vertex(xx+nx,yy+ny,layer),vertex(xx-nx,yy-ny,layer),vertex(x-nx,y-ny,layer),ink); - disc(x,y,w/2,ink,layer);disc(xx,yy,w/2,ink,layer);} - }); - }else if(shape==='custom'){ - for(const face of artifact.faces){const v=face.map(i=>point(c,12*scale,artifact.points[i][1]*scale,artifact.points[i][0]*scale));worldTriangle(v[0],v[1],v[2],symbolColor);} - }else if(shape==='star'||shape==='heart'){ - const center=point(c,12*scale,0,0),count=shape==='star'?10:32; - const vertex=i=>{const a=i*Math.PI*2/count; - if(shape==='heart')return point(c,12*scale,(13*Math.cos(a)-5*Math.cos(2*a)-2*Math.cos(3*a)-Math.cos(4*a))*.38*scale,16*Math.pow(Math.sin(a),3)*.38*scale); - const r=(i%2?2.6:6)*scale;return point(c,12*scale,Math.cos(a)*r,Math.sin(a)*r);}; - for(let i=0;ipoint(c,12*scale,(Math.sin(j*Math.PI/12)*r-3)*scale,Math.cos(j*Math.PI/12)*r*scale); - const r=8-band*.85;worldQuad(v(i,r),v(i+1,r),v(i+1,r-.85),v(i,r-.85),colors[band]); - } - }else{ - for(let i=0;i<6;i++){const a=i/6*Math.PI*2;ellipsoid(point(c,12*scale,Math.cos(a)*3*scale,Math.sin(a)*3*scale),.8*scale,2*scale,2*scale,[255,255,250],4,2);} - ellipsoid(point(c,13*scale,0,0),1*scale,2*scale,2*scale,symbolColor,4,2); - } + drawRunnerSymbol(player,c,point,scale,ellipsoid); } } if(!player.headless){ @@ -18602,6 +18875,7 @@ function drawRunner(player,t,showLabel=true){ try{return drawRunnerAtScale(player,t,showLabel);}finally{figureDrawScale=previous;} } function drawRunnerAtScale(player, t, showLabel = true) { + const fallen=ragdollBodies.get(player);if(fallen&&!player.alive){drawLooseRunner(player,fallen.pose,t,0);if(player.headless&&player.looseHead)drawCivilianLooseHead(player);return;} if(player.civilian && player.headless){drawCivilianDebris(player);return;} if(player.dummy)player=dummyView(player); // A fighter who is down is still on the stage: they read as the broken body @@ -18658,7 +18932,10 @@ function drawRunnerAtScale(player, t, showLabel = true) { } const lod = poolOnly()&&player.civilian?Math.max(geometry.head.radius<12?2:1,figureLod(player,geometry)):figureLod(player, geometry); if(poolOnly()&&player.alive&&!player.dummy){ - drawSpatialRunner(player,player.replayGeometry||player.frozenGeometry||runnerWorldGeometry(player,t),t,lod);return; + const world=player.replayGeometry||player.frozenGeometry||runnerWorldGeometry(player,t); + if(looseCartoonEnabled())drawLooseRunner(player,world,t,lod); + else drawSpatialRunner(player,world,t,lod); + return; } if (lod > 0) { drawFigureLod(player, geometry, lod, t); return; } // Preserve the fighter's identity color during hit flash. A pure white body @@ -22386,7 +22663,7 @@ function gamePaint() { drawRunner(renderable.item, t, showRunnerLabels); } } - if(poolOnly())drawAeselFairy(t); + if(poolOnly()){drawCerealMilk(t);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);} @@ -23047,7 +23324,7 @@ function drawSeatPlayerHud(ink){ if(p.dummy)continue; const i=p.pad,{idle,measure}=seatHudReadout(p,now); const accent=p.wheelTurbo?[209,129,255]:i?[174,161,255]:[255,144,188]; - const size=idle?(compactLayout()?64:84):54; + const size=42; const heartRadius=idle?size*.23:0,heartSpace=idle?heartRadius*3+18:0; const width=handleWidth(measure,size)+heartSpace; const solo=activePlayers().filter(p=>!p.dummy).length===1; @@ -23055,24 +23332,28 @@ function drawSeatPlayerHud(ink){ 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*.55; + const hx=x+handleWidth(measure,size)+heartSpace/2,hy=y+size*.75; drawSeatHeart(hx+5,hy+5,p,[9,12,24],heartRadius); drawSeatHeart(hx,hy,p,accent,heartRadius); } } triangleDepth=old; } +function seatActionText(p,now){ + 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(); + const held=[]; + if(p.axeHeld)held.push('AXE');if(p.swordHeld)held.push('SWORD');if(p.gunAmmo>0)held.push('PISTOL'); + if(p.heldBall>=0)held.push('BALL');if(p.heldPlayer>=0)held.push('PARTNER'); + return action+(held.length?' W/ '+held.join(' + '):''); +} function drawSeatAction(p){ - if(shellMode!=="GAME")return; - if(parkSupply.ko || !p.alive)return; - const now=runtime().monotonicUs; - const text=p.wheelTurbo?'SUPER TURBO':p.spin?'SPIN':now-(p.lastButtonAt||0)<850000?p.lastButton:''; - if(!text || ['NONE','LEFT','RIGHT','UP','DOWN'].includes(text))return; - const at=projectPoint(p.x,p.y,p.z||0),size=32; - at.y+=28; + if(shellMode!=="GAME"||parkSupply.ko||!p.alive||p.dummy)return; + const text=seatActionText(p,runtime().monotonicUs),size=42,safe=hudSafeRect(); const old=triangleDepth;triangleDepth=hudDepth; - const width=handleWidth(text,size),x=clamp(at.x-width/2,14,viewWidth()-width-14),y=clamp(at.y,14,viewHeight-size-30); - // CSS palette: violet, gold, springgreen, deepskyblue, hotpink, lightskyblue. + const width=handleWidth(text,size),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; @@ -23317,6 +23598,7 @@ function resetParkKids(){ 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.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; @@ -23381,9 +23663,15 @@ function damageParkCivilian(kid,owner,at,now,damage){ 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}); playDrum('snare',.65,panAt(kid.x,kid.z)); - if(kid.health<=0)popCivilianHead(kid,owner,runnerWorldGeometry(kid,(now-startedAt)/1e6),now); + if(kid.health<=0){ + startRagdoll(kid,at,[dx/length*500,-180,dz/length*500]); + kid.alive=false;kid.attackKind='';kid.vx=kid.vy=kid.vz=0; + } } function popCivilianHead(kid,owner,g,now){ + startRagdoll(kid,g.head,[Math.sign(kid.x-owner.x||1)*400,-120,0]); + const body=ragdollBodies.get(kid)?.body; + if(body){body.w[body.head]=0;for(let j=0;j=.23&&previousHeartPhase<.23) + playDrum('kick',.12,0); 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 fb476483c1..e084f77ba8 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 {mainNativeCamera,characterLocalCamera,spectatorState,netDrainHostInbox,players,updatePlayer,runnerWorldGeometry,projectRunnerWorldGeometry,cameraDoll, + return {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, @@ -218,12 +218,19 @@ test('idle camera eases close then opens back up when movement resumes',()=>{ p.vx=400;for(let i=0;i<120;i++)a.updateCameraDoll(1/60,a.now()); assert.ok(gap()>900);assert.ok(a.cameraDoll.target.x-p.x>200); }); -test('BPM emits one quiet beat when the local heart phase wraps',()=>{ - const a=playground(),p=a.players[0];p.heartPhase=.999;p.heartRate=68; +test('heartbeat stays audible while moving and adds a quieter second thump',()=>{ + const a=playground(),p=a.players[0];p.heartPhase=.999;p.heartRate=68;p.vx=500; a.updateSeatHeartbeat(1/60,a.now()); - assert.equal(a.drums.filter(d=>d.name==='kick'&&d.gain===.075).length,1); + assert.equal(a.drums.filter(d=>d.name==='kick'&&d.gain===.23).length,1); a.updateSeatHeartbeat(1/60,a.now()); - assert.equal(a.drums.filter(d=>d.name==='kick'&&d.gain===.075).length,1); + assert.equal(a.drums.filter(d=>d.name==='kick'&&d.gain===.23).length,1); +}); + +test('basic actions remain visible without a fresh button press',()=>{ + const a=playground(),p=a.players[0];p.lastButton='NONE';p.lastButtonAt=0; + assert.equal(a.seatActionText(p,a.now()),'STANDING'); + p.vx=100;assert.equal(a.seatActionText(p,a.now()),'WALKING'); + p.grounded=false;assert.equal(a.seatActionText(p,a.now()),'IN THE AIR'); }); test('spatial character remains volumetric at every LOD and heading',()=>{ @@ -383,3 +390,56 @@ test('custom drawings and concave outlines are data, applied once without evalua assert.equal(a.spectatorState(a.now()).aesel.artifact,outline,'crossed outline is rejected'); }finally{delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} }); + +test('lethal civilian hit retains a connected simulated body and finite shared hit geometry',()=>{ + const a=playground();a.resetParkKids();const kid=a.parkKids[0],p=a.players[0]; + const before=a.runnerWorldGeometry(kid,0); + a.damageParkCivilian(kid,p,before.head,a.now(),10); + assert.equal(kid.alive,false);assert.equal(kid.headless,undefined); + const body=a.ragdollBodies.get(kid).body;assert.ok(body.bones.length>8); + const headY=body.p[body.head*3+1]; + for(let i=0;i<120;i++)a.updateRagdolls(1/60); + assert.notEqual(body.p[body.head*3+1],headY); + const pose=a.runnerWorldGeometry(kid,0); + for(const bone of pose.segments)for(const key of ['x1','y1','z1','x2','y2','z2'])assert.ok(Number.isFinite(bone[key])); + assert.doesNotThrow(()=>a.drawRunner(kid,0)); + kid.alive=true;a.updateRagdolls(1/60);assert.equal(a.ragdollBodies.has(kid),false); +}); +test('artifact animation survives acknowledgment and rejects unknown motion',()=>{ + const a=playground(); + try {for(const animation of ['spin','pulse','float']){ + const wire=JSON.stringify({version:1,kind:'shirt-symbol',shape:'heart',color:'#ff4488',animation}); + globalThis.__oskiewarNetInbox=[{kind:'render-flags',flags:{aeselArtifact:wire}}];a.netDrainHostInbox(); + assert.equal(a.spectatorState(a.now()).aesel.artifact,wire); + assert.doesNotThrow(()=>a.drawRunner(a.players[0],0)); + globalThis.__oskiewarNetInbox=[{kind:'render-flags',flags:{aeselArtifact:wire.replace(animation,'unknown')}}];a.netDrainHostInbox(); + assert.equal(a.spectatorState(a.now()).aesel.artifact,wire); + }}finally{delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} +}); + +test('only the cereal bowl contains milk and its buoyancy works in 3D',()=>{ + const a=playground(),p=a.players[0]; + assert.equal(a.milkAt(a.parkPools[0].x,a.parkPools[0].z),null); + assert.equal(a.milkAt(a.parkPools[1].x,a.parkPools[1].z),null); + const bowl=a.parkPools[2],milk=a.milkAt(bowl.x,bowl.z);assert.ok(milk); + Object.assign(p,{x:bowl.x,z:bowl.z,y:a.poolFloorAt(bowl.x,bowl.z),vx:0,vy:0,vz:0,grounded:true}); + const bottom=p.y;for(let i=0;i<180;i++)a.step([],1/60); + assert.equal(p.swimming,true);assert.ok(p.ymilk.y); + p.x=bowl.x+1500;a.step();assert.equal(p.swimming,false); +}); +test('visual rope subdivision does not add combat hitboxes',()=>{ + const a=playground(),p=a.players[0],world=a.runnerWorldGeometry(p,0); + assert.ok(a.looseRunnerGeometry(world).segments.length>world.segments.length); + assert.equal(a.sampleCombatBoxes(p,a.now()).hurt.length,world.segments.length+1); +}); + +test('status line names held items alongside basic movement',()=>{ + const a=playground(),p=a.players[0];Object.assign(p,{lastButton:'NONE',lastButtonAt:0,axeHeld:true,swordHeld:false,gunAmmo:0,heldBall:-1,heldPlayer:-1}); + assert.equal(a.seatActionText(p,a.now()),'STANDING W/ AXE'); + p.vx=100;p.axeHeld=false;p.gunAmmo=5;assert.equal(a.seatActionText(p,a.now()),'WALKING W/ PISTOL'); +}); +test('a head behind the near plane does not hide visible limbs',()=>{ + const a=playground();a.cameraDoll.snap({position:{x:0,y:0,z:0},target:{x:0,y:0,z:1},width:1800,perspective:1}); + 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); +}); -- 2.51.2