Skip to content

Commit 1d116f2

Browse files
committed
feat(omokmaru): add per-avatar victory choreography
1 parent 8f384bf commit 1d116f2

12 files changed

Lines changed: 401 additions & 293 deletions

File tree

‎src/domains/games/renju/appearance.ts‎

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,54 @@ export const STONES = [
1515
] as const;
1616
export const AVATARS = ['petal', 'luna', 'apron', 'rose', 'serin'] as const;
1717
export const BOARDS = ['oak', 'walnut', 'linen', 'ink', 'meadow'] as const;
18-
export const VICTORIES = ['dance', 'spin', 'bow', 'cheer'] as const;
18+
export const DANCES = ['signature', 'encore'] as const;
1919

2020
export type StoneStyle = (typeof STONES)[number];
2121
export type AvatarStyle = (typeof AVATARS)[number];
2222
export type BoardStyle = (typeof BOARDS)[number];
23-
export type VictoryStyle = (typeof VICTORIES)[number];
23+
export type DanceStyle = (typeof DANCES)[number];
2424
export type PlayerAppearance = {
2525
stone: StoneStyle;
2626
avatar: AvatarStyle;
27-
victory: VictoryStyle;
27+
dance: DanceStyle;
28+
};
29+
export type Appearance = Omit<PlayerAppearance, 'dance'> & {
30+
board: BoardStyle;
31+
dances: Record<AvatarStyle, DanceStyle>;
32+
};
33+
34+
export const DEFAULT_DANCES: Record<AvatarStyle, DanceStyle> = {
35+
petal: 'signature',
36+
luna: 'signature',
37+
rose: 'signature',
38+
apron: 'signature',
39+
serin: 'signature',
40+
};
41+
export const DANCE_NAMES: Record<AvatarStyle, Record<DanceStyle, string>> = {
42+
petal: { signature: '꽃길 스텝', encore: '꽃잎 퍼레이드' },
43+
luna: { signature: '팝 웨이브', encore: '핑크 피날레' },
44+
rose: { signature: '노을 왈츠', encore: '저녁 별 스윙' },
45+
apron: { signature: '리본 스킵', encore: '앞치마 폴카' },
46+
serin: { signature: '나이트 그루브', encore: '문라이트 턴' },
2847
};
29-
export type Appearance = PlayerAppearance & { board: BoardStyle };
3048

3149
export const DEFAULT_APPEARANCE: Appearance = {
3250
stone: 'classic',
3351
avatar: 'petal',
3452
board: 'oak',
35-
victory: 'dance',
53+
dances: { ...DEFAULT_DANCES },
3654
};
3755

3856
export function appearanceForSeat(
3957
seat: 'black' | 'white',
4058
mySeat: 'black' | 'white' | 'spectator' | null,
4159
selected: Appearance,
4260
): PlayerAppearance {
43-
return { ...(seat === mySeat ? selected : DEFAULT_APPEARANCE), avatar: selected.avatar };
61+
return {
62+
stone: seat === mySeat ? selected.stone : DEFAULT_APPEARANCE.stone,
63+
avatar: selected.avatar,
64+
dance: selected.dances[selected.avatar],
65+
};
4466
}
4567
export const APPEARANCE_STORAGE_KEY = 'omokmaru-appearance-v1';
4668

@@ -83,21 +105,21 @@ export const APPEARANCE_OPTIONS = {
83105
{ id: 'ink', name: '먹빛', detail: '네 귀퉁이에 금속판을 단 판', colors: ['#7b8c96', '#3b4f5b'] },
84106
{ id: 'meadow', name: '초원', detail: '네 귀퉁이에 잎이 놓인 판', colors: ['#87916d', '#4b5e38'] },
85107
],
86-
victory: [
87-
{ id: 'dance', name: '신나는 춤', detail: '통통 뛰며 두 팔을 흔듭니다', colors: ['#d6aa63', '#f8e8be'] },
88-
{ id: 'spin', name: '빙글 회전', detail: '한 바퀴 돌며 승리를 즐깁니다', colors: ['#829faf', '#dbeaf0'] },
89-
{ id: 'bow', name: '예의 바른 인사', detail: '상대에게 고개를 숙여 인사합니다', colors: ['#8eaa90', '#e1eedf'] },
90-
{ id: 'cheer', name: '만세!', detail: '두 팔을 번쩍 들고 환호합니다', colors: ['#c98c7a', '#f6d4be'] },
91-
],
92108
} as const;
93109

94110
export function normalizeAppearance(value: unknown): Appearance {
95111
const input = value && typeof value === 'object' ? (value as Partial<Appearance>) : {};
112+
const storedDances = input.dances && typeof input.dances === 'object' ? input.dances : DEFAULT_DANCES;
96113
return {
97114
stone: STONES.includes(input.stone as StoneStyle) ? input.stone! : DEFAULT_APPEARANCE.stone,
98115
avatar: AVATARS.includes(input.avatar as AvatarStyle) ? input.avatar! : DEFAULT_APPEARANCE.avatar,
99116
board: BOARDS.includes(input.board as BoardStyle) ? input.board! : DEFAULT_APPEARANCE.board,
100-
victory: VICTORIES.includes(input.victory as VictoryStyle) ? input.victory! : DEFAULT_APPEARANCE.victory,
117+
dances: Object.fromEntries(
118+
AVATARS.map((avatar) => [
119+
avatar,
120+
DANCES.includes(storedDances[avatar] as DanceStyle) ? storedDances[avatar] : DEFAULT_DANCES[avatar],
121+
]),
122+
) as Record<AvatarStyle, DanceStyle>,
101123
};
102124
}
103125

‎src/domains/games/renju/avatar-gestures.ts‎

Lines changed: 111 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as THREE from 'three';
22
import type { AvatarMotion } from './petal-avatar';
3+
import type { DanceStyle } from './appearance';
34

45
export type VroidGestureStyle = 'luna' | 'apron' | 'rose' | 'serin';
56

@@ -52,6 +53,8 @@ const GESTURES: Record<VroidGestureStyle, Record<'win' | 'lose', Pose>> = {
5253
},
5354
};
5455

56+
export type DanceAvatar = VroidGestureStyle | 'petal';
57+
5558
type Dance = {
5659
leftZ: number;
5760
rightZ: number;
@@ -65,86 +68,107 @@ type Dance = {
6568
torso: number;
6669
head: number;
6770
};
71+
// Eight clear counts followed by a mirrored return phrase. Every character
72+
// has a different arm, footwork and turn sequence; Encore changes the count
73+
// order, lead side and accents rather than replaying the same loop faster.
74+
type Step = readonly [number, number, number, number, number, number, number, number, number, number, number];
75+
const CHOREOGRAPHY: Record<DanceAvatar, readonly Step[]> = {
76+
petal: [
77+
[-0.35, 0.35, -0.2, -0.2, 0.15, -0.1, -0.08, 0.01, -0.18, 0.1, -0.08],
78+
[-0.85, 0.15, 0.1, -0.5, 0.55, -0.2, -0.16, 0.1, -0.35, -0.16, 0.12],
79+
[-0.35, 0.95, -0.65, 0.15, -0.15, 0.45, 0.12, 0.02, 0.16, 0.2, -0.12],
80+
[0.45, 0.45, -0.75, -0.75, 0.2, 0.2, 0, 0.12, 0.48, -0.18, 0.1],
81+
[0.7, -0.7, -0.25, -0.25, -0.35, 0.55, 0.16, 0.02, 0.72, 0.24, -0.16],
82+
[-1.05, 0.85, 0.25, -0.4, 0.5, -0.3, -0.12, 0.1, 0.35, -0.2, 0.14],
83+
[-0.15, 0.15, -0.8, -0.8, -0.2, 0.5, 0.12, 0.04, -0.28, 0.18, -0.1],
84+
[-0.8, 0.8, -0.15, -0.15, 0.2, 0.2, 0, 0.12, 0, -0.08, 0],
85+
],
86+
luna: [
87+
[-0.25, 0.55, -0.55, 0.1, 0.35, -0.2, -0.12, 0.02, -0.2, 0.16, -0.1],
88+
[-1.05, 0.2, -0.1, -0.65, 0.65, -0.3, -0.2, 0.09, -0.42, -0.16, 0.15],
89+
[-0.25, 1.1, -0.8, 0.2, -0.2, 0.5, 0.16, 0.03, 0.25, 0.22, -0.12],
90+
[0.65, 0.65, -0.9, -0.9, 0.25, 0.25, 0, 0.12, 0.55, -0.2, 0.1],
91+
[0.9, -0.6, 0.15, -0.5, -0.4, 0.65, 0.2, 0.02, 0.8, 0.26, -0.18],
92+
[-0.8, 1.1, -0.5, 0.2, 0.55, -0.4, -0.16, 0.11, 0.4, -0.22, 0.16],
93+
[-0.5, 0.4, -0.95, -0.25, -0.15, 0.55, 0.14, 0.03, -0.32, 0.18, -0.08],
94+
[-1.15, 1.15, -0.25, -0.25, 0.25, 0.25, 0, 0.13, 0, -0.1, 0],
95+
],
96+
rose: [
97+
[-0.4, 0.8, 0.15, -0.65, 0.4, -0.2, -0.15, 0.02, -0.28, 0.25, -0.14],
98+
[0.45, 1.05, -0.7, -0.25, 0.65, -0.3, -0.23, 0.06, -0.58, -0.2, 0.18],
99+
[-1.05, 0.15, -0.25, -0.8, -0.2, 0.6, 0.18, 0.04, 0.18, 0.32, -0.2],
100+
[-0.95, 0.95, -0.8, -0.8, 0.15, 0.2, 0.05, 0.09, 0.8, -0.24, 0.14],
101+
[0.65, -0.95, -0.6, 0.1, -0.35, 0.65, 0.23, 0.02, 1.1, 0.3, -0.18],
102+
[-0.3, 0.9, 0.2, -0.85, 0.6, -0.3, -0.2, 0.08, 0.68, -0.3, 0.19],
103+
[-1.1, 0.45, -0.7, -0.15, -0.25, 0.5, 0.16, 0.03, 0.16, 0.22, -0.12],
104+
[-0.8, 0.8, -0.35, -0.35, 0.2, 0.2, 0, 0.09, -0.2, -0.1, 0],
105+
],
106+
apron: [
107+
[-0.7, 0.25, -0.45, 0.1, 0.4, -0.15, -0.1, 0.04, -0.25, 0.14, -0.1],
108+
[-0.25, 1.0, 0.15, -0.6, 0.7, -0.2, -0.17, 0.12, -0.42, -0.18, 0.16],
109+
[-1.0, 0.55, -0.6, -0.15, -0.2, 0.55, 0.13, 0.03, 0.2, 0.22, -0.1],
110+
[-0.65, 0.65, -0.95, -0.95, 0.15, 0.15, 0, 0.13, 0.45, -0.2, 0.12],
111+
[0.3, -0.75, 0.1, -0.65, -0.3, 0.65, 0.16, 0.04, 0.65, 0.2, -0.12],
112+
[-1.1, 0.4, -0.15, -0.6, 0.6, -0.3, -0.14, 0.12, 0.25, -0.18, 0.14],
113+
[-0.45, 1.05, -0.75, -0.15, -0.2, 0.55, 0.12, 0.04, -0.24, 0.2, -0.1],
114+
[-1.05, 1.05, -0.3, -0.3, 0.25, 0.25, 0, 0.14, 0, -0.08, 0],
115+
],
116+
serin: [
117+
[-0.2, 0.8, -0.65, 0.1, 0.45, -0.2, -0.12, 0.02, -0.24, 0.18, -0.12],
118+
[-1.05, 0.35, 0.2, -0.8, 0.65, -0.3, -0.2, 0.1, -0.5, -0.22, 0.16],
119+
[-0.6, 1.05, -0.95, -0.1, -0.2, 0.6, 0.16, 0.03, 0.26, 0.28, -0.14],
120+
[0.8, 0.8, -0.7, -0.7, 0.2, 0.2, 0, 0.13, 0.7, -0.24, 0.12],
121+
[1.1, -0.5, 0.1, -0.75, -0.4, 0.7, 0.2, 0.02, 0.95, 0.3, -0.2],
122+
[-0.9, 1.15, -0.6, 0.15, 0.65, -0.35, -0.17, 0.11, 0.44, -0.25, 0.18],
123+
[-0.45, 0.65, -1.05, -0.35, -0.25, 0.65, 0.15, 0.04, -0.32, 0.23, -0.1],
124+
[-1.1, 1.1, -0.35, -0.35, 0.3, 0.3, 0, 0.14, 0, -0.1, 0],
125+
],
126+
};
127+
const KEYS: (keyof Dance)[] = [
128+
'leftZ',
129+
'rightZ',
130+
'leftX',
131+
'rightX',
132+
'leftLeg',
133+
'rightLeg',
134+
'hipX',
135+
'hipY',
136+
'hipTurn',
137+
'torso',
138+
'head',
139+
];
140+
const ENCORE_ORDER = [0, 2, 5, 3, 6, 1, 4, 7];
141+
142+
function atCount(style: DanceAvatar, variant: DanceStyle, count: number): Dance {
143+
const secondPhrase = count >= 8;
144+
const index = count % 8;
145+
const sourceIndex =
146+
variant === 'encore' ? ENCORE_ORDER[secondPhrase ? 7 - index : index] : secondPhrase ? 7 - index : index;
147+
const step = CHOREOGRAPHY[style][sourceIndex];
148+
const mirrored = (variant === 'encore') !== secondPhrase;
149+
const values = mirrored
150+
? [-step[1], -step[0], step[3], step[2], step[5], step[4], -step[6], step[7], -step[8], -step[9], -step[10]]
151+
: step;
152+
return Object.fromEntries(KEYS.map((key, i) => [key, values[i]])) as Dance;
153+
}
68154

69-
function dance(style: VroidGestureStyle, time: number): Dance {
70-
const beat = (speed: number, offset = 0) => Math.sin(time * speed + offset);
71-
const hop = (speed: number) => Math.max(0, beat(speed));
72-
switch (style) {
73-
case 'luna': {
74-
// Paw steps: alternating knees and playful double paw punches.
75-
const step = beat(6.5);
76-
return {
77-
leftZ: -0.3 * step,
78-
rightZ: 0.3 * step,
79-
leftX: 0.48 * hop(6.5),
80-
rightX: 0.48 * hop(6.5 + 0.8),
81-
leftLeg: 0.35 * step,
82-
rightLeg: -0.35 * step,
83-
hipX: 0.08 * step,
84-
hipY: 0.085 * hop(13),
85-
hipTurn: 0.15 * beat(3.25),
86-
torso: 0.13 * step,
87-
head: -0.1 * step,
88-
};
89-
}
90-
case 'apron': {
91-
// Heel taps and a clap on every other beat.
92-
const step = beat(5.2);
93-
const clap = hop(5.2);
94-
return {
95-
leftZ: -0.38 * clap,
96-
rightZ: 0.38 * clap,
97-
leftX: -0.36 * clap,
98-
rightX: -0.36 * clap,
99-
leftLeg: 0.28 * hop(5.2),
100-
rightLeg: 0.28 * hop(5.2 + Math.PI),
101-
hipX: 0.12 * step,
102-
hipY: 0.045 * clap,
103-
hipTurn: -0.14 * step,
104-
torso: 0.14 * step,
105-
head: 0.09 * step,
106-
};
107-
}
108-
case 'rose': {
109-
// Slower waltz, sweeping arms, and a three-count turn.
110-
const step = beat(3.6);
111-
return {
112-
leftZ: -0.24 * beat(3.6, 1),
113-
rightZ: 0.42 * step,
114-
leftX: 0.25 * step,
115-
rightX: -0.25 * step,
116-
leftLeg: 0.29 * step,
117-
rightLeg: -0.29 * step,
118-
hipX: 0.15 * step,
119-
hipY: 0.055 * hop(7.2),
120-
hipTurn: 0.35 * beat(1.8),
121-
torso: 0.22 * step,
122-
head: -0.15 * step,
123-
};
124-
}
125-
case 'serin': {
126-
// A two-step groove with alternating heel lifts and crossing arm sweeps.
127-
const step = beat(5.1);
128-
return {
129-
leftZ: -0.53 * step,
130-
rightZ: 0.48 * beat(5.1, Math.PI / 2),
131-
leftX: -0.3 * beat(5.1, 0.8),
132-
rightX: 0.3 * beat(5.1, 0.8),
133-
leftLeg: 0.38 * Math.max(0, step),
134-
rightLeg: 0.38 * Math.max(0, -step),
135-
hipX: 0.17 * step,
136-
hipY: 0.07 * hop(10.2),
137-
hipTurn: -0.26 * beat(2.55),
138-
torso: 0.2 * beat(5.1, 0.7),
139-
head: -0.13 * step,
140-
};
141-
}
142-
}
155+
export function sampleVictoryDance(style: DanceAvatar, variant: DanceStyle, time: number): Dance {
156+
const pace = style === 'rose' ? 1.7 : style === 'apron' ? 2.2 : 2;
157+
const count = (time * pace) % 16;
158+
const index = Math.floor(count);
159+
const phase = count - index;
160+
const blend = phase * phase * (3 - 2 * phase);
161+
const from = atCount(style, variant, index);
162+
const to = atCount(style, variant, (index + 1) % 16);
163+
const sampled = Object.fromEntries(KEYS.map((key) => [key, from[key] + (to[key] - from[key]) * blend])) as Dance;
164+
sampled.hipY += Math.sin(phase * Math.PI) * (variant === 'encore' ? 0.055 : 0.035);
165+
return sampled;
143166
}
144167

145168
export function updateVroidGesture(
146169
style: VroidGestureStyle,
147170
motion: AvatarMotion,
171+
variant: DanceStyle,
148172
elapsed: number,
149173
delta: number,
150174
restHipY: number,
@@ -154,16 +178,20 @@ export function updateVroidGesture(
154178
head: THREE.Object3D;
155179
leftArm: THREE.Object3D;
156180
rightArm: THREE.Object3D;
181+
leftForearm?: THREE.Object3D;
182+
rightForearm?: THREE.Object3D;
157183
leftLeg: THREE.Object3D;
158184
rightLeg: THREE.Object3D;
185+
leftShin?: THREE.Object3D;
186+
rightShin?: THREE.Object3D;
159187
},
160188
) {
161189
const idle = motion === 'idle';
162190
const target = idle ? pose(1.19, -1.19, 0, 0, 0, 0, 0, 0, 0, 0.006, 1.8) : GESTURES[style][motion];
163191
const wave = Math.sin(elapsed * target.speed);
164192
const follow = delta === 0 ? 1 : 1 - Math.exp(-7 * delta);
165193
const settle = (current: number, next: number) => current + (next - current) * follow;
166-
const move = motion === 'win' ? dance(style, elapsed) : null;
194+
const move = motion === 'win' ? sampleVictoryDance(style, variant, elapsed) : null;
167195
const activity = idle ? 0.015 : 0.025;
168196
bones.leftArm.rotation.z = settle(bones.leftArm.rotation.z, target.left + activity * wave + (move?.leftZ ?? 0) * 1.5);
169197
bones.rightArm.rotation.z = settle(
@@ -180,6 +208,14 @@ export function updateVroidGesture(
180208
bones.rightLeg.rotation.x,
181209
move ? move.rightLeg * 1.4 : motion === 'lose' ? -0.08 * wave : 0,
182210
);
211+
if (bones.leftForearm)
212+
bones.leftForearm.rotation.x = settle(bones.leftForearm.rotation.x, move ? -0.25 + move.leftX * 0.55 : 0);
213+
if (bones.rightForearm)
214+
bones.rightForearm.rotation.x = settle(bones.rightForearm.rotation.x, move ? -0.25 + move.rightX * 0.55 : 0);
215+
if (bones.leftShin)
216+
bones.leftShin.rotation.x = settle(bones.leftShin.rotation.x, move ? Math.max(0, -move.leftLeg) * 0.8 : 0);
217+
if (bones.rightShin)
218+
bones.rightShin.rotation.x = settle(bones.rightShin.rotation.x, move ? Math.max(0, -move.rightLeg) * 0.8 : 0);
183219
bones.head.rotation.x = settle(bones.head.rotation.x, target.headX + 0.012 * wave);
184220
bones.head.rotation.z = settle(bones.head.rotation.z, target.headZ + 0.016 * wave + (move?.head ?? 0) * 1.2);
185221
bones.spine.rotation.x = settle(bones.spine.rotation.x, target.spineX);

0 commit comments

Comments
 (0)