@dotcraft/avatar 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +46 -43
- package/dist/accessories.css +14 -14
- package/dist/avatar.css +62 -62
- package/dist/composer/ComposerMascot.d.ts +1 -1
- package/dist/composer/ComposerMascot.d.ts.map +1 -1
- package/dist/composer/ComposerMascot.js +33 -107
- package/dist/composer/ComposerMascot.js.map +1 -1
- package/dist/composer/MascotIdleStage.d.ts +11 -0
- package/dist/composer/MascotIdleStage.d.ts.map +1 -0
- package/dist/composer/MascotIdleStage.js +9 -0
- package/dist/composer/MascotIdleStage.js.map +1 -0
- package/dist/composer/constants.d.ts +12 -1
- package/dist/composer/constants.d.ts.map +1 -1
- package/dist/composer/constants.js +9 -4
- package/dist/composer/constants.js.map +1 -1
- package/dist/composer/styles/energy.css +99 -99
- package/dist/composer/styles/expressions.css +20 -20
- package/dist/composer/styles/feedback.css +188 -188
- package/dist/composer/styles/focus.css +72 -72
- package/dist/composer/styles/foundation.css +301 -287
- package/dist/composer/styles/idle.css +449 -438
- package/dist/composer/styles/overdrive.css +264 -264
- package/dist/composer/styles/props.css +21 -21
- package/dist/composer/types.d.ts +1 -0
- package/dist/composer/types.d.ts.map +1 -1
- package/dist/composer/useComposerAvatarBehavior.d.ts +1 -0
- package/dist/composer/useComposerAvatarBehavior.d.ts.map +1 -1
- package/dist/composer/useComposerAvatarBehavior.js +2 -2
- package/dist/composer/useComposerAvatarBehavior.js.map +1 -1
- package/dist/composer/useMascotActiveIdle.d.ts +33 -0
- package/dist/composer/useMascotActiveIdle.d.ts.map +1 -0
- package/dist/composer/useMascotActiveIdle.js +128 -0
- package/dist/composer/useMascotActiveIdle.js.map +1 -0
- package/dist/react.d.ts +5 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +4 -0
- package/dist/react.js.map +1 -1
- package/dist/rig.css +265 -265
- package/dist/styles.css +13 -13
- package/package.json +56 -55
- package/src/AnimatedDecoration.tsx +43 -0
- package/src/AppearanceRig.tsx +15 -0
- package/src/Avatar.tsx +85 -0
- package/src/DecorationShapes.tsx +8 -0
- package/src/Decorations.tsx +31 -0
- package/src/Faces.tsx +37 -0
- package/src/HatDecorations.tsx +75 -0
- package/src/HeldDecorations.tsx +48 -0
- package/src/MascotRig.tsx +191 -0
- package/src/ObjectDecorations.tsx +55 -0
- package/src/accessories.css +14 -0
- package/src/appearanceModel.ts +65 -0
- package/src/avatar.css +62 -0
- package/src/characters.ts +5 -0
- package/src/composer/ComposerMascot.tsx +445 -0
- package/src/composer/ComposerMascotShadow.tsx +5 -0
- package/src/composer/MascotIdleStage.tsx +23 -0
- package/src/composer/constants.ts +89 -0
- package/src/composer/mascotHandoff.ts +19 -0
- package/src/composer/styles/energy.css +99 -0
- package/src/composer/styles/expressions.css +20 -0
- package/src/composer/styles/feedback.css +188 -0
- package/src/composer/styles/focus.css +72 -0
- package/src/composer/styles/foundation.css +301 -0
- package/src/composer/styles/idle.css +449 -0
- package/src/composer/styles/overdrive.css +264 -0
- package/src/composer/styles/props.css +21 -0
- package/src/composer/types.ts +32 -0
- package/src/composer/useComposerAvatarBehavior.ts +98 -0
- package/src/composer/useComposerMotion.ts +15 -0
- package/src/composer/useComposerProfile.ts +50 -0
- package/src/composer/useMascotActiveIdle.ts +170 -0
- package/src/decorationCatalog.ts +45 -0
- package/src/decorationMotion.ts +68 -0
- package/src/environment.ts +46 -0
- package/src/index.ts +5 -0
- package/src/palette.ts +52 -0
- package/src/react.ts +11 -0
- package/src/rig.css +265 -0
- package/src/styles.css +13 -0
- package/src/useEventReplay.ts +13 -0
- package/src/useGesture.ts +51 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { compatibility, primaryIds, type DecorationId, type PrimaryId, type SecondaryId } from './appearanceModel.js'
|
|
2
|
+
|
|
3
|
+
export interface Decoration {
|
|
4
|
+
id: DecorationId
|
|
5
|
+
name: string
|
|
6
|
+
category: 'Hats' | 'Novelty' | 'Accessories'
|
|
7
|
+
feature: string
|
|
8
|
+
colors: readonly string[]
|
|
9
|
+
mount: string
|
|
10
|
+
}
|
|
11
|
+
export const decorations: Decoration[] = [
|
|
12
|
+
{ id: 'baseball-cap', name: 'Baseball cap', category: 'Hats', feature: 'Rounded crown with a short right-facing brim', colors: ['#e87967', '#b94f50', '#f5a18c'], mount: 'Crown overlaps the top edge; the brim stays inside the body' },
|
|
13
|
+
{ id: 'bucket-hat', name: 'Bucket hat', category: 'Hats', feature: 'Flat top with a continuous downward brim', colors: ['#94bda6', '#5b887a', '#bed8c1'], mount: 'Brim follows the top edge with a slight center overlap' },
|
|
14
|
+
{ id: 'beret', name: 'Beret', category: 'Hats', feature: 'Soft left-leaning crown with a short stem', colors: ['#b17498', '#784c71', '#cf9db5'], mount: 'Narrow band rests on top; the crown leans left' },
|
|
15
|
+
{ id: 'beanie', name: 'Beanie', category: 'Hats', feature: 'Rounded crown and wide folded cuff with minimal texture', colors: ['#e9a653', '#bc753d', '#f7c676'], mount: 'Folded cuff sits on the top edge; crown stays centered' },
|
|
16
|
+
{ id: 'top-hat', name: 'Top hat', category: 'Hats', feature: 'Tall crown, narrow brim, and burgundy band', colors: ['#465069', '#293247', '#c96e85'], mount: 'Narrow brim rests flat on top; crown tapers at its base' },
|
|
17
|
+
{ id: 'wizard-hat', name: 'Wizard hat', category: 'Hats', feature: 'Curved point, wide brim, and gold star', colors: ['#8773c1', '#594b93', '#f4cc75'], mount: 'Wide brim rests on top; point curves into the center safe area' },
|
|
18
|
+
{ id: 'chef-hat', name: 'Chef hat', category: 'Hats', feature: 'Three-lobed puff with an upright band', colors: ['#f5eee3', '#d7caba', '#fffaf1'], mount: 'Band rests at the center of the top edge' },
|
|
19
|
+
{ id: 'party-hat', name: 'Party hat', category: 'Hats', feature: 'Cone with two colored bands and a pom-pom', colors: ['#ea9baf', '#8075b7', '#f5d07b'], mount: 'Shallow curved base follows the top of the body' },
|
|
20
|
+
{ id: 'crown', name: 'Crown', category: 'Hats', feature: 'Three rounded points with a central sapphire', colors: ['#efc65c', '#c99139', '#73b8cc'], mount: 'Curved gold band wraps the top edge; all points face up' },
|
|
21
|
+
{ id: 'hard-hat', name: 'Hard hat', category: 'Hats', feature: 'Rounded shell, center ridge, and short front brim', colors: ['#f5bd48', '#d68d32', '#ffdc79'], mount: 'Short brim overlaps the top edge; ridge stays centered' },
|
|
22
|
+
{ id: 'nightcap', name: 'Nightcap', category: 'Hats', feature: 'Soft drooping point with a pom-pom', colors: ['#789cc9', '#4c709f', '#dce8f4'], mount: 'Soft band follows the top edge; pom-pom stays upper right' },
|
|
23
|
+
{ id: 'straw-hat', name: 'Straw hat', category: 'Hats', feature: 'Low round crown, wide oval brim, and red band', colors: ['#e8c78a', '#c49b5e', '#c57568'], mount: 'Wide brim rests flat without entering the arm areas' },
|
|
24
|
+
{ id: 'poop', name: 'Poop', category: 'Novelty', feature: 'Three rounded layers with a playful curled tip', colors: ['#a97b5f', '#805840', '#c49976'], mount: 'Wide bottom layer rests directly on top; color separates each layer' },
|
|
25
|
+
{ id: 'banana', name: 'Banana', category: 'Novelty', feature: 'Crescent fruit with stems and an inner color plane', colors: ['#f3cf62', '#dbaa40', '#89705a'], mount: 'Outer curve touches the top; both stems point up' },
|
|
26
|
+
{ id: 'fried-egg', name: 'Fried egg', category: 'Novelty', feature: 'Irregular rounded white with a raised golden yolk', colors: ['#fff9e8', '#e6d9bb', '#f3be43'], mount: 'White spreads across the top; raised yolk defines the silhouette' },
|
|
27
|
+
{ id: 'rubber-duck', name: 'Rubber duck', category: 'Novelty', feature: 'Round head, flat orange bill, and short raised tail', colors: ['#f6d05e', '#e4ae3e', '#e58b4b'], mount: 'Belly rests flat on top, with bill left and tail right' },
|
|
28
|
+
{ id: 'paper-boat', name: 'Paper boat', category: 'Novelty', feature: 'Folded hull with a central triangular plane', colors: ['#d6e7eb', '#92b5c1', '#f1f7f5'], mount: 'Folded hull edge rests on top; main fold stays centered' },
|
|
29
|
+
{ id: 'traffic-cone', name: 'Traffic cone', category: 'Novelty', feature: 'Orange cone with a white band and flat base', colors: ['#ed985f', '#c96c43', '#fff0dd'], mount: 'Flat base rests on top; cone tapers upward' },
|
|
30
|
+
{ id: 'sprout', name: 'Sprout', category: 'Novelty', feature: 'Short stem with two leaves pointing in different directions', colors: ['#89b875', '#537f59', '#b5d38f'], mount: 'Stem grows directly from the top edge without a pot or base' },
|
|
31
|
+
{ id: 'donut', name: 'Donut', category: 'Novelty', feature: 'Clear center hole, half-ring of icing, and a few sprinkles', colors: ['#dbab70', '#bf8456', '#e5a0b3'], mount: 'Lower curve touches the top; center remains an open cutout' },
|
|
32
|
+
{ id: 'forehead-goggles', name: 'Forehead goggles', category: 'Accessories', feature: 'Two blue-gray lenses with a center bridge', colors: ['#8b7568', '#a2c5d1', '#dec8ae'], mount: 'Fits within the forehead band; bridge anchors above the screen' },
|
|
33
|
+
{"id":"task-board","name":"Task board","category":"Accessories","feature":"White planning board with connected role-color markers and a yellow accent","colors":["#ffffff","#3161f7","#f6b500"],"mount":"Screen-left hand; shares the arm pivot, stows for laptop and question sign, then restores"},
|
|
34
|
+
{"id":"wrench","name":"Wrench","category":"Accessories","feature":"White open-ended wrench with role-color outlines and a yellow handle inset","colors":["#ffffff","#3161f7","#f6b500"],"mount":"Screen-left hand; shares the arm pivot, stows for laptop and question sign, then restores"},
|
|
35
|
+
{"id":"shield","name":"Shield","category":"Accessories","feature":"White shield with a role-color outline and upright check","colors":["#ffffff","#3161f7","#f6b500"],"mount":"Screen-left hand; shares the arm pivot, stows for laptop and question sign, then restores"},
|
|
36
|
+
{"id":"magnifier","name":"Magnifier","category":"Accessories","feature":"Upright white lens frame with a role-color outline and a lower grip","colors":["#ffffff","#3161f7","#f6b500"],"mount":"Screen-left hand; shares the arm pivot, stows for laptop and question sign, then restores"},
|
|
37
|
+
{"id":"control-panel","name":"Control panel","category":"Accessories","feature":"White control panel with dark rails and role-color and yellow knobs","colors":["#ffffff","#3161f7","#f6b500"],"mount":"Screen-left hand; shares the arm pivot, stows for laptop and question sign, then restores"},
|
|
38
|
+
]
|
|
39
|
+
export const decorationOf = (id: DecorationId) => decorations.find(item => item.id === id)!
|
|
40
|
+
export const decorationName = (id: PrimaryId | SecondaryId) => id === 'none' ? 'None' : decorationOf(id).name
|
|
41
|
+
export function compatibleNames(item: Decoration) {
|
|
42
|
+
return item.category === 'Accessories'
|
|
43
|
+
? primaryIds.filter(id => compatibility[id].includes(item.id as SecondaryId)).map(decorationName).join(', ')
|
|
44
|
+
: compatibility[item.id as PrimaryId].map(decorationName).join(', ')
|
|
45
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { PrimaryId } from './appearanceModel.js'
|
|
2
|
+
import type { AvatarPose } from './characters.js'
|
|
3
|
+
|
|
4
|
+
export type MotionKind = 'lift' | 'fitted' | 'bounce' | 'rock' | 'squish' | 'sprout'
|
|
5
|
+
export interface MotionProfile { kind: MotionKind; x: number; y: number; height: number; angle: number; shadow: number }
|
|
6
|
+
const lift = (x = 512, y = 400): MotionProfile => ({ kind: 'lift', x, y, height: .05, angle: 3, shadow: 95 })
|
|
7
|
+
const fitted = (x = 512): MotionProfile => ({ kind: 'fitted', x, y: 400, height: 0, angle: 3, shadow: 0 })
|
|
8
|
+
const bounce = (x = 512): MotionProfile => ({ kind: 'bounce', x, y: 400, height: .06, angle: 5, shadow: 64 })
|
|
9
|
+
export const decorationMotionProfiles: Record<Exclude<PrimaryId, 'none'>, MotionProfile> = {
|
|
10
|
+
'baseball-cap': lift(478), 'bucket-hat': lift(), beret: lift(494), beanie: fitted(),
|
|
11
|
+
'top-hat': lift(), 'wizard-hat': lift(), 'chef-hat': lift(), 'party-hat': lift(), crown: lift(),
|
|
12
|
+
'hard-hat': fitted(), nightcap: fitted(493), 'straw-hat': lift(),
|
|
13
|
+
poop: bounce(), 'rubber-duck': bounce(520), donut: { ...bounce(), shadow: 40 },
|
|
14
|
+
banana: { kind: 'rock', x: 487, y: 402, height: 0, angle: 5, shadow: 0 },
|
|
15
|
+
'paper-boat': { kind: 'rock', x: 512, y: 399, height: 0, angle: 5, shadow: 0 },
|
|
16
|
+
'traffic-cone': { kind: 'rock', x: 512, y: 399, height: 0, angle: 2.5, shadow: 0 },
|
|
17
|
+
'fried-egg': { kind: 'squish', x: 512, y: 400, height: 0, angle: 0, shadow: 0 },
|
|
18
|
+
sprout: { kind: 'sprout', x: 512, y: 399, height: 0, angle: 6, shadow: 0 },
|
|
19
|
+
}
|
|
20
|
+
export const motionKindLabels: Record<MotionKind, string> = {
|
|
21
|
+
lift: 'Gentle lift', fitted: 'Fitted sway', bounce: 'Bounce and settle', rock: 'Rocking response', squish: 'Soft wobble', sprout: 'Nod and unfurl',
|
|
22
|
+
}
|
|
23
|
+
export interface DecorationFrame { x: number; y: number; angle: number; sy: number; shadow: number }
|
|
24
|
+
export const restFrame: DecorationFrame = { x: 0, y: 0, angle: 0, sy: 1, shadow: 1 }
|
|
25
|
+
export function eventDuration(pose: AvatarPose) {
|
|
26
|
+
return pose === 'done' ? 800 : pose === 'greeting' ? 600 : pose === 'acknowledge' ? 350 : pose === 'blocked' ? 250 : 0
|
|
27
|
+
}
|
|
28
|
+
const mix = (a: number, b: number, t: number) => a + (b - a) * t
|
|
29
|
+
export function mixFrame(a: DecorationFrame, b: DecorationFrame, t: number): DecorationFrame {
|
|
30
|
+
return { x: mix(a.x, b.x, t), y: mix(a.y, b.y, t), angle: mix(a.angle, b.angle, t), sy: mix(a.sy, b.sy, t), shadow: mix(a.shadow, b.shadow, t) }
|
|
31
|
+
}
|
|
32
|
+
export function decorationFrame(profile: MotionProfile, pose: AvatarPose, progress: number): DecorationFrame {
|
|
33
|
+
const t = Math.max(0, Math.min(1, progress))
|
|
34
|
+
if (!eventDuration(pose) || t === 0 || t === 1) return { ...restFrame }
|
|
35
|
+
// Convert a fraction of displayed avatar height into the production rig's 1.3x brand coordinates.
|
|
36
|
+
if (pose === 'blocked') return { ...restFrame, x: Math.sin(t * Math.PI * 6) * (1 - t) * 1024 / 1.3 * .008 }
|
|
37
|
+
const strength = pose === 'acknowledge' ? 1 / 3 : pose === 'greeting' ? .65 : 1
|
|
38
|
+
const sway = Math.sin(t * Math.PI * 2) * Math.sin(t * Math.PI) * strength
|
|
39
|
+
if (profile.kind === 'squish') return { ...restFrame, sy: 1 - .03 * Math.sin(t * Math.PI * 2) * Math.sin(t * Math.PI) * strength }
|
|
40
|
+
if (!profile.height) return { ...restFrame, angle: profile.angle * sway }
|
|
41
|
+
let liftAmount: number
|
|
42
|
+
if (t < .38) liftAmount = 1 - (1 - t / .38) ** 3
|
|
43
|
+
else if (t < .46) liftAmount = 1
|
|
44
|
+
else if (t < .82) liftAmount = 1 - ((t - .46) / .36) ** 2
|
|
45
|
+
else liftAmount = profile.kind === 'bounce' ? Math.sin((t - .82) / .18 * Math.PI) * .08 : 0
|
|
46
|
+
return { ...restFrame, y: -liftAmount * profile.height * 1024 / 1.3 * strength,
|
|
47
|
+
angle: profile.angle * sway, shadow: 1 - liftAmount * .45 * strength }
|
|
48
|
+
}
|
|
49
|
+
export interface DecorationClock { phase: 'return' | 'event' | 'rest'; elapsed: number; pose: AvatarPose; from: DecorationFrame; frame: DecorationFrame }
|
|
50
|
+
export function requestDecorationEvent(current: DecorationClock | null, pose: AvatarPose): DecorationClock {
|
|
51
|
+
const from = current?.frame ?? restFrame
|
|
52
|
+
const displaced = Math.abs(from.x) + Math.abs(from.y) + Math.abs(from.angle) + Math.abs(from.sy - 1) > .0001
|
|
53
|
+
return { phase: displaced ? 'return' : eventDuration(pose) ? 'event' : 'rest', elapsed: 0, pose, from, frame: { ...from } }
|
|
54
|
+
}
|
|
55
|
+
export function advanceDecoration(clock: DecorationClock, profile: MotionProfile, delta: number, paused = false): DecorationClock {
|
|
56
|
+
if (paused || clock.phase === 'rest') return clock
|
|
57
|
+
let elapsed = clock.elapsed + Math.max(0, delta)
|
|
58
|
+
if (clock.phase === 'return') {
|
|
59
|
+
if (elapsed < 160) return { ...clock, elapsed, frame: mixFrame(clock.from, restFrame, 1 - (1 - elapsed / 160) ** 3) }
|
|
60
|
+
elapsed -= 160
|
|
61
|
+
}
|
|
62
|
+
const duration = eventDuration(clock.pose)
|
|
63
|
+
if (!duration || elapsed >= duration) return { ...clock, phase: 'rest', elapsed: 0, frame: { ...restFrame } }
|
|
64
|
+
return { ...clock, phase: 'event', elapsed, frame: decorationFrame(profile, clock.pose, elapsed / duration) }
|
|
65
|
+
}
|
|
66
|
+
export function frameTransform(profile: MotionProfile, frame: DecorationFrame) {
|
|
67
|
+
return `translate(${frame.x} ${frame.y}) translate(${profile.x} ${profile.y}) rotate(${frame.angle}) scale(1 ${frame.sy}) translate(${-profile.x} ${-profile.y})`
|
|
68
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useState, type RefObject } from 'react'
|
|
2
|
+
import type { MotionMode } from './characters.js'
|
|
3
|
+
|
|
4
|
+
export function useMotionEnvironment(mode: MotionMode, observe = true) {
|
|
5
|
+
const [reduced, setReduced] = useState(true)
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (!observe || mode === 'off' || typeof window.matchMedia !== 'function') return
|
|
8
|
+
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
9
|
+
const update = () => setReduced(media.matches)
|
|
10
|
+
update()
|
|
11
|
+
media.addEventListener('change', update)
|
|
12
|
+
return () => media.removeEventListener('change', update)
|
|
13
|
+
}, [mode, observe])
|
|
14
|
+
return observe && (mode === 'on' || (mode === 'system' && !reduced))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useOnscreen(ref: RefObject<Element | null>, observe = true) {
|
|
18
|
+
const [visible, setVisible] = useState(true)
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!observe) return
|
|
21
|
+
let intersects = true
|
|
22
|
+
const update = () => setVisible(intersects && !document.hidden)
|
|
23
|
+
const observer = typeof IntersectionObserver === 'undefined' ? undefined
|
|
24
|
+
: new IntersectionObserver(([entry]) => { intersects = entry.isIntersecting; update() })
|
|
25
|
+
if (ref.current) observer?.observe(ref.current)
|
|
26
|
+
document.addEventListener('visibilitychange', update)
|
|
27
|
+
update()
|
|
28
|
+
return () => { observer?.disconnect(); document.removeEventListener('visibilitychange', update) }
|
|
29
|
+
}, [ref, observe])
|
|
30
|
+
return visible
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useTransitionPause(ref: RefObject<Element | null>, paused: boolean) {
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!paused) return
|
|
36
|
+
// CSS play-state freezes keyframes; Web Animations also freezes in-flight arm transitions.
|
|
37
|
+
const transitions = ref.current?.getAnimations?.({ subtree: true })
|
|
38
|
+
.filter(animation => typeof (animation as CSSTransition).transitionProperty === 'string') ?? []
|
|
39
|
+
transitions.forEach(animation => animation.pause())
|
|
40
|
+
return () => {
|
|
41
|
+
transitions.forEach(animation => {
|
|
42
|
+
if (ref.current?.isConnected && animation.playState === 'paused') animation.play()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}, [ref, paused])
|
|
46
|
+
}
|
package/src/index.ts
ADDED
package/src/palette.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface PaletteEntry {
|
|
2
|
+
key: string
|
|
3
|
+
bodyD: string
|
|
4
|
+
bodyM: string
|
|
5
|
+
bodyL: string
|
|
6
|
+
markD: string
|
|
7
|
+
markL: string
|
|
8
|
+
shadow: string
|
|
9
|
+
accent: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Complete paint vocabulary used by the Composer mascot. */
|
|
13
|
+
export interface MascotPaintPalette extends PaletteEntry {
|
|
14
|
+
/** Middle face-mark stop; profile palettes intentionally reuse `markL`. */
|
|
15
|
+
markM: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Canonical DotCraft mascot paint when no Agent Profile is selected. */
|
|
19
|
+
export const DEFAULT_MASCOT_PALETTE: MascotPaintPalette = {
|
|
20
|
+
key: 'default',
|
|
21
|
+
bodyD: '#2458f7',
|
|
22
|
+
bodyM: '#5f82f7',
|
|
23
|
+
bodyL: '#8fa5ff',
|
|
24
|
+
markD: '#2257f5',
|
|
25
|
+
markM: '#577df7',
|
|
26
|
+
markL: '#8ca2ff',
|
|
27
|
+
shadow: '#0b3d62',
|
|
28
|
+
accent: '#5f82f7'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 12 role palettes. The first five define the built-in Agent Profile roles. */
|
|
32
|
+
export const PALETTE: PaletteEntry[] = [
|
|
33
|
+
{ key: 'blue', bodyD: '#2563eb', bodyM: '#4f7cf6', bodyL: '#8198f5', markD: '#2563eb', markL: '#6f8df5', shadow: '#07307c', accent: '#4f7cf6' },
|
|
34
|
+
{ key: 'indigo', bodyD: '#4f46e5', bodyM: '#6366f1', bodyL: '#8b8ff8', markD: '#3730a3', markL: '#818cf8', shadow: '#1e1b4b', accent: '#6366f1' },
|
|
35
|
+
{ key: 'violet', bodyD: '#6d28d9', bodyM: '#8b5cf6', bodyL: '#a78bfa', markD: '#5b21b6', markL: '#a78bfa', shadow: '#4c1d95', accent: '#8b5cf6' },
|
|
36
|
+
{ key: 'fuchsia', bodyD: '#a21caf', bodyM: '#d946ef', bodyL: '#e9a8f5', markD: '#86198f', markL: '#e879f9', shadow: '#581c87', accent: '#d946ef' },
|
|
37
|
+
{ key: 'pink', bodyD: '#be185d', bodyM: '#ec4899', bodyL: '#f9a8d4', markD: '#9d174d', markL: '#f472b6', shadow: '#831843', accent: '#ec4899' },
|
|
38
|
+
{ key: 'rose', bodyD: '#be123c', bodyM: '#f43f5e', bodyL: '#fda4af', markD: '#9f1239', markL: '#fb7185', shadow: '#881337', accent: '#f43f5e' },
|
|
39
|
+
{ key: 'orange', bodyD: '#c2410c', bodyM: '#f97316', bodyL: '#fdba74', markD: '#9a3412', markL: '#fb923c', shadow: '#7c2d12', accent: '#f97316' },
|
|
40
|
+
{ key: 'amber', bodyD: '#d97706', bodyM: '#eab308', bodyL: '#fbbf24', markD: '#92400e', markL: '#f59e0b', shadow: '#78350f', accent: '#f59e0b' },
|
|
41
|
+
{ key: 'lime', bodyD: '#4d7c0f', bodyM: '#84cc16', bodyL: '#bef264', markD: '#3f6212', markL: '#a3e635', shadow: '#365314', accent: '#84cc16' },
|
|
42
|
+
{ key: 'green', bodyD: '#15803d', bodyM: '#22c55e', bodyL: '#4ade80', markD: '#166534', markL: '#4ade80', shadow: '#14532d', accent: '#22c55e' },
|
|
43
|
+
{ key: 'teal', bodyD: '#0f766e', bodyM: '#14b8a6', bodyL: '#5eead4', markD: '#115e59', markL: '#2dd4bf', shadow: '#134e4a', accent: '#14b8a6' },
|
|
44
|
+
{ key: 'sky', bodyD: '#0284c7', bodyM: '#0ea5e9', bodyL: '#38bdf8', markD: '#0369a1', markL: '#22d3ee', shadow: '#075985', accent: '#0ea5e9' }
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
export function paletteOf(spec: { palette: number }): PaletteEntry { return PALETTE[spec.palette] ?? DEFAULT_MASCOT_PALETTE }
|
|
49
|
+
export function mascotPaletteOf(spec?: { palette: number }): MascotPaintPalette {
|
|
50
|
+
if (!spec || spec.palette === -1) return DEFAULT_MASCOT_PALETTE
|
|
51
|
+
const paint = paletteOf(spec); return { ...paint, markM: paint.markL }
|
|
52
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { Avatar, AppearanceAvatar, type AvatarProps, type AppearanceAvatarProps } from './Avatar.js'
|
|
2
|
+
export { DecorationSwatch, PrimaryDecoration, SecondaryDecoration } from './Decorations.js'
|
|
3
|
+
|
|
4
|
+
export { ComposerMascot } from './composer/ComposerMascot.js'
|
|
5
|
+
export type { ComposerMascotProps, ComposerMascotContext, MascotExpression, MascotLight } from './composer/types.js'
|
|
6
|
+
export { ComposerMascotShadow } from './composer/ComposerMascotShadow.js'
|
|
7
|
+
export { useComposerAvatarBehavior } from './composer/useComposerAvatarBehavior.js'
|
|
8
|
+
export { useMascotActiveIdle, type MascotActiveIdleAttributes, type MascotActiveIdleHandle, type MascotActiveIdleOptions } from './composer/useMascotActiveIdle.js'
|
|
9
|
+
export { MascotIdleStage } from './composer/MascotIdleStage.js'
|
|
10
|
+
export { MASCOT_SLEEP_AFTER_MS } from './composer/constants.js'
|
|
11
|
+
export type { MascotActiveIdle, MascotActiveIdleMotion, MascotActiveIdlePhase, MascotActiveIdlePlan, MascotIdleDirection } from './composer/constants.js'
|
package/src/rig.css
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/* The robot's arm/antenna/face pieces carry stable dca-part-* classes (see
|
|
2
|
+
MascotRig.tsx). Each arm's white and blue pieces are separate elements moving in
|
|
3
|
+
lockstep, so transform-box: view-box resolves one shared origin for both. */
|
|
4
|
+
.dca-part-eyes,
|
|
5
|
+
.dca-part-glow,
|
|
6
|
+
.dca-part-light,
|
|
7
|
+
.dca-part-face {
|
|
8
|
+
transform-box: fill-box;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* The incoming face waits out a ~70ms blank beat so two faces never blend into a
|
|
12
|
+
double exposure. */
|
|
13
|
+
.dca-part-face {
|
|
14
|
+
opacity: 0;
|
|
15
|
+
transition: opacity 110ms ease;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.dca-part-robot[data-expression='neutral'] .dca-part-face-neutral,
|
|
19
|
+
.dca-part-robot[data-expression='happy'] .dca-part-face-happy,
|
|
20
|
+
.dca-part-robot[data-expression='operator'] .dca-part-face-operator,
|
|
21
|
+
.dca-part-robot[data-expression='sleep'] .dca-part-face-sleep {
|
|
22
|
+
opacity: 1;
|
|
23
|
+
transition: opacity 150ms ease 70ms;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.dca-part-eyes {
|
|
27
|
+
transform-origin: center;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.dca-part-glow {
|
|
31
|
+
opacity: 0;
|
|
32
|
+
transform-origin: center;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.dca-part-light {
|
|
36
|
+
transform-origin: center;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* The default 140ms transition-delay sequences "prop first, arm after" on retract, so
|
|
40
|
+
a prop is never left floating. Every rule that ENTERS a posed arm state overrides
|
|
41
|
+
the delay back to 0 so raises stay immediate. */
|
|
42
|
+
.dca-part-arm-l-w,
|
|
43
|
+
.dca-part-arm-l {
|
|
44
|
+
transform-box: view-box;
|
|
45
|
+
transform-origin: 233px 514px;
|
|
46
|
+
transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1) 140ms;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.dca-part-arm-r-w,
|
|
50
|
+
.dca-part-arm-r-b {
|
|
51
|
+
transform-box: view-box;
|
|
52
|
+
transform-origin: 791px 514px;
|
|
53
|
+
transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1) 140ms;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Every keyframe keeps the same function list (translate + rotate + scaleY) so the
|
|
57
|
+
interpolation stays smooth. translate dy=88 lands the hinge at ≈602 viewBox, the
|
|
58
|
+
height of the resting arm nub, so the hand lifts from where the arm already is. */
|
|
59
|
+
@keyframes dca-action-wave-arm {
|
|
60
|
+
0% {
|
|
61
|
+
transform: translate(0, 0) rotate(0) scaleY(1);
|
|
62
|
+
}
|
|
63
|
+
16% {
|
|
64
|
+
transform: translate(-48px, 88px) rotate(-122deg) scaleY(0.8);
|
|
65
|
+
}
|
|
66
|
+
32% {
|
|
67
|
+
transform: translate(-48px, 88px) rotate(-92deg) scaleY(0.8);
|
|
68
|
+
}
|
|
69
|
+
48% {
|
|
70
|
+
transform: translate(-48px, 88px) rotate(-122deg) scaleY(0.8);
|
|
71
|
+
}
|
|
72
|
+
64% {
|
|
73
|
+
transform: translate(-48px, 88px) rotate(-92deg) scaleY(0.8);
|
|
74
|
+
}
|
|
75
|
+
80% {
|
|
76
|
+
transform: translate(-48px, 88px) rotate(-114deg) scaleY(0.8);
|
|
77
|
+
}
|
|
78
|
+
100% {
|
|
79
|
+
transform: translate(0, 0) rotate(0) scaleY(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@keyframes dca-action-wave-lean {
|
|
84
|
+
0%,
|
|
85
|
+
100% {
|
|
86
|
+
transform: rotate(0);
|
|
87
|
+
}
|
|
88
|
+
20%,
|
|
89
|
+
80% {
|
|
90
|
+
transform: rotate(3deg);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.dca-action-wave .dca-part-arm-r-w,
|
|
95
|
+
.dca-action-wave .dca-part-arm-r-b {
|
|
96
|
+
animation: dca-action-wave-arm 1.6s ease-in-out both;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.dca-action-wave .dca-action-jelly {
|
|
100
|
+
animation: dca-action-wave-lean 1.6s ease-in-out both;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Props are always-mounted `dca-part-prop-*` groups inside MascotRobot at opacity 0,
|
|
104
|
+
revealed by these composer root classes. */
|
|
105
|
+
|
|
106
|
+
/* Mini terminal: the 1200ms transition delay keeps quick turns from flashing it. The
|
|
107
|
+
arms only tuck inward by pure translate — a rotation would trigger the solid-colour
|
|
108
|
+
swap and break the gradient's continuity with the torso. */
|
|
109
|
+
.dca-part-prop-laptop {
|
|
110
|
+
opacity: 0;
|
|
111
|
+
transition: opacity 200ms ease;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.dca-action-prop-laptop .dca-part-prop-laptop {
|
|
115
|
+
opacity: 1;
|
|
116
|
+
transition: opacity 240ms ease 1200ms;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.dca-action-prop-laptop .dca-part-arm-l-w,
|
|
120
|
+
.dca-action-prop-laptop .dca-part-arm-l {
|
|
121
|
+
transform: translate(18px, 0);
|
|
122
|
+
transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1) 1200ms;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.dca-action-prop-laptop .dca-part-arm-r-w,
|
|
126
|
+
.dca-action-prop-laptop .dca-part-arm-r-b {
|
|
127
|
+
transform: translate(-18px, 0);
|
|
128
|
+
transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1) 1200ms;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@keyframes dca-action-laptop-scroll {
|
|
132
|
+
0%,
|
|
133
|
+
58% {
|
|
134
|
+
transform: translateY(0);
|
|
135
|
+
}
|
|
136
|
+
66%,
|
|
137
|
+
100% {
|
|
138
|
+
transform: translateY(-36px);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.dca-action-prop-laptop .dca-part-laptop-lines {
|
|
143
|
+
animation: dca-action-laptop-scroll 2200ms ease-in-out 1640ms infinite;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@keyframes dca-action-laptop-caret {
|
|
147
|
+
0%,
|
|
148
|
+
100% {
|
|
149
|
+
opacity: 1;
|
|
150
|
+
}
|
|
151
|
+
50% {
|
|
152
|
+
opacity: 0;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.dca-action-prop-laptop .dca-part-laptop-caret {
|
|
157
|
+
animation: dca-action-laptop-caret 800ms steps(1, end) infinite;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* The sign fades in only after the arm settles (320ms spring plus a beat), and on
|
|
161
|
+
retract it hides first while the arm waits out its 140ms transition-delay, so the
|
|
162
|
+
prop is never left floating without a hand. */
|
|
163
|
+
.dca-action-hold-sign .dca-part-arm-r-w,
|
|
164
|
+
.dca-action-hold-sign .dca-part-arm-r-b {
|
|
165
|
+
transform: translate(-48px, 88px) rotate(-128deg) scaleY(0.8);
|
|
166
|
+
transition-delay: 0ms;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.dca-action-hold-sign .dca-part-arm-r-b {
|
|
170
|
+
fill: var(--dca-part-raised-arm-right, #7a96fb);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.dca-part-prop-sign {
|
|
174
|
+
opacity: 0;
|
|
175
|
+
transition: opacity 140ms ease;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.dca-action-hold-sign .dca-part-prop-sign {
|
|
179
|
+
opacity: 1;
|
|
180
|
+
transition: opacity 180ms ease 340ms;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Click greeting while the sign is held: only the angle moves, and it starts and ends
|
|
184
|
+
exactly at the held pose so the hand-off to .dca-action-hold-sign is seamless.
|
|
185
|
+
The compound selector outranks the lone .dca-action-wave arm rule. */
|
|
186
|
+
@keyframes dca-action-sign-wave-arm {
|
|
187
|
+
0% {
|
|
188
|
+
transform: translate(-48px, 88px) rotate(-128deg) scaleY(0.8);
|
|
189
|
+
}
|
|
190
|
+
18% {
|
|
191
|
+
transform: translate(-48px, 88px) rotate(-143deg) scaleY(0.8);
|
|
192
|
+
}
|
|
193
|
+
42% {
|
|
194
|
+
transform: translate(-48px, 88px) rotate(-113deg) scaleY(0.8);
|
|
195
|
+
}
|
|
196
|
+
66% {
|
|
197
|
+
transform: translate(-48px, 88px) rotate(-143deg) scaleY(0.8);
|
|
198
|
+
}
|
|
199
|
+
86% {
|
|
200
|
+
transform: translate(-48px, 88px) rotate(-120deg) scaleY(0.8);
|
|
201
|
+
}
|
|
202
|
+
100% {
|
|
203
|
+
transform: translate(-48px, 88px) rotate(-128deg) scaleY(0.8);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* Pure rotation about the hand's pivot: the shoulder hinge (791,514) shifted by the
|
|
208
|
+
held translate (-48,88), i.e. C = (743,602), by exactly the arm's angular deviation
|
|
209
|
+
from -128° at each step. Same centre and delta keeps the board gripped. */
|
|
210
|
+
@keyframes dca-action-sign-wave-prop {
|
|
211
|
+
0% {
|
|
212
|
+
transform: rotate(0deg);
|
|
213
|
+
}
|
|
214
|
+
18% {
|
|
215
|
+
transform: rotate(-15deg);
|
|
216
|
+
}
|
|
217
|
+
42% {
|
|
218
|
+
transform: rotate(15deg);
|
|
219
|
+
}
|
|
220
|
+
66% {
|
|
221
|
+
transform: rotate(-15deg);
|
|
222
|
+
}
|
|
223
|
+
86% {
|
|
224
|
+
transform: rotate(8deg);
|
|
225
|
+
}
|
|
226
|
+
100% {
|
|
227
|
+
transform: rotate(0deg);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.dca-action-hold-sign.dca-action-wave .dca-part-arm-r-w,
|
|
232
|
+
.dca-action-hold-sign.dca-action-wave .dca-part-arm-r-b {
|
|
233
|
+
animation: dca-action-sign-wave-arm 1.6s ease-in-out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.dca-action-hold-sign.dca-action-wave .dca-part-prop-sign {
|
|
237
|
+
transform-box: view-box;
|
|
238
|
+
transform-origin: 743px 602px;
|
|
239
|
+
animation: dca-action-sign-wave-prop 1.6s ease-in-out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* The cheer reuses the wave's slide-then-rotate hinge grammar, so the outline wraps
|
|
243
|
+
the band and the colour stays seam-continuous. */
|
|
244
|
+
.dca-action-celebrate .dca-part-arm-l-w,
|
|
245
|
+
.dca-action-celebrate .dca-part-arm-l {
|
|
246
|
+
transform: translate(48px, 88px) rotate(135deg) scaleY(0.82);
|
|
247
|
+
transition-delay: 0ms;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.dca-action-celebrate .dca-part-arm-r-w,
|
|
251
|
+
.dca-action-celebrate .dca-part-arm-r-b {
|
|
252
|
+
transform: translate(-48px, 88px) rotate(-135deg) scaleY(0.82);
|
|
253
|
+
transition-delay: 0ms;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Raised arms swap the body gradient for a solid hinge colour, so a rotated arm has
|
|
257
|
+
no gradient to diverge from the torso's field. White halo capsules stay white. */
|
|
258
|
+
.dca-action-wave .dca-part-arm-r-b,
|
|
259
|
+
.dca-action-celebrate .dca-part-arm-r-b {
|
|
260
|
+
fill: var(--dca-part-raised-arm-right, #7a96fb);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.dca-action-celebrate .dca-part-arm-l-b {
|
|
264
|
+
fill: var(--dca-part-raised-arm-left, #3161f7);
|
|
265
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
@import './rig.css';
|
|
2
|
+
@import './avatar.css';
|
|
3
|
+
@import './accessories.css';
|
|
4
|
+
|
|
5
|
+
@import "./composer/styles/foundation.css";
|
|
6
|
+
@import "./composer/styles/overdrive.css";
|
|
7
|
+
@import "./composer/styles/idle.css";
|
|
8
|
+
@import "./composer/styles/focus.css";
|
|
9
|
+
@import "./composer/styles/expressions.css";
|
|
10
|
+
@import "./composer/styles/feedback.css";
|
|
11
|
+
@import "./composer/styles/props.css";
|
|
12
|
+
|
|
13
|
+
@import "./composer/styles/energy.css";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
const eventAnimations = new Set(['dca-celebrate', 'dca-nod', 'dca-action-wave-arm', 'dca-action-wave-lean'])
|
|
4
|
+
export function useEventReplay(ref: RefObject<Element | null>, sequence: number, enabled: boolean, paused: boolean) {
|
|
5
|
+
const previous = useRef(sequence)
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (!enabled || paused || previous.current === sequence) return
|
|
8
|
+
previous.current = sequence
|
|
9
|
+
ref.current?.getAnimations?.({ subtree: true }).forEach(animation => {
|
|
10
|
+
if (eventAnimations.has((animation as CSSAnimation).animationName)) animation.currentTime = 0
|
|
11
|
+
})
|
|
12
|
+
}, [ref, sequence, enabled, paused])
|
|
13
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
import type { AvatarGesture } from './characters.js'
|
|
3
|
+
|
|
4
|
+
const durations = { blink: 520, 'look-left': 1100, 'look-right': 1100, 'antenna-bob': 720 }
|
|
5
|
+
function curve(points: readonly (readonly [number, number])[], progress: number) {
|
|
6
|
+
const next = points.findIndex(([at]) => at >= progress)
|
|
7
|
+
if (next <= 0) return points[Math.max(0, next)][1]
|
|
8
|
+
const [from, a] = points[next - 1], [to, b] = points[next]
|
|
9
|
+
const t = (progress - from) / (to - from)
|
|
10
|
+
return a + (b - a) * t * t * (3 - 2 * t)
|
|
11
|
+
}
|
|
12
|
+
export function useGesture(ref: RefObject<Element | null>, gesture: AvatarGesture | undefined, sequence: number,
|
|
13
|
+
enabled: boolean, paused: boolean, onComplete?: (sequence: number) => void) {
|
|
14
|
+
const clock = useRef(0)
|
|
15
|
+
const complete = useRef(onComplete)
|
|
16
|
+
const delivered = useRef(false)
|
|
17
|
+
complete.current = onComplete
|
|
18
|
+
function paint(elapsed: number) {
|
|
19
|
+
const root = ref.current
|
|
20
|
+
if (!root) return
|
|
21
|
+
const duration = gesture ? durations[gesture] : 1
|
|
22
|
+
const t = Math.min(1, elapsed / duration)
|
|
23
|
+
const active = enabled && !!gesture && t < 1
|
|
24
|
+
const eye = active && gesture === 'blink' && elapsed < 180 ? curve([[0,1],[.35,.08],[.62,.08],[1,1]], elapsed / 180) : 1
|
|
25
|
+
root.querySelectorAll<SVGElement>('.dca-part-eyes').forEach(e => { e.style.transform = `scaleY(${eye})` })
|
|
26
|
+
root.querySelectorAll<SVGElement>('.dca-part-caret').forEach(e => { e.style.opacity = active && gesture === 'blink' && Math.floor(elapsed / 130) % 2 ? '0' : '' })
|
|
27
|
+
const hold = t > .22 && t < .78 ? 1 : t >= .78 ? Math.sin(Math.PI / 2 * (1 - t) / .22) : Math.sin(Math.PI / 2 * t / .22)
|
|
28
|
+
const x = active && gesture?.startsWith('look-') ? (gesture === 'look-left' ? -34 : 34) * hold : 0
|
|
29
|
+
root.querySelector('.dca-face-motion')?.setAttribute('transform', `translate(${x} 0)`)
|
|
30
|
+
const bob = active && gesture === 'antenna-bob' ? curve([[0,0],[.3,-52],[.55,12],[.75,-16],[1,0]],t) : 0
|
|
31
|
+
root.querySelectorAll<SVGElement>('.dca-part-antenna-w,.dca-part-glow,.dca-part-light').forEach(e => { e.style.transform = `translateY(${bob}px)` })
|
|
32
|
+
}
|
|
33
|
+
useEffect(() => { clock.current = 0; delivered.current = false; paint(0) }, [gesture, sequence, enabled])
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!gesture || delivered.current) return
|
|
36
|
+
const finish = () => { delivered.current = true; complete.current?.(sequence) }
|
|
37
|
+
if (!enabled) { paint(durations[gesture]); finish(); return }
|
|
38
|
+
if (paused) return
|
|
39
|
+
let last: number | undefined
|
|
40
|
+
let frame = 0
|
|
41
|
+
const tick = (now: number) => {
|
|
42
|
+
clock.current += last === undefined ? 0 : now - last
|
|
43
|
+
last = now
|
|
44
|
+
paint(clock.current)
|
|
45
|
+
if (clock.current < durations[gesture]) frame = requestAnimationFrame(tick)
|
|
46
|
+
else finish()
|
|
47
|
+
}
|
|
48
|
+
frame = requestAnimationFrame(tick)
|
|
49
|
+
return () => cancelAnimationFrame(frame)
|
|
50
|
+
}, [gesture, sequence, enabled, paused])
|
|
51
|
+
}
|