cduss's picture
use default repo url
1e77c65
"""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