From 6cb8f3c2e7ff0b623ef083dfe206144eaee0cd29 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 5 Aug 2026 21:21:36 -0700 Subject: [PATCH] Animate OSKIEWAR title colors --- xbox/live/hello.js | 70 ++++++++++++++++++++++++++++------ xbox/live/tests/hello.test.mjs | 15 +++++++- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/xbox/live/hello.js b/xbox/live/hello.js index 904dcf50eb..8ac69dc0ec 100644 --- a/xbox/live/hello.js +++ b/xbox/live/hello.js @@ -2797,21 +2797,69 @@ function drawSelectionScreen(t, ink, panel) { typeWrite(controls.select, ox + 225, 958, 28, ...ink); } +const titlePaletteNight = [ + [255, 105, 190], [111, 232, 210], [255, 232, 92], [130, 150, 255], + [255, 126, 92], [188, 128, 255], [92, 205, 255], [246, 248, 255], +]; +const titlePaletteDay = [ + [190, 32, 118], [0, 126, 124], [176, 112, 0], [73, 82, 190], + [204, 59, 43], [114, 68, 185], [24, 107, 181], [47, 56, 82], +]; + +function animatedTitleColor(index, t, daylight = visualTheme.light) { + const count = titlePaletteNight.length; + const phase = (index + t * .42) % count; + const from = Math.floor(phase); + const amount = phase - from; + const eased = amount * amount * (3 - amount * 2); + const night = mixColor(titlePaletteNight[from], + titlePaletteNight[(from + 1) % count], eased); + const day = mixColor(titlePaletteDay[from], + titlePaletteDay[(from + 1) % count], eased); + return mixColor(night, day, daylight); +} + function drawTitleScreen(t, ink) { const compact = compactLayout(); const title = "oskiewar"; - const titleSize = compact ? 88 : 154; + const breath = 1 + Math.sin(t * .9) * .018; + const titleSize = (compact ? 88 : 154) * breath; const titleWidth = handleWidth(title, titleSize); - const pulseY = Math.round(Math.sin(t * 1.7) * (compact ? 3 : 5)); - typeWrite(title, viewCenterX() - titleWidth / 2, - viewHeight * (compact ? .38 : .35) + pulseY, titleSize, ...ink); - if ((t % 1.2) < .82) { - const prompt = controlLocale().title; - const promptSize = compact ? 28 : 38; - const promptWidth = handleWidth(prompt, promptSize); - typeWrite(prompt, viewCenterX() - promptWidth / 2, - viewHeight * (compact ? .61 : .64), promptSize, ...ink); - } + const titleX = viewCenterX() - titleWidth / 2; + const titleY = viewHeight * (compact ? .38 : .35); + + // Sparse orbiting motes give attract mode some life without adding panels + // or stripes behind the wordmark. + const moteCount = compact ? 8 : 12; + for (let index = 0; index < moteCount; index++) { + const angle = t * (.18 + index % 3 * .025) + index * 2.39996; + const reach = titleWidth * (.55 + Math.sin(t * .31 + index) * .06); + const x = viewCenterX() + Math.cos(angle) * reach; + const y = titleY + titleSize * .42 + + Math.sin(angle * 1.17) * titleSize * (compact ? .88 : 1.05); + const radius = (compact ? 2 : 3) + (index % 3); + circle(x, y, radius, Math.max(1.5, radius * .48), + animatedTitleColor(index, t * .7)); + } + + let cursor = titleX; + for (let index = 0; index < title.length; index++) { + const character = title[index]; + const bob = Math.sin(t * 2.05 + index * .72) * (compact ? 5 : 8); + const drift = Math.cos(t * 1.12 + index * .91) * (compact ? 1.5 : 2.5); + typeWrite(character, cursor + drift, titleY + bob, titleSize, + ...animatedTitleColor(index, t)); + cursor += comicGlyphAdvance(character, titleSize); + } + + const prompt = controlLocale().title; + const promptSize = compact ? 28 : 38; + const promptWidth = handleWidth(prompt, promptSize); + const promptPulse = .58 + (Math.sin(t * 2.2) + 1) * .21; + const mutedInk = visualTheme.light > .55 ? [118, 128, 150] : [72, 80, 112]; + const promptInk = mixColor(mutedInk, ink, promptPulse); + typeWrite(prompt, viewCenterX() - promptWidth / 2, + viewHeight * (compact ? .61 : .64), promptSize, ...promptInk); } function drawSpectatorQr(ink) { diff --git a/xbox/live/tests/hello.test.mjs b/xbox/live/tests/hello.test.mjs index 2c973b4259..88e4b72189 100644 --- a/xbox/live/tests/hello.test.mjs +++ b/xbox/live/tests/hello.test.mjs @@ -24,7 +24,7 @@ function createFight(startImmediately = true, enterGame = true, const drawLine = (...values) => lines.push(values); const fight = new Function( "runtime", "gamepad", "capabilities", "telemetry", "gameSignal", "saveReplay", "publishLive", "analytics", "drum", "wipe", "box", "line", "triangle", "write", "systemWrite", "gameView", - `${source}\nreturn { boot, sim, paint, controlLocale, players, ball, balls, bullets, grenades, gunPickups, grenadePickups, runnerWorldGeometry, runnerDistanceToPoint, disableBall: () => { ballEnabled = false; for (const item of balls) item.active = false; }, enableBall: (index = 0) => { ballEnabled = true; const item = balls[index]; item.active = true; item.serveAt = 0; item.safeUntil = 0; item.safePlayers = 0; }, setWind: (value) => { windAcceleration = value; }, windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), knockOut: () => killPlayer(players[1], 0, runtime().monotonicUs, "KO"), wackBall: () => { players[0].attackKind = "KICK"; returnBall(ball, players[0], runtime().monotonicUs, false); }, shieldBall: () => returnBall(ball, players[0], runtime().monotonicUs, true), crossWackBall: (contact = 1) => crossWackBall(ball, players.map((player) => ({ player, contact })), runtime().monotonicUs), enterGame: () => enterGame(runtime().monotonicUs), shellState: () => ({ mode: shellMode }), startFight: () => { shellMode = "GAME"; selecting = false; startReplay(runtime().monotonicUs); resetRound(runtime().monotonicUs, true); }, selectionState: () => ({ selecting, ready: selectionReady.slice() }), cameraState: () => ({ cameraWidth, cameraCenter, cameraCenterY, cameraAspect, stageRight, viewHeight, doll: { width: cameraDoll.width, target: { ...cameraDoll.target }, position: { ...cameraDoll.position }, roll: cameraDoll.roll } }), screenBounds: () => players.map((player) => runnerScreenBounds(player, runtime().monotonicUs / 1e6)), actionSafeRect, hudSafeRect, roundState: () => ({ roundResult, roundElapsedUs, matchOver }), viewerState: () => ({ active: Boolean(roundViewer), mode: roundViewerMode, status: roundViewerStatus, name: matchName }), instantReplayState: () => instantReplay ? { active: true, paused: instantReplay.paused, cursor: instantReplay.cursor, frames: instantReplay.frames.length } : { active: false }, replayFrameCount: () => roundReplayFrames.length };` + `${source}\nreturn { boot, sim, paint, controlLocale, animatedTitleColor, players, ball, balls, bullets, grenades, gunPickups, grenadePickups, runnerWorldGeometry, runnerDistanceToPoint, disableBall: () => { ballEnabled = false; for (const item of balls) item.active = false; }, enableBall: (index = 0) => { ballEnabled = true; const item = balls[index]; item.active = true; item.serveAt = 0; item.safeUntil = 0; item.safePlayers = 0; }, setWind: (value) => { windAcceleration = value; }, windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), knockOut: () => killPlayer(players[1], 0, runtime().monotonicUs, "KO"), wackBall: () => { players[0].attackKind = "KICK"; returnBall(ball, players[0], runtime().monotonicUs, false); }, shieldBall: () => returnBall(ball, players[0], runtime().monotonicUs, true), crossWackBall: (contact = 1) => crossWackBall(ball, players.map((player) => ({ player, contact })), runtime().monotonicUs), enterGame: () => enterGame(runtime().monotonicUs), shellState: () => ({ mode: shellMode }), startFight: () => { shellMode = "GAME"; selecting = false; startReplay(runtime().monotonicUs); resetRound(runtime().monotonicUs, true); }, selectionState: () => ({ selecting, ready: selectionReady.slice() }), cameraState: () => ({ cameraWidth, cameraCenter, cameraCenterY, cameraAspect, stageRight, viewHeight, doll: { width: cameraDoll.width, target: { ...cameraDoll.target }, position: { ...cameraDoll.position }, roll: cameraDoll.roll } }), screenBounds: () => players.map((player) => runnerScreenBounds(player, runtime().monotonicUs / 1e6)), actionSafeRect, hudSafeRect, roundState: () => ({ roundResult, roundElapsedUs, matchOver }), viewerState: () => ({ active: Boolean(roundViewer), mode: roundViewerMode, status: roundViewerStatus, name: matchName }), instantReplayState: () => instantReplay ? { active: true, paused: instantReplay.paused, cursor: instantReplay.cursor, frames: instantReplay.frames.length } : { active: false }, replayFrameCount: () => roundReplayFrames.length };` )( () => ({ monotonicUs: now, unixMs: 1785870000000 + Math.floor(now / 1000) }), (index = 0) => ({ ...pads[index], down: pads[index].down.slice() }), @@ -110,6 +110,19 @@ test("OSKIEWAR typography uses the packaged KidLisp Comic Relief face", () => { assert.match(source, /compactLayout\(\) \? 20 : 24/); }); +test("title letters carry distinct colors that animate between palettes", () => { + const { animatedTitleColor } = createFight(false, false).fight; + const firstFrame = Array.from({ length: 8 }, (_, index) => + animatedTitleColor(index, 0, 0)); + assert.equal(new Set(firstFrame.map((color) => color.join(","))).size, 8); + assert.notDeepEqual(animatedTitleColor(0, 0, 0), + animatedTitleColor(0, 1.25, 0)); + for (const light of [0, .5, 1]) { + for (const channel of animatedTitleColor(5, 99.25, light)) + assert.ok(channel >= 0 && channel <= 255); + } +}); + test("web camera follows landscape, 16:9, and portrait viewports", () => { for (const [viewport, expectedWidth] of [ [{ width: 2560, height: 1080 }, 2560], -- knotmirror 0.0.1