diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 5bf05b2e69..7040ab3dbe 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -49,7 +49,7 @@ struct ContentView: View { @State private var previewBounds = PreviewBounds() private var previewSize: CGSize { CGSize(width: previewBounds.width, height: previewBounds.height) } private var previewInset: CGFloat { previewBounds.right } - private var previewVisible: Bool { session.accountReady && session.previewURL != nil && !previewHidden && sheetSize.width >= 420 && sheetSize.height >= 300 } + private var previewVisible: Bool { session.accountReady && (session.previewURL != nil || !oskiewar.room.isEmpty) && !previewHidden && sheetSize.width >= 420 && sheetSize.height >= 300 } private var previewBlockHeight: CGFloat { paperTop } private var hasNotebook: Bool { session.displayedEntries.contains { $0.kind != .edit } || session.fatal != nil } // WebKit reserves a paint row for descenders; the next editor row shares it. @@ -161,7 +161,7 @@ struct ContentView: View { .foregroundStyle(paint.ink) .background { AeselCloth().ignoresSafeArea() } .background(HostCarrier(host: host).frame(width: 0, height: 0)) - .aeselWindowTitle(session.route, paper: paint.bg) + .aeselWindowTitle(oskiewar.room.isEmpty ? session.route : "Oskiewar · " + oskiewar.room, paper: paint.bg) .environment(\.paint, paint) .coordinateSpace(name: "aesel-ui") .animation(.easeInOut(duration: 0.5), value: paint) @@ -238,7 +238,7 @@ struct ContentView: View { .onChange(of: session.selectedRevision) { writing = false } .onChange(of: session.currentSessionID) { previewHidden = false; expandedPreview = false; attended = true } .onChange(of: preview.failure) { host.automation.previewFailure = preview.failure } - .onChange(of: session.busy) { if session.busy { attended = false } } + .onChange(of: session.busy) { if session.busy { attended = false }; oskiewar.setIntent(session.busy ? 1 : 0) } .onChange(of: writing) { if writing { attended = true } } .onChange(of: draft) { attended = true } .onChange(of: session.showSignIn) { if !session.showSignIn { host.cancelSignIn() } } @@ -437,17 +437,6 @@ struct ContentView: View { private var connectionNotices: some View { VStack(alignment: .leading, spacing: 6) { - if !oskiewar.room.isEmpty { - HStack(spacing: 10) { - Image(systemName: "sparkles").accessibilityHidden(true) - Text(oskiewar.status).font(Paint.font(13)) - Button { oskiewar.disconnect() } label: { - Image(systemName: "xmark").frame(width: 24, height: 24) - }.accessibilityLabel("Disconnect Oskiewar") - } - .padding(10).background(paint.bg, in: RoundedRectangle(cornerRadius: 8)) - .overlay { RoundedRectangle(cornerRadius: 8).stroke(paint.ink.opacity(0.3), lineWidth: 1) } - } ForEach(session.notices.keys.sorted(), id: \.self) { scope in if let notice = session.notices[scope] { HStack(spacing: 10) { @@ -479,7 +468,19 @@ struct ContentView: View { } @ViewBuilder private func title(availableWidth: CGFloat) -> some View { - if !session.route.isEmpty { + if !oskiewar.room.isEmpty { + HStack(spacing: 6) { + OskiewarHeadIcon().frame(width: 22, height: 24) + .opacity(oskiewar.status.hasPrefix("Connected") ? 1 : 0.5) + AeselTitle(text: "Oskiewar · " + oskiewar.room, size: compact ? 12 : 16, + maximumWidth: max(0, availableWidth - 28), horizontalInset: 0) + } + .frame(height: row) + .help(oskiewar.status) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Oskiewar, " + oskiewar.room + ". " + oskiewar.status) + .contextMenu { Button("Disconnect Oskiewar") { oskiewar.disconnect() } } + } else if !session.route.isEmpty { Button { if let url = titleURL { openURL(url) } } label: { AeselTitle(text: session.route, colors: session.handleColors, size: compact ? 12 : 16, maximumWidth: availableWidth, horizontalInset: 0, @@ -495,7 +496,20 @@ struct ContentView: View { /// The corner piece keeps the original desktop wood grain and resize edges. private func previewBox(container: CGSize) -> some View { ZStack(alignment: .topTrailing) { - if let url = session.previewURL { + if !oskiewar.room.isEmpty { + VStack(spacing: 4) { + ZStack { + Image(systemName: "tshirt.fill").resizable().scaledToFit().foregroundStyle(.white) + Image(systemName: "star.fill").resizable().scaledToFit().foregroundStyle(.yellow) + .frame(width: 22, height: 22) + }.frame(maxWidth: .infinity, maxHeight: .infinity).layoutPriority(-1) + Button("Apply star") { oskiewar.applyStar() }.font(.system(size: 12)) + Text(oskiewar.editStatus.isEmpty ? "Shirt preview" : oskiewar.editStatus) + .font(.system(size: 9)).lineLimit(1).minimumScaleFactor(0.7) + }.padding(8).frame(width: previewSize.width, height: previewSize.height) + .background(Color(red: 0.16, green: 0.13, blue: 0.22)) + .foregroundStyle(.white).clipped() + } else if let url = session.previewURL { PieceView(url: url, source: session.displayedSource, preview: preview) .frame(width: previewSize.width, height: previewSize.height, alignment: .topTrailing) .clipped() @@ -744,6 +758,7 @@ struct ContentView: View { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } if text.hasPrefix("oskiewar://") { oskiewar.connect(text); draft = ""; return } + if text == "/shirt star", !oskiewar.room.isEmpty { oskiewar.applyStar(); draft = ""; return } if text == "/disconnect" { oskiewar.disconnect(); draft = ""; return } switch Session.command(for: text) { case "new", "home": writing = false; showHome = true @@ -810,7 +825,7 @@ struct ContentView: View { "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)", + "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, "entryCount": session.displayedEntries.count, "totalEntryCount": session.entries.count, "history": session.history.map { ["id": $0.id, "title": $0.title, "route": $0.route] }], "provider": ["selected": session.provider, "ready": session.providerReady, @@ -994,6 +1009,23 @@ private struct CurtainAccountButtonStyle: ButtonStyle { @MainActor private final class OskiewarAttachment: ObservableObject { @Published var status = "" + @Published var editStatus = "" + func setIntent(_ value: Int) { + guard let socket else { return } + Task { try? await socket.send(.string("{\"type\":\"oskiewar:flags\",\"content\":{\"aeselIntent\":\(value)}}")) } + } + func applyStar() { + guard let socket, status.hasPrefix("Connected") else { editStatus = "Connect to a game first"; return } + Task { + do { + try await socket.send(.string("{\"type\":\"oskiewar:flags\",\"content\":{\"aeselIntent\":2}}")) + try await socket.send(.string("{\"type\":\"oskiewar:flags\",\"content\":{\"shirtSymbol\":2}}")) + editStatus = "Star sent to game" + try await Task.sleep(nanoseconds: 3_000_000_000) + try await socket.send(.string("{\"type\":\"oskiewar:flags\",\"content\":{\"aeselIntent\":0}}")) + } catch { editStatus = "Could not send star" } + } + } private var socket: URLSessionWebSocketTask? private var receiver: Task? private var heartbeat: Task? @@ -1044,3 +1076,22 @@ private final class OskiewarAttachment: ObservableObject { } } } + +private struct OskiewarHeadIcon: View { + var body: some View { + Canvas { context, size in + let face = CGRect(x: 1, y: 4, width: size.width - 2, height: size.height - 5) + context.fill(Path(ellipseIn: face), with: .color(Color(red: 0.95, green: 0.69, blue: 0.78))) + context.stroke(Path(ellipseIn: face), with: .color(Color(red: 0.13, green: 0.09, blue: 0.17)), lineWidth: 1.5) + for x in [size.width * 0.33, size.width * 0.67] { + context.fill(Path(ellipseIn: CGRect(x: x - 1.5, y: size.height * 0.48, width: 3, height: 4)), with: .color(.black)) + } + var mouth = Path(); mouth.move(to: CGPoint(x: size.width * 0.37, y: size.height * 0.76)) + mouth.addQuadCurve(to: CGPoint(x: size.width * 0.63, y: size.height * 0.76), control: CGPoint(x: size.width * 0.5, y: size.height * 0.85)) + context.stroke(mouth, with: .color(.black), lineWidth: 1) + var bow = Path(); bow.move(to: CGPoint(x: 5, y: 1)); bow.addLine(to: CGPoint(x: 17, y: 6)) + bow.addLine(to: CGPoint(x: 17, y: 1)); bow.addLine(to: CGPoint(x: 5, y: 6)); bow.closeSubpath() + context.fill(bow, with: .color(Color(red: 0.4, green: 0.85, blue: 0.7))) + }.accessibilityHidden(true) + } +} diff --git a/apple/aesel/Sources/SessionStore.swift b/apple/aesel/Sources/SessionStore.swift index 0ee17e031c..9c218b8da7 100644 --- a/apple/aesel/Sources/SessionStore.swift +++ b/apple/aesel/Sources/SessionStore.swift @@ -7,6 +7,10 @@ final class SessionStore { private var values: [String: String] private let sessionKey: String private let tokenService: String + // All windows share one credential read. In particular, SwiftUI body + // evaluation must not open another Keychain request while one is pending. + private enum TokenRead { case pending, loaded(String?) } + private static var tokenReads: [String: TokenRead] = [:] private let backup: URL private var writable = true @@ -124,16 +128,30 @@ final class SessionStore { kSecAttrAccount as String: "access-token"] } - func clearToken() { SecItemDelete(tokenQuery as CFDictionary) } + func clearToken() { + Self.tokenReads[tokenService] = .loaded(nil) + SecItemDelete(tokenQuery as CFDictionary) + } /// The signed-in AC access token, for the app's own calls to AC. func token() -> String? { + if let read = Self.tokenReads[tokenService] { + switch read { + case .pending: return nil + case .loaded(let value): return value + } + } + Self.tokenReads[tokenService] = .pending var query = tokenQuery query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, - let data = item as? Data, let token = String(data: data, encoding: .utf8), !token.isEmpty else { return nil } + let data = item as? Data, let token = String(data: data, encoding: .utf8), !token.isEmpty else { + Self.tokenReads[tokenService] = .loaded(nil) + return nil + } + Self.tokenReads[tokenService] = .loaded(token) return token } @@ -151,7 +169,8 @@ final class SessionStore { 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) } + if status == errSecSuccess { Self.tokenReads[tokenService] = .loaded(token) } + else { NSLog("[aesel] Keychain save failed (%d); sign in again after relaunch", status) } } func seedJSON() -> String? { @@ -162,13 +181,7 @@ final class SessionStore { var record = ((values["session"]?.data(using: .utf8)).flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }) ?? [:] - var query = tokenQuery - query[kSecReturnData as String] = true - var result: CFTypeRef? - if SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data, let token = String(data: data, encoding: .utf8) { - record["token"] = token - } + if let token = token() { record["token"] = token } if let data = try? JSONSerialization.data(withJSONObject: record), let text = String(data: data, encoding: .utf8) { values["session"] = text } guard !values.isEmpty, diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 687600ad1d..b1461b08ae 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -4045,11 +4045,24 @@ function drawAeselFairy(t){ 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]); + const radius=head.radius+18; + const c={x:head.x+Math.sin(t*1.8)*radius,y:head.y-head.radius*.6+Math.sin(t*3)*5,z:head.z+Math.cos(t*1.8)*radius}; + if(cameraDoll.dirty||!cameraDoll.view)cameraDoll.prepare(); + const {right,up}=cameraDoll.view; + const at=(x,y)=>({x:c.x+right.x*x+up.x*y,y:c.y+right.y*x+up.y*y,z:c.z+right.z*x+up.z*y}); + const wing=9+Math.sin(t*28)*4; + worldQuad(at(0,0),at(-wing,8),at(-wing,-5),at(0,-3),[183,243,237]); + worldQuad(at(0,-3),at(wing,-5),at(wing,8),at(0,0),[238,185,247]); + worldQuad(at(-2,5),at(2,5),at(2,-5),at(-2,-5),[255,231,127]); + const intent=globalThis.__oskiewarRenderFlags?.aeselIntent; + const label=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); + 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; + } + } } function drawAeselConnect(){ if(!poolOnly()||!sessionName)return; @@ -8894,7 +8907,11 @@ function netHostBegin(now) { } function netDrainHostInbox() { - const inbox = globalThis.__oskiewarNetInbox; + const inbox = globalThis.__oskiewarNetInbox ||= []; + if(typeof globalThis.oskiewarNetPoll === "function"){ + const packets=globalThis.oskiewarNetPoll(); + if(Array.isArray(packets))inbox.push(...packets); + } if (!Array.isArray(inbox) || !inbox.length) return; for (const packet of inbox) { if(packet?.kind==='render-flags'){ @@ -18272,14 +18289,20 @@ function dummyView(player) { 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); + const a=axes[0],b=axes[1],c=axes[2]; + const bcx=b.y*c.z-b.z*c.y,bcy=b.z*c.x-b.x*c.z,bcz=b.x*c.y-b.y*c.x; + const cax=c.y*a.z-c.z*a.y,cay=c.z*a.x-c.x*a.z,caz=c.x*a.y-c.y*a.x; + const abx=a.y*b.z-a.z*b.y,aby=a.z*b.x-a.x*b.z,abz=a.x*b.y-a.y*b.x; + const det=a.x*bcx+a.y*bcy+a.z*bcz; 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; + const dx=camera[0]-origin.x,dy=camera[1]-origin.y,dz=camera[2]-origin.z; + out[0]=(dx*bcx+dy*bcy+dz*bcz)/det; + out[1]=(dx*cax+dy*cay+dz*caz)/det; + out[2]=(dx*abx+dy*aby+dz*abz)/det; + for(let row=3;row<=9;row+=3){ + const x=camera[row],y=camera[row+1],z=camera[row+2]; + out[row]=x*a.x+y*a.y+z*a.z;out[row+1]=x*b.x+y*b.y+z*b.z;out[row+2]=x*c.x+y*c.y+z*c.z; + } // Mesh colours already contain the three cel-light bands. out[24]=0;out[25]=0;out[26]=-1; return out; @@ -18474,8 +18497,14 @@ 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)}; + if(player===players[0] && globalThis.__oskiewarRenderFlags?.shirtSymbol===2){ + const center=point(c,12*scale,0,0); + const vertex=i=>{const angle=i*Math.PI/5,r=(i%2?2.6:6)*scale;return point(c,12*scale,Math.cos(angle)*r,Math.sin(angle)*r);}; + for(let i=0;i<10;i++)worldTriangle(center,vertex(i),vertex(i+1),[250,200,60]); + }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,[250,200,60],4,2); + } } } if(!player.headless){ diff --git a/xbox/live/tests/pool-playground.test.mjs b/xbox/live/tests/pool-playground.test.mjs index 74fdebb495..86fed722d2 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 {spectatorState,netDrainHostInbox,players,updatePlayer,runnerWorldGeometry,projectRunnerWorldGeometry,cameraDoll, + return {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, @@ -312,3 +312,26 @@ test('native presence packets update cosmetic flags without entering combat netp a.netDrainHostInbox();assert.equal(globalThis.__oskiewarRenderFlags.aeselPulse,3);assert.equal(globalThis.__oskiewarNetInbox.length,0); }finally{delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} }); + +test('cached character cameras preserve view coordinates under nonuniform scale and shear',()=>{ + const a=playground();a.cameraDoll.snap({position:{x:300,y:-90,z:500},target:{x:20,y:0,z:0},width:1800,perspective:1}); + const origin={x:12,y:45,z:-20},axes=[{x:2,y:0,z:1},{x:.4,y:3,z:0},{x:-1,y:.2,z:2}]; + const world=Array.from(a.mainNativeCamera()),local=a.characterLocalCamera(origin,axes); + for(const point of [[0,0,0],[1,2,-3],[-5,7,2]]){ + const p=[origin.x,origin.y,origin.z].map((v,i)=>v+point.reduce((sum,n,j)=>sum+n*axes[j][['x','y','z'][i]],0)); + for(const row of [3,6,9]){ + const expected=p.reduce((sum,v,i)=>sum+(v-world[i])*world[row+i],0); + const actual=point.reduce((sum,v,i)=>sum+(v-local[i])*local[row+i],0); + assert.ok(Math.abs(actual-expected)<.001); + } + } +}); + +test('native polling delivers star edits once and drains the queue',()=>{ + const a=playground();let polls=0; + try{ + globalThis.oskiewarNetPoll=()=>++polls===1?[{kind:'render-flags',flags:{shirtSymbol:2}}]:[]; + a.netDrainHostInbox();assert.equal(globalThis.__oskiewarRenderFlags.shirtSymbol,2); + a.netDrainHostInbox();assert.equal(polls,2);assert.equal(globalThis.__oskiewarNetInbox.length,0); + }finally{delete globalThis.oskiewarNetPoll;delete globalThis.__oskiewarNetInbox;delete globalThis.__oskiewarRenderFlags;} +});