mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
feat(android): mascot mood engine and onboarding moods (#109311)
Ports the deterministic mascot animator to Android: full pose channels (face, gaze, droop, hard hat, effects), seeded mood loops including the hard-hat working hammer cycle, entrance gestures, and static poses when system animations are off. Onboarding heroes now feel the flow — working while the gateway connects, celebrating once paired, thinking during node approval, curious on permissions, sad on visible errors. Tinted silhouette marks keep their ambient idle loop and stay silent for TalkBack outside the welcome hero.
This commit is contained in:
committed by
GitHub
parent
5a0fcc2ff9
commit
7496f4d988
+164
-164
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTextField
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import ai.openclaw.app.ui.design.MascotMood
|
||||
import ai.openclaw.app.ui.design.OpenClawMascot
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
@@ -284,6 +285,34 @@ internal fun OnboardingErrorCode.nativeTextOrNull(): NativeText? {
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible errors outrank step defaults; connected recovery is the only pre-handoff success surface. */
|
||||
internal fun onboardingMascotMood(
|
||||
step: OnboardingStep,
|
||||
recoveryState: GatewayRecoveryUiState? = null,
|
||||
setupErrorCode: OnboardingErrorCode = OnboardingErrorCode.None,
|
||||
setupScanErrorCode: OnboardingErrorCode = OnboardingErrorCode.None,
|
||||
): MascotMood {
|
||||
if (
|
||||
setupErrorCode != OnboardingErrorCode.None ||
|
||||
setupScanErrorCode != OnboardingErrorCode.None ||
|
||||
recoveryState == GatewayRecoveryUiState.Failed
|
||||
) {
|
||||
return MascotMood.Sad
|
||||
}
|
||||
return when (step) {
|
||||
OnboardingStep.Recovery ->
|
||||
if (recoveryState == GatewayRecoveryUiState.Connected) MascotMood.Celebrating else MascotMood.Working
|
||||
OnboardingStep.NodeApproval -> MascotMood.Thinking
|
||||
OnboardingStep.Permissions -> MascotMood.Curious
|
||||
OnboardingStep.Welcome,
|
||||
OnboardingStep.Gateway,
|
||||
OnboardingStep.SetupCode,
|
||||
OnboardingStep.EnterSetupCode,
|
||||
OnboardingStep.Manual,
|
||||
-> MascotMood.Idle
|
||||
}
|
||||
}
|
||||
|
||||
private const val GATEWAY_CONNECT_SETTLING_MS = 2_500L
|
||||
private const val GATEWAY_CONNECT_TIMEOUT_MS = 20_000L
|
||||
private const val NODE_APPROVAL_REFRESH_OBSERVE_TIMEOUT_MS = 750L
|
||||
@@ -762,6 +791,11 @@ fun OnboardingFlow(
|
||||
setupScanErrorCode.nativeTextOrNull()?.let { message ->
|
||||
SetupScanErrorDialog(
|
||||
message = message.resolveNativeTextResource(),
|
||||
mascotMood =
|
||||
onboardingMascotMood(
|
||||
step = step,
|
||||
setupScanErrorCode = setupScanErrorCode,
|
||||
),
|
||||
onDismiss = { setupScanErrorCode = OnboardingErrorCode.None },
|
||||
onChooseAnotherImage = {
|
||||
setupScanErrorCode = OnboardingErrorCode.None
|
||||
@@ -816,6 +850,7 @@ fun OnboardingFlow(
|
||||
OnboardingStep.Welcome ->
|
||||
WelcomeScreen(
|
||||
modifier = modifier,
|
||||
mascotMood = onboardingMascotMood(step = step),
|
||||
onConnect = { step = OnboardingStep.Gateway },
|
||||
)
|
||||
OnboardingStep.Gateway ->
|
||||
@@ -879,6 +914,7 @@ fun OnboardingFlow(
|
||||
modifier = modifier,
|
||||
setupCode = setupCode,
|
||||
error = setupErrorCode.nativeTextOrNull()?.resolveNativeTextResource(),
|
||||
mascotMood = onboardingMascotMood(step = step, setupErrorCode = setupErrorCode),
|
||||
onBack = ::goBack,
|
||||
onSetupCodeChange = {
|
||||
setupCode = it
|
||||
@@ -895,6 +931,7 @@ fun OnboardingFlow(
|
||||
token = token,
|
||||
password = password,
|
||||
error = setupErrorCode.nativeTextOrNull()?.resolveNativeTextResource(),
|
||||
mascotMood = onboardingMascotMood(step = step, setupErrorCode = setupErrorCode),
|
||||
onBack = ::goBack,
|
||||
onManualHostChange = {
|
||||
manualHost = it
|
||||
@@ -990,6 +1027,7 @@ fun OnboardingFlow(
|
||||
|
||||
@Composable
|
||||
private fun WelcomeScreen(
|
||||
mascotMood: MascotMood,
|
||||
onConnect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -999,7 +1037,7 @@ private fun WelcomeScreen(
|
||||
OnboardingIntroHero(
|
||||
title = nativeString("Welcome to OpenClaw"),
|
||||
subtitle = nativeString("Turn this device into a secure OpenClaw node for chat, voice, camera, and device tools."),
|
||||
mark = { WelcomeLogo() },
|
||||
mark = { WelcomeLogo(mood = mascotMood, announceLogo = true) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
WelcomeChecklist()
|
||||
@@ -1014,7 +1052,12 @@ private fun WelcomeScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeLogo() {
|
||||
private fun WelcomeLogo(
|
||||
mood: MascotMood,
|
||||
// Only the welcome hero announces the logo; status/error reuses are
|
||||
// decorative and must stay silent for TalkBack.
|
||||
announceLogo: Boolean = false,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(OnboardingHeroMarkSize),
|
||||
shape = CircleShape,
|
||||
@@ -1023,7 +1066,11 @@ private fun WelcomeLogo() {
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) {
|
||||
OpenClawMascot(contentDescription = nativeString("OpenClaw logo"), modifier = Modifier.fillMaxSize())
|
||||
OpenClawMascot(
|
||||
contentDescription = if (announceLogo) nativeString("OpenClaw logo") else null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
mood = mood,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1307,6 +1354,7 @@ private fun SetupCodeInstructionsScreen(
|
||||
@Composable
|
||||
private fun SetupScanErrorDialog(
|
||||
message: String,
|
||||
mascotMood: MascotMood,
|
||||
onDismiss: () -> Unit,
|
||||
onChooseAnotherImage: () -> Unit,
|
||||
onEnterSetupCode: () -> Unit,
|
||||
@@ -1349,6 +1397,10 @@ private fun SetupScanErrorDialog(
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
WelcomeLogo(mood = mascotMood)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = ClawTheme.type.body,
|
||||
@@ -1644,6 +1696,7 @@ private fun analyzeSetupQrFrame(
|
||||
private fun SetupCodeEntryScreen(
|
||||
setupCode: String,
|
||||
error: String?,
|
||||
mascotMood: MascotMood,
|
||||
onBack: () -> Unit,
|
||||
onSetupCodeChange: (String) -> Unit,
|
||||
onUseSetupCode: () -> Unit,
|
||||
@@ -1653,6 +1706,11 @@ private fun SetupCodeEntryScreen(
|
||||
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
OnboardingHeader(title = nativeText("Enter setup code"), onBack = onBack)
|
||||
if (error != null) {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
WelcomeLogo(mood = mascotMood)
|
||||
}
|
||||
}
|
||||
LabeledField(label = nativeString("Setup code")) {
|
||||
ClawTextField(
|
||||
value = setupCode,
|
||||
@@ -1679,6 +1737,7 @@ private fun ManualGatewaySetupScreen(
|
||||
token: String,
|
||||
password: String,
|
||||
error: String?,
|
||||
mascotMood: MascotMood,
|
||||
onBack: () -> Unit,
|
||||
onManualHostChange: (String) -> Unit,
|
||||
onManualPortChange: (String) -> Unit,
|
||||
@@ -1789,6 +1848,11 @@ private fun ManualGatewaySetupScreen(
|
||||
}
|
||||
}
|
||||
error?.let { message ->
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
WelcomeLogo(mood = mascotMood)
|
||||
}
|
||||
}
|
||||
item {
|
||||
InlineError(title = nativeString("Could not test connection"), body = message)
|
||||
}
|
||||
@@ -1956,7 +2020,13 @@ private fun GatewayRecoveryScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
GatewayRecoveryIcon(state = recoveryState)
|
||||
WelcomeLogo(
|
||||
mood =
|
||||
onboardingMascotMood(
|
||||
step = OnboardingStep.Recovery,
|
||||
recoveryState = recoveryState,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(13.dp))
|
||||
Text(text = recoveryTitle.resolveNativeTextResource(), style = ClawTheme.type.display, color = ClawTheme.colors.text, textAlign = TextAlign.Center)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -2052,46 +2122,6 @@ private fun copyGatewayDiagnostic(
|
||||
Toast.makeText(context, nativeString("Details copied"), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayRecoveryIcon(state: GatewayRecoveryUiState) {
|
||||
val icon =
|
||||
when (state) {
|
||||
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
|
||||
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> Icons.Default.Security
|
||||
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.TakingLonger -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline
|
||||
}
|
||||
val tint =
|
||||
when (state) {
|
||||
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
|
||||
GatewayRecoveryUiState.NodeCapabilityApprovalPending -> ClawTheme.colors.warning
|
||||
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
|
||||
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
|
||||
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
|
||||
GatewayRecoveryUiState.TakingLonger -> ClawTheme.colors.warning
|
||||
GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.size(62.dp),
|
||||
shape = CircleShape,
|
||||
color =
|
||||
when (state) {
|
||||
GatewayRecoveryUiState.Connected -> ClawTheme.colors.successSoft
|
||||
GatewayRecoveryUiState.TakingLonger -> ClawTheme.colors.warningSoft
|
||||
GatewayRecoveryUiState.Failed -> ClawTheme.colors.warningSoft
|
||||
else -> ClawTheme.colors.surfaceRaised
|
||||
},
|
||||
contentColor = tint,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(36.dp), tint = tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeApprovalScreen(
|
||||
approval: GatewayNodeCapabilityApproval,
|
||||
@@ -2131,7 +2161,7 @@ private fun NodeApprovalScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
GatewayRecoveryIcon(state = GatewayRecoveryUiState.NodeCapabilityApprovalPending)
|
||||
WelcomeLogo(mood = onboardingMascotMood(step = OnboardingStep.NodeApproval))
|
||||
Spacer(modifier = Modifier.height(13.dp))
|
||||
Text(
|
||||
text = nativeString("Approve node access"),
|
||||
@@ -2366,6 +2396,11 @@ private fun PermissionSetupScreen(
|
||||
item {
|
||||
PermissionTopBar(onBack = onBack)
|
||||
}
|
||||
item {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), contentAlignment = Alignment.Center) {
|
||||
WelcomeLogo(mood = onboardingMascotMood(step = OnboardingStep.Permissions))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = nativeString("Only enable access you are comfortable letting OpenClaw use while this phone is connected. You can change these later in Android Settings."),
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val NONZERO_SEED: ULong = 0x9E37_79B9_7F4A_7C15uL
|
||||
private const val XORSHIFT_MULTIPLIER: ULong = 2_685_821_657_736_338_717uL
|
||||
private const val TAU = PI * 2.0
|
||||
private const val BLINK_DURATION = 0.16
|
||||
private const val CATCH_DURATION = 0.8
|
||||
|
||||
private enum class Gesture {
|
||||
Wave,
|
||||
Hop,
|
||||
Celebrate,
|
||||
Sigh,
|
||||
Yawn,
|
||||
ClawSnap,
|
||||
DonHardHat,
|
||||
WipeBrow,
|
||||
}
|
||||
|
||||
private fun clamp(
|
||||
value: Double,
|
||||
min: Double = 0.0,
|
||||
max: Double = 1.0,
|
||||
): Double = value.coerceIn(min, max)
|
||||
|
||||
private fun cyclePhase(
|
||||
time: Double,
|
||||
period: Double,
|
||||
): Double {
|
||||
val normalized = (time / period) % 1.0
|
||||
return if (normalized < 0.0) normalized + 1.0 else normalized
|
||||
}
|
||||
|
||||
private fun easeInOut(value: Double): Double {
|
||||
val t = clamp(value)
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
private fun bell(value: Double): Double {
|
||||
val t = clamp(value)
|
||||
return easeInOut(if (t < 0.5) t * 2.0 else (1.0 - t) * 2.0)
|
||||
}
|
||||
|
||||
private fun plateau(
|
||||
value: Double,
|
||||
attack: Double,
|
||||
release: Double,
|
||||
): Double {
|
||||
val t = clamp(value)
|
||||
if (t < attack) return easeInOut(t / attack)
|
||||
if (t > release) return easeInOut((1.0 - t) / (1.0 - release))
|
||||
return 1.0
|
||||
}
|
||||
|
||||
private fun gestureDuration(gesture: Gesture): Double =
|
||||
when (gesture) {
|
||||
Gesture.Wave -> 1.5
|
||||
Gesture.Hop -> 0.7
|
||||
Gesture.Celebrate -> 2.4
|
||||
Gesture.Sigh -> 1.8
|
||||
Gesture.Yawn,
|
||||
Gesture.WipeBrow,
|
||||
-> 2.0
|
||||
Gesture.ClawSnap -> 0.6
|
||||
Gesture.DonHardHat -> 1.0
|
||||
}
|
||||
|
||||
private class SeededGenerator(
|
||||
seed: ULong,
|
||||
) {
|
||||
private var state = if (seed == 0uL) NONZERO_SEED else seed
|
||||
|
||||
fun next(): ULong {
|
||||
state = state xor (state shr 12)
|
||||
state = state xor (state shl 25)
|
||||
state = state xor (state shr 27)
|
||||
return state * XORSHIFT_MULTIPLIER
|
||||
}
|
||||
|
||||
fun unit(): Double = (next() shr 11).toDouble() / 9_007_199_254_740_992.0
|
||||
}
|
||||
|
||||
/** Pure deterministic mood loops plus randomized blink, gaze, claw-snap, and mood-beat schedules. */
|
||||
class MascotAnimator(
|
||||
seed: ULong = System.nanoTime().toULong(),
|
||||
) {
|
||||
private val rng = SeededGenerator(seed)
|
||||
private var currentMood = MascotMood.Idle
|
||||
private var startTime: Double? = null
|
||||
private var lastPoseTime = 0.0
|
||||
private var activeGesture: Gesture? = null
|
||||
private var activeGestureStart = 0.0
|
||||
private var pendingGesture: Gesture? = null
|
||||
private var pendingGestureAt = 0.0
|
||||
private var nextBlinkAt = 0.0
|
||||
private var pendingDoubleBlink = false
|
||||
private val blinkStarts = mutableListOf<Double>()
|
||||
private var nextGlanceAt = 0.0
|
||||
private var gazeHoldUntil = 0.0
|
||||
private var gazeTarget = MascotGaze()
|
||||
private var currentGaze = MascotGaze()
|
||||
private var nextClawSnapAt = 0.0
|
||||
private var nextMoodBeatAt = 0.0
|
||||
private var teaseActive = false
|
||||
private var teaseChangedAt = 0.0
|
||||
private var catchStartedAt: Double? = null
|
||||
|
||||
fun setMood(
|
||||
mood: MascotMood,
|
||||
timeSeconds: Double,
|
||||
) {
|
||||
if (mood == currentMood) return
|
||||
currentMood = mood
|
||||
// Do not let a queued hello or old mood gesture leak into the new body language.
|
||||
pendingGesture = null
|
||||
activeGesture = null
|
||||
rescheduleMoodBeat(timeSeconds)
|
||||
entranceGesture(mood)?.let { startGesture(it, timeSeconds) }
|
||||
}
|
||||
|
||||
fun setTease(
|
||||
active: Boolean,
|
||||
timeSeconds: Double,
|
||||
) {
|
||||
teaseActive = active
|
||||
teaseChangedAt = timeSeconds
|
||||
}
|
||||
|
||||
fun playCatch(timeSeconds: Double) {
|
||||
catchStartedAt = timeSeconds
|
||||
}
|
||||
|
||||
fun poseAt(timeSeconds: Double): MascotPose {
|
||||
if (startTime == null) begin(timeSeconds)
|
||||
val dt = clamp(timeSeconds - lastPoseTime, 0.0, 0.1)
|
||||
lastPoseTime = timeSeconds
|
||||
advanceSchedules(timeSeconds)
|
||||
|
||||
// TS and Swift phase ambient loops from their host clocks. startTime only
|
||||
// gates first-run schedules; raw time here preserves cross-platform parity.
|
||||
val pose = basePose(currentMood, timeSeconds)
|
||||
applyGaze(pose, currentMood, timeSeconds, dt)
|
||||
applyBlinks(pose, timeSeconds)
|
||||
|
||||
activeGesture?.let { gesture ->
|
||||
val progress = (timeSeconds - activeGestureStart) / gestureDuration(gesture)
|
||||
if (progress >= 1.0) {
|
||||
activeGesture = null
|
||||
} else {
|
||||
applyGesture(gesture, pose, progress)
|
||||
}
|
||||
}
|
||||
|
||||
if (teaseActive && timeSeconds >= teaseChangedAt) {
|
||||
pose.mouthRound = max(pose.mouthRound, 0.5)
|
||||
pose.gaze = MascotGaze(x = 0.0, y = 0.6)
|
||||
}
|
||||
catchStartedAt?.let { startedAt ->
|
||||
val progress = (timeSeconds - startedAt) / CATCH_DURATION
|
||||
if (progress >= 1.0) {
|
||||
catchStartedAt = null
|
||||
} else if (progress >= 0.0) {
|
||||
val flash = bell(progress)
|
||||
applyGesture(Gesture.ClawSnap, pose, clamp(progress / 0.75))
|
||||
pose.happyEyes = max(pose.happyEyes, 0.9 * flash)
|
||||
pose.mouthCurve = max(pose.mouthCurve, 0.7 * flash)
|
||||
pose.blush = max(pose.blush, 0.65 * flash)
|
||||
}
|
||||
}
|
||||
|
||||
return pose.clamp()
|
||||
}
|
||||
|
||||
private fun begin(timeSeconds: Double) {
|
||||
startTime = timeSeconds
|
||||
lastPoseTime = timeSeconds
|
||||
nextBlinkAt = timeSeconds + random(0.8, 2.4)
|
||||
nextGlanceAt = timeSeconds + random(1.5, 4.0)
|
||||
nextClawSnapAt = timeSeconds + random(2.0, 5.0)
|
||||
rescheduleMoodBeat(timeSeconds)
|
||||
if (currentMood == MascotMood.Idle || currentMood == MascotMood.Curious || currentMood == MascotMood.Happy) {
|
||||
pendingGesture = Gesture.Wave
|
||||
pendingGestureAt = timeSeconds + 0.9
|
||||
}
|
||||
}
|
||||
|
||||
private fun advanceSchedules(timeSeconds: Double) {
|
||||
if (timeSeconds >= nextBlinkAt) {
|
||||
blinkStarts.add(timeSeconds)
|
||||
if (pendingDoubleBlink) {
|
||||
pendingDoubleBlink = false
|
||||
nextBlinkAt = timeSeconds + blinkInterval()
|
||||
} else if (random(0.0, 1.0) < 0.14) {
|
||||
pendingDoubleBlink = true
|
||||
nextBlinkAt = timeSeconds + 0.34
|
||||
} else {
|
||||
nextBlinkAt = timeSeconds + blinkInterval()
|
||||
}
|
||||
}
|
||||
blinkStarts.removeAll { start -> timeSeconds - start > BLINK_DURATION }
|
||||
|
||||
if (timeSeconds >= nextGlanceAt) {
|
||||
gazeTarget = randomGlanceTarget()
|
||||
gazeHoldUntil = timeSeconds + random(0.7, 1.9)
|
||||
nextGlanceAt = gazeHoldUntil + glanceInterval()
|
||||
} else if (timeSeconds >= gazeHoldUntil) {
|
||||
gazeTarget = MascotGaze()
|
||||
}
|
||||
|
||||
if (timeSeconds >= nextClawSnapAt) {
|
||||
if (activeGesture == null && currentMood != MascotMood.Sad && currentMood != MascotMood.Working) {
|
||||
startGesture(Gesture.ClawSnap, timeSeconds)
|
||||
}
|
||||
nextClawSnapAt = timeSeconds + random(4.0, 9.0)
|
||||
}
|
||||
|
||||
if (timeSeconds >= nextMoodBeatAt) {
|
||||
if (activeGesture == null) {
|
||||
when (currentMood) {
|
||||
MascotMood.Sad -> startGesture(Gesture.Sigh, timeSeconds)
|
||||
MascotMood.Sleepy -> startGesture(Gesture.Yawn, timeSeconds)
|
||||
MascotMood.Working -> startGesture(Gesture.WipeBrow, timeSeconds)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
rescheduleMoodBeat(timeSeconds)
|
||||
}
|
||||
|
||||
val pending = pendingGesture
|
||||
if (pending != null && timeSeconds >= pendingGestureAt && activeGesture == null) {
|
||||
pendingGesture = null
|
||||
startGesture(pending, timeSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun basePose(
|
||||
mood: MascotMood,
|
||||
timeSeconds: Double,
|
||||
): MascotPose {
|
||||
val pose = MascotPose()
|
||||
when (mood) {
|
||||
MascotMood.Idle -> {
|
||||
pose.floatOffset = -4.8 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 4.0)))
|
||||
pose.antennaDegrees = -3.0 * sin(TAU * cyclePhase(timeSeconds, 2.0))
|
||||
}
|
||||
MascotMood.Curious -> {
|
||||
pose.floatOffset = -4.2 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.4)))
|
||||
pose.antennaDegrees = -4.0 * sin(TAU * cyclePhase(timeSeconds, 1.7))
|
||||
pose.bodyTilt = 1.6 * sin(TAU * cyclePhase(timeSeconds, 5.2))
|
||||
}
|
||||
MascotMood.Thinking -> {
|
||||
pose.floatOffset = -3.2 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 5.0)))
|
||||
pose.antennaDegrees = -5.0 * sin(TAU * cyclePhase(timeSeconds, 1.3))
|
||||
pose.bodyTilt = 2.0 * sin(TAU * cyclePhase(timeSeconds, 6.0))
|
||||
pose.eyeGlowAlpha = 0.9 + 0.1 * sin(TAU * cyclePhase(timeSeconds, 0.8))
|
||||
}
|
||||
MascotMood.Working -> {
|
||||
val phase = cyclePhase(timeSeconds, 0.95)
|
||||
pose.rightClawDegrees =
|
||||
when {
|
||||
phase < 0.05 -> -6.0
|
||||
phase < 0.6 -> -6.0 - 28.0 * easeInOut((phase - 0.05) / 0.55)
|
||||
phase < 0.72 -> {
|
||||
val strike = clamp((phase - 0.6) / 0.12)
|
||||
-34.0 + 46.0 * strike * strike
|
||||
}
|
||||
else -> 12.0 - 18.0 * easeInOut((phase - 0.72) / 0.28)
|
||||
}
|
||||
pose.leftClawDegrees = 4.0 + 2.0 * sin(TAU * phase)
|
||||
val impact = bell(clamp((phase - 0.72) / 0.14))
|
||||
pose.floatOffset = -2.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.8))) + 0.8 * impact
|
||||
pose.bodyStretch = 1.0 - 0.03 * impact
|
||||
pose.bodyTilt = 2.2 + 0.6 * sin(TAU * cyclePhase(timeSeconds, 5.0))
|
||||
if (phase >= 0.72) {
|
||||
val recoil = clamp((phase - 0.72) / 0.28)
|
||||
pose.antennaDegrees = 6.0 * (1.0 - recoil) * sin(recoil * 3.0 * PI)
|
||||
}
|
||||
pose.leftEyeOpenness = 0.85
|
||||
pose.rightEyeOpenness = 0.85
|
||||
pose.mouthCurve = 0.18
|
||||
pose.hardHat = 1.0
|
||||
pose.effect = MascotEffect.Sparks
|
||||
val strikePhase = (phase - 0.72) % 1.0
|
||||
pose.effectPhase = if (strikePhase < 0.0) strikePhase + 1.0 else strikePhase
|
||||
}
|
||||
MascotMood.Happy -> {
|
||||
pose.floatOffset = -6.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 3.0)))
|
||||
pose.antennaDegrees = -4.5 * sin(TAU * cyclePhase(timeSeconds, 1.6))
|
||||
pose.mouthCurve = 0.55 + 0.1 * sin(TAU * cyclePhase(timeSeconds, 3.0))
|
||||
pose.happyEyes = 0.35
|
||||
}
|
||||
MascotMood.Celebrating -> {
|
||||
val hop = abs(sin(TAU * cyclePhase(timeSeconds, 1.6)))
|
||||
pose.floatOffset = -9.0 * hop
|
||||
pose.bodyStretch = 1.0 + 0.03 * hop
|
||||
pose.antennaDegrees = -6.0 * sin(TAU * cyclePhase(timeSeconds, 0.8))
|
||||
val clawWave = sin(TAU * cyclePhase(timeSeconds, 0.9))
|
||||
pose.leftClawDegrees = 20.0 + 8.0 * clawWave
|
||||
pose.rightClawDegrees = -20.0 + 8.0 * clawWave
|
||||
pose.mouthCurve = 0.9
|
||||
pose.mouthOpen = 0.35
|
||||
pose.happyEyes = 0.7
|
||||
pose.glowScale = 1.1
|
||||
pose.effect = MascotEffect.Sparkles
|
||||
pose.effectPhase = cyclePhase(timeSeconds, 2.2)
|
||||
}
|
||||
MascotMood.Sad -> {
|
||||
pose.floatOffset = -2.4 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 5.5)))
|
||||
pose.antennaDegrees = -1.5 * sin(TAU * cyclePhase(timeSeconds, 3.0))
|
||||
pose.antennaDroop = 0.75
|
||||
pose.mouthCurve = -0.55
|
||||
pose.eyeGlowAlpha = 0.6
|
||||
}
|
||||
MascotMood.Sleepy -> {
|
||||
pose.floatOffset = -2.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 6.0)))
|
||||
pose.antennaDroop = 0.35
|
||||
pose.leftEyeOpenness = 0.22 + 0.08 * sin(TAU * cyclePhase(timeSeconds, 3.0))
|
||||
pose.rightEyeOpenness = pose.leftEyeOpenness
|
||||
pose.eyeGlowAlpha = 0.5
|
||||
pose.mouthRound = 0.15
|
||||
pose.bodyTilt = 2.5 * sin(TAU * cyclePhase(timeSeconds, 6.0))
|
||||
pose.effect = MascotEffect.Zzz
|
||||
pose.effectPhase = cyclePhase(timeSeconds, 3.0)
|
||||
}
|
||||
MascotMood.Attentive -> {
|
||||
pose.floatOffset = -3.0 * (1.0 - cos(TAU * cyclePhase(timeSeconds, 4.0)))
|
||||
pose.antennaDegrees = -2.5 * sin(TAU * cyclePhase(timeSeconds, 2.0))
|
||||
pose.mouthCurve = 0.25
|
||||
}
|
||||
}
|
||||
return pose
|
||||
}
|
||||
|
||||
private fun applyGaze(
|
||||
pose: MascotPose,
|
||||
mood: MascotMood,
|
||||
timeSeconds: Double,
|
||||
dt: Double,
|
||||
) {
|
||||
val target =
|
||||
when (mood) {
|
||||
MascotMood.Thinking -> MascotGaze(x = 0.4 * sin(TAU * cyclePhase(timeSeconds, 3.8)), y = -0.55)
|
||||
MascotMood.Working ->
|
||||
MascotGaze(
|
||||
x = 0.55 + 0.04 * sin(TAU * cyclePhase(timeSeconds, 4.6)),
|
||||
y = 0.45 + 0.02 * cos(TAU * cyclePhase(timeSeconds, 3.9)),
|
||||
)
|
||||
MascotMood.Attentive -> MascotGaze(x = gazeTarget.x * 0.5, y = 0.35)
|
||||
MascotMood.Sad -> MascotGaze(x = gazeTarget.x * 0.3, y = 0.5)
|
||||
MascotMood.Sleepy -> MascotGaze(x = 0.0, y = 0.4)
|
||||
MascotMood.Idle,
|
||||
MascotMood.Curious,
|
||||
MascotMood.Happy,
|
||||
MascotMood.Celebrating,
|
||||
-> gazeTarget
|
||||
}
|
||||
val blend = 1.0 - exp(-dt * 9.0)
|
||||
currentGaze =
|
||||
MascotGaze(
|
||||
x = currentGaze.x + (target.x - currentGaze.x) * blend,
|
||||
y = currentGaze.y + (target.y - currentGaze.y) * blend,
|
||||
)
|
||||
pose.gaze = currentGaze
|
||||
}
|
||||
|
||||
private fun applyBlinks(
|
||||
pose: MascotPose,
|
||||
timeSeconds: Double,
|
||||
) {
|
||||
if (pose.happyEyes >= 0.6) return
|
||||
for (start in blinkStarts) {
|
||||
val progress = (timeSeconds - start) / BLINK_DURATION
|
||||
if (progress < 0.0 || progress > 1.0) continue
|
||||
val closure = bell(progress)
|
||||
pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1.0 - closure)
|
||||
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1.0 - closure)
|
||||
pose.eyeGlowAlpha *= max(0.3, 1.0 - closure)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyGesture(
|
||||
gesture: Gesture,
|
||||
pose: MascotPose,
|
||||
progress: Double,
|
||||
) {
|
||||
val p = clamp(progress)
|
||||
when (gesture) {
|
||||
Gesture.Wave -> {
|
||||
val raised = plateau(p, 0.18, 0.82)
|
||||
pose.rightClawDegrees += raised * (-28.0 + 9.0 * sin(p * 6.0 * PI))
|
||||
pose.bodyTilt += -2.0 * raised
|
||||
pose.mouthCurve = max(pose.mouthCurve, 0.5 * raised)
|
||||
}
|
||||
Gesture.Hop -> {
|
||||
val air = bell(clamp((p - 0.2) / 0.6))
|
||||
pose.floatOffset += -9.0 * air
|
||||
pose.bodyStretch +=
|
||||
0.045 * air -
|
||||
0.1 * bell(clamp(p / 0.2)) -
|
||||
0.06 * bell(clamp((p - 0.82) / 0.18))
|
||||
pose.mouthCurve = max(pose.mouthCurve, 0.4 * air)
|
||||
}
|
||||
Gesture.Celebrate -> {
|
||||
val envelope = plateau(p, 0.12, 0.88)
|
||||
val hops = abs(sin(p * 4.0 * PI))
|
||||
pose.floatOffset += -11.0 * hops * envelope
|
||||
pose.bodyStretch += 0.035 * hops * envelope
|
||||
pose.leftClawDegrees += 38.0 * envelope
|
||||
pose.rightClawDegrees += -38.0 * envelope
|
||||
pose.happyEyes = max(pose.happyEyes, envelope)
|
||||
pose.mouthCurve = max(pose.mouthCurve, envelope)
|
||||
pose.mouthOpen = max(pose.mouthOpen, 0.6 * bell(p))
|
||||
pose.antennaDroop = 0.0
|
||||
pose.glowScale = max(pose.glowScale, 1.0 + 0.2 * envelope)
|
||||
pose.effect = MascotEffect.Sparkles
|
||||
pose.effectPhase = p
|
||||
}
|
||||
Gesture.Sigh -> {
|
||||
val rise = easeInOut(clamp(p / 0.3))
|
||||
val fall = easeInOut(clamp((p - 0.3) / 0.45))
|
||||
pose.bodyStretch += 0.025 * rise - 0.08 * fall * (1.0 - clamp((p - 0.85) / 0.15))
|
||||
pose.gaze = MascotGaze(x = pose.gaze.x, y = 0.5 * fall)
|
||||
pose.antennaDroop = min(1.0, pose.antennaDroop + 0.15 * fall)
|
||||
}
|
||||
Gesture.Yawn -> {
|
||||
val openness = plateau(p, 0.3, 0.75)
|
||||
pose.mouthRound = max(pose.mouthRound, 0.9 * openness)
|
||||
pose.leftEyeOpenness = min(pose.leftEyeOpenness, 1.0 - 0.9 * openness)
|
||||
pose.rightEyeOpenness = min(pose.rightEyeOpenness, 1.0 - 0.9 * openness)
|
||||
pose.bodyStretch += 0.03 * openness
|
||||
pose.bodyTilt += -2.0 * openness
|
||||
}
|
||||
Gesture.ClawSnap -> {
|
||||
pose.leftClawDegrees += -8.0 * bell(clamp(p / 0.7))
|
||||
pose.rightClawDegrees += -8.0 * bell(clamp((p - 0.25) / 0.7))
|
||||
}
|
||||
Gesture.DonHardHat -> {
|
||||
val drop = easeInOut(clamp(p / 0.55))
|
||||
pose.hardHat = min(pose.hardHat, drop)
|
||||
if (p < 0.55) pose.gaze = MascotGaze(x = 0.0, y = -0.9 * (1.0 - p))
|
||||
pose.bodyStretch -= 0.04 * bell(clamp((p - 0.5) / 0.2))
|
||||
val ready = bell(clamp((p - 0.7) / 0.3))
|
||||
pose.leftClawDegrees += -8.0 * ready
|
||||
pose.rightClawDegrees += 8.0 * ready
|
||||
}
|
||||
Gesture.WipeBrow -> {
|
||||
val envelope = plateau(p, 0.2, 0.8)
|
||||
pose.leftClawDegrees *= 1.0 - envelope
|
||||
pose.rightClawDegrees *= 1.0 - envelope
|
||||
pose.leftClawDegrees += 38.0 * envelope * (0.9 + 0.1 * sin(p * 5.0 * PI))
|
||||
pose.bodyTilt *= 1.0 - envelope
|
||||
pose.bodyStretch += 0.02 * envelope
|
||||
pose.happyEyes = max(pose.happyEyes, 0.7 * envelope)
|
||||
pose.mouthCurve = max(pose.mouthCurve, 0.5 * envelope)
|
||||
pose.gaze = MascotGaze(x = pose.gaze.x * (1.0 - envelope), y = pose.gaze.y * (1.0 - envelope))
|
||||
pose.effect = MascotEffect.Sweat
|
||||
pose.effectPhase = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun entranceGesture(mood: MascotMood): Gesture? =
|
||||
when (mood) {
|
||||
MascotMood.Happy -> Gesture.Hop
|
||||
MascotMood.Celebrating -> Gesture.Celebrate
|
||||
MascotMood.Sad -> Gesture.Sigh
|
||||
MascotMood.Sleepy -> Gesture.Yawn
|
||||
MascotMood.Working -> Gesture.DonHardHat
|
||||
MascotMood.Idle,
|
||||
MascotMood.Curious,
|
||||
MascotMood.Thinking,
|
||||
MascotMood.Attentive,
|
||||
-> null
|
||||
}
|
||||
|
||||
private fun startGesture(
|
||||
gesture: Gesture,
|
||||
timeSeconds: Double,
|
||||
) {
|
||||
activeGesture = gesture
|
||||
activeGestureStart = timeSeconds
|
||||
}
|
||||
|
||||
private fun random(
|
||||
min: Double,
|
||||
max: Double,
|
||||
): Double = min + (max - min) * rng.unit()
|
||||
|
||||
private fun blinkInterval(): Double = if (currentMood == MascotMood.Attentive) random(1.8, 4.0) else random(2.2, 5.5)
|
||||
|
||||
private fun glanceInterval(): Double =
|
||||
when (currentMood) {
|
||||
MascotMood.Curious -> random(1.6, 4.0)
|
||||
MascotMood.Thinking -> random(1.2, 3.0)
|
||||
else -> random(3.0, 8.0)
|
||||
}
|
||||
|
||||
private fun randomGlanceTarget(): MascotGaze {
|
||||
val magnitude = random(0.5, 1.0)
|
||||
val angle = random(0.0, TAU)
|
||||
return MascotGaze(x = cos(angle) * magnitude, y = sin(angle) * magnitude * 0.6)
|
||||
}
|
||||
|
||||
private fun rescheduleMoodBeat(timeSeconds: Double) {
|
||||
nextMoodBeatAt = timeSeconds + random(6.0, 12.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
enum class MascotMood {
|
||||
Idle,
|
||||
Curious,
|
||||
Thinking,
|
||||
Working,
|
||||
Happy,
|
||||
Celebrating,
|
||||
Sad,
|
||||
Sleepy,
|
||||
Attentive,
|
||||
}
|
||||
|
||||
enum class MascotEffect {
|
||||
None,
|
||||
Sparkles,
|
||||
Zzz,
|
||||
Sparks,
|
||||
Sweat,
|
||||
}
|
||||
|
||||
data class MascotGaze(
|
||||
val x: Double = 0.0,
|
||||
val y: Double = 0.0,
|
||||
)
|
||||
|
||||
data class MascotPose(
|
||||
var floatOffset: Double = 0.0,
|
||||
var antennaDegrees: Double = 0.0,
|
||||
var antennaDroop: Double = 0.0,
|
||||
var leftClawDegrees: Double = 0.0,
|
||||
var rightClawDegrees: Double = 0.0,
|
||||
var eyeGlowAlpha: Double = 1.0,
|
||||
var glowScale: Double = 1.0,
|
||||
var leftEyeOpenness: Double = 1.0,
|
||||
var rightEyeOpenness: Double = 1.0,
|
||||
var happyEyes: Double = 0.0,
|
||||
var gaze: MascotGaze = MascotGaze(),
|
||||
var mouthCurve: Double = 0.0,
|
||||
var mouthOpen: Double = 0.0,
|
||||
var mouthRound: Double = 0.0,
|
||||
var blush: Double = 0.0,
|
||||
var hardHat: Double = 0.0,
|
||||
var bodyTilt: Double = 0.0,
|
||||
var bodyStretch: Double = 1.0,
|
||||
var effect: MascotEffect = MascotEffect.None,
|
||||
var effectPhase: Double = 0.0,
|
||||
) {
|
||||
/** Keeps every channel inside the drawable 120x120 art-space bounds. */
|
||||
fun clamp(): MascotPose {
|
||||
floatOffset = floatOffset.coerceIn(-12.0, 2.0)
|
||||
antennaDegrees = antennaDegrees.coerceIn(-14.0, 14.0)
|
||||
antennaDroop = antennaDroop.coerceIn(0.0, 1.0)
|
||||
leftClawDegrees = leftClawDegrees.coerceIn(-45.0, 45.0)
|
||||
rightClawDegrees = rightClawDegrees.coerceIn(-45.0, 45.0)
|
||||
eyeGlowAlpha = eyeGlowAlpha.coerceIn(0.0, 1.0)
|
||||
glowScale = glowScale.coerceIn(0.5, 1.6)
|
||||
leftEyeOpenness = leftEyeOpenness.coerceIn(0.0, 1.0)
|
||||
rightEyeOpenness = rightEyeOpenness.coerceIn(0.0, 1.0)
|
||||
happyEyes = happyEyes.coerceIn(0.0, 1.0)
|
||||
gaze = MascotGaze(gaze.x.coerceIn(-1.2, 1.2), gaze.y.coerceIn(-1.2, 1.2))
|
||||
mouthCurve = mouthCurve.coerceIn(-1.0, 1.0)
|
||||
mouthOpen = mouthOpen.coerceIn(0.0, 1.0)
|
||||
mouthRound = mouthRound.coerceIn(0.0, 1.0)
|
||||
blush = blush.coerceIn(0.0, 1.0)
|
||||
hardHat = hardHat.coerceIn(0.0, 1.0)
|
||||
bodyTilt = bodyTilt.coerceIn(-8.0, 8.0)
|
||||
bodyStretch = bodyStretch.coerceIn(0.86, 1.05)
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Motionless mood expression used when Android animations are disabled. */
|
||||
fun staticPose(mood: MascotMood): MascotPose =
|
||||
MascotPose().apply {
|
||||
when (mood) {
|
||||
MascotMood.Idle,
|
||||
MascotMood.Curious,
|
||||
MascotMood.Attentive,
|
||||
-> Unit
|
||||
MascotMood.Thinking -> gaze = MascotGaze(x = 0.3, y = -0.5)
|
||||
MascotMood.Working -> {
|
||||
hardHat = 1.0
|
||||
rightClawDegrees = -28.0
|
||||
gaze = MascotGaze(x = 0.4, y = 0.35)
|
||||
mouthCurve = 0.15
|
||||
bodyTilt = 2.0
|
||||
}
|
||||
MascotMood.Happy -> {
|
||||
mouthCurve = 0.6
|
||||
happyEyes = 0.4
|
||||
}
|
||||
MascotMood.Celebrating -> {
|
||||
mouthCurve = 0.9
|
||||
mouthOpen = 0.4
|
||||
happyEyes = 0.8
|
||||
leftClawDegrees = 30.0
|
||||
rightClawDegrees = -30.0
|
||||
}
|
||||
MascotMood.Sad -> {
|
||||
antennaDroop = 0.75
|
||||
mouthCurve = -0.55
|
||||
eyeGlowAlpha = 0.6
|
||||
gaze = MascotGaze(x = 0.0, y = 0.5)
|
||||
}
|
||||
MascotMood.Sleepy -> {
|
||||
leftEyeOpenness = 0.25
|
||||
rightEyeOpenness = 0.25
|
||||
eyeGlowAlpha = 0.5
|
||||
antennaDroop = 0.35
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun staticPose(mood: MascotMood): MascotPose = MascotPose.staticPose(mood)
|
||||
|
||||
/**
|
||||
* Tinted silhouettes stay on the ambient idle loop: mood faces cannot read in
|
||||
* monochrome, and tiny tinted toolbar marks must not hammer or celebrate.
|
||||
*/
|
||||
internal fun effectiveMascotMood(
|
||||
mood: MascotMood,
|
||||
tinted: Boolean,
|
||||
): MascotMood = if (tinted) MascotMood.Idle else mood
|
||||
@@ -1,25 +1,24 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.InfiniteRepeatableSpec
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.StartOffset
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.withTransform
|
||||
@@ -29,6 +28,10 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
// Canonical 120x120 mascot geometry from ui/public/favicon.svg; parts stay
|
||||
// separate paths so claws, antennae, and eyes can animate independently.
|
||||
@@ -44,40 +47,56 @@ private val RightClawPath =
|
||||
PathParser().parsePathString("M100 45 C115 40 120 50 115 60 C110 70 100 65 95 55 C92 48 95 45 100 45Z").toPath()
|
||||
private val LeftAntennaPath = PathParser().parsePathString("M45 15 Q35 5 30 8").toPath()
|
||||
private val RightAntennaPath = PathParser().parsePathString("M75 15 Q85 5 90 8").toPath()
|
||||
private val HardHatDomePath =
|
||||
PathParser().parsePathString("M45 15 C47 7 54 3 60 3 C66 3 73 7 75 15 L45 15 Z").toPath()
|
||||
|
||||
private val CoralBright = Color(0xFFFF4D4D)
|
||||
private val CoralDark = Color(0xFF991B1B)
|
||||
private val EyeDark = Color(0xFF050810)
|
||||
private val EyeGlow = Color(0xFF00E5CC)
|
||||
private val Blush = Color(0xFFFF9EAE)
|
||||
private val HatAmber = Color(0xFFF2A833)
|
||||
private val HatLight = Color(0xFFFFD659)
|
||||
private val HatOutline = Color(0xB8B8731F)
|
||||
private val SweatBlue = Color(0xFF80D4FF)
|
||||
|
||||
// Claws hinge on their body-facing edge, antennae rotate around their own center.
|
||||
private val LeftClawPivot = Offset(26f, 53f)
|
||||
private val RightClawPivot = Offset(94f, 53f)
|
||||
private val LeftAntennaPivot = Offset(37.5f, 11f)
|
||||
private val RightAntennaPivot = Offset(82.5f, 11f)
|
||||
private val LeftEyeCenter = Offset(45f, 35f)
|
||||
private val RightEyeCenter = Offset(75f, 35f)
|
||||
|
||||
private val EaseInOut = CubicBezierEasing(0.42f, 0f, 0.58f, 1f)
|
||||
|
||||
private class MascotPose(
|
||||
val floatOffset: State<Float>,
|
||||
val antennaDegrees: State<Float>,
|
||||
val leftClawDegrees: State<Float>,
|
||||
val rightClawDegrees: State<Float>,
|
||||
val eyeGlowAlpha: State<Float>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Animated OpenClaw mascot mirroring the openclaw.ai hero mark: body float,
|
||||
* antenna wiggle, eye blink, and staggered claw snaps. With [tint] the mascot
|
||||
* renders as a single-color silhouette (replacement for tinted [Icon] usage).
|
||||
*/
|
||||
/** Animated 120x120 OpenClaw mascot. [tint] keeps the single-color icon rendering path. */
|
||||
@Composable
|
||||
fun OpenClawMascot(
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color? = null,
|
||||
contentDescription: String? = null,
|
||||
mood: MascotMood = MascotMood.Idle,
|
||||
) {
|
||||
val pose = rememberMascotPose()
|
||||
val context = LocalContext.current
|
||||
val animationsEnabled =
|
||||
remember(context) {
|
||||
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
|
||||
}
|
||||
val animator = remember { MascotAnimator() }
|
||||
var pose by remember { mutableStateOf(staticPose(mood)) }
|
||||
|
||||
LaunchedEffect(animationsEnabled, mood, tint) {
|
||||
if (!animationsEnabled) {
|
||||
pose = staticPose(effectiveMascotMood(mood = mood, tinted = tint != null))
|
||||
return@LaunchedEffect
|
||||
}
|
||||
while (true) {
|
||||
withFrameNanos { frameTimeNanos ->
|
||||
val timeSeconds = frameTimeNanos / 1_000_000_000.0
|
||||
animator.setMood(effectiveMascotMood(mood = mood, tinted = tint != null), timeSeconds)
|
||||
pose = animator.poseAt(timeSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val semantics =
|
||||
if (contentDescription == null) {
|
||||
Modifier
|
||||
@@ -88,143 +107,313 @@ fun OpenClawMascot(
|
||||
}
|
||||
}
|
||||
Canvas(modifier = modifier.then(semantics)) {
|
||||
val scale = size.minDimension / 120f
|
||||
val artScale = size.minDimension / 120f
|
||||
withTransform({
|
||||
scale(scale, scale, pivot = Offset.Zero)
|
||||
translate(top = pose.floatOffset.value)
|
||||
scale(artScale, artScale, pivot = Offset.Zero)
|
||||
translate(top = pose.floatOffset.toFloat())
|
||||
}) {
|
||||
drawMascot(pose, tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberMascotPose(): MascotPose {
|
||||
val context = LocalContext.current
|
||||
// Compose infinite transitions ignore the system animator scale; honor the
|
||||
// OS "remove animations" setting explicitly with a static pose.
|
||||
val animationsEnabled =
|
||||
remember(context) {
|
||||
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) > 0f
|
||||
}
|
||||
if (!animationsEnabled) {
|
||||
return remember {
|
||||
val zero = mutableFloatStateOf(0f)
|
||||
MascotPose(zero, zero, zero, zero, mutableFloatStateOf(1f))
|
||||
}
|
||||
}
|
||||
|
||||
val transition = rememberInfiniteTransition(label = "openclawMascot")
|
||||
val floatOffset =
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 0f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
keyframes {
|
||||
durationMillis = 4000
|
||||
0f at 0 using EaseInOut
|
||||
-5f at 2000 using EaseInOut
|
||||
},
|
||||
),
|
||||
label = "float",
|
||||
)
|
||||
val antennaDegrees =
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 0f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
keyframes {
|
||||
durationMillis = 2000
|
||||
0f at 0 using EaseInOut
|
||||
-3f at 500 using EaseInOut
|
||||
3f at 1500 using EaseInOut
|
||||
},
|
||||
),
|
||||
label = "antenna",
|
||||
)
|
||||
val leftClawDegrees =
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(clawSnapKeyframes()),
|
||||
label = "clawLeft",
|
||||
)
|
||||
val rightClawDegrees =
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 0f,
|
||||
animationSpec =
|
||||
InfiniteRepeatableSpec(
|
||||
clawSnapKeyframes(),
|
||||
RepeatMode.Restart,
|
||||
initialStartOffset = StartOffset(200),
|
||||
),
|
||||
label = "clawRight",
|
||||
)
|
||||
val eyeGlowAlpha =
|
||||
transition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
keyframes {
|
||||
durationMillis = 3000
|
||||
1f at 0 using EaseInOut
|
||||
1f at 2700 using EaseInOut
|
||||
0.3f at 2850 using EaseInOut
|
||||
},
|
||||
),
|
||||
label = "blink",
|
||||
)
|
||||
return remember(transition) {
|
||||
MascotPose(floatOffset, antennaDegrees, leftClawDegrees, rightClawDegrees, eyeGlowAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clawSnapKeyframes() =
|
||||
keyframes {
|
||||
durationMillis = 4000
|
||||
0f at 0 using EaseInOut
|
||||
0f at 3400 using EaseInOut
|
||||
-8f at 3600 using EaseInOut
|
||||
0f at 3800 using EaseInOut
|
||||
}
|
||||
|
||||
private fun DrawScope.drawMascot(
|
||||
pose: MascotPose,
|
||||
tint: Color?,
|
||||
) {
|
||||
val bodyBrush =
|
||||
if (tint == null) {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(CoralBright, CoralDark),
|
||||
start = Offset.Zero,
|
||||
end = Offset(120f, 120f),
|
||||
)
|
||||
} else {
|
||||
SolidColor(tint)
|
||||
val stretchY = pose.bodyStretch.toFloat()
|
||||
val stretchX = (1.0 + (1.0 - pose.bodyStretch) * 0.5).coerceIn(0.97, 1.03).toFloat()
|
||||
withTransform({
|
||||
if (stretchY != 1f) scale(stretchX, stretchY, pivot = Offset(60f, 110f))
|
||||
if (pose.bodyTilt != 0.0) rotate(pose.bodyTilt.toFloat(), pivot = Offset(60f, 60f))
|
||||
}) {
|
||||
val bodyBrush =
|
||||
tint?.let(::SolidColor)
|
||||
?: Brush.linearGradient(
|
||||
colors = listOf(CoralBright, CoralDark),
|
||||
start = Offset(15f, 10f),
|
||||
end = Offset(105f, 110f),
|
||||
)
|
||||
val leftClawBrush =
|
||||
tint?.let(::SolidColor)
|
||||
?: Brush.linearGradient(
|
||||
colors = listOf(CoralBright, CoralDark),
|
||||
start = Offset(3.125f, 43.67f),
|
||||
end = Offset(26.197f, 65.451f),
|
||||
)
|
||||
val rightClawBrush =
|
||||
tint?.let(::SolidColor)
|
||||
?: Brush.linearGradient(
|
||||
colors = listOf(CoralBright, CoralDark),
|
||||
start = Offset(93.803f, 43.67f),
|
||||
end = Offset(116.875f, 65.451f),
|
||||
)
|
||||
|
||||
drawPath(BodyPath, bodyBrush)
|
||||
withTransform({ rotate(pose.leftClawDegrees.toFloat(), pivot = LeftClawPivot) }) {
|
||||
drawPath(LeftClawPath, leftClawBrush)
|
||||
}
|
||||
withTransform({ rotate(pose.rightClawDegrees.toFloat(), pivot = RightClawPivot) }) {
|
||||
drawPath(RightClawPath, rightClawBrush)
|
||||
}
|
||||
|
||||
val antennaColor = tint ?: CoralBright
|
||||
val antennaStroke = Stroke(width = 2f, cap = StrokeCap.Round)
|
||||
val wiggle = (pose.antennaDegrees * (1.0 - pose.antennaDroop)).toFloat()
|
||||
withTransform({ rotate((-pose.antennaDroop * 40.0).toFloat(), pivot = Offset(45f, 15f)) }) {
|
||||
withTransform({ rotate(wiggle, pivot = LeftAntennaPivot) }) {
|
||||
drawPath(LeftAntennaPath, antennaColor, style = antennaStroke)
|
||||
}
|
||||
}
|
||||
withTransform({ rotate((pose.antennaDroop * 40.0).toFloat(), pivot = Offset(75f, 15f)) }) {
|
||||
withTransform({ rotate(wiggle, pivot = RightAntennaPivot) }) {
|
||||
drawPath(RightAntennaPath, antennaColor, style = antennaStroke)
|
||||
}
|
||||
}
|
||||
|
||||
drawHardHat(pose.hardHat.toFloat(), tint)
|
||||
if (tint == null) {
|
||||
drawBlush(pose)
|
||||
drawEye(LeftEyeCenter, pose.leftEyeOpenness, pose)
|
||||
drawEye(RightEyeCenter, pose.rightEyeOpenness, pose)
|
||||
drawMouth(pose)
|
||||
drawEffect(pose)
|
||||
}
|
||||
// Same paint order as favicon.svg: body, claws, antennae, eyes.
|
||||
drawPath(BodyPath, bodyBrush)
|
||||
withTransform({ rotate(pose.leftClawDegrees.value, pivot = LeftClawPivot) }) {
|
||||
drawPath(LeftClawPath, bodyBrush)
|
||||
}
|
||||
withTransform({ rotate(pose.rightClawDegrees.value, pivot = RightClawPivot) }) {
|
||||
drawPath(RightClawPath, bodyBrush)
|
||||
}
|
||||
val antennaColor = tint ?: CoralBright
|
||||
val antennaStroke = Stroke(width = 3f, cap = StrokeCap.Round)
|
||||
withTransform({ rotate(pose.antennaDegrees.value, pivot = LeftAntennaPivot) }) {
|
||||
drawPath(LeftAntennaPath, antennaColor, style = antennaStroke)
|
||||
}
|
||||
withTransform({ rotate(pose.antennaDegrees.value, pivot = RightAntennaPivot) }) {
|
||||
drawPath(RightAntennaPath, antennaColor, style = antennaStroke)
|
||||
}
|
||||
drawCircle(tint ?: EyeDark, radius = 6f, center = Offset(45f, 35f))
|
||||
drawCircle(tint ?: EyeDark, radius = 6f, center = Offset(75f, 35f))
|
||||
val glowColor = tint ?: EyeGlow
|
||||
drawCircle(glowColor, radius = 2.5f, center = Offset(46f, 34f), alpha = pose.eyeGlowAlpha.value)
|
||||
drawCircle(glowColor, radius = 2.5f, center = Offset(76f, 34f), alpha = pose.eyeGlowAlpha.value)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawEye(
|
||||
center: Offset,
|
||||
openness: Double,
|
||||
pose: MascotPose,
|
||||
) {
|
||||
val shifted =
|
||||
Offset(
|
||||
x = center.x + (pose.gaze.x * 2.0).toFloat(),
|
||||
y = center.y + (pose.gaze.y * 1.5).toFloat(),
|
||||
)
|
||||
if (pose.happyEyes < 1.0) {
|
||||
val height = max(1.2, 12.0 * openness * (1.0 - 0.6 * pose.happyEyes)).toFloat()
|
||||
val eyeCenterY = shifted.y - 6f + (12f - height) * 0.65f + height / 2f
|
||||
drawOval(
|
||||
color = EyeDark,
|
||||
topLeft = Offset(shifted.x - 6f, eyeCenterY - height / 2f),
|
||||
size = Size(12f, height),
|
||||
alpha = (1.0 - pose.happyEyes).toFloat(),
|
||||
)
|
||||
}
|
||||
if (pose.happyEyes > 0.0) {
|
||||
val arc =
|
||||
Path().apply {
|
||||
moveTo(shifted.x - 6f, shifted.y + 2f)
|
||||
quadraticTo(shifted.x, shifted.y - 5.5f, shifted.x + 6f, shifted.y + 2f)
|
||||
}
|
||||
drawPath(
|
||||
path = arc,
|
||||
color = EyeDark,
|
||||
alpha = pose.happyEyes.toFloat(),
|
||||
style = Stroke(width = 2.6f, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
|
||||
val glowVisibility = pose.eyeGlowAlpha * openness * (1.0 - pose.happyEyes)
|
||||
if (glowVisibility <= 0.01) return
|
||||
val glowRadius = (2.0 * pose.glowScale).toFloat()
|
||||
val glowCenter =
|
||||
Offset(
|
||||
x = shifted.x + 1f + (pose.gaze.x * 1.2).toFloat(),
|
||||
y = shifted.y - 1f + (pose.gaze.y * 0.9).toFloat(),
|
||||
)
|
||||
drawCircle(EyeGlow, radius = glowRadius, center = glowCenter, alpha = glowVisibility.toFloat())
|
||||
}
|
||||
|
||||
private fun DrawScope.drawMouth(pose: MascotPose) {
|
||||
when {
|
||||
pose.mouthRound > 0.05 -> {
|
||||
val radiusX = (1.0 + 3.2 * pose.mouthRound).toFloat()
|
||||
val radiusY = (1.0 + 4.2 * pose.mouthRound).toFloat()
|
||||
drawOval(
|
||||
color = EyeDark,
|
||||
topLeft = Offset(60f - radiusX, 51f - radiusY),
|
||||
size = Size(radiusX * 2f, radiusY * 2f),
|
||||
)
|
||||
}
|
||||
pose.mouthOpen > 0.05 -> {
|
||||
val grin =
|
||||
Path().apply {
|
||||
moveTo(52.5f, 48.5f)
|
||||
quadraticTo(60f, (48.5 + 14.0 * pose.mouthOpen).toFloat(), 67.5f, 48.5f)
|
||||
close()
|
||||
}
|
||||
drawPath(grin, EyeDark)
|
||||
}
|
||||
kotlin.math.abs(pose.mouthCurve) > 0.05 -> {
|
||||
val curve =
|
||||
Path().apply {
|
||||
moveTo(52.5f, 49f)
|
||||
quadraticTo(60f, (49.0 + 8.0 * pose.mouthCurve).toFloat(), 67.5f, 49f)
|
||||
}
|
||||
drawPath(curve, EyeDark, style = Stroke(width = 2.2f, cap = StrokeCap.Round))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBlush(pose: MascotPose) {
|
||||
if (pose.blush <= 0.02) return
|
||||
val alpha = (pose.blush * 0.55).toFloat()
|
||||
drawOval(Blush, topLeft = Offset(32.5f, 42.5f), size = Size(9f, 5f), alpha = alpha)
|
||||
drawOval(Blush, topLeft = Offset(78.5f, 42.5f), size = Size(9f, 5f), alpha = alpha)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawHardHat(
|
||||
amount: Float,
|
||||
tint: Color?,
|
||||
) {
|
||||
if (amount <= 0.01f) return
|
||||
withTransform({
|
||||
translate(top = -14f * (1f - amount))
|
||||
rotate(-5f, pivot = Offset(60f, 15f))
|
||||
}) {
|
||||
val fill =
|
||||
tint?.let(::SolidColor)
|
||||
?: Brush.verticalGradient(colors = listOf(HatLight, HatAmber), startY = 3f, endY = 16f)
|
||||
drawPath(HardHatDomePath, fill, alpha = amount)
|
||||
if (tint == null) {
|
||||
drawPath(HardHatDomePath, HatOutline, alpha = amount, style = Stroke(width = 0.8f))
|
||||
}
|
||||
drawRoundRect(
|
||||
brush = tint?.let(::SolidColor) ?: SolidColor(HatAmber),
|
||||
topLeft = Offset(41f, 14f),
|
||||
size = Size(38f, 5f),
|
||||
cornerRadius = CornerRadius(2f, 2f),
|
||||
alpha = amount,
|
||||
)
|
||||
if (tint == null) {
|
||||
drawRoundRect(
|
||||
color = HatOutline,
|
||||
topLeft = Offset(41f, 14f),
|
||||
size = Size(38f, 5f),
|
||||
cornerRadius = CornerRadius(2f, 2f),
|
||||
alpha = amount,
|
||||
style = Stroke(width = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawEffect(pose: MascotPose) {
|
||||
when (pose.effect) {
|
||||
MascotEffect.None -> Unit
|
||||
MascotEffect.Sparkles -> {
|
||||
repeat(6) { index ->
|
||||
val phase = (pose.effectPhase + index * 0.37) % 1.0
|
||||
val alpha = effectBell(phase)
|
||||
if (alpha > 0.05) {
|
||||
val angle = PI + PI * (index + 0.5) / 6.0
|
||||
val center =
|
||||
Offset(
|
||||
x = (60.0 + cos(angle) * (50.0 + index % 3 * 4.0)).toFloat(),
|
||||
y = (55.0 + sin(angle) * (40.0 + (index * 5) % 3 * 4.0)).toFloat(),
|
||||
)
|
||||
drawPath(
|
||||
sparklePath(center, (2.5 + 2.0 * alpha).toFloat()),
|
||||
if (index % 2 == 0) EyeGlow else CoralBright,
|
||||
alpha = alpha.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MascotEffect.Zzz -> {
|
||||
repeat(3) { index ->
|
||||
val phase = (pose.effectPhase + index * 0.33) % 1.0
|
||||
val alpha = if (phase < 0.2) phase / 0.2 else 1.0 - (phase - 0.2) / 0.8
|
||||
if (alpha > 0.05) {
|
||||
drawZ(
|
||||
position =
|
||||
Offset(
|
||||
x = (86.0 + 14.0 * phase + 2.0 * sin(phase * 4.0 * PI)).toFloat(),
|
||||
y = (24.0 - 20.0 * phase).toFloat(),
|
||||
),
|
||||
size = (6.0 + 4.0 * phase).toFloat(),
|
||||
alpha = alpha.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MascotEffect.Sparks -> {
|
||||
repeat(5) { index ->
|
||||
val rawPhase = pose.effectPhase - index * 0.025
|
||||
if (rawPhase >= 0.0 && rawPhase < 0.45) {
|
||||
val alpha = if (rawPhase < 0.08) rawPhase / 0.08 else 1.0 - (rawPhase - 0.08) / 0.37
|
||||
val angle = Math.toRadians(-160.0 + index * 35.0)
|
||||
val radius = 5.0 + 12.0 * rawPhase / 0.45
|
||||
val particleSize = (2.2 + index % 3 * 0.8).toFloat()
|
||||
val center =
|
||||
Offset(
|
||||
x = (106.0 + cos(angle) * radius).coerceIn(particleSize.toDouble(), 120.0 - particleSize).toFloat(),
|
||||
y = (66.0 + sin(angle) * radius).coerceIn(particleSize.toDouble(), 120.0 - particleSize).toFloat(),
|
||||
)
|
||||
drawPath(
|
||||
sparklePath(center, particleSize),
|
||||
if (index % 2 == 0) EyeGlow else HatAmber,
|
||||
alpha = alpha.toFloat(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MascotEffect.Sweat -> {
|
||||
val alpha = effectBell(pose.effectPhase)
|
||||
if (alpha > 0.02) {
|
||||
val center = Offset(42f, (24.0 + 7.0 * pose.effectPhase).toFloat())
|
||||
val drop =
|
||||
Path().apply {
|
||||
moveTo(center.x, center.y - 3f)
|
||||
cubicTo(center.x - 4f, center.y + 1f, center.x - 2f, center.y + 3f, center.x, center.y + 3f)
|
||||
cubicTo(center.x + 2f, center.y + 3f, center.x + 4f, center.y + 1f, center.x, center.y - 3f)
|
||||
close()
|
||||
}
|
||||
drawPath(drop, SweatBlue, alpha = alpha.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sparklePath(
|
||||
center: Offset,
|
||||
size: Float,
|
||||
): Path =
|
||||
Path().apply {
|
||||
moveTo(center.x, center.y - size)
|
||||
listOf(1f to 0f, 0f to 1f, -1f to 0f, 0f to -1f).forEach { (dx, dy) ->
|
||||
quadraticTo(center.x, center.y, center.x + size * dx, center.y + size * dy)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
private fun DrawScope.drawZ(
|
||||
position: Offset,
|
||||
size: Float,
|
||||
alpha: Float,
|
||||
) {
|
||||
val width = size * 0.62f
|
||||
val height = size * 0.78f
|
||||
val path =
|
||||
Path().apply {
|
||||
moveTo(position.x - width / 2f, position.y - height / 2f)
|
||||
lineTo(position.x + width / 2f, position.y - height / 2f)
|
||||
lineTo(position.x - width / 2f, position.y + height / 2f)
|
||||
lineTo(position.x + width / 2f, position.y + height / 2f)
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = EyeGlow,
|
||||
alpha = alpha * 0.9f,
|
||||
style = Stroke(width = max(1.2f, size * 0.16f), cap = StrokeCap.Round, join = StrokeJoin.Round),
|
||||
)
|
||||
}
|
||||
|
||||
private fun effectBell(value: Double): Double {
|
||||
val t = value.coerceIn(0.0, 1.0)
|
||||
val edge = if (t < 0.5) t * 2.0 else (1.0 - t) * 2.0
|
||||
return edge * edge * (3.0 - 2.0 * edge)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.i18n.nativeText
|
||||
import ai.openclaw.app.i18n.resolveNativeText
|
||||
import ai.openclaw.app.ui.design.MascotMood
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -19,6 +20,43 @@ import org.junit.Test
|
||||
import java.util.Base64
|
||||
|
||||
class OnboardingFlowLogicTest {
|
||||
@Test
|
||||
fun mascotMoodTracksVisibleOnboardingState() {
|
||||
assertEquals(MascotMood.Idle, onboardingMascotMood(OnboardingStep.Welcome))
|
||||
assertEquals(MascotMood.Curious, onboardingMascotMood(OnboardingStep.Permissions))
|
||||
assertEquals(MascotMood.Thinking, onboardingMascotMood(OnboardingStep.NodeApproval))
|
||||
assertEquals(
|
||||
MascotMood.Working,
|
||||
onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Finishing),
|
||||
)
|
||||
assertEquals(
|
||||
MascotMood.Working,
|
||||
onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.TakingLonger),
|
||||
)
|
||||
assertEquals(
|
||||
MascotMood.Celebrating,
|
||||
onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Connected),
|
||||
)
|
||||
assertEquals(
|
||||
MascotMood.Sad,
|
||||
onboardingMascotMood(OnboardingStep.Recovery, GatewayRecoveryUiState.Failed),
|
||||
)
|
||||
assertEquals(
|
||||
MascotMood.Sad,
|
||||
onboardingMascotMood(
|
||||
step = OnboardingStep.EnterSetupCode,
|
||||
setupErrorCode = OnboardingErrorCode.SetupCodeRejected,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
MascotMood.Sad,
|
||||
onboardingMascotMood(
|
||||
step = OnboardingStep.SetupCode,
|
||||
setupScanErrorCode = OnboardingErrorCode.InvalidSetupQr,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onboardingBackDestinationsMatchTheVisibleFlow() {
|
||||
assertEquals(null, onboardingBackDestination(OnboardingStep.Welcome))
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MascotAnimatorTest {
|
||||
@Test
|
||||
fun allMoodChannelsStayInsideBoundsForThirtySeconds() {
|
||||
MascotMood.entries.forEachIndexed { index, mood ->
|
||||
val animator = MascotAnimator(seed = (index + 1).toULong())
|
||||
animator.setMood(mood, 0.0)
|
||||
|
||||
repeat(3_001) { frame ->
|
||||
val pose = animator.poseAt(frame / 100.0)
|
||||
assertInBounds(pose, mood, frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameSeedProducesIdenticalPoses() {
|
||||
val first = MascotAnimator(seed = 0xCAFE_BABEuL)
|
||||
val second = MascotAnimator(seed = 0xCAFE_BABEuL)
|
||||
first.setMood(MascotMood.Curious, 0.0)
|
||||
second.setMood(MascotMood.Curious, 0.0)
|
||||
|
||||
repeat(1_500) { frame ->
|
||||
val time = frame * 0.023
|
||||
if (frame == 400) {
|
||||
first.setMood(MascotMood.Thinking, time)
|
||||
second.setMood(MascotMood.Thinking, time)
|
||||
}
|
||||
if (frame == 900) {
|
||||
first.setMood(MascotMood.Happy, time)
|
||||
second.setMood(MascotMood.Happy, time)
|
||||
}
|
||||
assertEquals(first.poseAt(time), second.poseAt(time))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workingCycleSeatsHatSwingsClawAndShowsWorkEffects() {
|
||||
val animator = MascotAnimator(seed = 7uL)
|
||||
animator.setMood(MascotMood.Working, 0.0)
|
||||
var minRightClaw = Double.POSITIVE_INFINITY
|
||||
var maxRightClaw = Double.NEGATIVE_INFINITY
|
||||
var seatedHat = false
|
||||
var sawSparks = false
|
||||
var sawSweat = false
|
||||
|
||||
repeat(2_001) { frame ->
|
||||
val time = frame / 100.0
|
||||
val pose = animator.poseAt(time)
|
||||
minRightClaw = minOf(minRightClaw, pose.rightClawDegrees)
|
||||
maxRightClaw = maxOf(maxRightClaw, pose.rightClawDegrees)
|
||||
seatedHat = seatedHat || (time >= 1.0 && pose.hardHat >= 0.99)
|
||||
sawSparks = sawSparks || pose.effect == MascotEffect.Sparks
|
||||
sawSweat = sawSweat || pose.effect == MascotEffect.Sweat
|
||||
}
|
||||
|
||||
assertTrue("hard hat never seated", seatedHat)
|
||||
assertTrue("hammer swing was ${maxRightClaw - minRightClaw}°", maxRightClaw - minRightClaw > 25.0)
|
||||
assertTrue("impact sparks never appeared", sawSparks)
|
||||
assertTrue("wipe-brow sweat never appeared", sawSweat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moodChangeCancelsQueuedAndActiveGestures() {
|
||||
val animator = MascotAnimator(seed = 11uL)
|
||||
animator.poseAt(0.0)
|
||||
animator.poseAt(0.95)
|
||||
animator.setMood(MascotMood.Thinking, 0.95)
|
||||
|
||||
val afterWaveCancellation = animator.poseAt(1.0)
|
||||
assertEquals(0.0, afterWaveCancellation.rightClawDegrees, 0.000_001)
|
||||
|
||||
animator.setMood(MascotMood.Working, 1.1)
|
||||
assertTrue(animator.poseAt(1.3).hardHat < 1.0)
|
||||
animator.setMood(MascotMood.Sad, 1.3)
|
||||
|
||||
val afterHatCancellation = animator.poseAt(1.31)
|
||||
assertEquals(0.0, afterHatCancellation.hardHat, 0.000_001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staticPoseSignaturesMatchMoodContract() {
|
||||
assertEquals(MascotPose(), staticPose(MascotMood.Idle))
|
||||
assertEquals(MascotGaze(x = 0.3, y = -0.5), staticPose(MascotMood.Thinking).gaze)
|
||||
|
||||
val working = staticPose(MascotMood.Working)
|
||||
assertEquals(1.0, working.hardHat, 0.0)
|
||||
assertEquals(-28.0, working.rightClawDegrees, 0.0)
|
||||
|
||||
val celebrating = staticPose(MascotMood.Celebrating)
|
||||
assertEquals(0.8, celebrating.happyEyes, 0.0)
|
||||
assertEquals(30.0, celebrating.leftClawDegrees, 0.0)
|
||||
assertEquals(-30.0, celebrating.rightClawDegrees, 0.0)
|
||||
|
||||
val sad = staticPose(MascotMood.Sad)
|
||||
assertEquals(0.75, sad.antennaDroop, 0.0)
|
||||
assertEquals(-0.55, sad.mouthCurve, 0.0)
|
||||
|
||||
val sleepy = staticPose(MascotMood.Sleepy)
|
||||
assertEquals(0.25, sleepy.leftEyeOpenness, 0.0)
|
||||
assertEquals(0.5, sleepy.eyeGlowAlpha, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clampCoversEveryBoundedChannel() {
|
||||
val pose =
|
||||
MascotPose(
|
||||
floatOffset = 100.0,
|
||||
antennaDegrees = -100.0,
|
||||
antennaDroop = 2.0,
|
||||
leftClawDegrees = -100.0,
|
||||
rightClawDegrees = 100.0,
|
||||
eyeGlowAlpha = -1.0,
|
||||
glowScale = 9.0,
|
||||
leftEyeOpenness = -1.0,
|
||||
rightEyeOpenness = 2.0,
|
||||
happyEyes = 2.0,
|
||||
gaze = MascotGaze(x = -9.0, y = 9.0),
|
||||
mouthCurve = -9.0,
|
||||
mouthOpen = 9.0,
|
||||
mouthRound = -9.0,
|
||||
blush = 9.0,
|
||||
hardHat = -9.0,
|
||||
bodyTilt = 90.0,
|
||||
bodyStretch = 9.0,
|
||||
effect = MascotEffect.Sweat,
|
||||
effectPhase = 0.75,
|
||||
).clamp()
|
||||
|
||||
assertEquals(2.0, pose.floatOffset, 0.0)
|
||||
assertEquals(-14.0, pose.antennaDegrees, 0.0)
|
||||
assertEquals(1.0, pose.antennaDroop, 0.0)
|
||||
assertEquals(-45.0, pose.leftClawDegrees, 0.0)
|
||||
assertEquals(45.0, pose.rightClawDegrees, 0.0)
|
||||
assertEquals(0.0, pose.eyeGlowAlpha, 0.0)
|
||||
assertEquals(1.6, pose.glowScale, 0.0)
|
||||
assertEquals(0.0, pose.leftEyeOpenness, 0.0)
|
||||
assertEquals(1.0, pose.rightEyeOpenness, 0.0)
|
||||
assertEquals(1.0, pose.happyEyes, 0.0)
|
||||
assertEquals(MascotGaze(x = -1.2, y = 1.2), pose.gaze)
|
||||
assertEquals(-1.0, pose.mouthCurve, 0.0)
|
||||
assertEquals(1.0, pose.mouthOpen, 0.0)
|
||||
assertEquals(0.0, pose.mouthRound, 0.0)
|
||||
assertEquals(1.0, pose.blush, 0.0)
|
||||
assertEquals(0.0, pose.hardHat, 0.0)
|
||||
assertEquals(8.0, pose.bodyTilt, 0.0)
|
||||
assertEquals(1.05, pose.bodyStretch, 0.0)
|
||||
assertEquals(MascotEffect.Sweat, pose.effect)
|
||||
assertEquals(0.75, pose.effectPhase, 0.0)
|
||||
}
|
||||
|
||||
private fun assertInBounds(
|
||||
pose: MascotPose,
|
||||
mood: MascotMood,
|
||||
frame: Int,
|
||||
) {
|
||||
val location = "$mood frame $frame"
|
||||
assertTrue(location, pose.floatOffset in -12.0..2.0)
|
||||
assertTrue(location, pose.antennaDegrees in -14.0..14.0)
|
||||
assertTrue(location, pose.antennaDroop in 0.0..1.0)
|
||||
assertTrue(location, pose.leftClawDegrees in -45.0..45.0)
|
||||
assertTrue(location, pose.rightClawDegrees in -45.0..45.0)
|
||||
assertTrue(location, pose.eyeGlowAlpha in 0.0..1.0)
|
||||
assertTrue(location, pose.glowScale in 0.5..1.6)
|
||||
assertTrue(location, pose.leftEyeOpenness in 0.0..1.0)
|
||||
assertTrue(location, pose.rightEyeOpenness in 0.0..1.0)
|
||||
assertTrue(location, pose.happyEyes in 0.0..1.0)
|
||||
assertTrue(location, pose.gaze.x in -1.2..1.2)
|
||||
assertTrue(location, pose.gaze.y in -1.2..1.2)
|
||||
assertTrue(location, pose.mouthCurve in -1.0..1.0)
|
||||
assertTrue(location, pose.mouthOpen in 0.0..1.0)
|
||||
assertTrue(location, pose.mouthRound in 0.0..1.0)
|
||||
assertTrue(location, pose.blush in 0.0..1.0)
|
||||
assertTrue(location, pose.hardHat in 0.0..1.0)
|
||||
assertTrue(location, pose.bodyTilt in -8.0..8.0)
|
||||
assertTrue(location, pose.bodyStretch in 0.86..1.05)
|
||||
assertTrue(location, pose.effectPhase in 0.0..1.0)
|
||||
}
|
||||
}
|
||||
|
||||
class EffectiveMascotMoodTest {
|
||||
@Test
|
||||
fun `tinted mascots stay ambient regardless of requested mood`() {
|
||||
for (mood in MascotMood.entries) {
|
||||
assertEquals(MascotMood.Idle, effectiveMascotMood(mood = mood, tinted = true))
|
||||
assertEquals(mood, effectiveMascotMood(mood = mood, tinted = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user