diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index 3589e6ee1d..be5ad85258 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -17,6 +17,11 @@ struct AeselApp: App { .windowStyle(.hiddenTitleBar) .defaultSize(width: 840, height: 680) .commands { AeselWindowCommands() } + Window("About Aesel", id: "about") { + AeselAboutView() + } + .windowResizability(.contentSize) + .defaultPosition(.center) #else WindowGroup { AeselWorkspace(windowID: "main") } #endif @@ -34,10 +39,26 @@ struct AeselApp: App { } #if os(macOS) +private struct AeselAboutView: View { + var body: some View { + VStack(spacing: 18) { + AeselDonkey(busy: false, failed: false).accessibilityHidden(true) + Text("Aesel").font(.system(size: 36, weight: .medium, design: .serif)) + Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "") + .font(.system(size: 13, design: .monospaced)).foregroundStyle(.secondary) + Link("Privacy Policy", destination: URL(string: "https://aesthetic.computer/privacy-policy.html")!) + } + .padding(32).frame(width: 320) + } +} + private struct AeselWindowCommands: Commands { @Environment(\.openWindow) private var openWindow @AppStorage("aesel.uiScale") private var uiScale = 1.0 var body: some Commands { + CommandGroup(replacing: .appInfo) { + Button("About Aesel") { openWindow(id: "about") } + } CommandGroup(replacing: .newItem) { Button("New Window") { openWindow(id: "workspace", value: UUID().uuidString) } .keyboardShortcut("n", modifiers: .command) diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index b09ad328b0..4c9872001b 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -22,11 +22,10 @@ struct ContentView: View { @State private var fileNotice: String? @State private var showSettings = false @State private var signInArrived = false - @State private var showAccountDeletion = false - @State private var deletionAccount = "" @State private var showVolume = false @State private var braincells = Braincells() @StateObject private var preview = PiecePreview() + @StateObject private var oskiewar = OskiewarAttachment() @State private var expandedPreview = false @State private var previewHidden = false @State private var notebookHeight: CGFloat = 24 @@ -245,13 +244,18 @@ struct ContentView: View { .onChange(of: session.showSignIn) { if !session.showSignIn { host.cancelSignIn() } } .onChange(of: session.signedIn) { if session.signedIn { Task { await braincells.load() } } } .task { braincells.start(token: { host.accessToken() }, credited: { host.refreshCredits() }) } - .onDisappear { host.automation.stop() } + .onChange(of: oskiewar.status) { fileNotice = oskiewar.status } + .onDisappear { host.automation.stop(); oskiewar.disconnect() } .onAppear { host.automation.inspect = { automationState } host.automation.perform = { action, params in try await automationAction(action, params) } host.automation.preview = preview.view host.automation.retryPreview = { preview.reload() } host.automation.start() + if let connection = ProcessInfo.processInfo.environment["AESEL_OSKIEWAR"], connection.hasPrefix("oskiewar://") { + draft = connection + oskiewar.connect(connection) + } #if DEBUG if ProcessInfo.processInfo.environment["AESEL_NOTEBOOK_PREVIEW"] == "1" { showHome = false @@ -268,14 +272,6 @@ struct ContentView: View { } message: { Text("Your Aesthetic Computer account is locked and will be deleted\(session.accountDeletionDate.map { " on " + $0.formatted(date: .long, time: .omitted) } ?? " in 14 days").\(session.accountDeletionMailed ? " We emailed you a link to keep it." : "") Your local notebooks remain on this device.") } - .alert("Delete \(deletionAccount)?", isPresented: $showAccountDeletion) { - Button("Cancel", role: .cancel) { host.cancelAccountDeletion() } - Button("Delete Account", role: .destructive) { host.deleteAccount() } - } message: { - Text((session.accountDeletionSummary.isEmpty - ? "Your account locks now and is deleted after 14 days: your handle, pieces, paintings, moods, tapes, chat messages and uploads. KidLisp that is minted or used in other people's pieces stays, without your name. Unused braincells are lost. We email you a link to keep the account until then." - : session.accountDeletionSummary) + " Your local notebooks stay on this device.") - } .sheet(item: Binding(get: { session.approval }, set: { value in if value == nil, let pending = session.approval { host.respondToApproval(id: pending.id, decision: "decline") } })) { request in @@ -635,28 +631,24 @@ struct ContentView: View { Rectangle().fill(paint.ink.opacity(0.16)).frame(height: 1) if session.signedIn { Text(session.handle.isEmpty ? "Aesthetic Computer account" : "@\(session.handle)") - HStack { - Button { host.signOut() } label: { - Label("Log out", systemImage: "rectangle.portrait.and.arrow.right") - .padding(.horizontal, 14).padding(.vertical, 10) - }.buttonStyle(AeselTintedButtonStyle(tint: Color(rgb: 0x287bdb))) - Spacer() - Menu { - Button(session.accountDeletionBusy ? "Deleting account…" : "Delete account…", role: .destructive) { - if let label = host.prepareAccountDeletion() { - deletionAccount = label - showAccountDeletion = true - } - }.disabled(session.busy || session.accountDeletionBusy) - } label: { Image(systemName: "ellipsis").frame(width: 32, height: 32) } - .menuStyle(.borderlessButton).fixedSize() - .accessibilityLabel("Account actions") + .font(.custom("ComicRelief-Bold", size: 26)) + .foregroundStyle(paint.ink) + .shadow(color: paint.accent.opacity(0.55), radius: 0, x: 2, y: 2) + .padding(.bottom, 4) + HStack(spacing: 16) { + accountAction("Log out") { closeSettings { host.signOut() } } + Spacer(minLength: 0) + Button { + openURL(URL(string: "https://aesthetic.computer/delete-erase-and-forget-me")!) + } label: { + Label("Manage account", systemImage: "arrow.up.right") + } + .accessibilityHint("Opens account management in your browser") } } else { settingsItem("Sign in to Aesthetic Computer") { closeSettings { host.signIn() } } } if !session.accountNotice.isEmpty { Text(session.accountNotice).font(Paint.font(13)) } - Link("Privacy policy", destination: URL(string: "https://aesthetic.computer/privacy-policy.html")!) Rectangle().fill(paint.ink.opacity(0.16)).frame(height: 1) AeselVersionList(session: session, host: host, onSelect: { closeSettings() }) }.padding(.horizontal, 20).padding(.bottom, 20) @@ -742,6 +734,8 @@ struct ContentView: View { guard !session.viewingHistory else { return } let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } + if text.hasPrefix("oskiewar://") { oskiewar.connect(text); draft = ""; return } + if text == "/disconnect" { oskiewar.disconnect(); draft = ""; return } switch Session.command(for: text) { case "new", "home": writing = false; showHome = true case "publish": host.publish() @@ -805,6 +799,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], "composer": ["characters": draft.count, "placeholder": "", "focused": writing], "title": ["opacity": 1, "linked": titleURL != nil], "footer": "v\(session.displayedRevision)", "session": ["id": session.currentSessionID, "busy": session.busy, "status": session.status, "signedIn": session.signedIn, "accountReady": session.accountReady, @@ -984,3 +979,59 @@ private struct CurtainAccountButtonStyle: ButtonStyle { blue: Double(hex & 255) / 255) } } + +// A live game attachment, owned by this window. Presence expires in the game +// if the window closes, the process stops, or the network drops. +@MainActor +private final class OskiewarAttachment: ObservableObject { + @Published var status = "" + private var socket: URLSessionWebSocketTask? + private var receiver: Task? + private var heartbeat: Task? + private(set) var room = "" + + func disconnect() { + receiver?.cancel(); heartbeat?.cancel() + socket?.cancel(with: .goingAway, reason: nil) + socket = nil; receiver = nil; heartbeat = nil; room = ""; status = "Oskiewar disconnected" + } + + func connect(_ text: String) { + let name = String(text.dropFirst("oskiewar://".count)).lowercased() + guard name.range(of: "^[a-z0-9-]{5,24}$", options: .regularExpression) != nil else { + status = "Invalid Oskiewar connection string"; return + } + disconnect(); room = name; status = "Connecting to Oskiewar…" + var url = URLComponents(string: "wss://session-server.aesthetic.computer/oskiewar-live")! + url.queryItems = [URLQueryItem(name: "match", value: "ow-" + name), URLQueryItem(name: "role", value: "agent")] + let task = URLSession.shared.webSocketTask(with: url.url!) + socket = task; task.resume() + receiver = Task { [weak self] in + do { + while !Task.isCancelled { + let message = try await task.receive() + guard let self, self.socket === task else { return } + guard case .string(let text) = message, + let data = text.data(using: .utf8), + let value = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + if value["type"] as? String == "oskiewar:error" { + self.status = "Oskiewar connection rejected"; self.heartbeat?.cancel(); return + } + if value["type"] as? String == "oskiewar:state" && self.heartbeat == nil { + self.status = "Connected · oskiewar://" + name + self.heartbeat = Task { [weak self] in + var pulse = 0 + do { + while !Task.isCancelled { + pulse = (pulse + 1) % 64 + try await task.send(.string("{\"type\":\"oskiewar:flags\",\"content\":{\"aeselPulse\":\(pulse)}}")) + try await Task.sleep(nanoseconds: 1_000_000_000) + } + } catch { if !Task.isCancelled { self?.status = "Oskiewar connection lost" } } + } + } + } + } catch { if !Task.isCancelled { self?.status = "Oskiewar connection lost" } } + } + } +} diff --git a/apple/aesel/Sources/SessionStore.swift b/apple/aesel/Sources/SessionStore.swift index ac802f0d79..c461bcba49 100644 --- a/apple/aesel/Sources/SessionStore.swift +++ b/apple/aesel/Sources/SessionStore.swift @@ -136,12 +136,19 @@ final class SessionStore { } private func saveToken(_ token: String) { - clearToken() - guard !token.isEmpty else { return } - var query = tokenQuery - query[kSecValueData as String] = Data(token.utf8) - query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly - let status = SecItemAdd(query as CFDictionary, nil) + guard !token.isEmpty else { clearToken(); return } + // Session checkpoints repeat the token. Do not delete/recreate its + // Keychain item: that loses the user's saved access permission. + guard self.token() != token else { return } + let data = Data(token.utf8) + var status = SecItemUpdate(tokenQuery as CFDictionary, + [kSecValueData as String: data] as CFDictionary) + if status == errSecItemNotFound { + var query = tokenQuery + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + status = SecItemAdd(query as CFDictionary, nil) + } if status != errSecSuccess { NSLog("[aesel] Keychain save failed (%d); sign in again after relaunch", status) } } diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 0aae247e9b..687600ad1d 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 = 187; +const buildVersion = 188; 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 @@ -4039,6 +4039,32 @@ let navigationPrevious = [[], []]; // it. Every flag missing means the full picture; xbox/tools/oskiewar-ablate // walks them and reads the price back out of the live fps telemetry. let renderFlags = {}; +let aeselPulseSeen=null,aeselSeenAt=-Infinity; +function drawAeselFairy(t){ + const pulse=globalThis.__oskiewarRenderFlags?.aeselPulse,now=runtime().monotonicUs; + if(Number.isFinite(pulse)&&pulse!==aeselPulseSeen){aeselPulseSeen=pulse;aeselSeenAt=now;} + if(now-aeselSeenAt>3500000||!players[0].alive)return; + const head=runnerWorldGeometry(players[0],t).head; + const c={x:head.x+Math.sin(t*1.8)*20,y:head.y-70+Math.sin(t*3)*5,z:head.z+Math.cos(t*1.8)*20}; + const wing=14+Math.sin(t*28)*6; + worldQuad({x:c.x,y:c.y-5,z:c.z},{x:c.x-wing,y:c.y-13,z:c.z-5},{x:c.x-wing,y:c.y+7,z:c.z+5},c,[183,243,237]); + worldQuad(c,{x:c.x+wing,y:c.y+7,z:c.z+5},{x:c.x+wing,y:c.y-13,z:c.z-5},{x:c.x,y:c.y-5,z:c.z},[238,185,247]); + worldQuad({x:c.x-3,y:c.y-8,z:c.z-3},{x:c.x+3,y:c.y-8,z:c.z+3},{x:c.x+2,y:c.y+8,z:c.z+2},{x:c.x-2,y:c.y+8,z:c.z-2},[255,231,127]); +} +function drawAeselConnect(){ + if(!poolOnly()||!sessionName)return; + const label=sessionName,size=32,safe=hudSafeRect(); + const saved=triangleDepth;triangleDepth=-1.49; + const x=safe.right-handleWidth(label,size),y=safe.top+72; + 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); + typeWrite(label,x+1,y+2,size,12,18,30);typeWrite(label,x,y,size,231,247,241); + triangleDepth=saved; +} + // Temporary live combat inspector. Keep this explicit so the production view // can return to a clean presentation without changing combat geometry. let debugHitboxes = false; @@ -4369,8 +4395,8 @@ function spectatorState(now, nextRoundId = "") { ...(workshopMap ? { workshop: workshopMap } : {}) }, at: run.unixMs || 0, phase, course: skateparkMap ? "skatepark" : "station", - ropes: skateRopes.map((rope) => rope.nodes.map((node) => - [node.x, node.y, node.px, node.py].map((value) => Math.round(value * 100) / 100))), + ropes: skateRopes.length ? skateRopes.map((rope) => rope.nodes.map((node) => + [node.x, node.y, node.px, node.py].map((value) => Math.round(value * 100) / 100))) : undefined, previousRoundId: previousRoundName ? "ow-" + previousRoundName : "", fighters: players.map((player) => ({ // The title's still variant seats a fighter with no name yet, and the @@ -4496,7 +4522,7 @@ function publishSpectator(now, { target = matchName, nextRoundId = "", // which both points any watcher at the fight and retires this room's publisher // so the native shell's single socket is free to follow. function publishSession(now) { - if ((!debugHitboxes && !globalThis.__oskiewarWorkshopEnabled) || + if ((!poolOnly() && !debugHitboxes && !globalThis.__oskiewarWorkshopEnabled) || !sessionName || livePublishFailed || typeof publishLive !== "function") return; const liveRound = roundIsTimed() && matchName ? matchName : ""; @@ -8871,6 +8897,18 @@ function netDrainHostInbox() { const inbox = globalThis.__oskiewarNetInbox; if (!Array.isArray(inbox) || !inbox.length) return; for (const packet of inbox) { + if(packet?.kind==='render-flags'){ + const flags=packet.flags; + if(flags && typeof flags==='object' && !Array.isArray(flags)){ + const entries=Object.entries(flags); + if(entries.length<=8 && entries.every(([key,value])=>/^[a-z][a-zA-Z0-9]{0,23}$/.test(key) && + (typeof value==='boolean'||Number.isFinite(value)&&Math.abs(value)<=64))){ + const target=globalThis.__oskiewarRenderFlags ||= Object.create(null); + for(const [key,value] of entries)if(!['__proto__','constructor','prototype'].includes(key))target[key]=value; + } + } + continue; + } if (netSession) netInbox.push(packet); else netHandlePreSession(packet); } @@ -8938,7 +8976,7 @@ function gameBoot() { // a crash the restart exists to paper over. if (!sessionName) { seedNames(Math.floor(Math.random() * 4294967296)); - sessionName = pronounceableMatchName(); + sessionName = /^[a-z0-9-]{5,24}$/.test(globalThis.__oskiewarSessionName||'') ? globalThis.__oskiewarSessionName : pronounceableMatchName(); } startedAt = runtime().monotonicUs; roundStartedAt = startedAt; @@ -9592,7 +9630,7 @@ function updateCameraDoll(dt, now) { 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 zoom=playerCameraZoom,angle=poolCameraYaw+.14*(1-poolCameraDip)+playerCameraYaw+Math.PI*poolIdleClose; const airHeight=Math.max(0,poolFloorAt(p.x,p.z||0)-p.y); const distance=lerp(1350+Math.min(850,airHeight*.35)-poolCameraDip*120,270,poolIdleClose)*zoom; const look=(340+Math.min(450,speed*.16)+(poolCameraReturnYaw!==null?lerp(140,420,clamp(airHeight/600,0,1)):0))*(1-poolIdleClose); @@ -10503,7 +10541,7 @@ function updatePoolBullets(dt,now,combat){ 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 target.startledUntil=now+900000; + 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); break; @@ -11858,14 +11896,17 @@ function resolveMelee(now) { break; } if (attacker.attackHit) continue; - const target = players[attacker.pad === 0 ? 1 : 0]; - if (!target.alive) continue; + const targets=poolOnly()?[...activePlayers(),...parkKids]:[players[attacker.pad===0?1:0]]; + for(const target of targets){ + if (target===attacker||!target.alive) continue; const contact = combatBoxContact(attacking, sampleOf(target)); if (contact?.separation <= 3) { attacker.attackHit = true; contacts.push({ attacker, target, strike: { x: contact.x, y: contact.y, z: contact.z }, headshot: contact.headshot, segmentIndex: contact.segmentIndex }); + break; + } } } for (const { attacker, target, strike, headshot, segmentIndex } of contacts) { @@ -11873,6 +11914,7 @@ function resolveMelee(now) { spawnImpact({ x: strike.x, y: strike.y, z: strike.z, life: .2, duration: .2, death: false, explosion: false }); impactHitboxesUntil = Math.max(impactHitboxesUntil, now + 350000); + if(target.civilian){damageParkCivilian(target,attacker,strike,now,1);continue;} const away = Math.sign(target.x - attacker.x) || -attacker.facing; const backBlocking = target.inputX === away; if (target.blocking || backBlocking) { @@ -11938,8 +11980,8 @@ function resolvePogoAttacks(now) { for (const attacker of players) { if (!attacker.alive || !isPogo(attacker) || attacker.grounded || attacker.pogoHit) continue; - const target = players[attacker.pad === 0 ? 1 : 0]; - if (!target.alive) continue; + const target=players[attacker.pad===0?1:0]; + if(!target.alive)continue; const contact = combatBoxContact(sampleCombatBoxes(attacker,now),sampleCombatBoxes(target,now)); if (!contact || contact.separation > 3) continue; attacker.pogoHit = true; @@ -14408,6 +14450,7 @@ function gameSim() { updateDetachedParts(dt); updateBlood(dt); updateBoardState(now); + updatePonytails(dt,now); updateCamera(dt); updateCameraDoll(dt, now); captureFrameTelemetry(now); @@ -15435,12 +15478,14 @@ function buildPoolWalkGeometry(p) { const phase=p.poolStridePhase||0,speed=clamp(Math.hypot(p.vx,p.vz||0)/700,0,1); const moving=clamp(Math.hypot(p.vx,p.vz||0)/200,0,1),crouch=clamp(p.crouchBlend||0,0,1); const grounded=p.grounded,air=grounded?0:1,cy=Math.cos(p.poolYaw||0),sy=Math.sin(p.poolYaw||0); - const bob=grounded?Math.cos(phase*Math.PI*4)*3*speed:0; + const breath=(p.breathPhase||0)*Math.PI*2; + const sway=Math.sin(breath)*2.5*(1-speed)+Math.sin(phase*Math.PI*2)*2*speed; + const bob=grounded?Math.cos(phase*Math.PI*4)*3*speed+Math.sin(breath)*1.4*(1-speed):0; const hipHeight=86-11*speed-30*crouch+bob; - const lean=(p.rig?.lean||0)*.18+(p.inputZ||0)*speed*9+crouch*13; + const lean=(p.rig?.lean||0)*.65+(p.inputZ||0)*speed*9+crouch*13+sway; const point=(x,y,z)=>({x:p.x+x,y:p.y+y,z:(p.z||0)+z}); const hip=point(-crouch*9,-hipHeight,0),shoulder=point(lean,-hipHeight-52+12*crouch,0); - const head=point(lean+2,-hipHeight-80+12*crouch,0),segments=[];head.radius=22; + const head=point(lean+2+sway*.6,-hipHeight-80+12*crouch,Math.sin(breath+.7)*1.4),segments=[];head.radius=22; const bone=(a,b,role,part,width=10)=>{if(hasPart(p,part))segments.push({x1:a.x,y1:a.y,z1:a.z,x2:b.x,y2:b.y,z2:b.z,width,role,part});}; // Solve a knee/elbow in 3D in the plane containing the forward axis. const joint=(a,b,length,bend=1)=>{ @@ -15464,7 +15509,7 @@ function buildPoolWalkGeometry(p) { const foot=point(localX,surface-5-lift,localZ),pelvis={...hip,z:hip.z+side*12}; const knee=joint(pelvis,foot,48); bone(pelvis,knee,name+'-thigh',name+'-leg',11);bone(knee,foot,name+'-shin',name+'-leg',10); - const upper=shoulders[index],swing=-footX*.52; + const upper=shoulders[index],swing=-footX*.52+(p.rig?.arms||0)*9+Math.sin(breath+index)*2; const hand=point(lean+swing+air*20,shoulder.y-p.y+61-10*speed-15*air,side*(26+air*12)); if(p.gunAmmo>0&&name+'-arm'===itemHand(p)){ hand.x=upper.x+54;hand.y=upper.y+16;hand.z=upper.z; @@ -16348,6 +16393,7 @@ function sweptProjectileContact(segments,target,t) { const area=combatRect('sweep',Math.min(s.x1,s.x2)-r,Math.min(s.y1,s.y2)-r, Math.max(s.x1,s.x2)+r,Math.max(s.y1,s.y2)+r,(s.z1+s.z2)/2,Math.abs(s.z2-s.z1)/2+r); for(const hurt of queryBoxTree(boxes.tree,area)) { + if(poolOnly()&&hurt.capsule&&segmentSegmentClosest(s,hurt.capsule).distance>r+hurt.capsule.width/2)continue; const amount=segmentBoxEntry(s,hurt,r); if(amount===null || (result && amount>=result.amount))continue; result={amount,x:s.x1+(s.x2-s.x1)*amount,y:s.y1+(s.y2-s.y1)*amount, @@ -16365,17 +16411,18 @@ function sampleCombatBoxes(player, now, world = null) { world ||= runnerWorldGeometry(player, (now-startedAt)/1e6); const hurt = []; const head = world.head; - hurt.push(combatRect('head', head.x-22, head.y-22, head.x+22, head.y+22, head.z, 22, - { part: 'head', segmentIndex: -1 })); + const headRadius=poolOnly()?head.radius:22; + hurt.push(combatRect('head', head.x-headRadius, head.y-headRadius, head.x+headRadius, head.y+headRadius, head.z, headRadius, + { 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; i 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||'')); + 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, + Math.abs(limb.z2-limb.z1)/2+radius,{part,capsule:{...limb,width:radius*2}}));} + }else{ const tip = meleeTarget(player, now); const halfX = kick ? 26 : 22; const halfY = kick && player.lowKick ? 17 : 20; @@ -16396,6 +16450,7 @@ function sampleCombatBoxes(player, now, world = null) { const outer = tip.x+player.facing*halfX; hit.push(combatRect(player.attackKind.toLowerCase(), Math.min(inner,outer), tip.y-halfY, Math.max(inner,outer), tip.y+halfY, tip.z, 18, { part })); + } } } const guard = []; @@ -16412,7 +16467,8 @@ function combatBoxContact(attacker, target) { for (const hit of attacker.hit) { const overlaps = queryBoxTree(target.tree, hit).sort((a,b) => (a.part === 'head' ? -1 : b.part === 'head' ? 1 : a.segmentIndex-b.segmentIndex)); - const hurt = overlaps[0]; + const hurt = overlaps.find(h=>!poolOnly()||!hit.capsule||!h.capsule|| + segmentSegmentClosest(hit.capsule,h.capsule).distance<=(hit.capsule.width+h.capsule.width)/2); if (!hurt) continue; result = { x:(Math.max(hit.left,hurt.left)+Math.min(hit.right,hurt.right))/2, y:(Math.max(hit.top,hurt.top)+Math.min(hit.bottom,hurt.bottom))/2, @@ -18211,6 +18267,254 @@ function dummyView(player) { if (!view) dummyViews.set(player, view = {}); return Object.assign(view, player, dummyOverlay); } +// A connected surface around the animated rig. All vertices keep their own +// world depth, so the floor cannot slice a billboard body at its feet. +const characterMeshes=new Map(),characterCamera=new Float32Array(27); +function characterLocalCamera(origin,axes,out=characterCamera){ + const camera=mainNativeCamera();out.set(camera); + const [a,b,c]=axes; + const cross=(u,v)=>({x:u.y*v.z-u.z*v.y,y:u.z*v.x-u.x*v.z,z:u.x*v.y-u.y*v.x}); + const dot=(u,v)=>u.x*v.x+u.y*v.y+u.z*v.z; + const bc=cross(b,c),ca=cross(c,a),ab=cross(a,b),det=dot(a,bc); + if(Math.abs(det)<1e-9)return null; + const delta={x:camera[0]-origin.x,y:camera[1]-origin.y,z:camera[2]-origin.z}; + out[0]=dot(delta,bc)/det;out[1]=dot(delta,ca)/det;out[2]=dot(delta,ab)/det; + for(const row of [3,6,9])for(let i=0;i<3;i++)out[row+i]=camera[row]*axes[i].x+camera[row+1]*axes[i].y+camera[row+2]*axes[i].z; + // Mesh colours already contain the three cel-light bands. + out[24]=0;out[25]=0;out[26]=-1; + return out; +} +function characterMesh(key,build,origin,axes){ + let mesh=characterMeshes.get(key); + if(!mesh){ + mesh=captureQuadMesh(build); + for(const face of mesh.faces){ + const n=face.normal,light=n[0]*.35-n[1]*.8-n[2]*.45; + const faceSurface=key.includes(':head:'); + const blend=clamp((light+.35)/.85,0,1); + const band=faceSurface?.87+.13*blend*blend*(3-2*blend):light>.35?1:light>-.25?.8:.55; + face.color=face.color.map(c=>Math.round(c*band));face.normal=[0,0,1]; + } + if(characterMeshes.size>=192){const oldest=characterMeshes.keys().next().value;releaseQuadMesh(characterMeshes.get(oldest));characterMeshes.delete(oldest);} + characterMeshes.set(key,mesh); + } + if(nativeRetainedMeshes){ + const handle=meshHandle(mesh),camera=characterLocalCamera(origin,axes); + if(handle>=0&&camera){nativeMeshDraw(handle,camera,0);return;} + } + const at=p=>({x:origin.x+axes[0].x*p.x+axes[1].x*p.y+axes[2].x*p.z, + y:origin.y+axes[0].y*p.x+axes[1].y*p.y+axes[2].y*p.z, + z:origin.z+axes[0].z*p.x+axes[1].z*p.y+axes[2].z*p.z}); + for(const face of mesh.faces){const p=face.ids.map(i=>at(mesh.vertices[i]));worldQuad(p[0],p[1],p[2],p[3],face.color);} +} +// Cosmetic physics stays outside gameplay/replay state. Six particles form a +// tapered ponytail: pinned root, gravity, drag, length/bend constraints and body +// contact. Advance on simulation ticks, never as a side effect of drawing. +const ponytailStates=new WeakMap(); +function ponytailAnchor(player,world){ + const yaw=(player.poolYaw||0)+(player.spin?.angle||0),h=world.head; + return {x:h.x-Math.cos(yaw)*h.radius*1.03,y:h.y-h.radius*.2, + z:h.z-Math.sin(yaw)*h.radius*1.03}; +} +function updatePonytail(player,world,dt){ + if(!player.alive||player.headless||!player.skin||player.dummy){ponytailStates.delete(player);return;} + const anchor=ponytailAnchor(player,world),r=world.head.radius,len=r*.36; + let state=ponytailStates.get(player); + if(!state||Math.hypot(anchor.x-state.anchor.x,anchor.y-state.anchor.y,anchor.z-state.anchor.z)>r*8||Math.abs(state.length-len)>.01){ + const yaw=(player.poolYaw||0)+(player.spin?.angle||0); + state={anchor:{...anchor},length:len,points:Array.from({length:6},(_,i)=>{ + const x=anchor.x-Math.cos(yaw)*len*i*.6,y=anchor.y+len*i*.8,z=anchor.z-Math.sin(yaw)*len*i*.6; + return {x,y,z,px:x,py:y,pz:z}; + })}; + ponytailStates.set(player,state); + } + if(!(dt>0))return state; + const steps=Math.min(8,Math.max(1,Math.ceil(dt*120))),h=Math.min(dt,1/15)/steps; + const points=state.points,torso=world.segments.find(b=>b.role==='torso'); + const collide=(p,cx,cy,cz,radius)=>{ + let x=p.x-cx,y=p.y-cy,z=p.z-cz,d=Math.hypot(x,y,z); + if(d>=radius)return; + if(d<1e-7){x=-Math.cos(player.poolYaw||0);y=0;z=-Math.sin(player.poolYaw||0);d=1;} + const k=radius/d;p.x=cx+x*k;p.y=cy+y*k;p.z=cz+z*k; + }; + for(let step=1;step<=steps;step++){ + const root=points[0],u=step/steps; + root.x=lerp(state.anchor.x,anchor.x,u);root.y=lerp(state.anchor.y,anchor.y,u);root.z=lerp(state.anchor.z,anchor.z,u); + // Exponential drag is independent of the simulation tick frequency. + const drag=Math.exp(-3*h)*(state.stepSize?h/state.stepSize:1); + state.stepSize=h; + for(let i=1;i=12 && parkActorVisible(kid))updatePonytail(kid,runnerWorldGeometry(kid,t),dt); + else ponytailStates.delete(kid); + } +} + +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 + // polygons. It sits behind the volume, at real actor depth, not HUD depth. + if(lod<2){ + const projected=projectRunnerWorldGeometry(world),saved=triangleDepth; + const ink=[20,17,28],edge=lod?1.1:2.2; + if(!projected.behind){ + triangleDepth=projected.head.depth+world.head.radius*cameraDoll.depthSlope; + filledDisc(projected.head.x,projected.head.y,projected.head.radius+edge,ink); + for(const bone of projected.segments){ + if(bone.hidden||bone.hitboxOnly||['torso','shoulders'].includes(bone.role))continue; + triangleDepth=bone.depth+.002; + filledCapsule(bone.x1,bone.y1,bone.x2,bone.y2,bone.width+edge*2,ink); + } + } + triangleDepth=saved; + } + const yaw=(player.poolYaw||0)+(player.spin?.angle||0); + const forward={x:Math.cos(yaw),y:0,z:Math.sin(yaw)}; + const side={x:-forward.z,y:0,z:forward.x}; + const point=(c,f,u,r)=>({x:c.x+forward.x*f+side.x*r,y:c.y-u,z:c.z+forward.z*f+side.z*r}); + const end=(b,n)=>({x:b['x'+n],y:b['y'+n],z:b['z'+n]}); + const skin=player.color,shirt=player.shirtColor||[252,252,250]; + const dressed=player.skin&&!player.dummy; + const tube=(a,b,r1,r2,ink,sides=lod?4:8)=>{ + const axis=normalize3({x:b.x-a.x,y:b.y-a.y,z:b.z-a.z}); + const ref=Math.abs(axis.y)<.9?{x:0,y:1,z:0}:{x:1,y:0,z:0}; + const u=normalize3({x:axis.y*ref.z-axis.z*ref.y,y:axis.z*ref.x-axis.x*ref.z,z:axis.x*ref.y-axis.y*ref.x}); + const v={x:axis.y*u.z-axis.z*u.y,y:axis.z*u.x-axis.x*u.z,z:axis.x*u.y-axis.y*u.x}; + const ratio=r2/r1,length=Math.hypot(b.x-a.x,b.y-a.y,b.z-a.z); + characterMesh('tube:'+sides+':'+ratio.toFixed(3)+':'+ink.join(','),()=>{ + const ring=(y,r,i)=>{const angle=i/sides*Math.PI*2;return {x:Math.cos(angle)*r,y,z:Math.sin(angle)*r};}; + for(let i=0;i{ + characterMesh('sphere:'+cols+':'+rows+':'+(key||ink.join(',')),()=>{ + const vertex=(i,j)=>{const lon=i/cols*Math.PI*2,lat=j/rows*Math.PI; + return {x:Math.cos(lon)*Math.sin(lat),y:-Math.cos(lat),z:Math.sin(lon)*Math.sin(lat)};}; + for(let j=0;jb.role==='torso'); + if(player.skateboard)drawSkateboard(player); + for(const bone of world.segments){ + if(['torso','shoulders'].includes(bone.role)||!hasPart(player,bone.part))continue; + const a=end(bone,1),b=end(bone,2),r=(bone.width||10)*.5; + const pants=player.pantsColor&&/thigh|shin/.test(bone.role); + tube(a,b,r,r*.86,pants?player.pantsColor:skin); + if(dressed&&/upper-arm$/.test(bone.role)){ + const cuff={x:lerp(a.x,b.x,.42),y:lerp(a.y,b.y,.42),z:lerp(a.z,b.z,.42)}; + tube(a,cuff,r+1.5*scale,r+1*scale,shirt); + } + if(/shin$/.test(bone.role))ellipsoid(point(b,5*scale,3*scale,0),13*scale,6*scale,7*scale,player.shoeColor||[70,70,96],8,4); + } + if(torso&&hasPart(player,'torso')){ + const hip=end(torso,2),neck=end(torso,1),ink=dressed?shirt:skin; + const breathing=1+Math.sin((player.breathPhase||0)*Math.PI*2)*.035; + const axes=[{x:forward.x*scale*breathing,y:0,z:forward.z*scale*breathing}, + {x:neck.x-hip.x,y:neck.y-hip.y,z:neck.z-hip.z},{x:side.x*scale,y:0,z:side.z*scale}]; + characterMesh('torso:'+ink.join(','),()=>{ + const rings=[]; + for(let j=0;j<5;j++){ + const u=j/4,curve=Math.sin(u*Math.PI)*2.5; + rings.push(Array.from({length:12},(_,i)=>{const a=i/12*Math.PI*2; + return {x:Math.cos(a)*[8,7,10,11,8][j]-curve,y:u,z:Math.sin(a)*[13,11,16,21,17][j]};})); + } + for(let j=0;j<4;j++)for(let i=0;i<12;i++)worldQuad(rings[j][i],rings[j][(i+1)%12],rings[j+1][(i+1)%12],rings[j+1][i],ink); + },hip,axes); + if(dressed&&!player.pantsColor){ + const skirt=player.skirtColor||[188,164,226],count=16,rows=3; + const sway=Math.sin((player.poolStridePhase||0)*Math.PI*2)*3+(player.rig?.lean||0)*.16+Math.sin((player.breathPhase||0)*Math.PI*2)*1.2; + characterMesh('skirt:'+skirt.join(','),()=>{ + const ring=(i,j)=>{const u=j/rows,a=i/count*Math.PI*2,fold=(i%2?1:-1)*1.4*u; + return {x:Math.cos(a)*(10+u*10+fold),y:u,z:Math.sin(a)*(15+u*12+fold)};}; + for(let j=0;jj0){ + const pose=gunPose(player,runtime().monotonicUs); + 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; +} + function drawRunner(player,t,showLabel=true){ const previous=figureDrawScale; const world=player.replayGeometry||player.frozenGeometry||runnerWorldGeometry(player,t); @@ -18272,7 +18576,10 @@ function drawRunnerAtScale(player, t, showLabel = true) { segment.y1 += dy; segment.y2 += dy; } } - const lod = figureLod(player, geometry); + 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; + } if (lod > 0) { drawFigureLod(player, geometry, lod, t); return; } // Preserve the fighter's identity color during hit flash. A pure white body // disappeared against the daylight arena, so impact now changes only its rim. @@ -21998,7 +22305,7 @@ function gamePaint() { // from a raised lens it cut their legs off at the shins. Standing them at // the floor's near edge keeps the whole body in front of the ground they // are standing on. - if (renderable.kind === "player") + if (renderable.kind === "player" && !poolOnly()) triangleDepth = Math.min(triangleDepth, projectPoint(renderable.x, renderable.y, worldNear + 2).z); if (renderable.kind === "bullet") drawBullet(renderable.item); @@ -22013,6 +22320,7 @@ function gamePaint() { drawRunner(renderable.item, t, showRunnerLabels); } } + if(poolOnly())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);} @@ -22022,6 +22330,7 @@ function gamePaint() { triangleDepth = -1.475; // Meters and screen UI sit in front of the debug geometry. drawFrameMeter(); + drawAeselConnect(); drawImpacts(); drawTitleHeadDoor(t, titleInk, reelMinimal); const counting = !roundResult && introAge < roundIntroDurationUs(); @@ -22745,17 +23054,23 @@ function meshHandle(mesh){ // mesh and is rejected before projection or a native bridge call. function sceneBoundsVisible(bounds){ if(!bounds)return true; - const corners=[]; - for(const x of [bounds.minX,bounds.maxX])for(const y of [bounds.minY,bounds.maxY]) - for(const z of [bounds.minZ,bounds.maxZ])corners.push(cameraDoll.toView({x,y,z})); - if(corners.every(p=>p.zp.zcameraDoll.projectView(p)); - const left=clipView?.x??0,right=clipView?clipView.x+clipView.w:viewWidth(); - const top=clipView?.y??0,bottom=clipView?clipView.y+clipView.h:viewHeight; - return !(points.every(p=>p.xp.x>right+24)|| - points.every(p=>p.yp.y>bottom+24)); + if(cameraDoll.dirty||!cameraDoll.view)cameraDoll.prepare(); + const v=cameraDoll.view,p=cameraDoll.position; + const x=(bounds.minX+bounds.maxX)*.5-p.x,y=(bounds.minY+bounds.maxY)*.5-p.y,z=(bounds.minZ+bounds.maxZ)*.5-p.z; + const hx=(bounds.maxX-bounds.minX)*.5,hy=(bounds.maxY-bounds.minY)*.5,hz=(bounds.maxZ-bounds.minZ)*.5; + const cx=x*v.right.x+y*v.right.y+z*v.right.z,cy=x*v.up.x+y*v.up.y+z*v.up.z,cz=x*v.forward.x+y*v.forward.y+z*v.forward.z; + const ex=hx*Math.abs(v.right.x)+hy*Math.abs(v.right.y)+hz*Math.abs(v.right.z); + const ey=hx*Math.abs(v.up.x)+hy*Math.abs(v.up.y)+hz*Math.abs(v.up.z); + const ez=hx*Math.abs(v.forward.x)+hy*Math.abs(v.forward.y)+hz*Math.abs(v.forward.z); + if(cz+ez0?nearScale:farScale); + const top=v.centerY-maxY*(maxY>0?nearScale:farScale),bottom=v.centerY-minY*(minY<0?nearScale:farScale); + return right>=(clipView?.x??0)-24&&left<=(clipView?clipView.x+clipView.w:viewWidth())+24&& + bottom>=(clipView?.y??0)-24&&top<=(clipView?clipView.y+clipView.h:viewHeight)+24; } function buildParkScene(mesh){ const entries=mesh.faces.map(face=>{ @@ -22816,7 +23131,7 @@ function releaseQuadMesh(mesh){ if(mesh)mesh.nativeHandle=-1; } function drawQuadMesh(mesh){ - if(!mesh||!sceneBoundsVisible(mesh.bounds))return; + if(!mesh||(!nativeRetainedMeshes&&!sceneBoundsVisible(mesh.bounds)))return; if(nativeRetainedMeshes){ const handle=meshHandle(mesh); if(handle>=0){nativeMeshDraw(handle,mainNativeCamera(),clipView?0:cameraScale());return;} @@ -22992,6 +23307,15 @@ function drawTurboParticles(){ triangleDepth=saved; } +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; + 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}); + playDrum('snare',.65,panAt(kid.x,kid.z)); + if(kid.health<=0)popCivilianHead(kid,owner,runnerWorldGeometry(kid,(now-startedAt)/1e6),now); +} function popCivilianHead(kid,owner,g,now){ kid.headless=true;kid.alive=false;kid.frozenGeometry=g;kid.vx=0;kid.attackKind=''; kid.looseHead={...g.head,vx:Math.sign(kid.x-owner.x||1)*350,vy:-430,vz:(kid.pad%2?1:-1)*100}; diff --git a/xbox/live/tests/pool-playground.test.mjs b/xbox/live/tests/pool-playground.test.mjs index 27625cfddd..74fdebb495 100644 --- a/xbox/live/tests/pool-playground.test.mjs +++ b/xbox/live/tests/pool-playground.test.mjs @@ -1,18 +1,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import {validateOskiewarLiveState} from '../../../session-server/oskiewar-live-manager.mjs'; import { readFile } from 'node:fs/promises'; const source=await readFile(new URL('../oskiewar.js',import.meta.url),'utf8'); 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 {players,updatePlayer,runnerWorldGeometry,projectRunnerWorldGeometry,cameraDoll, + return {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, boundParkBody,parkWindowWalls,brokenParkWindows,parkWindowShards,resetParkWindows, breakParkWindow,updateParkWindowShards,insidePark,parkLotMargin,updateCameraDoll,clearPoolCamera,updateMotorAudio,updateSkateAudio,balls, - updateSeatHeartbeat,sceneBoundsVisible,buildParkScene,parkActorVisible,figureLod, + updateSeatHeartbeat,updatePonytail,ponytailAnchor,ponytailStates,drawSpatialRunner,sampleCombatBoxes,sweptProjectileContact,damageParkCivilian,drawAeselFairy,sceneBoundsVisible,buildParkScene,parkActorVisible,figureLod, state:()=>({halfpipe:parkHalfPipe3D})};`)(()=>({monotonicUs:now}),()=>({platform:'web'}),noop,noop,(name,gain,pan)=>drums.push({name,gain,pan}),noop,noop,noop,noop,noop,noop,(hz,gain)=>audio.push({hz,gain}),()=>audio.push({stop:true})); const p=api.players[0];Object.assign(p,{x:1080,z:0,y:api.poolFloorAt(1080,0),grounded:true,alive:true,dummy:false,skateboard:false,poolYaw:0,previous:[],spin:null,directionChanges:[],poolLastSteer:0}); api.step=(down=[],dt=1/60)=>{now+=dt*1e6;api.updatePlayer(p,{down,leftX:0,leftY:0},dt,now);}; @@ -224,3 +225,90 @@ test('BPM emits one quiet beat when the local heart phase wraps',()=>{ a.updateSeatHeartbeat(1/60,a.now()); assert.equal(a.drums.filter(d=>d.name==='kick'&&d.gain===.075).length,1); }); + +test('spatial character remains volumetric at every LOD and heading',()=>{ + const a=playground(),p=a.players[0];p.skin=true; + for(const yaw of [0,Math.PI/2,Math.PI])for(const lod of [0,1,2,3]){ + p.poolYaw=yaw;const world=a.runnerWorldGeometry(p,0); + const mesh=a.captureQuadMesh(()=>a.drawSpatialRunner(p,world,0,lod)); + assert.ok(mesh.faces.length>150);assert.ok(mesh.faces.length<850); + assert.ok(mesh.vertices.every(v=>Number.isFinite(v.x+v.y+v.z))); + assert.ok(mesh.bounds.maxX-mesh.bounds.minX>30); + assert.ok(mesh.bounds.maxZ-mesh.bounds.minZ>30); + } +}); +test('spatial hurt capsules follow scaled rotated limbs and reject misses in depth',()=>{ + const a=playground();a.resetParkKids(a.now());const p=a.parkKids[0];p.poolYaw=Math.PI/2; + const pose=a.runnerWorldGeometry(p,0),boxes=a.sampleCombatBoxes(p,a.now()); + assert.equal(boxes.hurt[0].capsule.width,pose.head.radius*2); + for(let i=0;i{ + const a=playground();a.resetParkKids(a.now());const p=a.parkKids[0],owner=a.players[0]; + a.damageParkCivilian(p,owner,{x:p.x,y:p.y-90,z:p.z},a.now(),2); + assert.equal(p.health,2);assert.equal(p.sparringPartner,owner.pad);assert.ok(p.alive); + a.damageParkCivilian(p,owner,{x:p.x,y:p.y-90,z:p.z},a.now(),2);assert.equal(p.alive,false); +}); +test('Aesel fairy requires a fresh connection heartbeat',()=>{ + const a=playground();globalThis.__oskiewarRenderFlags={aeselPulse:1}; + try{ + assert.ok(a.captureQuadMesh(()=>a.drawAeselFairy(0)).faces.length>0); + for(let i=0;i<360;i++)a.step(); + assert.equal(a.captureQuadMesh(()=>a.drawAeselFairy(4)).faces.length,0); + globalThis.__oskiewarRenderFlags.aeselPulse=2; + assert.ok(a.captureQuadMesh(()=>a.drawAeselFairy(4)).faces.length>0); + }finally{delete globalThis.__oskiewarRenderFlags;} +}); + +test('ponytail inertia follows 3D motion, settles, and stays outside body colliders',()=>{ + const a=playground(),p=a.players[0];p.skin=true; + const pose=()=>a.runnerWorldGeometry(p,0); + let state; + for(let i=0;i<240;i++)state=a.updatePonytail(p,pose(),1/60); + const initial={...state.points[5]}; + p.x+=15;p.z+=12; + state=a.updatePonytail(p,pose(),1/60); + assert.ok(state.points[5].x-initial.x<14,'tip lags behind acceleration'); + assert.ok(state.points[5].z-initial.z<11,'inertia also exists in depth'); + for(let i=0;i<600;i++)state=a.updatePonytail(p,pose(),1/60); + const settled={...state.points[5]}; + for(let i=0;i<60;i++)state=a.updatePonytail(p,pose(),1/60); + assert.ok(Math.hypot(state.points[5].x-settled.x,state.points[5].y-settled.y,state.points[5].z-settled.z)<.1,'drag settles the chain'); + const world=pose(),anchor=a.ponytailAnchor(p,world); + assert.deepEqual({x:state.points[0].x,y:state.points[0].y,z:state.points[0].z},anchor); + for(let i=1;i<6;i++){ + const q=state.points[i],prev=state.points[i-1]; + assert.ok(Math.abs(Math.hypot(q.x-prev.x,q.y-prev.y,q.z-prev.z)-state.length)<.7,'bounded link stretch'); + assert.ok(Math.hypot(q.x-world.head.x,q.y-world.head.y,q.z-world.head.z)>=world.head.radius,'head contact'); + } + p.x+=10000;state=a.updatePonytail(p,pose(),1/60); + assert.ok(Math.abs(state.points[5].x-p.x)<100,'teleport resets instead of stretching across the world'); + p.headless=true;a.updatePonytail(p,pose(),1/60);assert.equal(a.ponytailStates.has(p),false); +}); +test('ponytail remains finite through turns, spins and variable frame times',()=>{ + const a=playground(),p=a.players[0];p.skin=true; + for(let i=0;i<500;i++){ + p.poolYaw=i*.03;p.spin={angle:i*.04};p.x+=Math.sin(i*.08)*2;p.z+=Math.cos(i*.08)*2; + const state=a.updatePonytail(p,a.runnerWorldGeometry(p,0),[1/120,1/60,1/30,.2][i%4]); + for(const q of state.points)assert.ok([q.x,q.y,q.z].every(Number.isFinite)); + for(let j=1;j<6;j++){ + const q=state.points[j],v=state.points[j-1]; + assert.ok(Math.hypot(q.x-v.x,q.y-v.y,q.z-v.z){ + const a=playground();const state=a.spectatorState(a.now()); + assert.equal(state.ropes,undefined);assert.equal(validateOskiewarLiveState(state),null); +}); +test('native presence packets update cosmetic flags without entering combat netplay',()=>{ + const a=playground(); + try{ + globalThis.__oskiewarNetInbox=[{kind:'render-flags',flags:{aeselPulse:3}},{kind:'render-flags',flags:{aeselPulse:Infinity}}]; + a.netDrainHostInbox();assert.equal(globalThis.__oskiewarRenderFlags.aeselPulse,3);assert.equal(globalThis.__oskiewarNetInbox.length,0); + }finally{delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} +}); diff --git a/xbox/native-bios/OskiewarLivePublisher.cpp b/xbox/native-bios/OskiewarLivePublisher.cpp index d68311c94a..fa27d61a20 100644 --- a/xbox/native-bios/OskiewarLivePublisher.cpp +++ b/xbox/native-bios/OskiewarLivePublisher.cpp @@ -157,7 +157,10 @@ void Connect(const std::shared_ptr& state) { const auto size = reader->UnconsumedBufferLength; if (!size || size > 16384) return; auto message = Windows::Data::Json::JsonObject::Parse(reader->ReadString(size)); - if (message->GetNamedString("type", "") != "oskiewar:net") return; + const auto type = message->GetNamedString("type", ""); + const bool render_flags = type == "oskiewar:flags"; + if (type != "oskiewar:net" && !render_flags) return; + if (render_flags && size > 512) return; auto content = message->GetNamedObject("content")->Stringify(); const int count = WideCharToMultiByte(CP_UTF8, 0, content->Data(), content->Length(), nullptr, 0, nullptr, nullptr); @@ -165,6 +168,7 @@ void Connect(const std::shared_ptr& state) { std::string packet(count, '\0'); WideCharToMultiByte(CP_UTF8, 0, content->Data(), content->Length(), packet.data(), count, nullptr, nullptr); + if (render_flags) packet = "{\"kind\":\"render-flags\",\"flags\":" + packet + "}"; const auto state = weak.lock(); if (!state) return; std::lock_guard lock(state->mutex); diff --git a/xbox/native-bios/Package.appxmanifest b/xbox/native-bios/Package.appxmanifest index 6c9ea808ab..17626a1ad3 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.56" />