diff --git a/system/tests/oskiewar-generation.test.mjs b/system/tests/oskiewar-generation.test.mjs index e270ec6bbd..a0ff82bb6d 100644 --- a/system/tests/oskiewar-generation.test.mjs +++ b/system/tests/oskiewar-generation.test.mjs @@ -154,3 +154,33 @@ test('withdrawal uses the signed-in subject and works without a generation token assert.equal(sent.frozen_fields.operation_kind,'WITHDRAW_CONSENT'); }finally{globalThis.fetch=originalFetch;for(const key of ['REGARDE_GATEWAY_URL','REGARDE_SUBJECT_SALT','REGARDE_GATEWAY_TOKEN','OPENAI_API_KEY']){if(saved[key]===undefined)delete process.env[key];else process.env[key]=saved[key];}} }); + +test('shared character follows the game bones without mutating physics or restoring missing limbs', async () => { + const { fighterParts, poseFighter, previewPose } = await import('../../xbox/live/oskiewar-fighter.mjs'); + const a = validateFighter({version:1,recipe:RECIPE,hash:'a'.repeat(64),appearance}); + const model = fighterParts(a), world = previewPose(), before = structuredClone(world); + // Combat changes role prefixes while preserving the anatomical part. + world.segments.find(b=>b.role==='left-thigh').role='lead-thigh'; + world.segments.find(b=>b.role==='right-thigh').role='rear-thigh'; + world.segments.find(b=>b.role==='left-upper-arm').role='attack-upper-arm'; + before.segments = structuredClone(world.segments); + const instances = poseFighter(model, world); + assert.equal(instances.length, 16); + assert.equal(instances.find(i=>i.name==='head').faces,model.parts.head); + assert.deepEqual(world,before); + const missing = poseFighter(model,world,{headless:true,hasPart:part=>part!=='left-arm'}); + assert.equal(missing.length,12); + assert.ok(!missing.some(i=>i.name==='head')); + const arm = world.segments.find(b=>b.role==='right-forearm'); + Object.assign(arm,{x2:arm.x1,y2:arm.y1,z2:arm.z1+32}); + const posed = poseFighter(model,world,{yaw:0}); + for(const {axes,faces,origin} of posed) { + assert.ok(axes.every(v=>Object.values(v).every(Number.isFinite))); + assert.ok(faces.every(f=>f.points.flat().every(Number.isFinite))); + assert.ok([origin.x,origin.y,origin.z].every(Number.isFinite)); + } + const rightForearm = posed.filter(i=>i.name==='forearm')[1]; + assert.deepEqual(rightForearm.axes[1],{x:0,y:0,z:32}); + assert.ok(Math.hypot(...Object.values(rightForearm.axes[0]))>.99); + assert.equal(fighterParts(a),model,'geometry is cached across animation frames'); +}); diff --git a/system/tests/oskiewar-practice.browser.mjs b/system/tests/oskiewar-practice.browser.mjs index 2ecf416bab..fcbf1fd0cd 100644 --- a/system/tests/oskiewar-practice.browser.mjs +++ b/system/tests/oskiewar-practice.browser.mjs @@ -29,7 +29,15 @@ try { await page.evaluate(()=>{globalThis.__oskiewarFighterAppearance={handle:'@fixture',validUntil:Date.now()+600000,appearance:{skin:[217,164,126],hair:[54,42,34],shirt:[54,95,144],pants:[24,55,104],shoes:[235,235,235],hairStyle:'short',beard:false,glasses:false,sleeves:'long'}};}); await page.keyboard.down('Enter'); await new Promise(r=>setTimeout(r,300)); await page.keyboard.up('Enter'); await page.waitForFunction(()=>globalThis.__oskiewarTouch?.practiceFighter==='@fixture',{timeout:30000}); + await page.waitForFunction(()=>globalThis.__oskiewarTouch?.practiceModel?.version===2); + assert.ok(await page.evaluate(()=>globalThis.__oskiewarTouch.practiceModel.parts>=12)); await page.screenshot({path:dir+'/practice.png'}); + await page.keyboard.down('ArrowRight'); await new Promise(r=>setTimeout(r,500)); await page.keyboard.up('ArrowRight'); + await page.keyboard.press('Space'); await new Promise(r=>setTimeout(r,250)); + await page.screenshot({path:dir+'/moving.png'}); + await page.evaluate(()=>{globalThis.__oskiewarFighterAppearance.validUntil=1;}); + await page.waitForFunction(()=>!globalThis.__oskiewarTouch?.practiceModel); + await page.evaluate(()=>{globalThis.__oskiewarFighterAppearance=null;}); await page.waitForFunction(()=>!globalThis.__oskiewarTouch?.practiceFighter); assert.deepEqual(errors,[]); assert.deepEqual(sockets,[]); assert.deepEqual(replays,[]); diff --git a/xbox/live/oskiewar-fighter.mjs b/xbox/live/oskiewar-fighter.mjs index 4cc72e2d4f..0a0f284a1f 100644 --- a/xbox/live/oskiewar-fighter.mjs +++ b/xbox/live/oskiewar-fighter.mjs @@ -11,79 +11,230 @@ export function validateFighter(fighter) { return { ...a, ...Object.fromEntries(['skin', 'hair', 'shirt', 'pants', 'shoes'].map(k => [k, rgb(a[k])])) }; } -// A compact, actual triangle mesh preview of the same capsule body used in -// play. Body lengths are fixed; generation can only select its appearance. -export function fighterMesh(appearance) { - const faces = []; - function ellipsoid(center, scale, color) { - const point = (row, col) => { - const t = row / 8 * Math.PI, p = col / 12 * Math.PI * 2; - return [center[0] + Math.sin(t) * Math.cos(p) * scale[0], - center[1] + Math.cos(t) * scale[1], center[2] + Math.sin(t) * Math.sin(p) * scale[2]]; +// One articulated mesh for both the consent preview and the playable rig. +// Coordinates use the game's downward Y. Bone-local Y spans 0..1; transverse +// dimensions stay in world units. No model values enter collision or movement. +const cache = new Map(); +const tint = (color, amount) => color.map(c => Math.round(c + (amount > 0 ? 255 - c : c) * amount)); +export function fighterParts(a) { + const key = JSON.stringify(a); + if (cache.has(key)) return cache.get(key); + const parts = {}; + let faces; + const quad = (a,b,c,d,color) => faces.push({points:[a,d,c,b],color}); + function ellipsoid(center, scale, color, cols=12, rows=8) { + const at = (j,i) => { + const lat=j/rows*Math.PI, lon=i/cols*Math.PI*2; + return [center[0]+Math.cos(lon)*Math.sin(lat)*scale[0], center[1]-Math.cos(lat)*scale[1], center[2]+Math.sin(lon)*Math.sin(lat)*scale[2]]; }; - for (let row = 0; row < 8; row++) for (let col = 0; col < 12; col++) { - const a = point(row, col), b = point(row + 1, col), c = point(row + 1, col + 1), d = point(row, col + 1); - faces.push({ points: [a, b, c], color }, { points: [a, c, d], color }); - } + for(let j=0;j[Math.cos(i/cols*Math.PI*2)*r[1],r[0],Math.sin(i/cols*Math.PI*2)*r[2]]; + for(let j=0;j{faces=[];draw();parts[name]=faces;}; + part('torso',()=>{ + rings([[0,8,7],[.07,19,10],[.22,21,12],[.52,18,12],[.83,16,11],[1,17,11],[1.03,0,0]],a.shirt,16); + // Collar and placket add readable clothing construction at game scale. + for(const side of [-1,1]) quad([side*2,.03,7.5],[side*9,.06,9],[side*8,.25,12.4],[side*.8,.12,11],tint(a.shirt,-.1)); + quad([-.6,.15,12.2],[.6,.15,12.2],[.6,.85,11.8],[-.6,.85,11.8],tint(a.shirt,-.075)); + for(const y of [.32,.51,.7]) ellipsoid([0,y,12.5],[.7,.014,.4],tint(a.shirt,-.28),6,4); + }); + part('neck',()=>rings([[0,5,5],[.15,6,6],[.85,6.5,6],[1,5,5]],a.skin)); + part('pelvis',()=>ellipsoid([0,5,0],[17,7,11],a.pants)); + part('upper-arm',()=>{ + const shirtEnd=a.sleeves==='long'?1:.52; + rings([[0,0,0],[.08,8,8],[.4,7.8,8],[shirtEnd,6.7,7]],a.shirt); + if(shirtEnd<1) rings([[shirtEnd,6.4,6.5],[.8,6,6],[1,5.8,5.8]],a.skin); + }); + part('forearm',()=>{ + const color=a.sleeves==='long'?a.shirt:a.skin; + rings([[0,5.8,5.8],[.25,6.7,6.5],[.78,5.3,5.2],[.94,4.8,4.8],[1,4.8,4.8]],color); + if(a.sleeves==='long') rings([[.88,5.5,5.4],[.98,5.3,5.3]],tint(color,-.1)); + }); + part('hand',()=>{ellipsoid([0,3,0],[5.4,7.2,4],a.skin,10,6);ellipsoid([4,1,2],[2.5,4,2.5],a.skin,8,4);}); + part('thigh',()=>rings([[0,8,9],[.1,10,11],[.5,9.7,10],[.9,7.8,8],[1,7.5,8]],a.pants)); + part('shin',()=>{ + rings([[0,7.5,8],[.3,8,8.5],[.75,6.7,7],[1,6.5,6.5]],a.pants); + rings([[.93,6.9,6.9],[1,6.9,6.9]],tint(a.pants,-.14)); + }); + part('shoe',()=>{ + ellipsoid([0,-1,4],[7.5,5,13],tint(a.shoes,.07),12,6); + ellipsoid([0,2,4],[7.8,2.2,13.4],tint(a.shoes,.18),12,4); + ellipsoid([0,-3,1],[5,3,6],a.shoes,8,4); + }); + part('head',()=>{ + // Less spherical jaw, a brow, ears and a projecting nose. + rings([[-23,0,0],[-21,10,10],[-16,16,15],[-8,18,16],[0,17,16],[9,15,14],[17,11,10],[20,5,6],[21,0,0]],a.skin,20); + for(const side of [-1,1]) { + ellipsoid([side*17,1,0],[3.4,5.3,3.3],a.skin,8,6); + ellipsoid([side*6.6,-2,14.8],[4,1.9,1.65],[245,240,230],10,6); + ellipsoid([side*6.6,-1.9,16.3],[1.4,1.5,.7],[57,49,43],8,6); + ellipsoid([side*6.4,-6,14.9],[4.2,.85,1],a.hair,8,4); + } + ellipsoid([0,1,15.8],[2.3,4.9,2.5],a.skin,10,6); + ellipsoid([0,4,17.3],[3,2.1,2.2],tint(a.skin,-.03),10,6); + ellipsoid([0,10,12.7],[4.8,.7,.9],tint(a.skin,-.32),10,4); + ellipsoid([0,11.2,12.3],[4,.65,.8],tint(a.skin,-.12),10,4); + if(a.hairStyle!=='none') { + // Back volume is behind the head; the front stays open below the part. + ellipsoid([0,-13,-5],[18.8,12,13.6],a.hair,16,8); + if(a.hairStyle==='long') { + ellipsoid([0,-.5,-11],[18.5,24,8.5],a.hair,14,8); + for(const side of [-1,1]) { + ellipsoid([side*17.4,0,-2],[5,20,10],a.hair,10,8); + ellipsoid([side*18.3,13,0],[3.8,11,7],tint(a.hair,.035),8,6); + } + } + if(a.hairStyle==='curly') for(let i=0;i<10;i++) { + const t=i/10*Math.PI*2; + ellipsoid([Math.cos(t)*14,-17+Math.sin(t)*3,Math.sin(t)*10],[6.5,7,6.5],tint(a.hair,i%2*.045),8,6); + } + else { + // Two swept lobes leave a narrow visible part, instead of a helmet. + ellipsoid([-8,-19,5],[10,6.5,12],tint(a.hair,.055),12,6); + ellipsoid([10,-17,5],[8,8,11.5],a.hair,12,6); + for(const side of [-1,1]) ellipsoid([side*15,-11,8],[4,9,6],tint(a.hair,.025),10,6); + } } + if(a.beard) ellipsoid([0,13,7],[12.5,9,8],a.hair,14,6); + if(a.glasses) { + for(const side of [-1,1]) { + // Open lenses keep eyes visible. + for(let i=0;i<12;i++) { + const t=i/12*Math.PI*2; + ellipsoid([side*7+Math.cos(t)*5,-2+Math.sin(t)*3.6,17],[1.2,1,.8],[28,32,39],6,4); + } + } + ellipsoid([0,-2.7,17],[2.8,.65,.8],[28,32,39],8,4); + } + }); + const model={key,parts,version:2}; + if(cache.size>=8) cache.delete(cache.keys().next().value); + cache.set(key,model); return model; +} +const vector=(x,y,z)=>({x,y,z}); +const cross=(a,b)=>vector(a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x); +const unit=a=>{const n=Math.hypot(a.x,a.y,a.z)||1;return vector(a.x/n,a.y/n,a.z/n);}; +export function poseFighter(model, world, {yaw=0,headless=false,hasPart=()=>true}={}) { + const instances=[], front=vector(Math.sin(yaw),0,Math.cos(yaw)); + const frame=(bone)=>{ + const axis=vector(bone.x2-bone.x1,bone.y2-bone.y1,bone.z2-bone.z1),down=unit(axis); + // At a punch pointing straight at the camera, keep the frame nonsingular. + let right=cross(down,front); if(Math.hypot(right.x,right.y,right.z)<.05) right=cross(down,vector(0,1,0)); + right=unit(right); const forward=unit(cross(right,down)); + return [right,axis,forward]; + }; + const add=(name,origin,axes)=>instances.push({name,faces:model.parts[name],origin,axes}); + const endpoint=(b,n)=>vector(b['x'+n],b['y'+n],b['z'+n]); + const torso=world.segments.find(b=>b.role==='torso'); + const basis=torso?frame(torso):[vector(Math.cos(yaw),0,-Math.sin(yaw)),vector(0,1,0),front]; + const rigid=[basis[0],unit(basis[1]),basis[2]]; + for(const bone of world.segments) { + if(bone.hidden||bone.hitboxOnly||!hasPart(bone.part))continue; + const name=bone.role.replace(/^(left|right|lead|rear|attack|rest|grab|item)-/,''); + if(!model.parts[name])continue; + add(name,endpoint(bone,1),frame(bone)); + if(name==='torso')add('pelvis',endpoint(bone,2),rigid); + if(name==='forearm')add('hand',endpoint(bone,2),[...frame(bone).map(unit)]); + if(name==='shin')add('shoe',endpoint(bone,2),rigid); + } + if(!headless) { + const scale=(world.head.radius||22)/22; + add('head',world.head,rigid.map(v=>vector(v.x*scale,v.y*scale,v.z*scale))); + } + return instances; +} +export function previewPose() { + const segments=[]; + const bone=(role,part,a,b)=>segments.push({role,part,x1:a[0],y1:a[1],z1:a[2],x2:b[0],y2:b[1],z2:b[2]}); + bone('torso','torso',[0,-137,0],[0,-90,0]); + bone('neck','torso',[0,-151,0],[0,-135,0]); + for(const side of [-1,1]){ + const name=side<0?'left':'right'; + bone(name+'-upper-arm',name+'-arm',[side*19,-133,0],[side*28,-101,0]); + bone(name+'-forearm',name+'-arm',[side*28,-101,0],[side*30,-72,3]); + bone(name+'-thigh',name+'-leg',[side*10,-90,0],[side*12,-47,1]); + bone(name+'-shin',name+'-leg',[side*12,-47,1],[side*13,-5,0]); + } + return {head:{x:0,y:-174,z:0,radius:22},segments}; +} +export function fighterMesh(appearance) { + return poseFighter(fighterParts(appearance),previewPose()).flatMap(({faces,origin:o,axes:a})=>faces.map(face=>({color:face.color,points:face.points.map(([x,y,z])=>[ + (o.x+a[0].x*x+a[1].x*y+a[2].x*z)/62, + -(o.y+a[0].y*x+a[1].y*y+a[2].y*z+99)/62, + (o.z+a[0].z*x+a[1].z*y+a[2].z*z)/62])}))); +} +// The module loads through the wizard before the game boots. Native builds +// without the account UI retain their existing renderer. +globalThis.__oskiewarFighterModel={build:fighterParts,pose:poseFighter,version:2}; +// Use a depth buffer for intersecting sleeves, hair and clothing. Sorting +// whole faces alone produces spikes at the waist and lets back hair leak through. +let previewContext; +function previewSurface(mesh) { + const canvas=previewContext?.canvas || document.createElement('canvas');canvas.width=640;canvas.height=760; + const gl=canvas.getContext('webgl',{alpha:true,antialias:true,preserveDrawingBuffer:true}); + if(!gl)return null; + if(previewContext){gl.deleteBuffer(previewContext.buffer);gl.deleteProgram(previewContext.program);} + const shader=(type,source)=>{const s=gl.createShader(type);gl.shaderSource(s,source);gl.compileShader(s);return s;}; + const program=gl.createProgram(); + gl.attachShader(program,shader(gl.VERTEX_SHADER,` + attribute vec3 position; attribute vec3 color; attribute vec3 normal; + uniform float angle; varying vec3 ink; + void main(){ + float c=cos(angle),s=sin(angle); + mat3 turn=mat3(c,0.,-s,0.,1.,0.,s,0.,c); + vec3 p=turn*position,n=normalize(turn*normal); + float diffuse=max(0.,dot(n,normalize(vec3(-.4,.65,.65)))); + ink=min(vec3(1.),color*(.75+.25*diffuse)+pow(diffuse,18.)*.05); + float w=7.-p.z; + gl_Position=vec4(p.x*4.2875,p.y*3.610526-.010526*w,1.1*w-2.1,w); + }`)); + gl.attachShader(program,shader(gl.FRAGMENT_SHADER,`precision mediump float;varying vec3 ink;void main(){gl_FragColor=vec4(ink,1.);}`)); + gl.linkProgram(program); + for(const s of gl.getAttachedShaders(program))gl.deleteShader(s); + if(!gl.getProgramParameter(program,gl.LINK_STATUS))return null; + const vertices=[]; + for(const f of mesh)for(const ids of [[0,1,2],[0,2,3]]) { + const [a,b,c]=ids.map(i=>f.points[i]); + const u=b.map((v,i)=>v-a[i]),v=c.map((n,i)=>n-a[i]); + const n=[u[2]*v[1]-u[1]*v[2],u[0]*v[2]-u[2]*v[0],u[1]*v[0]-u[0]*v[1]],length=Math.hypot(...n); + if(length<1e-8)continue; + for(const point of [a,b,c])vertices.push(...point,...f.color.map(c=>c/255),...n.map(v=>v/length)); } - if (a.beard) ellipsoid([0, 1.04, .165], [.22, .16, .14], a.hair); - if (a.glasses) for (const side of [-1, 1]) ellipsoid([side * .115, 1.31, .29], [.09, .065, .02], [18, 20, 28]); - return faces; + gl.useProgram(program);const buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(vertices),gl.STATIC_DRAW); + for(const [i,name] of ['position','color','normal'].entries()) {const at=gl.getAttribLocation(program,name);gl.enableVertexAttribArray(at);gl.vertexAttribPointer(at,3,gl.FLOAT,false,36,i*12);} + const angleUniform=gl.getUniformLocation(program,'angle');gl.enable(gl.DEPTH_TEST); + previewContext={canvas,buffer,program}; + return angle=>{gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);gl.uniform1f(angleUniform,angle);gl.drawArrays(gl.TRIANGLES,0,vertices.length/9);return canvas;}; } export function mountFighterPreview(host, fighter) { - const appearance = validateFighter(fighter), mesh = fighterMesh(appearance); - const canvas = document.createElement('canvas'); - canvas.width = 640; canvas.height = 760; - canvas.style.cssText = 'position:static;inset:auto;width:100%;height:auto;max-height:38vh;object-fit:contain;background:#f2f6fc;border-radius:20px'; - canvas.setAttribute('aria-label', 'Generated fighter preview. Use the rotation slider to inspect all sides.'); - const slider = document.createElement('input'); - slider.type = 'range'; slider.min = '-180'; slider.max = '180'; slider.value = '-20'; - slider.setAttribute('aria-label', 'Rotate fighter'); slider.style.cssText = 'width:100%;margin:14px 0 0;accent-color:#0866ff'; - host.replaceChildren(canvas, slider); - const ctx = canvas.getContext('2d'); - function paint() { - const angle = Number(slider.value) * Math.PI / 180, c = Math.cos(angle), s = Math.sin(angle); - ctx.fillStyle = '#f2f6fc'; ctx.fillRect(0, 0, 640, 760); - ctx.fillStyle = '#294b7a1c'; ctx.beginPath(); ctx.ellipse(320, 699, 100, 16, 0, 0, Math.PI * 2); ctx.fill(); - const rotate = ([x, y, z]) => [x * c + z * s, y, z * c - x * s]; - const projected = mesh.map(face => ({ color: face.color, points: face.points.map(rotate) })); - projected.sort((a, b) => a.points.reduce((n, p) => n + p[2], 0) - b.points.reduce((n, p) => n + p[2], 0)); - for (const face of projected) { - const [a, b, d] = face.points; - const u = b.map((v, i) => v - a[i]), v = d.map((n, i) => n - a[i]); - const normal = [u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0]]; - const length = Math.hypot(...normal) || 1; - const light = .65 + .35 * Math.abs((normal[0] * -.35 + normal[1] * .65 + normal[2] * .7) / length); - ctx.fillStyle = `rgb(${face.color.map(v => Math.round(v * light)).join(',')})`; - ctx.beginPath(); face.points.forEach(([x, y, z], i) => { const scale = 205 * 5 / (5 - z); ctx[i ? 'lineTo' : 'moveTo'](320 + x * scale, 390 - y * scale); }); - ctx.closePath(); ctx.fill(); + const appearance=validateFighter(fighter), mesh=fighterMesh(appearance); + const canvas=document.createElement('canvas'); canvas.width=640;canvas.height=760; + canvas.style.cssText='position:static;inset:auto;display:block;width:100%;height:auto;max-height:38vh;object-fit:contain;border-radius:22px;background:#e8eef8;box-shadow:inset 0 1px 0 #fff,0 1px 2px #15335b15'; + canvas.setAttribute('aria-label','Generated fighter preview. Use the rotation slider to inspect all sides.'); + const slider=document.createElement('input'); slider.type='range';slider.min='-180';slider.max='180';slider.value='-18'; + slider.setAttribute('aria-label','Rotate fighter');slider.style.cssText='width:100%;margin:14px 0 0;accent-color:#0866ff'; + host.replaceChildren(canvas,slider);const ctx=canvas.getContext('2d'),surface=previewSurface(mesh); + function paint(){ + const angle=Number(slider.value)*Math.PI/180,c=Math.cos(angle),s=Math.sin(angle); + const bg=ctx.createLinearGradient(0,0,640,760);bg.addColorStop(0,'#f9fbff');bg.addColorStop(.55,'#e8effb');bg.addColorStop(1,'#d7e3f5');ctx.fillStyle=bg;ctx.fillRect(0,0,640,760); + const halo=ctx.createRadialGradient(270,270,10,320,340,330);halo.addColorStop(0,'#ffffffda');halo.addColorStop(1,'#ffffff00');ctx.fillStyle=halo;ctx.fillRect(0,0,640,760); + ctx.fillStyle='#ffffffa8';ctx.beginPath();ctx.ellipse(320,701,160,27,0,0,Math.PI*2);ctx.fill(); + const shadow=ctx.createRadialGradient(320,700,3,320,700,105);shadow.addColorStop(0,'#3d527e50');shadow.addColorStop(1,'#3d527e00');ctx.save();ctx.translate(0,550);ctx.scale(1,.215);ctx.fillStyle=shadow;ctx.fillRect(180,570,280,240);ctx.restore(); + if(surface){ctx.drawImage(surface(angle),0,0);return;} + const rotate=([x,y,z])=>[x*c+z*s,y,z*c-x*s]; + const projected=mesh.map(f=>({color:f.color,points:f.points.map(rotate)})); + projected.sort((a,b)=>a.points.reduce((n,p)=>n+p[2],0)-b.points.reduce((n,p)=>n+p[2],0)); + for(const face of projected){ + const [a,b,d]=face.points;const u=b.map((v,i)=>v-a[i]),v=d.map((n,i)=>n-a[i]); + const normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]],length=Math.hypot(...normal)||1; + const diffuse=Math.max(0,-(normal[0]*-.4+normal[1]*.65+normal[2]*.65)/length); + const light=.75+.25*diffuse,shine=Math.pow(diffuse,18)*13; + ctx.fillStyle=`rgb(${face.color.map(v=>Math.min(255,Math.round(v*light+shine))).join(',')})`; + ctx.beginPath();face.points.forEach(([x,y,z],i)=>{const scale=196*7/(7-z);ctx[i?'lineTo':'moveTo'](320+x*scale,384-y*scale);});ctx.closePath();ctx.fill(); } } - slider.addEventListener('input', paint); paint(); - return appearance; + slider.addEventListener('input',paint);paint();return appearance; } diff --git a/xbox/live/oskiewar-wizard.mjs b/xbox/live/oskiewar-wizard.mjs index eca73d2901..9f23ab5109 100644 --- a/xbox/live/oskiewar-wizard.mjs +++ b/xbox/live/oskiewar-wizard.mjs @@ -39,16 +39,17 @@ export function scopeForMedia({ photo, voice }) { const STYLE = ` #wizard-panel { position:fixed; inset:0; z-index:21; display:grid; place-items:center; - padding:20px; background:rgba(18,28,45,.48); backdrop-filter:blur(12px); } + padding:20px; background:radial-gradient(ellipse at 50% 15%,#739adb38,transparent 65%),rgba(13,24,43,.58); backdrop-filter:blur(18px) saturate(1.2); } #wizard-panel[hidden] { display:none; } #wizard-card { box-sizing:border-box; width:100%; max-width:480px; max-height:calc(100dvh - 40px); overflow:auto; display:flex; flex-direction:column; gap:20px; padding:28px; - border:1px solid #ffffffa8; border-radius:28px; background:#fff; color:#182638; - box-shadow:0 24px 80px #081b3d40; font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; + border:1px solid #ffffffd9; border-radius:30px; background:linear-gradient(145deg,#fff 15%,#f6f9ff 65%,#edf3fd); color:#182638; + box-shadow:0 32px 100px #03133166,0 8px 24px #09245524,inset 0 2px 0 #fff,inset 0 -1px 0 #cbdaf180; font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; scrollbar-width:thin; scrollbar-color:#ccd5df transparent; } #wizard-card > * { flex-shrink:0; } #wizard-brand { align-self:flex-start; display:flex; align-items:center; gap:7px; - padding:6px 11px; border:1px solid #e1e7ee; border-radius:99px; background:#f6f8fb; + padding:7px 12px; border:1px solid #d5dfed; border-radius:99px; background:linear-gradient(170deg,#fff 20%,#e6edf8 95%); + box-shadow:0 2px 4px #243e6512,inset 0 1px 0 #fff,inset 0 -1px 0 #c8d5e780; color:#33445a; font-size:15px; font-weight:650; line-height:1; letter-spacing:-.03em; } #wizard-brand svg { width:19px; height:19px; } #wizard-card h2 { margin:0; font-size:29px; font-weight:700; line-height:1.15; letter-spacing:-.045em; } @@ -56,7 +57,7 @@ const STYLE = ` #wizard-card fieldset { border:0; margin:0; padding:0; display:grid; gap:12px; } #wizard-card legend { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); } #wizard-card label.row { display:flex; gap:12px; align-items:center; padding:16px; - border:1px solid #d7e3f3; border-radius:16px; cursor:pointer; font-size:16px; font-weight:600; background:#f4f8fe; } + border:1px solid #d7e3f3; border-radius:16px; cursor:pointer; font-size:16px; font-weight:600; background:linear-gradient(135deg,#fff,#eaf2ff); box-shadow:inset 0 1px 0 #fff,0 2px 6px #1c437508; } #wizard-card label.row input { width:20px; height:20px; margin:0; accent-color:#0866ff; } #wizard-upload { min-width:0; } #wizard-upload:empty { display:none; } @@ -77,16 +78,18 @@ const STYLE = ` #wizard-actions { display:flex; gap:10px; } #wizard-panel button { flex:1; min-height:48px; padding:12px 16px; border:1px solid transparent; border-radius:14px; font:600 15px/1.25 -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif; - background:#0866ff; color:#fff; cursor:pointer; transition:background .15s,transform .15s; } -#wizard-panel button:hover { background:#0059e0; } + background:linear-gradient(180deg,#358cff,#0866f5 60%,#0759d9); color:#fff; cursor:pointer; + border-color:#1268dd; box-shadow:inset 0 1px 0 #ffffff66,0 3px 7px #0866ff29; + text-shadow:0 1px 1px #004aaa50; transition:filter .15s,transform .15s,box-shadow .15s; } +#wizard-panel button:hover { filter:brightness(1.055); box-shadow:inset 0 1px 0 #ffffff66,0 4px 12px #0866ff38; } #wizard-panel button:active { transform:scale(.98); } #wizard-panel button:focus-visible, #wizard-panel input:focus-visible, #wizard-proof summary:focus-visible { outline:3px solid #88b7ff; outline-offset:3px; } #wizard-panel button[disabled] { opacity:.45; cursor:default; } -#wizard-panel #wizard-back { background:#edf1f6; color:#33445a; } +#wizard-panel #wizard-back { background:linear-gradient(#fff,#e9eef6); border-color:#d5dfec; color:#33445a; text-shadow:none; box-shadow:inset 0 1px 0 #fff,0 2px 3px #1b365a0c; } #wizard-panel #wizard-back:hover { background:#e2e8f0; } #wizard-panel #wizard-withdraw { flex:none; min-height:36px; padding:8px; background:transparent; - color:#9a3346; font-size:13px; font-weight:500; } + border-color:transparent; box-shadow:none; text-shadow:none; color:#9a3346; font-size:13px; font-weight:500; } #wizard-panel #wizard-withdraw:hover { background:#fff1f3; } #wizard-panel.working #wizard-card { cursor:progress; } #wizard-panel.working #wizard-go { background:#5a91e6; } diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index aad5e4102f..3088d190e6 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 = 259; +const buildVersion = 260; const parkDecalResolution=Number(globalThis.decalSurfaceSize)||2048; const floorY = 1800; // Oskiewar now opens as a versus game. An ordinary web visit hosts a room — @@ -20620,8 +20620,9 @@ function characterMesh(key,build,origin,axes){ 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 generated=key.startsWith('generated:'); 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; + const band=generated?.7+.3*blend: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);} @@ -21250,6 +21251,34 @@ function drawRunnerFlat(player, t) { } return true; } +function drawGeneratedRunner(player, world, appearance, t, geometry) { + const renderer = globalThis.__oskiewarFighterModel; + if (!renderer) return false; + const model = renderer.build(appearance); + const yaw = Math.PI - (player.facing || 1) * .38 + (player.spin?.angle || 0); + const instances = renderer.pose(model, world, { yaw, headless: player.headless, + hasPart: part => hasPart(player, part) }); + if (player.skateboard) drawSkateboard(player); + for (const instance of instances) { + characterMesh('generated:' + model.version + ':' + model.key + ':' + instance.name, () => { + for (const face of instance.faces) { + const points = face.points.map(([x,y,z]) => ({x,y,z})); + worldQuad(points[0], points[1], points[2], points[3], face.color); + } + }, instance.origin, instance.axes); + } + if (globalThis.__oskiewarTouch) { + globalThis.__oskiewarTouch.practiceFighter = globalThis.__oskiewarFighterAppearance.handle; + globalThis.__oskiewarTouch.practiceModel = { version: model.version, parts: instances.length }; + } + drawInventory(player, player.frozenAt || runtime().monotonicUs, geometry); + drawHeldAxe(player, t); + if (player.blocking && !poolOnly()) { + const shield = shieldGeometry(player), p = projectPoint(shield.x, shield.y, shield.z); + drawBubble(player, p.x, p.y, Math.max(18, shield.radius * cameraScale()), t); + } + return true; +} function drawRunnerAtScale(player, t, showLabel = true) { if (!generatedAppearance(player) && flatFiguresOn() && drawRunnerFlat(player, t)) return; const fallen=ragdollBodies.get(player);if(fallen&&!player.alive){drawLooseRunner(player,fallen.pose,t,0);if(player.headless&&player.looseHead)drawCivilianLooseHead(player);return;} @@ -21309,6 +21338,9 @@ function drawRunnerAtScale(player, t, showLabel = true) { } const flatFight=!!parkFightRival(player); const appearance = generatedAppearance(player); + if (appearance && drawGeneratedRunner(player, + player.replayGeometry || player.frozenGeometry || runnerWorldGeometry(player, t), + appearance, t, geometry)) return; const lod = appearance || flatFight?0:poolOnly()&&player.civilian?Math.max(geometry.head.radius<12?2:1,figureLod(player,geometry)):figureLod(player, geometry); if(poolOnly()&&player.alive&&!player.dummy&&!flatFight){ const world=player.replayGeometry||player.frozenGeometry||runnerWorldGeometry(player,t); @@ -24977,6 +25009,7 @@ function gamePaint() { const run = runtime(); if (globalThis.__oskiewarTouch) { globalThis.__oskiewarTouch.practiceFighter = null; + globalThis.__oskiewarTouch.practiceModel = null; globalThis.__oskiewarTouch.screen = shellMode === "MENU" ? titleTransitionAt !== null ? "title-transition" : "title" : selecting ? "select" : "game";