22 <div
33 id =" player-container"
44 :class =" checkMobile() ? 'mobile' : 'computer'"
5+ :style =" { visibility: market.live2d.isVisible ? 'visible' : 'hidden', opacity: market.live2d.isVisible ? 1 : 0 }"
56 ></div >
67</template >
78
@@ -16,9 +17,11 @@ import spine41 from '@/utils/spine/spine-player4.1'
1617
1718import { globalParams , messagesEnum } from ' @/utils/enum/globalParams'
1819import type { AttachmentInterface , AttachmentItemColorInterface } from ' @/utils/interfaces/live2d'
20+ import { animationMappings } from ' @/utils/animationMappings'
1921
2022let canvas: HTMLCanvasElement | null = null
2123let spineCanvas: any = null
24+ let currentLoadId = 0 // Track active load requests
2225const market = useMarket ()
2326
2427// http://esotericsoftware.com/spine-player#Viewports
@@ -37,14 +40,135 @@ onMounted(() => {
3740const SPINE_DEFAULT_MIX = 0.25
3841let spinePlayer: any = null
3942
43+ const resetAttachmentColors = (player : any ) => {
44+ if (! player ?.animationState ?.data ?.skeletonData ?.defaultSkin ?.attachments ) return
45+
46+ player .animationState .data .skeletonData .defaultSkin .attachments .forEach ((a : any []) => {
47+ if (a ) {
48+ const keys = Object .keys (a )
49+ if (keys !== null && keys !== undefined && keys .length > 0 ) {
50+ keys .forEach ((k : string ) => {
51+ a [k as any ].color = {
52+ r: 1 ,
53+ g: 1 ,
54+ b: 1 ,
55+ a: 1
56+ }
57+ })
58+ }
59+ }
60+ })
61+ }
62+
63+ const resolveAnimation = (requested : string , available : string []): string | null => {
64+ console .log (` [Loader] Resolving animation: '${requested }' against available: ` , available )
65+
66+ if (! requested || requested === ' none' ) return null
67+ if (available .includes (requested )) {
68+ console .log (` [Loader] Found exact match: ${requested } ` )
69+ return requested
70+ }
71+
72+ const lowerRequested = requested .toLowerCase ()
73+
74+ // Special handling for multi-stage anger (e.g. Chime)
75+ const specialMappings = [
76+ {
77+ target: ' angry' ,
78+ condition : (avail : string []) => avail .filter ((a ) => a .toLowerCase ().includes (' angry' )).length > 1 ,
79+ triggers: [' irritated' , ' bothered' , ' grumpy' , ' frustrated' , ' annoyed' , ' displeased' ]
80+ },
81+ {
82+ target: ' angry_02' ,
83+ condition : (avail : string []) => avail .includes (' angry_02' ),
84+ triggers: [' very angry' , ' furious' , ' rage' , ' shouting' , ' yelling' , ' livid' , ' outraged' , ' irate' , ' mad' ]
85+ },
86+ {
87+ target: ' angry_03' ,
88+ condition : (avail : string []) => avail .includes (' angry_03' ),
89+ triggers: [' stern' , ' frown' , ' slightly angry' , ' serious' , ' disapproving' , ' cold' , ' glaring' ]
90+ }
91+ ]
92+
93+ for (const { target, condition, triggers } of specialMappings ) {
94+ if (condition (available ) && triggers .some ((t ) => lowerRequested .includes (t ))) {
95+ console .log (` [Loader] Mapped '${requested }' to '${target }' ` )
96+ return target
97+ }
98+ }
99+
100+ // Direct fuzzy match
101+ const directMatch = available .find ((a ) => a .toLowerCase ().includes (lowerRequested ))
102+ if (directMatch ) {
103+ console .log (` [Loader] Found direct fuzzy match: ${directMatch } ` )
104+ return directMatch
105+ }
106+
107+ // Semantic mapping
108+ for (const [targetAnim, triggers] of Object .entries (animationMappings )) {
109+ // If requested animation contains the target name OR any of the triggers
110+ if (lowerRequested .includes (targetAnim ) || triggers .some ((t ) => lowerRequested .includes (t ))) {
111+
112+ // Try to find the target animation in available
113+ // exact match of targetAnim (fuzzy)...
114+ let match = available .find ((a ) => a .toLowerCase ().includes (targetAnim ))
115+ if (match ) {
116+ console .log (` [Loader] Found semantic match for ${targetAnim } (base): ${match } ` )
117+ return match
118+ }
119+
120+ // ...or match any of the triggers in available
121+ for (const trigger of triggers ) {
122+ match = available .find ((a ) => a .toLowerCase ().includes (trigger ))
123+ if (match ) {
124+ console .log (` [Loader] Found semantic match for ${targetAnim } (trigger: ${trigger }): ${match } ` )
125+ return match
126+ }
127+ }
128+ }
129+ }
130+
131+ console .warn (` [Loader] No match found for animation: ${requested } ` )
132+ return null
133+ }
134+
135+ watch (() => market .live2d .current_animation , (newAnim ) => {
136+ if (spinePlayer && newAnim ) {
137+ try {
138+ const resolvedAnim = resolveAnimation (newAnim , market .live2d .animations )
139+
140+ if (resolvedAnim ) {
141+ spinePlayer .animationState .setAnimation (0 , resolvedAnim , true )
142+ } else {
143+ console .warn (` Animation ${newAnim } not found and no fallback discovered. ` )
144+ }
145+ } catch (e ) {
146+ console .error (' Error setting animation:' , e )
147+ }
148+ }
149+ })
150+
40151const spineLoader = () => {
152+ if (! market .live2d .current_id ) {
153+ console .log (' [Loader] No current_id set, skipping load.' )
154+ return
155+ }
156+
157+ currentLoadId ++
158+ const thisLoadId = currentLoadId
159+
41160 const skelUrl = getPathing (' skel' )
42161 const request = new XMLHttpRequest ()
43162
44163 request .responseType = ' arraybuffer'
45164 request .open (' GET' , skelUrl , true )
46165 request .send ()
47166 request .onloadend = () => {
167+ if (thisLoadId !== currentLoadId ) {
168+ console .log (' [Loader] Ignoring stale load request' )
169+ return
170+ }
171+
48172 if (request .status !== 200 ) {
49173 console .error (' Failed to load skel file:' , request .statusText )
50174 return
@@ -85,6 +209,7 @@ const spineLoader = () => {
85209 atlasUrl: getPathing (' atlas' ),
86210 animation: getDefaultAnimation (),
87211 skin: market .live2d .getSkin (),
212+ showControls: ! market .live2d .hideUI && market .route .name !== ' story-gen' ,
88213 backgroundColor: ' #00000000' ,
89214 alpha: true ,
90215 premultipliedAlpha: true ,
@@ -95,24 +220,42 @@ const spineLoader = () => {
95220 defaultMix: SPINE_DEFAULT_MIX ,
96221 success : (player : any ) => {
97222
98- spineCanvas .animationState .data .skeletonData .defaultSkin .attachments .forEach ((a : any []) => {
99- if (a ) {
100- const keys = Object .keys (a )
101- if (keys !== null && keys !== undefined && keys .length > 0 ) {
102- keys .forEach ((k : string ) => {
103- a [k as any ].color = {
104- r: 1 ,
105- g: 1 ,
106- b: 1 ,
107- a: 1
108- }
109- })
110- }
111- }
112- })
113-
114223 spinePlayer = player
224+ resetAttachmentColors (player )
115225 market .live2d .attachments = player .animationState .data .skeletonData .defaultSkin .attachments
226+ market .live2d .animations = player .animationState .data .skeletonData .animations .map ((a : any ) => a .name )
227+
228+ const currentAnim = market .live2d .current_animation
229+ let resolvedAnim = resolveAnimation (currentAnim , market .live2d .animations )
230+
231+ if (! resolvedAnim ) {
232+ // Try default animation from config
233+ resolvedAnim = resolveAnimation (player .config .animation , market .live2d .animations )
234+ }
235+
236+ if (! resolvedAnim && market .live2d .animations .length > 0 ) {
237+ // Fallback to first available animation
238+ resolvedAnim = market .live2d .animations [0 ]
239+ console .warn (` No valid animation found. Falling back to first available: ${resolvedAnim } ` )
240+ }
241+
242+ if (resolvedAnim ) {
243+ console .log (` [Loader] Setting initial animation to: ${resolvedAnim } (Requested: ${currentAnim }) ` )
244+ market .live2d .current_animation = resolvedAnim
245+
246+ // Force set animation with a slight delay to ensure player is ready
247+ setTimeout (() => {
248+ try {
249+ player .animationState .setAnimation (0 , resolvedAnim , true )
250+ player .play ()
251+ } catch (e ) {
252+ console .error (' [Loader] Failed to set animation in timeout' , e )
253+ }
254+ }, 100 )
255+ } else {
256+ console .error (' [Loader] No animations available for this character.' )
257+ }
258+
116259 market .live2d .triggerFinishedLoading ()
117260 successfullyLoaded ()
118261 },
@@ -156,7 +299,19 @@ const customSpineLoader = () => {
156299 defaultMix: SPINE_DEFAULT_MIX ,
157300 success : (player : any ) => {
158301 spinePlayer = player
302+ resetAttachmentColors (player )
159303 market .live2d .attachments = player .animationState .data .skeletonData .defaultSkin .attachments
304+ market .live2d .animations = player .animationState .data .skeletonData .animations .map ((a : any ) => a .name )
305+
306+ const currentAnim = market .live2d .current_animation
307+ const hasAnim = market .live2d .animations .includes (currentAnim )
308+
309+ if (hasAnim ) {
310+ player .animationState .setAnimation (0 , currentAnim , true )
311+ } else {
312+ market .live2d .current_animation = player .config .animation
313+ }
314+
160315 market .live2d .triggerFinishedLoading ()
161316 successfullyLoaded ()
162317 try {
@@ -307,15 +462,23 @@ watch(() => market.live2d.exportAnimationTimestamp, (newVal, oldVal) => {
307462})
308463
309464watch (() => market .live2d .customLoad , () => {
310- spineCanvas .dispose ()
465+ if (spineCanvas ) {
466+ try {
467+ spineCanvas .dispose ()
468+ } catch (e ) {
469+ console .warn (' [Loader] Error disposing spineCanvas for customLoad:' , e )
470+ }
471+ spineCanvas = null
472+ }
311473 market .load .beginLoad ()
312474 customSpineLoader ()
313475 applyDefaultStyle2Canvas ()
314476})
315477
316478watch (() => market .live2d .hideUI , () => {
317479 const controls = document .querySelector (' .spine-player-controls' ) as HTMLElement
318- if (market .live2d .hideUI === false ) {
480+ if (! controls ) return
481+ if (market .live2d .hideUI === false && market .route .name !== ' story-gen' ) {
319482 controls .style .visibility = ' visible'
320483 } else {
321484 controls .style .visibility = ' hidden'
@@ -442,7 +605,14 @@ async function exportAnimationFrames(timestamp: number) {
442605
443606const loadSpineAfterWatcher = () => {
444607 if (market .live2d .canLoadSpine ) {
445- spineCanvas .dispose ()
608+ if (spineCanvas ) {
609+ try {
610+ spineCanvas .dispose ()
611+ } catch (e ) {
612+ console .warn (' [Loader] Error disposing spineCanvas:' , e )
613+ }
614+ spineCanvas = null
615+ }
446616 market .load .beginLoad ()
447617 spineLoader ()
448618 applyDefaultStyle2Canvas ()
@@ -531,8 +701,8 @@ document.addEventListener('mousemove', (e) => {
531701 const newX = e .clientX
532702 const newY = e .clientY
533703
534- const stylel = parseInt (canvas .style .left .replaceAll ( ' px ' , ' ' ))
535- const stylet = parseInt (canvas .style .top .replaceAll ( ' px ' , ' ' ))
704+ const stylel = parseInt (canvas .style .left .replace ( / px / g , ' ' ))
705+ const stylet = parseInt (canvas .style .top .replace ( / px / g , ' ' ))
536706
537707 if (newX !== oldX ) {
538708 canvas .style .left = stylel + (newX - oldX ) + ' px'
@@ -604,20 +774,43 @@ const checkIfAssetCanYap = () => {
604774 })
605775 }
606776 setYappable (yappable )
777+
778+ if (yappable && market .live2d .isYapping && market .live2d .yapEnabled ) {
779+ try {
780+ spineCanvas .animationState .setAnimation (1 , YAP_TRACK , true )
781+ } catch (e ) {
782+ console .warn (' Could not add yap track on load' , e )
783+ }
784+ }
607785}
608786
609787const setYappable = (bool : boolean ) => {
610788 market .live2d .canYap = bool
611- market .live2d .isYapping = false
789+ if (! bool ) {
790+ market .live2d .isYapping = false
791+ }
612792}
613793
614794watch (() => market .live2d .isYapping , (value ) => {
795+ if (! spineCanvas || ! spineCanvas .animationState ) return
796+
797+ console .log (` [Loader] isYapping changed to: ${value } ` )
615798
616- if (value ) {
617- spineCanvas .animationState .addAnimation (1 , YAP_TRACK )
618- spineCanvas .animationState .setAnimation (1 , YAP_TRACK , true )
799+ // Only allow yapping if asset supports it AND user enabled it
800+ if (value && market .live2d .canYap && market .live2d .yapEnabled ) {
801+ try {
802+ console .log (' [Loader] Setting yap animation' )
803+ spineCanvas .animationState .setAnimation (1 , YAP_TRACK , true )
804+ } catch (e ) {
805+ console .warn (' Could not add yap track' , e )
806+ }
619807 } else {
620- spineCanvas .animationState .tracks = [spineCanvas .animationState .tracks [0 ]]
808+ try {
809+ console .log (' [Loader] Clearing yap animation' )
810+ spineCanvas .animationState .setEmptyAnimation (1 , 0 )
811+ } catch (e ) {
812+ console .warn (' Could not clear yap track' , e )
813+ }
621814 }
622815})
623816
0 commit comments