File size: 14,064 Bytes
b52ebca
 
 
 
 
 
35d4d89
b52ebca
 
 
35d4d89
 
b52ebca
95f8321
 
 
b52ebca
 
 
 
 
 
 
1e77c65
 
 
 
 
b52ebca
1e77c65
 
 
454ac7d
b52ebca
 
 
1e77c65
 
 
454ac7d
b52ebca
 
 
1e77c65
 
 
454ac7d
b52ebca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
"""Visual Novel Engine - Core classes and builder for creating interactive stories."""

from __future__ import annotations

import copy
import os
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)

DEFAULT_BACKGROUND = "https://images.unsplash.com/photo-1506744038136-46273834b3fb?auto=format&fit=crop&w=1200&q=80"

# Test URL to verify external images work
TEST_IMAGE_URL = "https://picsum.photos/1200/800"
POSITION_OFFSETS = {
    "left": "20%",
    "center": "50%",
    "right": "80%",
}


# HuggingFace Space configuration
HF_SPACE_REPO = "cduss/reachymini_vn_example"
HF_BASE_URL = f"https://huggingface.co/spaces/{HF_SPACE_REPO}/resolve/main"

# Asset helper functions - HuggingFace repo URLs
def background_asset(filename: str) -> str:
    """Get the URL for a background image from HF repo."""
    url = f"{HF_BASE_URL}/assets/backgrounds/{filename}"
    logger.info(f"Background asset: {url}")
    return url


def sprite_asset(filename: str) -> str:
    """Get the URL for a sprite image from HF repo."""
    url = f"{HF_BASE_URL}/assets/sprites/{filename}"
    logger.info(f"Sprite asset: {url}")
    return url


def audio_asset(filename: str) -> str:
    """Get the URL for an audio file from HF repo."""
    url = f"{HF_BASE_URL}/assets/audio/{filename}"
    logger.info(f"Audio asset: {url}")
    return url


def create_sprite_data_url(bg_color: str = "#fef3c7", border_color: str = "#ea580c") -> str:
    """Create a simple inline SVG data-URI for a character sprite."""
    svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="200" height="400" viewBox="0 0 200 400">
      <rect width="200" height="400" fill="{bg_color}" rx="20"/>
      <circle cx="100" cy="120" r="50" fill="{border_color}" opacity="0.6"/>
      <rect x="60" y="180" width="80" height="140" fill="{border_color}" opacity="0.4" rx="10"/>
    </svg>"""
    encoded = svg.replace('"', '%22').replace('#', '%23').replace('<', '%3C').replace('>', '%3E')
    return f"data:image/svg+xml,{encoded}"


@dataclass
class CharacterDefinition:
    name: str
    image_url: str
    animated: bool = False


@dataclass
class CharacterSprite:
    name: str
    image_url: str
    position: str = "center"
    visible: bool = False
    animation: str = ""  # Animation type: "", "idle", "shake", "bounce", "pulse"
    scale: float = 1.0  # Scale multiplier (1.0 = 100%, 0.5 = 50%, 2.0 = 200%)


@dataclass
class Choice:
    text: str
    next_scene_index: int


@dataclass
class InputRequest:
    prompt: str
    variable_name: str


@dataclass
class MotorCommand:
    motor_id: int
    position: int  # Position in degrees (0-360)


@dataclass
class RobotPose:
    """Robot pose command for Reachy Mini control."""
    head_x: float = 0.0  # meters
    head_y: float = 0.0  # meters
    head_z: float = 0.0  # meters
    head_roll: float = 0.0  # radians
    head_pitch: float = 0.0  # radians
    head_yaw: float = 0.0  # radians
    body_yaw: float = 0.0  # radians
    antenna_left: float = 0.0  # radians
    antenna_right: float = 0.0  # radians


@dataclass
class SceneState:
    background_url: str
    background_label: str
    characters: Dict[str, CharacterSprite]
    speaker: str
    text: str
    note: str
    show_camera: bool = False
    show_voice: bool = False
    show_motors: bool = False
    show_robot: bool = False  # Show robot control widget
    background_blur: int = 0  # Blur amount in pixels (0 = no blur, 5-10 = good range)
    stage_url: str = ""  # Stage image on top of background, below characters
    stage_blur: int = 0  # Blur amount for stage layer
    choices: Optional[List[Choice]] = None
    input_request: Optional[InputRequest] = None
    path: Optional[str] = None  # Which story branch this scene belongs to
    motor_commands: List[MotorCommand] = field(default_factory=list)  # Commands to execute on scene entry
    audio_file: Optional[str] = None  # Audio file to play when scene is displayed
    robot_pose: Optional[RobotPose] = None  # Robot pose to send when scene is displayed


class VisualNovelBuilder:
    """Builder to construct a linear or branching visual novel scene-by-scene."""

    def __init__(self) -> None:
        self._states: List[SceneState] = []
        self._character_defs: Dict[str, CharacterDefinition] = {}
        self._current_background: str = DEFAULT_BACKGROUND
        self._current_label: str = ""
        self._current_sprites: Dict[str, CharacterSprite] = {}
        self._current_show_camera: bool = False
        self._current_show_voice: bool = False
        self._current_show_motors: bool = False
        self._current_show_robot: bool = False
        self._current_background_blur: int = 0
        self._current_stage: str = ""
        self._current_stage_blur: int = 0
        self._current_path: Optional[str] = None

    def set_characters(self, characters: List[CharacterDefinition]) -> None:
        """Register character definitions (name, image_url, animated)."""
        for char in characters:
            self._character_defs[char.name] = char
            self._current_sprites[char.name] = CharacterSprite(
                name=char.name,
                image_url=char.image_url,
                position="center",
                visible=False,
                animation="idle" if char.animated else "",
            )

    def set_background(self, image_url: str, label: str = "") -> None:
        """Change the background image and optionally set a label."""
        state = self._clone_state()
        state.background_url = image_url
        state.background_label = label
        state.note = f"Background: {label or 'custom'}"
        self._push_state(state)

    def set_camera(self, show: bool) -> None:
        """Toggle the camera display for the next scene."""
        self._current_show_camera = show

    def set_voice(self, show: bool) -> None:
        """Toggle the voice capture UI for the next scene."""
        self._current_show_voice = show

    def set_motors(self, show: bool) -> None:
        """Toggle the motor control UI for the next scene."""
        self._current_show_motors = show

    def set_robot(self, show: bool) -> None:
        """Toggle the robot control UI for the next scene."""
        self._current_show_robot = show

    def set_background_blur(self, blur_amount: int) -> None:
        """Set the background blur amount in pixels (0 = no blur, 5-10 is typical range)."""
        self._current_background_blur = blur_amount

    def set_stage(self, image_url: str) -> None:
        """Set the stage image (layer between background and characters)."""
        self._current_stage = image_url

    def set_stage_blur(self, blur_amount: int) -> None:
        """Set the stage blur amount in pixels (0 = no blur, 5-10 is typical range)."""
        self._current_stage_blur = blur_amount

    def set_path(self, path: Optional[str]) -> None:
        """Set the story path for subsequent scenes."""
        self._current_path = path

    def show_character(self, name: str, position: str = "center") -> None:
        """Display a character at a specific position."""
        state = self._clone_state()
        if name in state.characters:
            state.characters[name].visible = True
            state.characters[name].position = position
        state.note = f"Show {name} at {position}"
        self._push_state(state)

    def hide_character(self, name: str) -> None:
        """Hide a character from the scene."""
        state = self._clone_state()
        if name in state.characters:
            state.characters[name].visible = False
        state.note = f"Hide {name}"
        self._push_state(state)

    def move_character(self, name: str, position: str) -> None:
        """Move a character to a new position."""
        state = self._clone_state()
        if name in state.characters:
            state.characters[name].position = position
        state.note = f"Move {name} to {position}"
        self._push_state(state)

    def change_character_sprite(self, name: str, image_url: str) -> None:
        """Change a character's sprite image (e.g., for different emotions)."""
        state = self._clone_state()
        if name in state.characters:
            state.characters[name].image_url = image_url
        state.note = f"Change {name} sprite"
        self._push_state(state)

    def set_character_animation(self, name: str, animation: str) -> None:
        """Set character animation. Options: '', 'idle', 'shake', 'bounce', 'pulse'."""
        state = self._clone_state()
        if name in state.characters:
            state.characters[name].animation = animation
        state.note = f"{name} animation: {animation or 'none'}"
        self._push_state(state)

    def set_character_scale(self, name: str, scale: float) -> None:
        """Set character scale. 1.0 = 100%, 0.5 = 50%, 2.0 = 200%."""
        state = self._clone_state()
        if name in state.characters:
            state.characters[name].scale = scale
        state.note = f"{name} scale: {scale}"
        self._push_state(state)

    def dialogue(self, speaker: str, text: str) -> None:
        """Add a dialogue line."""
        state = self._clone_state()
        state.speaker = speaker
        state.text = text
        state.note = f"{speaker}: {text[:30]}..."
        self._push_state(state)

    def narration(self, text: str) -> None:
        """Add narration (no speaker)."""
        state = self._clone_state()
        state.speaker = ""
        state.text = text
        state.note = f"Narration: {text[:30]}..."
        self._push_state(state)

    def request_input(self, prompt: str, variable_name: str) -> None:
        """Request text input from the user."""
        state = self._clone_state()
        state.input_request = InputRequest(prompt=prompt, variable_name=variable_name)
        state.note = f"Input: {variable_name}"
        self._push_state(state)

    def send_motor_command(self, motor_id: int, position: int) -> None:
        """Send a motor command when this scene is displayed."""
        state = self._clone_state()
        state.motor_commands.append(MotorCommand(motor_id=motor_id, position=position))
        state.note = f"Motor {motor_id}{position}°"
        self._push_state(state)

    def send_motor_commands(self, commands: List[tuple[int, int]]) -> None:
        """Send multiple motor commands when this scene is displayed.

        Args:
            commands: List of (motor_id, position) tuples
        """
        state = self._clone_state()
        for motor_id, position in commands:
            state.motor_commands.append(MotorCommand(motor_id=motor_id, position=position))
        state.note = f"Motors: {len(commands)} commands"
        self._push_state(state)

    def send_robot_pose(
        self,
        head_x: float = 0.0,
        head_y: float = 0.0,
        head_z: float = 0.0,
        head_roll: float = 0.0,
        head_pitch: float = 0.0,
        head_yaw: float = 0.0,
        body_yaw: float = 0.0,
        antenna_left: float = 0.0,
        antenna_right: float = 0.0,
    ) -> None:
        """Send a robot pose command when this scene is displayed.

        Args:
            head_x: X position in meters
            head_y: Y position in meters
            head_z: Z position in meters
            head_roll: Roll angle in radians
            head_pitch: Pitch angle in radians
            head_yaw: Yaw angle in radians
            body_yaw: Body yaw angle in radians
            antenna_left: Left antenna angle in radians
            antenna_right: Right antenna angle in radians
        """
        state = self._clone_state()
        state.robot_pose = RobotPose(
            head_x=head_x,
            head_y=head_y,
            head_z=head_z,
            head_roll=head_roll,
            head_pitch=head_pitch,
            head_yaw=head_yaw,
            body_yaw=body_yaw,
            antenna_left=antenna_left,
            antenna_right=antenna_right,
        )
        state.note = "Robot pose command"
        self._push_state(state)

    def play_sound(self, audio_file: str) -> None:
        """Play an audio file when this scene is displayed.

        Args:
            audio_file: Path to audio file (relative to assets/audio/ or absolute path)
        """
        state = self._clone_state()
        state.audio_file = audio_file
        state.note = f"Audio: {audio_file}"
        self._push_state(state)

    def add_choice(self, text: str, next_scene_index: int) -> None:
        """Add a choice to the current scene (for branching)."""
        if self._states:
            if self._states[-1].choices is None:
                self._states[-1].choices = []
            self._states[-1].choices.append(Choice(text=text, next_scene_index=next_scene_index))

    def _clone_state(self) -> SceneState:
        """Clone the current state for the next scene."""
        return SceneState(
            background_url=self._current_background,
            background_label=self._current_label,
            characters=copy.deepcopy(self._current_sprites),
            speaker="",
            text="",
            note="",
            show_camera=self._current_show_camera,
            show_voice=self._current_show_voice,
            show_motors=self._current_show_motors,
            show_robot=self._current_show_robot,
            background_blur=self._current_background_blur,
            stage_url=self._current_stage,
            stage_blur=self._current_stage_blur,
            path=self._current_path,
        )

    def _push_state(self, state: SceneState) -> None:
        """Push a new state and update internal tracking."""
        self._states.append(state)
        self._current_background = state.background_url
        self._current_label = state.background_label
        self._current_sprites = copy.deepcopy(state.characters)

    def build(self) -> List[SceneState]:
        """Return the finalized list of scene states."""
        return self._states