From 6c66764ccea37b4b782ecc9eb42748f85e6b7ac3 Mon Sep 17 00:00:00 2001 From: empty Date: Thu, 1 Jan 2026 15:25:15 +0800 Subject: [PATCH] feat: add gameplay enhancements and visual improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add weather system with 6 weather types and transition probabilities - Add day/night cycle (dawn, day, dusk, night) with phase modifiers - Add mood system for agents (happy, neutral, sad, anxious) - Add new commands: heal, talk, encourage, revive - Add agent social interaction system with relationships - Add casual mode with auto-revive and reduced decay rates Frontend (Web): - Add world state display (weather, time of day) - Add mood bar to agent cards - Add new action buttons for heal, encourage, talk, revive - Handle new event types from server Unity Client: - Add EnvironmentManager with dynamic sky gradient and island scene - Add WeatherEffects with rain, sun rays, fog, and heat particles - Add SceneBootstrap for automatic visual system initialization - Improve AgentVisual with better character sprites and animations - Add breathing and bobbing idle animations - Add character shadows - Improve UI panels with rounded corners and borders - Improve SpeechBubble with rounded corners and proper tail - Add support for all new server events and commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/app/engine.py | 981 +++++++++++++----- backend/app/llm.py | 185 +++- backend/app/models.py | 143 ++- backend/app/schemas.py | 21 + frontend/app.js | 230 +++- frontend/debug_client.html | 65 +- unity-client/Assets/Scripts/AgentVisual.cs | 417 +++++++- unity-client/Assets/Scripts/GameManager.cs | 181 +++- unity-client/Assets/Scripts/Models.cs | 129 +++ unity-client/Assets/Scripts/NetworkManager.cs | 80 ++ unity-client/Assets/Scripts/SpeechBubble.cs | 90 +- unity-client/Assets/Scripts/Visual.meta | 8 + .../Scripts/Visual/EnvironmentManager.cs | 627 +++++++++++ .../Scripts/Visual/EnvironmentManager.cs.meta | 2 + .../Assets/Scripts/Visual/SceneBootstrap.cs | 95 ++ .../Scripts/Visual/SceneBootstrap.cs.meta | 2 + .../Assets/Scripts/Visual/WeatherEffects.cs | 473 +++++++++ .../Scripts/Visual/WeatherEffects.cs.meta | 2 + 18 files changed, 3418 insertions(+), 313 deletions(-) create mode 100644 unity-client/Assets/Scripts/Visual.meta create mode 100644 unity-client/Assets/Scripts/Visual/EnvironmentManager.cs create mode 100644 unity-client/Assets/Scripts/Visual/EnvironmentManager.cs.meta create mode 100644 unity-client/Assets/Scripts/Visual/SceneBootstrap.cs create mode 100644 unity-client/Assets/Scripts/Visual/SceneBootstrap.cs.meta create mode 100644 unity-client/Assets/Scripts/Visual/WeatherEffects.cs create mode 100644 unity-client/Assets/Scripts/Visual/WeatherEffects.cs.meta diff --git a/backend/app/engine.py b/backend/app/engine.py index c5cc2bf..d34b863 100644 --- a/backend/app/engine.py +++ b/backend/app/engine.py @@ -1,6 +1,6 @@ """ Core Game Engine - The Island Survival Simulation. -Manages survival mechanics, agent states, and user interactions. +Manages survival mechanics, agent states, weather, time, social interactions, and user commands. """ import asyncio @@ -8,11 +8,11 @@ import logging import random import re import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from .schemas import GameEvent, EventType from .database import init_db, get_db_session -from .models import User, Agent, WorldState +from .models import User, Agent, WorldState, GameConfig, AgentRelationship from .llm import llm_service if TYPE_CHECKING: @@ -20,93 +20,181 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# ============================================================================= # Command patterns +# ============================================================================= FEED_PATTERN = re.compile(r"feed\s+(\w+)", re.IGNORECASE) CHECK_PATTERN = re.compile(r"(check|查询|状态)", re.IGNORECASE) RESET_PATTERN = re.compile(r"(reset|重新开始|重置)", re.IGNORECASE) +HEAL_PATTERN = re.compile(r"heal\s+(\w+)", re.IGNORECASE) +TALK_PATTERN = re.compile(r"talk\s+(\w+)\s*(.*)?", re.IGNORECASE) +ENCOURAGE_PATTERN = re.compile(r"encourage\s+(\w+)", re.IGNORECASE) +REVIVE_PATTERN = re.compile(r"revive\s+(\w+)", re.IGNORECASE) +# ============================================================================= # Game constants -TICK_INTERVAL = 5.0 # Seconds between ticks -ENERGY_DECAY_PER_TICK = 2 # Energy lost per tick -HP_DECAY_WHEN_STARVING = 5 # HP lost when energy is 0 -FEED_COST = 10 # Gold cost to feed an agent -FEED_ENERGY_RESTORE = 20 # Energy restored when fed -INITIAL_USER_GOLD = 100 # Starting gold for new users -IDLE_CHAT_PROBABILITY = 0.1 # 10% chance of idle chat per tick +# ============================================================================= +TICK_INTERVAL = 5.0 # Seconds between ticks + +# Survival (base values, modified by difficulty) +BASE_ENERGY_DECAY_PER_TICK = 2 +BASE_HP_DECAY_WHEN_STARVING = 5 + +# Command costs and effects +FEED_COST = 10 +FEED_ENERGY_RESTORE = 20 +HEAL_COST = 15 +HEAL_HP_RESTORE = 30 +ENCOURAGE_COST = 5 +ENCOURAGE_MOOD_BOOST = 15 +REVIVE_COST = 10 # Casual mode cost + +INITIAL_USER_GOLD = 100 +IDLE_CHAT_PROBABILITY = 0.15 + +# ============================================================================= +# Day/Night cycle +# ============================================================================= +TICKS_PER_DAY = 120 # 10 minutes per day at 5s/tick + +DAY_PHASES = { + "dawn": (0, 15), # Ticks 0-15 + "day": (16, 75), # Ticks 16-75 + "dusk": (76, 90), # Ticks 76-90 + "night": (91, 119) # Ticks 91-119 +} + +PHASE_MODIFIERS = { + "dawn": {"energy_decay": 0.8, "hp_recovery": 1, "mood_change": 3}, + "day": {"energy_decay": 1.0, "hp_recovery": 2, "mood_change": 2}, + "dusk": {"energy_decay": 1.2, "hp_recovery": 0, "mood_change": -2}, + "night": {"energy_decay": 1.3, "hp_recovery": 0, "mood_change": -3} +} + +# ============================================================================= +# Weather system +# ============================================================================= +WEATHER_TYPES = { + "Sunny": {"energy_modifier": 1.0, "mood_change": 5}, + "Cloudy": {"energy_modifier": 1.0, "mood_change": 0}, + "Rainy": {"energy_modifier": 1.2, "mood_change": -8}, + "Stormy": {"energy_modifier": 1.4, "mood_change": -15}, + "Hot": {"energy_modifier": 1.3, "mood_change": -5}, + "Foggy": {"energy_modifier": 1.1, "mood_change": -3} +} + +WEATHER_TRANSITIONS = { + "Sunny": {"Sunny": 0.5, "Cloudy": 0.3, "Hot": 0.15, "Rainy": 0.05}, + "Cloudy": {"Cloudy": 0.3, "Sunny": 0.3, "Rainy": 0.25, "Foggy": 0.1, "Stormy": 0.05}, + "Rainy": {"Rainy": 0.3, "Cloudy": 0.35, "Stormy": 0.2, "Foggy": 0.15}, + "Stormy": {"Stormy": 0.2, "Rainy": 0.5, "Cloudy": 0.3}, + "Hot": {"Hot": 0.3, "Sunny": 0.5, "Cloudy": 0.15, "Stormy": 0.05}, + "Foggy": {"Foggy": 0.2, "Cloudy": 0.4, "Rainy": 0.25, "Sunny": 0.15} +} + +WEATHER_MIN_DURATION = 15 +WEATHER_MAX_DURATION = 35 + +# ============================================================================= +# Social interactions +# ============================================================================= +SOCIAL_INTERACTIONS = { + "chat": {"affection": (1, 4), "trust": (0, 2), "weight": 0.4}, + "share_food": {"affection": (3, 7), "trust": (2, 4), "weight": 0.15, "min_energy": 40}, + "help": {"affection": (4, 8), "trust": (3, 6), "weight": 0.15, "min_energy": 30}, + "comfort": {"affection": (3, 6), "trust": (2, 4), "weight": 0.2, "target_max_mood": 40}, + "argue": {"affection": (-8, -3), "trust": (-5, -2), "weight": 0.1, "max_mood": 35} +} # Initial NPC data INITIAL_AGENTS = [ - {"name": "Jack", "personality": "Brave"}, - {"name": "Luna", "personality": "Cunning"}, - {"name": "Bob", "personality": "Honest"}, + {"name": "Jack", "personality": "Brave", "social_tendency": "extrovert"}, + {"name": "Luna", "personality": "Cunning", "social_tendency": "neutral"}, + {"name": "Bob", "personality": "Honest", "social_tendency": "introvert"}, ] class GameEngine: """ The core game engine for island survival simulation. - Manages agents, users, and survival mechanics with database persistence. + Manages agents, users, time, weather, social interactions, and survival mechanics. """ def __init__(self, connection_manager: "ConnectionManager") -> None: - """ - Initialize the game engine. - - Args: - connection_manager: The WebSocket connection manager for broadcasting - """ self._manager = connection_manager self._running = False self._tick_count = 0 self._tick_interval = TICK_INTERVAL + self._config: Optional[GameConfig] = None @property def is_running(self) -> bool: - """Check if the engine is currently running.""" return self._running + def _get_config(self) -> GameConfig: + """Get or load game configuration.""" + if self._config is None: + with get_db_session() as db: + config = db.query(GameConfig).first() + if config is None: + config = GameConfig() + # Expunge to detach from session, then make it usable outside + db.expunge(config) + # Access all attributes while still valid to load them + _ = (config.difficulty, config.energy_decay_multiplier, + config.hp_decay_multiplier, config.auto_revive_enabled, + config.auto_revive_delay_ticks, config.revive_hp, + config.revive_energy, config.social_interaction_probability) + self._config = config + return self._config + def _seed_initial_data(self) -> None: - """Seed initial agents and world state if database is empty.""" + """Seed initial agents, world state, and config if database is empty.""" with get_db_session() as db: - # Check if agents exist - agent_count = db.query(Agent).count() - if agent_count == 0: + # Seed agents + if db.query(Agent).count() == 0: logger.info("Seeding initial agents...") for agent_data in INITIAL_AGENTS: agent = Agent( name=agent_data["name"], personality=agent_data["personality"], + social_tendency=agent_data.get("social_tendency", "neutral"), status="Alive", hp=100, - energy=100 + energy=100, + mood=70 ) db.add(agent) logger.info(f"Created {len(INITIAL_AGENTS)} initial agents") - # Check if world state exists - world = db.query(WorldState).first() - if world is None: + # Seed world state + if db.query(WorldState).first() is None: logger.info("Seeding initial world state...") world = WorldState(day_count=1, weather="Sunny", resource_level=100) db.add(world) + # Seed game config (casual mode by default) + if db.query(GameConfig).first() is None: + logger.info("Seeding game config (casual mode)...") + config = GameConfig(difficulty="casual") + db.add(config) + def _get_or_create_user(self, db, username: str) -> User: """Get existing user or create new one.""" user = db.query(User).filter(User.username == username).first() if user is None: user = User(username=username, gold=INITIAL_USER_GOLD) db.add(user) - db.flush() # Get the ID without committing + db.flush() logger.info(f"New user registered: {username}") return user + # ========================================================================= + # Event broadcasting + # ========================================================================= async def _broadcast_event(self, event_type: str, data: dict) -> None: """Create and broadcast a game event.""" - event = GameEvent( - event_type=event_type, - timestamp=time.time(), - data=data - ) + event = GameEvent(event_type=event_type, timestamp=time.time(), data=data) await self._manager.broadcast(event) async def _broadcast_agents_status(self) -> None: @@ -114,243 +202,573 @@ class GameEngine: with get_db_session() as db: agents = db.query(Agent).all() agents_data = [agent.to_dict() for agent in agents] + await self._broadcast_event(EventType.AGENTS_UPDATE, {"agents": agents_data}) - await self._broadcast_event( - EventType.AGENTS_UPDATE, - {"agents": agents_data} - ) + async def _broadcast_world_status(self) -> None: + """Broadcast world state.""" + with get_db_session() as db: + world = db.query(WorldState).first() + if world: + await self._broadcast_event(EventType.WORLD_UPDATE, world.to_dict()) - async def _trigger_agent_speak( - self, - agent_id: int, - agent_name: str, - agent_personality: str, - agent_hp: int, - agent_energy: int, - event_description: str, - event_type: str = "feed" - ) -> None: - """ - Fire-and-forget LLM call to generate agent speech. - This runs asynchronously without blocking the game loop. - """ + # ========================================================================= + # Day/Night cycle (Phase 2) + # ========================================================================= + async def _advance_time(self) -> Optional[dict]: + """Advance time and return phase change info if applicable.""" + with get_db_session() as db: + world = db.query(WorldState).first() + if not world: + return None + + old_phase = world.time_of_day + world.current_tick_in_day += 1 + + # New day + if world.current_tick_in_day >= TICKS_PER_DAY: + world.current_tick_in_day = 0 + world.day_count += 1 + await self._broadcast_event(EventType.DAY_CHANGE, { + "day": world.day_count, + "message": f"Day {world.day_count} begins!" + }) + + # Determine current phase + tick = world.current_tick_in_day + for phase, (start, end) in DAY_PHASES.items(): + if start <= tick <= end: + if world.time_of_day != phase: + world.time_of_day = phase + return {"old_phase": old_phase, "new_phase": phase, "day": world.day_count} + break + + return None + + # ========================================================================= + # Weather system (Phase 3) + # ========================================================================= + async def _update_weather(self) -> Optional[dict]: + """Update weather based on transition probabilities.""" + with get_db_session() as db: + world = db.query(WorldState).first() + if not world: + return None + + world.weather_duration += 1 + + # Check if weather should change + target_duration = random.randint(WEATHER_MIN_DURATION, WEATHER_MAX_DURATION) + if world.weather_duration >= target_duration: + old_weather = world.weather + transitions = WEATHER_TRANSITIONS.get(old_weather, {"Sunny": 1.0}) + new_weather = random.choices( + list(transitions.keys()), + weights=list(transitions.values()) + )[0] + + if new_weather != old_weather: + world.weather = new_weather + world.weather_duration = 0 + return {"old_weather": old_weather, "new_weather": new_weather} + + return None + + async def _update_moods(self) -> None: + """Update agent moods based on weather and time.""" + with get_db_session() as db: + world = db.query(WorldState).first() + if not world: + return + + weather_effect = WEATHER_TYPES.get(world.weather, {}).get("mood_change", 0) + phase_effect = PHASE_MODIFIERS.get(world.time_of_day, {}).get("mood_change", 0) + + agents = db.query(Agent).filter(Agent.status == "Alive").all() + for agent in agents: + # Apply mood changes (scaled down for per-tick application) + mood_delta = (weather_effect + phase_effect) * 0.1 + agent.mood = max(0, min(100, agent.mood + mood_delta)) + + # Update mood state + if agent.mood >= 70: + agent.mood_state = "happy" + elif agent.mood >= 40: + agent.mood_state = "neutral" + elif agent.mood >= 20: + agent.mood_state = "sad" + else: + agent.mood_state = "anxious" + + # ========================================================================= + # Survival mechanics + # ========================================================================= + async def _process_survival_tick(self) -> None: + """Process survival mechanics with difficulty modifiers.""" + config = self._get_config() + deaths = [] + + with get_db_session() as db: + world = db.query(WorldState).first() + phase_mod = PHASE_MODIFIERS.get(world.time_of_day if world else "day", {}) + weather_mod = WEATHER_TYPES.get(world.weather if world else "Sunny", {}) + + alive_agents = db.query(Agent).filter(Agent.status == "Alive").all() + + for agent in alive_agents: + # Calculate energy decay with all modifiers + base_decay = BASE_ENERGY_DECAY_PER_TICK + decay = base_decay * config.energy_decay_multiplier + decay *= phase_mod.get("energy_decay", 1.0) + decay *= weather_mod.get("energy_modifier", 1.0) + + agent.energy = max(0, agent.energy - int(decay)) + + # HP recovery during day phases + hp_recovery = phase_mod.get("hp_recovery", 0) + if hp_recovery > 0 and agent.energy > 20: + agent.hp = min(100, agent.hp + hp_recovery) + + # Starvation damage + if agent.energy <= 0: + hp_decay = BASE_HP_DECAY_WHEN_STARVING * config.hp_decay_multiplier + agent.hp = max(0, agent.hp - int(hp_decay)) + + # Death check + if agent.hp <= 0: + agent.status = "Dead" + agent.death_tick = self._tick_count + deaths.append({"name": agent.name, "personality": agent.personality}) + logger.info(f"Agent {agent.name} has died!") + + # Broadcast death events + for death in deaths: + await self._broadcast_event(EventType.AGENT_DIED, { + "agent_name": death["name"], + "message": f"{death['name']} ({death['personality']}) has died..." + }) + + async def _process_auto_revive(self) -> None: + """Auto-revive dead agents in casual mode.""" + config = self._get_config() + if not config.auto_revive_enabled: + return + + with get_db_session() as db: + dead_agents = db.query(Agent).filter(Agent.status == "Dead").all() + + for agent in dead_agents: + if agent.death_tick is None: + continue + + ticks_dead = self._tick_count - agent.death_tick + if ticks_dead >= config.auto_revive_delay_ticks: + agent.status = "Alive" + agent.hp = config.revive_hp + agent.energy = config.revive_energy + agent.mood = 50 + agent.mood_state = "neutral" + agent.death_tick = None + + await self._broadcast_event(EventType.AUTO_REVIVE, { + "agent_name": agent.name, + "message": f"{agent.name} has been revived!" + }) + logger.info(f"Agent {agent.name} auto-revived") + + # ========================================================================= + # Social system (Phase 5) + # ========================================================================= + async def _process_social_tick(self) -> None: + """Process autonomous social interactions between agents.""" + config = self._get_config() + if random.random() > config.social_interaction_probability: + return + + with get_db_session() as db: + alive_agents = db.query(Agent).filter(Agent.status == "Alive").all() + if len(alive_agents) < 2: + return + + # Select initiator (extroverts more likely) + weights = [] + for agent in alive_agents: + if agent.social_tendency == "extrovert": + weights.append(2.0) + elif agent.social_tendency == "introvert": + weights.append(0.5) + else: + weights.append(1.0) + + initiator = random.choices(alive_agents, weights=weights)[0] + + # Select target (not self) + targets = [a for a in alive_agents if a.id != initiator.id] + if not targets: + return + target = random.choice(targets) + + # Get or create relationship + relationship = db.query(AgentRelationship).filter( + AgentRelationship.agent_from_id == initiator.id, + AgentRelationship.agent_to_id == target.id + ).first() + + if not relationship: + relationship = AgentRelationship( + agent_from_id=initiator.id, + agent_to_id=target.id + ) + db.add(relationship) + db.flush() + + # Determine interaction type + interaction_type = self._select_interaction(initiator, target, relationship) + if not interaction_type: + return + + # Apply interaction effects + effects = SOCIAL_INTERACTIONS[interaction_type] + affection_change = random.randint(*effects["affection"]) + trust_change = random.randint(*effects.get("trust", (0, 0))) + + relationship.affection = max(-100, min(100, relationship.affection + affection_change)) + relationship.trust = max(-100, min(100, relationship.trust + trust_change)) + relationship.interaction_count += 1 + relationship.last_interaction_tick = self._tick_count + relationship.update_relationship_type() + + # Store data for broadcasting + interaction_data = { + "initiator_id": initiator.id, + "initiator_name": initiator.name, + "target_id": target.id, + "target_name": target.name, + "interaction_type": interaction_type, + "relationship_type": relationship.relationship_type + } + world = db.query(WorldState).first() + weather = world.weather if world else "Sunny" + time_of_day = world.time_of_day if world else "day" + + # Generate LLM dialogue for interaction + asyncio.create_task(self._trigger_social_dialogue( + interaction_data, weather, time_of_day + )) + + def _select_interaction(self, initiator: Agent, target: Agent, relationship: AgentRelationship) -> Optional[str]: + """Select appropriate interaction type based on conditions.""" + valid_interactions = [] + weights = [] + + for itype, config in SOCIAL_INTERACTIONS.items(): + # Check conditions + if "min_energy" in config and initiator.energy < config["min_energy"]: + continue + if "max_mood" in config and initiator.mood > config["max_mood"]: + continue + if "target_max_mood" in config and target.mood > config["target_max_mood"]: + continue + + valid_interactions.append(itype) + weights.append(config["weight"]) + + if not valid_interactions: + return None + + return random.choices(valid_interactions, weights=weights)[0] + + async def _trigger_social_dialogue(self, interaction_data: dict, weather: str, time_of_day: str) -> None: + """Generate and broadcast social interaction dialogue.""" + try: + dialogue = await llm_service.generate_social_interaction( + initiator_name=interaction_data["initiator_name"], + target_name=interaction_data["target_name"], + interaction_type=interaction_data["interaction_type"], + relationship_type=interaction_data["relationship_type"], + weather=weather, + time_of_day=time_of_day + ) + + await self._broadcast_event(EventType.SOCIAL_INTERACTION, { + **interaction_data, + "dialogue": dialogue + }) + + except Exception as e: + logger.error(f"Error in social dialogue: {e}") + + # ========================================================================= + # LLM-powered agent speech + # ========================================================================= + async def _trigger_agent_speak( + self, agent_id: int, agent_name: str, agent_personality: str, + agent_hp: int, agent_energy: int, agent_mood: int, + event_description: str, event_type: str = "feed" + ) -> None: + """Fire-and-forget LLM call to generate agent speech.""" try: - # Create a lightweight agent-like object for LLM class AgentSnapshot: - def __init__(self, name, personality, hp, energy): + def __init__(self, name, personality, hp, energy, mood): self.name = name self.personality = personality self.hp = hp self.energy = energy + self.mood = mood agent_snapshot = AgentSnapshot( - agent_name, agent_personality, agent_hp, agent_energy + agent_name, agent_personality, agent_hp, agent_energy, agent_mood ) - text = await llm_service.generate_reaction( - agent_snapshot, event_description, event_type - ) + text = await llm_service.generate_reaction(agent_snapshot, event_description, event_type) - await self._broadcast_event( - EventType.AGENT_SPEAK, - { - "agent_id": agent_id, - "agent_name": agent_name, - "text": text - } - ) - logger.debug(f"Agent {agent_name} says: {text}") + await self._broadcast_event(EventType.AGENT_SPEAK, { + "agent_id": agent_id, + "agent_name": agent_name, + "text": text + }) except Exception as e: logger.error(f"Error in agent speak: {e}") async def _trigger_idle_chat(self) -> None: - """ - Randomly select an alive agent to say something about their situation. - Called with IDLE_CHAT_PROBABILITY chance each tick. - """ + """Randomly select an alive agent to say something.""" with get_db_session() as db: alive_agents = db.query(Agent).filter(Agent.status == "Alive").all() world = db.query(WorldState).first() weather = world.weather if world else "Sunny" + time_of_day = world.time_of_day if world else "day" if not alive_agents: return - # Pick a random alive agent agent = random.choice(alive_agents) agent_data = { - "id": agent.id, - "name": agent.name, - "personality": agent.personality, - "hp": agent.hp, - "energy": agent.energy + "id": agent.id, "name": agent.name, "personality": agent.personality, + "hp": agent.hp, "energy": agent.energy, "mood": agent.mood, + "mood_state": agent.mood_state } try: class AgentSnapshot: - def __init__(self, name, personality, hp, energy): + def __init__(self, name, personality, hp, energy, mood): self.name = name self.personality = personality self.hp = hp self.energy = energy + self.mood = mood agent_snapshot = AgentSnapshot( - agent_data["name"], - agent_data["personality"], - agent_data["hp"], - agent_data["energy"] + agent_data["name"], agent_data["personality"], + agent_data["hp"], agent_data["energy"], agent_data["mood"] ) - text = await llm_service.generate_idle_chat(agent_snapshot, weather) + text = await llm_service.generate_idle_chat(agent_snapshot, weather, time_of_day) - await self._broadcast_event( - EventType.AGENT_SPEAK, - { - "agent_id": agent_data["id"], - "agent_name": agent_data["name"], - "text": text - } - ) - logger.debug(f"Idle chat - {agent_data['name']}: {text}") + await self._broadcast_event(EventType.AGENT_SPEAK, { + "agent_id": agent_data["id"], + "agent_name": agent_data["name"], + "text": text + }) except Exception as e: logger.error(f"Error in idle chat: {e}") - async def _process_survival_tick(self) -> None: - """ - Process survival mechanics for all alive agents. - - Decrease energy - - If energy <= 0, decrease HP - - If HP <= 0, mark as Dead - """ - deaths = [] - - with get_db_session() as db: - alive_agents = db.query(Agent).filter(Agent.status == "Alive").all() - - for agent in alive_agents: - # Energy decay - agent.energy = max(0, agent.energy - ENERGY_DECAY_PER_TICK) - - # Starvation damage - if agent.energy <= 0: - agent.hp = max(0, agent.hp - HP_DECAY_WHEN_STARVING) - - # Check for death - if agent.hp <= 0: - agent.status = "Dead" - deaths.append({ - "name": agent.name, - "personality": agent.personality - }) - logger.info(f"Agent {agent.name} has died!") - - # Broadcast death events - for death in deaths: - await self._broadcast_event( - EventType.AGENT_DIED, - { - "agent_name": death["name"], - "message": f"{death['name']} ({death['personality']}) has died of starvation..." - } - ) - + # ========================================================================= + # Command handlers + # ========================================================================= async def _handle_feed(self, username: str, agent_name: str) -> None: - """ - Handle feed command. - - Args: - username: The user feeding the agent - agent_name: Name of the agent to feed - """ - # Variables to store data for broadcasting (outside session) + """Handle feed command.""" feed_result = None with get_db_session() as db: user = self._get_or_create_user(db, username) - - # Find the agent - agent = db.query(Agent).filter( - Agent.name.ilike(agent_name) - ).first() + agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first() if agent is None: - await self._broadcast_event( - EventType.ERROR, - {"message": f"Agent '{agent_name}' not found"} - ) + await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"}) return if agent.status != "Alive": - await self._broadcast_event( - EventType.ERROR, - {"message": f"{agent.name} is already dead and cannot be fed"} - ) + await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"}) return if user.gold < FEED_COST: - await self._broadcast_event( - EventType.ERROR, - { - "user": username, - "message": f"Not enough gold! Need {FEED_COST}, you have {user.gold}" - } - ) + await self._broadcast_event(EventType.ERROR, { + "user": username, "message": f"Not enough gold! Need {FEED_COST}, have {user.gold}" + }) return - # Perform feed user.gold -= FEED_COST old_energy = agent.energy agent.energy = min(100, agent.energy + FEED_ENERGY_RESTORE) - actual_restore = agent.energy - old_energy + agent.mood = min(100, agent.mood + 5) - # Store data for broadcasting before session closes feed_result = { - "agent_id": agent.id, - "agent_name": agent.name, - "agent_personality": agent.personality, - "agent_hp": agent.hp, - "actual_restore": actual_restore, - "agent_energy": agent.energy, - "user_gold": user.gold + "agent_id": agent.id, "agent_name": agent.name, + "agent_personality": agent.personality, "agent_hp": agent.hp, + "agent_energy": agent.energy, "agent_mood": agent.mood, + "actual_restore": agent.energy - old_energy, "user_gold": user.gold } - # Broadcast outside of session if feed_result: - await self._broadcast_event( - EventType.FEED, - { - "user": username, - "agent_name": feed_result["agent_name"], - "energy_restored": feed_result["actual_restore"], - "agent_energy": feed_result["agent_energy"], - "user_gold": feed_result["user_gold"], - "message": f"{username} fed {feed_result['agent_name']}! " - f"Restored {feed_result['actual_restore']} energy (now: {feed_result['agent_energy']}/100)" - } + await self._broadcast_event(EventType.FEED, { + "user": username, "agent_name": feed_result["agent_name"], + "energy_restored": feed_result["actual_restore"], + "agent_energy": feed_result["agent_energy"], "user_gold": feed_result["user_gold"], + "message": f"{username} fed {feed_result['agent_name']}!" + }) + await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": feed_result["user_gold"]}) + + asyncio.create_task(self._trigger_agent_speak( + feed_result["agent_id"], feed_result["agent_name"], + feed_result["agent_personality"], feed_result["agent_hp"], + feed_result["agent_energy"], feed_result["agent_mood"], + f"User {username} gave you food!", "feed" + )) + + async def _handle_heal(self, username: str, agent_name: str) -> None: + """Handle heal command.""" + with get_db_session() as db: + user = self._get_or_create_user(db, username) + agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first() + + if agent is None: + await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"}) + return + + if agent.status != "Alive": + await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"}) + return + + if user.gold < HEAL_COST: + await self._broadcast_event(EventType.ERROR, { + "user": username, "message": f"Not enough gold! Need {HEAL_COST}, have {user.gold}" + }) + return + + user.gold -= HEAL_COST + old_hp = agent.hp + agent.hp = min(100, agent.hp + HEAL_HP_RESTORE) + + await self._broadcast_event(EventType.HEAL, { + "user": username, "agent_name": agent.name, + "hp_restored": agent.hp - old_hp, "agent_hp": agent.hp, + "user_gold": user.gold, "message": f"{username} healed {agent.name}!" + }) + await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold}) + + asyncio.create_task(self._trigger_agent_speak( + agent.id, agent.name, agent.personality, agent.hp, agent.energy, agent.mood, + f"User {username} healed you!", "heal" + )) + + async def _handle_encourage(self, username: str, agent_name: str) -> None: + """Handle encourage command.""" + with get_db_session() as db: + user = self._get_or_create_user(db, username) + agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first() + + if agent is None: + await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"}) + return + + if agent.status != "Alive": + await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"}) + return + + if user.gold < ENCOURAGE_COST: + await self._broadcast_event(EventType.ERROR, { + "user": username, "message": f"Not enough gold! Need {ENCOURAGE_COST}, have {user.gold}" + }) + return + + user.gold -= ENCOURAGE_COST + old_mood = agent.mood + agent.mood = min(100, agent.mood + ENCOURAGE_MOOD_BOOST) + + await self._broadcast_event(EventType.ENCOURAGE, { + "user": username, "agent_name": agent.name, + "mood_boost": agent.mood - old_mood, "agent_mood": agent.mood, + "user_gold": user.gold, "message": f"{username} encouraged {agent.name}!" + }) + await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold}) + + asyncio.create_task(self._trigger_agent_speak( + agent.id, agent.name, agent.personality, agent.hp, agent.energy, agent.mood, + f"User {username} encouraged you!", "encourage" + )) + + async def _handle_talk(self, username: str, agent_name: str, topic: str = "") -> None: + """Handle talk command - free conversation with agent.""" + with get_db_session() as db: + agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first() + + if agent is None: + await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"}) + return + + if agent.status != "Alive": + await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"}) + return + + agent_data = { + "id": agent.id, "name": agent.name, "personality": agent.personality, + "hp": agent.hp, "energy": agent.energy, "mood": agent.mood + } + + # Generate conversation response + try: + response = await llm_service.generate_conversation_response( + agent_name=agent_data["name"], + agent_personality=agent_data["personality"], + agent_mood=agent_data["mood"], + username=username, + topic=topic or "just chatting" ) - await self._broadcast_event( - EventType.USER_UPDATE, - { - "user": username, - "gold": feed_result["user_gold"] - } - ) + await self._broadcast_event(EventType.TALK, { + "user": username, "agent_name": agent_data["name"], + "topic": topic, "response": response + }) - # Fire-and-forget: Trigger LLM response asynchronously - asyncio.create_task( - self._trigger_agent_speak( - agent_id=feed_result["agent_id"], - agent_name=feed_result["agent_name"], - agent_personality=feed_result["agent_personality"], - agent_hp=feed_result["agent_hp"], - agent_energy=feed_result["agent_energy"], - event_description=f"User {username} gave you food. You feel more energetic!", - event_type="feed" - ) - ) + except Exception as e: + logger.error(f"Error in talk: {e}") + + async def _handle_revive(self, username: str, agent_name: str) -> None: + """Handle revive command (casual mode).""" + config = self._get_config() + + with get_db_session() as db: + user = self._get_or_create_user(db, username) + agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first() + + if agent is None: + await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"}) + return + + if agent.status == "Alive": + await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is already alive!"}) + return + + if user.gold < REVIVE_COST: + await self._broadcast_event(EventType.ERROR, { + "user": username, "message": f"Not enough gold! Need {REVIVE_COST}, have {user.gold}" + }) + return + + user.gold -= REVIVE_COST + agent.status = "Alive" + agent.hp = config.revive_hp + agent.energy = config.revive_energy + agent.mood = 50 + agent.mood_state = "neutral" + agent.death_tick = None + + await self._broadcast_event(EventType.REVIVE, { + "user": username, "agent_name": agent.name, + "user_gold": user.gold, "message": f"{username} revived {agent.name}!" + }) + await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold}) async def _handle_check(self, username: str) -> None: """Handle check/status command.""" @@ -358,64 +776,68 @@ class GameEngine: user = self._get_or_create_user(db, username) agents = db.query(Agent).all() world = db.query(WorldState).first() + config = db.query(GameConfig).first() - # Extract data before session closes user_data = {"username": user.username, "gold": user.gold} agents_data = [agent.to_dict() for agent in agents] world_data = world.to_dict() if world else {} - message = f"{username}'s status - Gold: {user_data['gold']}" + config_data = config.to_dict() if config else {} - await self._broadcast_event( - EventType.CHECK, - { - "user": user_data, - "agents": agents_data, - "world": world_data, - "message": message - } - ) + await self._broadcast_event(EventType.CHECK, { + "user": user_data, "agents": agents_data, + "world": world_data, "config": config_data, + "message": f"{username}'s status - Gold: {user_data['gold']}" + }) async def _handle_reset(self, username: str) -> None: - """Handle reset/restart command - reset all agents to full HP/energy.""" + """Handle reset command - reset all agents.""" with get_db_session() as db: agents = db.query(Agent).all() for agent in agents: agent.hp = 100 agent.energy = 100 + agent.mood = 70 + agent.mood_state = "neutral" agent.status = "Alive" + agent.death_tick = None - # Also reset world state world = db.query(WorldState).first() if world: world.day_count = 1 + world.current_tick_in_day = 0 + world.time_of_day = "day" + world.weather = "Sunny" + world.weather_duration = 0 - await self._broadcast_event( - EventType.SYSTEM, - {"message": f"{username} triggered a restart! All survivors have been revived."} - ) - - # Broadcast updated agent states + await self._broadcast_event(EventType.SYSTEM, { + "message": f"{username} triggered a restart! All survivors have been revived." + }) await self._broadcast_agents_status() async def process_comment(self, user: str, message: str) -> None: - """ - Process a comment through command matching. + """Process a comment through command matching.""" + await self._broadcast_event(EventType.COMMENT, {"user": user, "message": message}) - Args: - user: Username of the commenter - message: The comment text - """ - # Broadcast the incoming comment - await self._broadcast_event( - EventType.COMMENT, - {"user": user, "message": message} - ) + # Match commands in priority order + if match := FEED_PATTERN.search(message): + await self._handle_feed(user, match.group(1)) + return - # Match commands - feed_match = FEED_PATTERN.search(message) - if feed_match: - agent_name = feed_match.group(1) - await self._handle_feed(user, agent_name) + if match := HEAL_PATTERN.search(message): + await self._handle_heal(user, match.group(1)) + return + + if match := TALK_PATTERN.search(message): + topic = match.group(2) or "" + await self._handle_talk(user, match.group(1), topic.strip()) + return + + if match := ENCOURAGE_PATTERN.search(message): + await self._handle_encourage(user, match.group(1)) + return + + if match := REVIVE_PATTERN.search(message): + await self._handle_revive(user, match.group(1)) return if CHECK_PATTERN.search(message): @@ -426,51 +848,73 @@ class GameEngine: await self._handle_reset(user) return - # No command matched - treat as regular chat - + # ========================================================================= + # Game loop + # ========================================================================= async def _game_loop(self) -> None: - """ - The main game loop - survival simulation. - - Every tick: - 1. Process survival mechanics (energy/HP decay) - 2. Broadcast agent states - 3. Random chance for idle chat - """ - logger.info("Game loop started - Island survival simulation") - - # Initial broadcast + """The main game loop with all systems.""" + logger.info("Game loop started - The Island awaits...") await self._broadcast_agents_status() + await self._broadcast_world_status() while self._running: self._tick_count += 1 - # Process survival mechanics + # 1. Advance time (Phase 2) + phase_change = await self._advance_time() + if phase_change: + await self._broadcast_event(EventType.PHASE_CHANGE, { + "old_phase": phase_change["old_phase"], + "new_phase": phase_change["new_phase"], + "day": phase_change["day"], + "message": f"The {phase_change['new_phase']} begins..." + }) + + # 2. Update weather (Phase 3) + weather_change = await self._update_weather() + if weather_change: + await self._broadcast_event(EventType.WEATHER_CHANGE, { + "old_weather": weather_change["old_weather"], + "new_weather": weather_change["new_weather"], + "message": f"Weather changed to {weather_change['new_weather']}" + }) + + # 3. Process survival (with difficulty modifiers) await self._process_survival_tick() - # Broadcast current state - await self._broadcast_agents_status() + # 4. Auto-revive check (casual mode) + await self._process_auto_revive() - # Broadcast tick event + # 5. Update moods (Phase 3) + await self._update_moods() + + # 6. Social interactions (Phase 5) + await self._process_social_tick() + + # 7. Idle chat with get_db_session() as db: alive_count = db.query(Agent).filter(Agent.status == "Alive").count() - world = db.query(WorldState).first() - day = world.day_count if world else 1 - await self._broadcast_event( - EventType.TICK, - { - "tick": self._tick_count, - "day": day, - "alive_agents": alive_count - } - ) - - # Random idle chat (10% chance per tick) if alive_count > 0 and random.random() < IDLE_CHAT_PROBABILITY: asyncio.create_task(self._trigger_idle_chat()) - logger.debug(f"Tick {self._tick_count}: {alive_count} agents alive") + # 8. Broadcast states + await self._broadcast_agents_status() + + # Tick event + with get_db_session() as db: + world = db.query(WorldState).first() + day = world.day_count if world else 1 + time_of_day = world.time_of_day if world else "day" + weather = world.weather if world else "Sunny" + + await self._broadcast_event(EventType.TICK, { + "tick": self._tick_count, + "day": day, + "time_of_day": time_of_day, + "weather": weather, + "alive_agents": alive_count + }) await asyncio.sleep(self._tick_interval) @@ -482,11 +926,14 @@ class GameEngine: logger.warning("Engine already running") return - # Initialize database and seed data logger.info("Initializing database...") init_db() self._seed_initial_data() + # Reload config + self._config = None + self._get_config() + self._running = True asyncio.create_task(self._game_loop()) logger.info("Game engine started - The Island awaits...") diff --git a/backend/app/llm.py b/backend/app/llm.py index e0426a6..35ace35 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -220,7 +220,8 @@ class LLMService: async def generate_idle_chat( self, agent: "Agent", - weather: str = "Sunny" + weather: str = "Sunny", + time_of_day: str = "day" ) -> str: """ Generate idle chatter for an agent based on current conditions. @@ -228,6 +229,7 @@ class LLMService: Args: agent: The Agent model instance weather: Current weather condition + time_of_day: Current time of day (dawn/day/dusk/night) Returns: A spontaneous thought or comment from the agent @@ -249,7 +251,7 @@ class LLMService: f"Personality: {agent.personality}. " f"Current Status: HP={agent.hp}, Energy={agent.energy}. " f"You are stranded on a survival island. " - f"The weather is {weather}. " + f"It is currently {time_of_day} and the weather is {weather}. " f"Say something brief (under 15 words) about your situation or thoughts. " f"Speak naturally, as if talking to yourself or nearby survivors." ) @@ -280,6 +282,185 @@ class LLMService: logger.error(f"LLM API error for idle chat: {e}") return self._get_mock_response(event_type) + async def generate_conversation_response( + self, + agent_name: str, + agent_personality: str, + agent_mood: int, + username: str, + topic: str = "just chatting" + ) -> str: + """ + Generate a conversation response when a user talks to an agent. + + Args: + agent_name: Name of the agent + agent_personality: Agent's personality trait + agent_mood: Agent's current mood (0-100) + username: Name of the user talking to the agent + topic: Topic of conversation + + Returns: + Agent's response to the user + """ + if self._mock_mode: + mood_state = "happy" if agent_mood >= 70 else "neutral" if agent_mood >= 40 else "sad" + responses = { + "happy": [ + f"Hey {username}! Great to see a friendly face!", + f"Oh, you want to chat? I'm in a good mood today!", + f"Nice of you to talk to me, {username}!", + ], + "neutral": [ + f"Oh, hi {username}. What's on your mind?", + f"Sure, I can chat for a bit.", + f"What do you want to talk about?", + ], + "sad": [ + f"*sighs* Oh... hey {username}...", + f"I'm not really in the mood, but... okay.", + f"What is it, {username}?", + ] + } + return random.choice(responses.get(mood_state, responses["neutral"])) + + try: + mood_desc = "happy and energetic" if agent_mood >= 70 else \ + "calm and neutral" if agent_mood >= 40 else \ + "a bit down" if agent_mood >= 20 else "anxious and worried" + + system_prompt = ( + f"You are {agent_name}, a survivor on a deserted island. " + f"Personality: {agent_personality}. " + f"Current mood: {mood_desc} (mood level: {agent_mood}/100). " + f"A viewer named {username} wants to chat with you. " + f"Respond naturally in character (under 30 words). " + f"Be conversational and show your personality." + ) + + user_msg = f"{username} says: {topic}" if topic != "just chatting" else \ + f"{username} wants to chat with you." + + kwargs = { + "model": self._model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_msg} + ], + "max_tokens": 80, + "temperature": 0.85, + } + if self._api_base: + kwargs["api_base"] = self._api_base + if self._api_key and not self._api_key_header: + kwargs["api_key"] = self._api_key + if self._extra_headers: + kwargs["extra_headers"] = self._extra_headers + + response = await self._acompletion(**kwargs) + return response.choices[0].message.content.strip() + + except Exception as e: + logger.error(f"LLM API error for conversation: {e}") + return f"*nods at {username}* Hey there." + + async def generate_social_interaction( + self, + initiator_name: str, + target_name: str, + interaction_type: str, + relationship_type: str, + weather: str = "Sunny", + time_of_day: str = "day" + ) -> str: + """ + Generate dialogue for social interaction between two agents. + + Args: + initiator_name: Name of the agent initiating interaction + target_name: Name of the target agent + interaction_type: Type of interaction (chat, share_food, help, argue, comfort) + relationship_type: Current relationship (stranger, acquaintance, friend, etc.) + weather: Current weather + time_of_day: Current time of day + + Returns: + A brief dialogue exchange between the two agents + """ + if self._mock_mode: + dialogues = { + "chat": [ + f"{initiator_name}: Hey {target_name}, how are you holding up?\n{target_name}: Could be better, but I'm managing.", + f"{initiator_name}: Nice weather today, huh?\n{target_name}: Yeah, at least something's going right.", + ], + "share_food": [ + f"{initiator_name}: Here, take some of my food.\n{target_name}: Really? Thanks, I appreciate it!", + f"{initiator_name}: You look hungry. Have some of this.\n{target_name}: You're a lifesaver!", + ], + "help": [ + f"{initiator_name}: Need a hand with that?\n{target_name}: Yes, thank you so much!", + f"{initiator_name}: Let me help you out.\n{target_name}: I owe you one!", + ], + "argue": [ + f"{initiator_name}: This is all your fault!\n{target_name}: My fault? You're the one who-", + f"{initiator_name}: I can't believe you did that!\n{target_name}: Just leave me alone!", + ], + "comfort": [ + f"{initiator_name}: Hey, are you okay?\n{target_name}: *sniff* I'll be fine... thanks for asking.", + f"{initiator_name}: Don't worry, we'll get through this.\n{target_name}: I hope you're right...", + ] + } + return random.choice(dialogues.get(interaction_type, dialogues["chat"])) + + try: + relationship_desc = { + "stranger": "barely know each other", + "acquaintance": "are getting to know each other", + "friend": "are friends", + "close_friend": "are close friends who trust each other", + "rival": "have tensions between them" + }.get(relationship_type, "are acquaintances") + + interaction_desc = { + "chat": "having a casual conversation", + "share_food": "sharing food with", + "help": "helping with a task", + "argue": "having a disagreement with", + "comfort": "comforting" + }.get(interaction_type, "talking to") + + system_prompt = ( + f"You are writing dialogue for two survivors on a deserted island. " + f"{initiator_name} and {target_name} {relationship_desc}. " + f"It is {time_of_day} and the weather is {weather}. " + f"{initiator_name} is {interaction_desc} {target_name}. " + f"Write a brief, natural dialogue exchange (2-3 lines total). " + f"Format: '{initiator_name}: [line]\\n{target_name}: [response]'" + ) + + kwargs = { + "model": self._model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Write a {interaction_type} dialogue between {initiator_name} and {target_name}."} + ], + "max_tokens": 100, + "temperature": 0.9, + } + if self._api_base: + kwargs["api_base"] = self._api_base + if self._api_key and not self._api_key_header: + kwargs["api_key"] = self._api_key + if self._extra_headers: + kwargs["extra_headers"] = self._extra_headers + + response = await self._acompletion(**kwargs) + return response.choices[0].message.content.strip() + + except Exception as e: + logger.error(f"LLM API error for social interaction: {e}") + return f"{initiator_name}: ...\n{target_name}: ..." + # Global instance for easy import llm_service = LLMService() diff --git a/backend/app/models.py b/backend/app/models.py index 061852c..bd31ed4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,11 +1,11 @@ """ SQLAlchemy ORM models for The Island. -Defines User (viewers), Agent (NPCs), and WorldState entities. +Defines User (viewers), Agent (NPCs), WorldState, GameConfig, and AgentRelationship. """ from datetime import datetime -from sqlalchemy import Column, Integer, String, DateTime, func +from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, ForeignKey, UniqueConstraint, func from .database import Base @@ -29,7 +29,7 @@ class User(Base): class Agent(Base): """ Represents an NPC survivor on the island. - Has personality, health, energy, and inventory. + Has personality, health, energy, mood, and social attributes. """ __tablename__ = "agents" @@ -41,8 +41,23 @@ class Agent(Base): energy = Column(Integer, default=100) inventory = Column(String(500), default="{}") # JSON string + # Mood system (Phase 3) + mood = Column(Integer, default=70) # 0-100 scale + mood_state = Column(String(20), default="neutral") # happy, neutral, sad, anxious + + # Revival tracking (Phase 1) + death_tick = Column(Integer, nullable=True) # Tick when agent died + + # Social attributes (Phase 5) + social_tendency = Column(String(20), default="neutral") # introvert, extrovert, neutral + def __repr__(self): - return f"" + return f"" + + @property + def is_alive(self) -> bool: + """Check if agent is alive.""" + return self.status == "Alive" def to_dict(self): """Convert to dictionary for JSON serialization.""" @@ -53,14 +68,17 @@ class Agent(Base): "status": self.status, "hp": self.hp, "energy": self.energy, - "inventory": self.inventory + "inventory": self.inventory, + "mood": self.mood, + "mood_state": self.mood_state, + "social_tendency": self.social_tendency } class WorldState(Base): """ Global state of the island environment. - Tracks day count, weather, and shared resources. + Tracks day count, time of day, weather, and resources. """ __tablename__ = "world_state" @@ -69,13 +87,122 @@ class WorldState(Base): weather = Column(String(20), default="Sunny") resource_level = Column(Integer, default=100) + # Day/Night cycle (Phase 2) + current_tick_in_day = Column(Integer, default=0) # 0 to TICKS_PER_DAY + time_of_day = Column(String(10), default="day") # dawn, day, dusk, night + + # Weather system (Phase 3) + weather_duration = Column(Integer, default=0) # Ticks since last weather change + def __repr__(self): - return f"" + return f"" def to_dict(self): """Convert to dictionary for JSON serialization.""" return { "day_count": self.day_count, "weather": self.weather, - "resource_level": self.resource_level + "resource_level": self.resource_level, + "current_tick_in_day": self.current_tick_in_day, + "time_of_day": self.time_of_day } + + +class GameConfig(Base): + """ + Game configuration for difficulty settings. + Supports casual and normal modes. + """ + __tablename__ = "game_config" + + id = Column(Integer, primary_key=True, index=True) + difficulty = Column(String(20), default="casual") # normal, casual + + # Decay multipliers (1.0 = normal, 0.5 = casual) + energy_decay_multiplier = Column(Float, default=0.5) + hp_decay_multiplier = Column(Float, default=0.5) + + # Revival settings + auto_revive_enabled = Column(Boolean, default=True) + auto_revive_delay_ticks = Column(Integer, default=12) # 60 seconds at 5s/tick + revive_hp = Column(Integer, default=50) + revive_energy = Column(Integer, default=50) + + # Social settings + social_interaction_probability = Column(Float, default=0.3) + + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary for JSON serialization.""" + return { + "difficulty": self.difficulty, + "energy_decay_multiplier": self.energy_decay_multiplier, + "hp_decay_multiplier": self.hp_decay_multiplier, + "auto_revive_enabled": self.auto_revive_enabled, + "auto_revive_delay_ticks": self.auto_revive_delay_ticks, + "social_interaction_probability": self.social_interaction_probability + } + + +class AgentRelationship(Base): + """ + Tracks relationships between agents. + Affection, trust determine relationship type. + """ + __tablename__ = "agent_relationships" + + id = Column(Integer, primary_key=True, index=True) + agent_from_id = Column(Integer, ForeignKey("agents.id"), nullable=False) + agent_to_id = Column(Integer, ForeignKey("agents.id"), nullable=False) + + # Relationship metrics (-100 to 100) + affection = Column(Integer, default=0) # Liking + trust = Column(Integer, default=0) # Trust level + + # Derived from metrics + relationship_type = Column(String(30), default="stranger") + # Types: stranger, acquaintance, friend, close_friend, rival + + # Interaction tracking + interaction_count = Column(Integer, default=0) + last_interaction_tick = Column(Integer, default=0) + + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint('agent_from_id', 'agent_to_id', name='unique_relationship'), + ) + + def __repr__(self): + return f"{self.agent_to_id} {self.relationship_type}>" + + def to_dict(self): + """Convert to dictionary for JSON serialization.""" + return { + "agent_from_id": self.agent_from_id, + "agent_to_id": self.agent_to_id, + "affection": self.affection, + "trust": self.trust, + "relationship_type": self.relationship_type, + "interaction_count": self.interaction_count + } + + def update_relationship_type(self): + """Calculate and update relationship type based on metrics.""" + total = self.affection + self.trust + + if total <= -50: + self.relationship_type = "rival" + elif total <= 20: + self.relationship_type = "stranger" + elif total <= 50: + self.relationship_type = "acquaintance" + elif total <= 100: + self.relationship_type = "friend" + else: + self.relationship_type = "close_friend" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ca9c057..243088e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -15,6 +15,7 @@ class EventType(str, Enum): TICK = "tick" SYSTEM = "system" ERROR = "error" + # Island survival events AGENTS_UPDATE = "agents_update" # All agents status broadcast AGENT_DIED = "agent_died" # An agent has died @@ -24,6 +25,26 @@ class EventType(str, Enum): WORLD_UPDATE = "world_update" # World state update CHECK = "check" # Status check response + # Day/Night cycle (Phase 2) + TIME_UPDATE = "time_update" # Time tick update + PHASE_CHANGE = "phase_change" # Dawn/day/dusk/night transition + DAY_CHANGE = "day_change" # New day started + + # Weather system (Phase 3) + WEATHER_CHANGE = "weather_change" # Weather changed + MOOD_UPDATE = "mood_update" # Agent mood changed + + # New commands (Phase 4) + HEAL = "heal" # User healed an agent + TALK = "talk" # User talked to an agent + ENCOURAGE = "encourage" # User encouraged an agent + REVIVE = "revive" # User revived a dead agent + + # Social system (Phase 5) + SOCIAL_INTERACTION = "social_interaction" # Agents interacted + RELATIONSHIP_CHANGE = "relationship_change" # Relationship status changed + AUTO_REVIVE = "auto_revive" # Agent auto-revived (casual mode) + class GameEvent(BaseModel): """ diff --git a/frontend/app.js b/frontend/app.js index 0cfd57d..7851b23 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -12,6 +12,13 @@ let userGold = 100; // Agents state let agents = []; +// World state +let worldState = { + day_count: 1, + time_of_day: 'day', + weather: 'Sunny' +}; + // DOM Elements const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); @@ -87,6 +94,9 @@ function handleGameEvent(event) { updateAgentsUI(data.agents); break; case 'feed': + case 'heal': + case 'encourage': + case 'revive': case 'user_update': updateUserGold(data); break; @@ -97,7 +107,35 @@ function handleGameEvent(event) { } break; case 'agent_speak': - showSpeechBubble(data.agent_id, data.agent_name, data.text); + case 'talk': + if (data.agent_id !== undefined) { + showSpeechBubble(data.agent_id, data.agent_name, data.text || data.response); + } + break; + case 'social_interaction': + showSocialInteraction(data); + break; + case 'world_update': + updateWorldState(data); + break; + case 'weather_change': + worldState.weather = data.new_weather; + updateWorldDisplay(); + break; + case 'phase_change': + worldState.time_of_day = data.new_phase; + updateWorldDisplay(); + break; + case 'day_change': + worldState.day_count = data.day; + updateWorldDisplay(); + break; + case 'tick': + // Update world state from tick data + if (data.day) worldState.day_count = data.day; + if (data.time_of_day) worldState.time_of_day = data.time_of_day; + if (data.weather) worldState.weather = data.weather; + updateWorldDisplay(); break; } @@ -152,6 +190,10 @@ function createAgentCard(agent) { const statusClass = isDead ? 'dead' : 'alive'; const statusText = isDead ? '已死亡' : '存活'; + // Mood emoji and color + const moodEmoji = getMoodEmoji(agent.mood_state); + const moodColor = getMoodColor(agent.mood_state); + card.innerHTML = `
@@ -178,18 +220,175 @@ function createAgentCard(agent) {
- +
+
+ ${moodEmoji} 心情 + ${agent.mood}/100 +
+
+
+
+
+
+ ${isDead ? ` + + ` : ` + + + + + `} +
`; return card; } +/** + * Get mood emoji based on mood state + */ +function getMoodEmoji(moodState) { + const emojis = { + 'happy': '😊', + 'neutral': '😐', + 'sad': '😢', + 'anxious': '😰' + }; + return emojis[moodState] || '😐'; +} + +/** + * Get mood color based on mood state + */ +function getMoodColor(moodState) { + const colors = { + 'happy': '#4ade80', + 'neutral': '#fbbf24', + 'sad': '#60a5fa', + 'anxious': '#f87171' + }; + return colors[moodState] || '#fbbf24'; +} + +/** + * Update world state from server + */ +function updateWorldState(data) { + if (data.day_count) worldState.day_count = data.day_count; + if (data.time_of_day) worldState.time_of_day = data.time_of_day; + if (data.weather) worldState.weather = data.weather; + updateWorldDisplay(); +} + +/** + * Update the world display panel + */ +function updateWorldDisplay() { + const worldDisplay = document.getElementById('worldDisplay'); + if (!worldDisplay) return; + + const weatherEmojis = { + 'Sunny': '☀️', + 'Cloudy': '☁️', + 'Rainy': '🌧️', + 'Stormy': '⛈️', + 'Hot': '🔥', + 'Foggy': '🌫️' + }; + + const phaseEmojis = { + 'dawn': '🌅', + 'day': '☀️', + 'dusk': '🌇', + 'night': '🌙' + }; + + const phaseNames = { + 'dawn': '黎明', + 'day': '白天', + 'dusk': '黄昏', + 'night': '夜晚' + }; + + worldDisplay.innerHTML = ` + 📅 第${worldState.day_count}天 + ${phaseEmojis[worldState.time_of_day] || '☀️'} ${phaseNames[worldState.time_of_day] || '白天'} + ${weatherEmojis[worldState.weather] || '☀️'} ${worldState.weather} + `; +} + +/** + * Show social interaction notification + */ +function showSocialInteraction(data) { + const interactionNames = { + 'chat': '聊天', + 'share_food': '分享食物', + 'help': '互相帮助', + 'argue': '争吵', + 'comfort': '安慰' + }; + + const message = `${data.initiator_name} 和 ${data.target_name} ${interactionNames[data.interaction_type] || '互动'}了`; + logEvent({ + event_type: 'social_interaction', + timestamp: Date.now() / 1000, + data: { message, dialogue: data.dialogue } + }); +} + /** * Feed an agent */ function feedAgent(agentName) { + sendCommand(`feed ${agentName}`); +} + +/** + * Heal an agent + */ +function healAgent(agentName) { + sendCommand(`heal ${agentName}`); +} + +/** + * Encourage an agent + */ +function encourageAgent(agentName) { + sendCommand(`encourage ${agentName}`); +} + +/** + * Talk to an agent + */ +function talkToAgent(agentName) { + const topic = prompt(`想和 ${agentName} 聊什么?(留空则随便聊聊)`); + if (topic !== null) { + sendCommand(`talk ${agentName} ${topic}`.trim()); + } +} + +/** + * Revive a dead agent + */ +function reviveAgent(agentName) { + sendCommand(`revive ${agentName}`); +} + +/** + * Send a command to the server + */ +function sendCommand(command) { if (!ws || ws.readyState !== WebSocket.OPEN) { alert('未连接到服务器'); return; @@ -198,7 +397,7 @@ function feedAgent(agentName) { const user = getCurrentUser(); const payload = { action: 'send_comment', - payload: { user, message: `feed ${agentName}` } + payload: { user, message: command } }; ws.send(JSON.stringify(payload)); @@ -315,19 +514,38 @@ function formatEventData(eventType, data) { case 'comment': return `${data.user}: ${data.message}`; case 'tick': - return `Tick #${data.tick} | 第${data.day}天 | 存活: ${data.alive_agents}人`; + const phaseEmoji = { 'dawn': '🌅', 'day': '☀️', 'dusk': '🌇', 'night': '🌙' }; + const weatherEmoji = { 'Sunny': '☀️', 'Cloudy': '☁️', 'Rainy': '🌧️', 'Stormy': '⛈️', 'Hot': '🔥', 'Foggy': '🌫️' }; + return `第${data.day}天 ${phaseEmoji[data.time_of_day] || ''}${data.time_of_day} | ${weatherEmoji[data.weather] || ''}${data.weather} | 存活: ${data.alive_agents}人`; case 'system': case 'error': case 'feed': + case 'heal': + case 'encourage': + case 'revive': + case 'auto_revive': case 'agent_died': case 'check': return data.message; case 'agent_speak': return `💬 ${data.agent_name}: "${data.text}"`; + case 'talk': + return `💬 ${data.agent_name} 对 ${data.user} 说: "${data.response}"`; case 'agents_update': return `角色状态已更新`; case 'user_update': return `${data.user} 金币: ${data.gold}`; + case 'weather_change': + return `🌤️ 天气变化: ${data.old_weather} → ${data.new_weather}`; + case 'phase_change': + return `🕐 ${data.message}`; + case 'day_change': + return `📅 ${data.message}`; + case 'social_interaction': + const dialogue = data.dialogue ? `\n"${data.dialogue}"` : ''; + return `👥 ${data.message}${dialogue}`; + case 'world_update': + return `🌍 世界状态已更新`; default: return JSON.stringify(data); } diff --git a/frontend/debug_client.html b/frontend/debug_client.html index 9c5716f..75ac9d5 100644 --- a/frontend/debug_client.html +++ b/frontend/debug_client.html @@ -326,10 +326,64 @@ .event.feed { border-color: #ffaa00; background: rgba(255, 170, 0, 0.1); color: #ffcc66; } .event.agent_died { border-color: #ff4444; background: rgba(255, 68, 68, 0.15); color: #ff8888; } .event.agent_speak { border-color: #88ccff; background: rgba(136, 204, 255, 0.1); color: #aaddff; } + .event.talk { border-color: #88ccff; background: rgba(136, 204, 255, 0.1); color: #aaddff; } .event.check { border-color: #88cc88; background: rgba(136, 204, 136, 0.1); } + .event.heal { border-color: #ff88cc; background: rgba(255, 136, 204, 0.1); color: #ffaadd; } + .event.encourage { border-color: #ffcc44; background: rgba(255, 204, 68, 0.1); color: #ffdd88; } + .event.revive { border-color: #cc88ff; background: rgba(204, 136, 255, 0.1); color: #ddaaff; } + .event.auto_revive { border-color: #cc88ff; background: rgba(204, 136, 255, 0.1); color: #ddaaff; } + .event.weather_change { border-color: #66cccc; background: rgba(102, 204, 204, 0.1); color: #88dddd; } + .event.phase_change { border-color: #ccaa44; background: rgba(204, 170, 68, 0.1); color: #ddcc88; } + .event.day_change { border-color: #88ccff; background: rgba(136, 204, 255, 0.15); color: #aaddff; } + .event.social_interaction { border-color: #ff8888; background: rgba(255, 136, 136, 0.1); color: #ffaaaa; } .event-time { color: #888; font-size: 11px; } .event-type { font-weight: bold; text-transform: uppercase; font-size: 11px; } - .event-data { margin-top: 5px; } + .event-data { margin-top: 5px; white-space: pre-wrap; } + + /* World Display */ + .world-panel { + background: rgba(102, 204, 204, 0.1); + border: 1px solid rgba(102, 204, 204, 0.3); + border-radius: 12px; + padding: 12px 20px; + margin-bottom: 20px; + display: flex; + justify-content: center; + gap: 30px; + font-size: 1rem; + } + .world-panel span { + display: flex; + align-items: center; + gap: 6px; + } + + /* Agent Actions */ + .agent-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; + } + .action-btn { + padding: 6px 8px; + font-size: 0.8rem; + border-radius: 6px; + border: none; + cursor: pointer; + transition: all 0.2s; + font-weight: bold; + } + .action-btn.feed { background: #88cc88; color: #1a2a1a; } + .action-btn.feed:hover { background: #66aa66; } + .action-btn.heal { background: #ff88cc; color: #1a2a1a; } + .action-btn.heal:hover { background: #ff66bb; } + .action-btn.encourage { background: #ffcc44; color: #1a2a1a; } + .action-btn.encourage:hover { background: #ffbb22; } + .action-btn.talk { background: #88ccff; color: #1a2a1a; } + .action-btn.talk:hover { background: #66bbff; } + .action-btn.revive { background: #cc88ff; color: #1a2a1a; grid-column: span 2; } + .action-btn.revive:hover { background: #bb66ff; } @@ -356,6 +410,13 @@ + +
+ 📅 第1天 + ☀️ 白天 + ☀️ Sunny +
+
@@ -379,7 +440,7 @@

- 指令: feed [名字] - 投喂 | check - 查询 | reset - 重新开始 + 指令: feed/heal/encourage/revive [名字] | talk [名字] [话题] | check | reset

diff --git a/unity-client/Assets/Scripts/AgentVisual.cs b/unity-client/Assets/Scripts/AgentVisual.cs index 8a68d43..68b6b44 100644 --- a/unity-client/Assets/Scripts/AgentVisual.cs +++ b/unity-client/Assets/Scripts/AgentVisual.cs @@ -39,6 +39,12 @@ namespace TheIsland.Visual [SerializeField] private Color hpLowColor = new Color(0.9f, 0.3f, 0.3f); [SerializeField] private Color energyHighColor = new Color(1f, 0.8f, 0.2f); [SerializeField] private Color energyLowColor = new Color(1f, 0.5f, 0.1f); + + [Header("Mood Colors")] + [SerializeField] private Color moodHappyColor = new Color(0.3f, 0.9f, 0.5f); + [SerializeField] private Color moodNeutralColor = new Color(0.98f, 0.75f, 0.15f); + [SerializeField] private Color moodSadColor = new Color(0.4f, 0.65f, 0.98f); + [SerializeField] private Color moodAnxiousColor = new Color(0.97f, 0.53f, 0.53f); #endregion #region References @@ -48,8 +54,11 @@ namespace TheIsland.Visual private TextMeshProUGUI _personalityLabel; private Image _hpBarFill; private Image _energyBarFill; + private Image _moodBarFill; private TextMeshProUGUI _hpText; private TextMeshProUGUI _energyText; + private TextMeshProUGUI _moodText; + private TextMeshProUGUI _moodEmoji; private GameObject _deathOverlay; private SpeechBubble _speechBubble; private Billboard _spriteBillboard; @@ -61,6 +70,12 @@ namespace TheIsland.Visual private int _agentId; private AgentData _currentData; private Coroutine _speechCoroutine; + + // Animation state + private float _idleAnimTimer; + private float _breathScale = 1f; + private Vector3 _originalSpriteScale; + private float _bobOffset; #endregion #region Properties @@ -76,6 +91,33 @@ namespace TheIsland.Visual CreateVisuals(); } + private void Update() + { + if (!IsAlive) return; + + // Idle breathing animation + _idleAnimTimer += Time.deltaTime; + _breathScale = 1f + Mathf.Sin(_idleAnimTimer * 2f) * 0.02f; + + // Gentle bobbing + _bobOffset = Mathf.Sin(_idleAnimTimer * 1.5f) * 0.05f; + + if (_spriteRenderer != null && _originalSpriteScale != Vector3.zero) + { + // Apply breathing scale + _spriteRenderer.transform.localScale = new Vector3( + _originalSpriteScale.x * _breathScale, + _originalSpriteScale.y * _breathScale, + _originalSpriteScale.z + ); + + // Apply bobbing + var pos = _spriteRenderer.transform.localPosition; + pos.y = 1f + _bobOffset; + _spriteRenderer.transform.localPosition = pos; + } + } + private void OnMouseDown() { if (!IsAlive) @@ -162,8 +204,63 @@ namespace TheIsland.Visual RegeneratePlaceholderSprite(); } + // Store original scale for animation + _originalSpriteScale = spriteObj.transform.localScale; + // Add billboard _spriteBillboard = spriteObj.AddComponent(); + + // Add shadow + CreateShadow(spriteObj.transform); + } + + private void CreateShadow(Transform spriteTransform) + { + var shadowObj = new GameObject("Shadow"); + shadowObj.transform.SetParent(transform); + shadowObj.transform.localPosition = new Vector3(0, 0.01f, 0); + shadowObj.transform.localRotation = Quaternion.Euler(90, 0, 0); + shadowObj.transform.localScale = new Vector3(1.2f, 0.6f, 1f); + + var shadowRenderer = shadowObj.AddComponent(); + shadowRenderer.sprite = CreateShadowSprite(); + shadowRenderer.sortingOrder = sortingOrder - 1; + shadowRenderer.color = new Color(0, 0, 0, 0.3f); + } + + private Sprite CreateShadowSprite() + { + int size = 32; + Texture2D tex = new Texture2D(size, size); + tex.filterMode = FilterMode.Bilinear; + + Vector2 center = new Vector2(size / 2f, size / 2f); + Color[] pixels = new Color[size * size]; + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + float dx = (x - center.x) / (size * 0.4f); + float dy = (y - center.y) / (size * 0.4f); + float dist = dx * dx + dy * dy; + + if (dist < 1) + { + float alpha = Mathf.Clamp01(1 - dist) * 0.5f; + pixels[y * size + x] = new Color(0, 0, 0, alpha); + } + else + { + pixels[y * size + x] = Color.clear; + } + } + } + + tex.SetPixels(pixels); + tex.Apply(); + + return Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), 100f); } private void RegeneratePlaceholderSprite() @@ -183,7 +280,7 @@ namespace TheIsland.Visual private Texture2D CreatePlaceholderTexture(int width, int height) { Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false); - texture.filterMode = FilterMode.Point; + texture.filterMode = FilterMode.Bilinear; // Clear to transparent Color[] pixels = new Color[width * height]; @@ -192,30 +289,193 @@ namespace TheIsland.Visual pixels[i] = Color.clear; } - // Draw simple character shape Vector2 center = new Vector2(width / 2f, height / 2f); - // Body (ellipse) - DrawEllipse(pixels, width, height, center + Vector2.down * 8, 14, 20, placeholderBodyColor); + // Create highlight and shadow colors + Color highlight = Color.Lerp(placeholderBodyColor, Color.white, 0.3f); + Color shadow = Color.Lerp(placeholderBodyColor, Color.black, 0.3f); + Color skinTone = new Color(0.95f, 0.8f, 0.7f); + Color skinShadow = new Color(0.85f, 0.65f, 0.55f); - // Head (circle) - DrawCircle(pixels, width, height, center + Vector2.up * 12, 12, placeholderBodyColor); + // Body (ellipse with shading) + Vector2 bodyCenter = center + Vector2.down * 6; + DrawShadedEllipse(pixels, width, height, bodyCenter, 16, 22, placeholderBodyColor, highlight, shadow); - // Outline - DrawCircleOutline(pixels, width, height, center + Vector2.up * 12, 12, placeholderOutlineColor, 2); - DrawEllipseOutline(pixels, width, height, center + Vector2.down * 8, 14, 20, placeholderOutlineColor, 2); + // Head (circle with skin tone) + Vector2 headCenter = center + Vector2.up * 14; + DrawShadedCircle(pixels, width, height, headCenter, 13, skinTone, Color.Lerp(skinTone, Color.white, 0.2f), skinShadow); + + // Hair (top of head) + Color hairColor = placeholderOutlineColor; + DrawHair(pixels, width, height, headCenter, 13, hairColor); // Eyes - DrawCircle(pixels, width, height, center + new Vector2(-4, 14), 2, Color.white); - DrawCircle(pixels, width, height, center + new Vector2(4, 14), 2, Color.white); - DrawCircle(pixels, width, height, center + new Vector2(-4, 14), 1, Color.black); - DrawCircle(pixels, width, height, center + new Vector2(4, 14), 1, Color.black); + DrawCircle(pixels, width, height, headCenter + new Vector2(-4, -1), 3, Color.white); + DrawCircle(pixels, width, height, headCenter + new Vector2(4, -1), 3, Color.white); + DrawCircle(pixels, width, height, headCenter + new Vector2(-4, -1), 1.5f, new Color(0.2f, 0.15f, 0.1f)); + DrawCircle(pixels, width, height, headCenter + new Vector2(4, -1), 1.5f, new Color(0.2f, 0.15f, 0.1f)); + // Eye highlights + DrawCircle(pixels, width, height, headCenter + new Vector2(-3, 0), 0.8f, Color.white); + DrawCircle(pixels, width, height, headCenter + new Vector2(5, 0), 0.8f, Color.white); + + // Mouth (smile) + DrawSmile(pixels, width, height, headCenter + Vector2.down * 5, 4); + + // Blush + DrawCircle(pixels, width, height, headCenter + new Vector2(-7, -3), 2, new Color(1f, 0.6f, 0.6f, 0.4f)); + DrawCircle(pixels, width, height, headCenter + new Vector2(7, -3), 2, new Color(1f, 0.6f, 0.6f, 0.4f)); + + // Arms + DrawArm(pixels, width, height, bodyCenter + new Vector2(-14, 5), -30, skinTone); + DrawArm(pixels, width, height, bodyCenter + new Vector2(14, 5), 30, skinTone); + + // Legs + DrawLeg(pixels, width, height, bodyCenter + new Vector2(-6, -20), placeholderBodyColor); + DrawLeg(pixels, width, height, bodyCenter + new Vector2(6, -20), placeholderBodyColor); + + // Outline + AddOutline(pixels, width, height, placeholderOutlineColor); texture.SetPixels(pixels); texture.Apply(); return texture; } + private void DrawShadedCircle(Color[] pixels, int width, int height, Vector2 center, float radius, Color baseColor, Color highlight, Color shadow) + { + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float dist = Vector2.Distance(new Vector2(x, y), center); + if (dist <= radius) + { + // Shading based on position relative to light source (top-left) + float dx = (x - center.x) / radius; + float dy = (y - center.y) / radius; + float shade = (-dx * 0.3f + dy * 0.7f) * 0.5f + 0.5f; + Color color = Color.Lerp(highlight, shadow, shade); + color = Color.Lerp(color, baseColor, 0.5f); + pixels[y * width + x] = color; + } + } + } + } + + private void DrawShadedEllipse(Color[] pixels, int width, int height, Vector2 center, float rx, float ry, Color baseColor, Color highlight, Color shadow) + { + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float dx = (x - center.x) / rx; + float dy = (y - center.y) / ry; + if (dx * dx + dy * dy <= 1) + { + float shade = (-dx * 0.3f + dy * 0.5f) * 0.5f + 0.5f; + Color color = Color.Lerp(highlight, shadow, shade); + color = Color.Lerp(color, baseColor, 0.5f); + pixels[y * width + x] = color; + } + } + } + } + + private void DrawHair(Color[] pixels, int width, int height, Vector2 headCenter, float headRadius, Color hairColor) + { + // Draw hair on top half of head + for (int y = (int)(headCenter.y); y < height; y++) + { + for (int x = 0; x < width; x++) + { + float dist = Vector2.Distance(new Vector2(x, y), headCenter); + if (dist <= headRadius + 2 && dist >= headRadius - 4 && y > headCenter.y - 2) + { + float noise = Mathf.PerlinNoise(x * 0.3f, y * 0.3f); + if (noise > 0.3f) + { + pixels[y * width + x] = Color.Lerp(hairColor, hairColor * 0.7f, noise); + } + } + } + } + } + + private void DrawSmile(Color[] pixels, int width, int height, Vector2 center, float smileWidth) + { + Color mouthColor = new Color(0.8f, 0.4f, 0.4f); + for (int x = (int)(center.x - smileWidth); x <= (int)(center.x + smileWidth); x++) + { + float t = (x - center.x + smileWidth) / (smileWidth * 2); + int y = (int)(center.y - Mathf.Sin(t * Mathf.PI) * 2); + if (x >= 0 && x < width && y >= 0 && y < height) + { + pixels[y * width + x] = mouthColor; + if (y > 0) pixels[(y - 1) * width + x] = mouthColor; + } + } + } + + private void DrawArm(Color[] pixels, int width, int height, Vector2 start, float angle, Color skinColor) + { + float rad = angle * Mathf.Deg2Rad; + int length = 10; + for (int i = 0; i < length; i++) + { + int x = (int)(start.x + Mathf.Sin(rad) * i); + int y = (int)(start.y - Mathf.Cos(rad) * i); + DrawCircle(pixels, width, height, new Vector2(x, y), 2, skinColor); + } + } + + private void DrawLeg(Color[] pixels, int width, int height, Vector2 start, Color clothColor) + { + for (int i = 0; i < 8; i++) + { + int x = (int)start.x; + int y = (int)(start.y - i); + if (y >= 0 && y < height) + { + DrawCircle(pixels, width, height, new Vector2(x, y), 3, clothColor); + } + } + // Shoe + DrawCircle(pixels, width, height, start + Vector2.down * 8, 4, new Color(0.3f, 0.2f, 0.15f)); + } + + private void AddOutline(Color[] pixels, int width, int height, Color outlineColor) + { + Color[] newPixels = (Color[])pixels.Clone(); + for (int y = 1; y < height - 1; y++) + { + for (int x = 1; x < width - 1; x++) + { + if (pixels[y * width + x].a < 0.1f) + { + // Check neighbors + bool hasNeighbor = false; + for (int dy = -1; dy <= 1; dy++) + { + for (int dx = -1; dx <= 1; dx++) + { + if (pixels[(y + dy) * width + (x + dx)].a > 0.5f) + { + hasNeighbor = true; + break; + } + } + if (hasNeighbor) break; + } + if (hasNeighbor) + { + newPixels[y * width + x] = outlineColor; + } + } + } + } + System.Array.Copy(newPixels, pixels, pixels.Length); + } + private void DrawCircle(Color[] pixels, int width, int height, Vector2 center, float radius, Color color) { for (int y = 0; y < height; y++) @@ -294,31 +554,35 @@ namespace TheIsland.Visual _uiCanvas.sortingOrder = sortingOrder + 1; var canvasRect = canvasObj.GetComponent(); - canvasRect.sizeDelta = new Vector2(400, 150); + canvasRect.sizeDelta = new Vector2(400, 180); // Add billboard to canvas (configured for UI - full facing) _uiBillboard = canvasObj.AddComponent(); _uiBillboard.ConfigureForUI(); - // Create UI panel - var panel = CreateUIPanel(canvasObj.transform, new Vector2(350, 120)); + // Create UI panel (increased height for mood bar) + var panel = CreateUIPanel(canvasObj.transform, new Vector2(350, 150)); // Name label _nameLabel = CreateUIText(panel.transform, "NameLabel", "Agent", 36, Color.white, FontStyles.Bold); - SetRectPosition(_nameLabel.rectTransform, 0, 45, 320, 45); + SetRectPosition(_nameLabel.rectTransform, 0, 60, 320, 45); // Personality label _personalityLabel = CreateUIText(panel.transform, "PersonalityLabel", "(Personality)", 20, new Color(0.8f, 0.8f, 0.8f), FontStyles.Italic); - SetRectPosition(_personalityLabel.rectTransform, 0, 15, 320, 25); + SetRectPosition(_personalityLabel.rectTransform, 0, 30, 320, 25); // HP Bar var hpBar = CreateProgressBar(panel.transform, "HPBar", "HP", hpHighColor, out _hpBarFill, out _hpText); - SetRectPosition(hpBar, 0, -15, 280, 24); + SetRectPosition(hpBar, 0, 0, 280, 24); // Energy Bar var energyBar = CreateProgressBar(panel.transform, "EnergyBar", "Energy", energyHighColor, out _energyBarFill, out _energyText); - SetRectPosition(energyBar, 0, -45, 280, 24); + SetRectPosition(energyBar, 0, -30, 280, 24); + + // Mood Bar + var moodBar = CreateProgressBar(panel.transform, "MoodBar", "Mood", moodNeutralColor, out _moodBarFill, out _moodText); + SetRectPosition(moodBar, 0, -60, 280, 24); // Death overlay _deathOverlay = CreateDeathOverlay(panel.transform); @@ -338,11 +602,79 @@ namespace TheIsland.Visual rect.anchoredPosition = Vector2.zero; var bg = panel.AddComponent(); - bg.color = new Color(0, 0, 0, 0.6f); + bg.sprite = CreateRoundedRectSprite(32, 32, 8); + bg.type = Image.Type.Sliced; + bg.color = new Color(0.1f, 0.12f, 0.18f, 0.85f); + + // Add subtle border + var borderObj = new GameObject("Border"); + borderObj.transform.SetParent(panel.transform); + borderObj.transform.localPosition = Vector3.zero; + borderObj.transform.localRotation = Quaternion.identity; + borderObj.transform.localScale = Vector3.one; + + var borderRect = borderObj.AddComponent(); + borderRect.anchorMin = Vector2.zero; + borderRect.anchorMax = Vector2.one; + borderRect.offsetMin = new Vector2(-2, -2); + borderRect.offsetMax = new Vector2(2, 2); + borderRect.SetAsFirstSibling(); + + var borderImg = borderObj.AddComponent(); + borderImg.sprite = CreateRoundedRectSprite(32, 32, 8); + borderImg.type = Image.Type.Sliced; + borderImg.color = new Color(0.3f, 0.35f, 0.45f, 0.5f); return panel; } + private Sprite CreateRoundedRectSprite(int width, int height, int radius) + { + Texture2D tex = new Texture2D(width, height); + tex.filterMode = FilterMode.Bilinear; + + Color[] pixels = new Color[width * height]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + bool inRect = true; + + // Check corners for rounding + if (x < radius && y < radius) + { + // Bottom-left corner + inRect = Vector2.Distance(new Vector2(x, y), new Vector2(radius, radius)) <= radius; + } + else if (x >= width - radius && y < radius) + { + // Bottom-right corner + inRect = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, radius)) <= radius; + } + else if (x < radius && y >= height - radius) + { + // Top-left corner + inRect = Vector2.Distance(new Vector2(x, y), new Vector2(radius, height - radius - 1)) <= radius; + } + else if (x >= width - radius && y >= height - radius) + { + // Top-right corner + inRect = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, height - radius - 1)) <= radius; + } + + pixels[y * width + x] = inRect ? Color.white : Color.clear; + } + } + + tex.SetPixels(pixels); + tex.Apply(); + + // Create 9-sliced sprite + return Sprite.Create(tex, new Rect(0, 0, width, height), new Vector2(0.5f, 0.5f), 100f, + 0, SpriteMeshType.FullRect, new Vector4(radius, radius, radius, radius)); + } + private TextMeshProUGUI CreateUIText(Transform parent, string name, string text, float fontSize, Color color, FontStyles style = FontStyles.Normal) { @@ -510,6 +842,26 @@ namespace TheIsland.Visual _energyText.text = $"Energy: {data.energy}"; } + // Update Mood bar + float moodPercent = data.mood / 100f; + if (_moodBarFill != null) + { + _moodBarFill.rectTransform.anchorMax = new Vector2(moodPercent, 1); + _moodBarFill.color = GetMoodColor(data.mood_state); + } + if (_moodText != null) + { + string moodIndicator = GetMoodEmoji(data.mood_state); + string moodLabel = data.mood_state switch + { + "happy" => "Happy", + "sad" => "Sad", + "anxious" => "Anxious", + _ => "Neutral" + }; + _moodText.text = $"{moodIndicator} {moodLabel}: {data.mood}"; + } + // Update death state if (!data.IsAlive) { @@ -521,6 +873,29 @@ namespace TheIsland.Visual } } + private Color GetMoodColor(string moodState) + { + return moodState switch + { + "happy" => moodHappyColor, + "sad" => moodSadColor, + "anxious" => moodAnxiousColor, + _ => moodNeutralColor + }; + } + + private string GetMoodEmoji(string moodState) + { + // Use text symbols instead of emoji for font compatibility + return moodState switch + { + "happy" => "+", + "sad" => "-", + "anxious" => "!", + _ => "~" + }; + } + private void OnDeath() { if (_deathOverlay != null) _deathOverlay.SetActive(true); diff --git a/unity-client/Assets/Scripts/GameManager.cs b/unity-client/Assets/Scripts/GameManager.cs index 53003a3..58455ad 100644 --- a/unity-client/Assets/Scripts/GameManager.cs +++ b/unity-client/Assets/Scripts/GameManager.cs @@ -54,10 +54,17 @@ namespace TheIsland.Core private int _currentTick; private int _currentDay; private int _nextSpawnIndex; + + // World state + private string _currentTimeOfDay = "day"; + private string _currentWeather = "Sunny"; #endregion #region Properties public int PlayerGold => _playerGold; + public string CurrentTimeOfDay => _currentTimeOfDay; + public string CurrentWeather => _currentWeather; + public int CurrentDay => _currentDay; public int AliveAgentCount { get @@ -135,6 +142,16 @@ namespace TheIsland.Core network.OnTick += HandleTick; network.OnSystemMessage += HandleSystemMessage; network.OnUserUpdate += HandleUserUpdate; + + // New phase events + network.OnWeatherChange += HandleWeatherChange; + network.OnPhaseChange += HandlePhaseChange; + network.OnDayChange += HandleDayChange; + network.OnHeal += HandleHeal; + network.OnEncourage += HandleEncourage; + network.OnTalk += HandleTalk; + network.OnRevive += HandleRevive; + network.OnSocialInteraction += HandleSocialInteraction; } private void UnsubscribeFromNetworkEvents() @@ -151,6 +168,16 @@ namespace TheIsland.Core network.OnTick -= HandleTick; network.OnSystemMessage -= HandleSystemMessage; network.OnUserUpdate -= HandleUserUpdate; + + // New phase events + network.OnWeatherChange -= HandleWeatherChange; + network.OnPhaseChange -= HandlePhaseChange; + network.OnDayChange -= HandleDayChange; + network.OnHeal -= HandleHeal; + network.OnEncourage -= HandleEncourage; + network.OnTalk -= HandleTalk; + network.OnRevive -= HandleRevive; + network.OnSocialInteraction -= HandleSocialInteraction; } #endregion @@ -215,7 +242,17 @@ namespace TheIsland.Core { if (tickInfo == null) return; - tickInfo.text = $"Day {_currentDay} | Tick {_currentTick} | Alive: {AliveAgentCount}"; + // Format time of day nicely + string timeDisplay = _currentTimeOfDay switch + { + "dawn" => "Dawn", + "day" => "Day", + "dusk" => "Dusk", + "night" => "Night", + _ => "Day" + }; + + tickInfo.text = $"Day {_currentDay} | {timeDisplay} | {_currentWeather} | Tick {_currentTick} | Alive: {AliveAgentCount}"; } private void UpdateGoldDisplay() @@ -324,6 +361,17 @@ namespace TheIsland.Core { _currentTick = data.tick; _currentDay = data.day; + + // Update weather and time of day from tick data + if (!string.IsNullOrEmpty(data.time_of_day)) + { + _currentTimeOfDay = data.time_of_day; + } + if (!string.IsNullOrEmpty(data.weather)) + { + _currentWeather = data.weather; + } + UpdateTickInfo(); } @@ -341,6 +389,102 @@ namespace TheIsland.Core UpdateGoldDisplay(); } } + + private void HandleWeatherChange(WeatherChangeData data) + { + Debug.Log($"[GameManager] Weather changed: {data.old_weather} -> {data.new_weather}"); + _currentWeather = data.new_weather; + ShowNotification($"Weather: {data.new_weather}"); + UpdateTickInfo(); + } + + private void HandlePhaseChange(PhaseChangeData data) + { + Debug.Log($"[GameManager] Phase changed: {data.old_phase} -> {data.new_phase}"); + _currentTimeOfDay = data.new_phase; + ShowNotification($"The {data.new_phase} begins..."); + UpdateTickInfo(); + } + + private void HandleDayChange(DayChangeData data) + { + Debug.Log($"[GameManager] New day: {data.day}"); + _currentDay = data.day; + ShowNotification($"Day {data.day} begins!"); + UpdateTickInfo(); + } + + private void HandleHeal(HealEventData data) + { + Debug.Log($"[GameManager] Heal event: {data.message}"); + + // Update gold if this was our action + if (data.user == NetworkManager.Instance.Username) + { + _playerGold = data.user_gold; + UpdateGoldDisplay(); + } + + ShowNotification(data.message); + } + + private void HandleEncourage(EncourageEventData data) + { + Debug.Log($"[GameManager] Encourage event: {data.message}"); + + // Update gold if this was our action + if (data.user == NetworkManager.Instance.Username) + { + _playerGold = data.user_gold; + UpdateGoldDisplay(); + } + + ShowNotification(data.message); + } + + private void HandleTalk(TalkEventData data) + { + Debug.Log($"[GameManager] Talk event: {data.agent_name} responds about '{data.topic}'"); + + // Show the agent's speech response + if (_agentVisuals.TryGetValue(GetAgentIdByName(data.agent_name), out AgentVisual agentVisual)) + { + agentVisual.ShowSpeech(data.response); + } + else if (_agentUIs.TryGetValue(GetAgentIdByName(data.agent_name), out AgentUI agentUI)) + { + agentUI.ShowSpeech(data.response); + } + } + + private void HandleRevive(ReviveEventData data) + { + Debug.Log($"[GameManager] Revive event: {data.message}"); + + // Update gold if this was our action + if (data.user == NetworkManager.Instance.Username) + { + _playerGold = data.user_gold; + UpdateGoldDisplay(); + } + + ShowNotification(data.message); + } + + private void HandleSocialInteraction(SocialInteractionData data) + { + Debug.Log($"[GameManager] Social: {data.initiator_name} -> {data.target_name} ({data.interaction_type})"); + + // Show dialogue from initiator + if (_agentVisuals.TryGetValue(data.initiator_id, out AgentVisual initiatorVisual)) + { + initiatorVisual.ShowSpeech(data.dialogue); + } + else if (_agentUIs.TryGetValue(data.initiator_id, out AgentUI initiatorUI)) + { + initiatorUI.ShowSpeech(data.dialogue); + } + } #endregion #region Agent Management @@ -429,6 +573,41 @@ namespace TheIsland.Core } return null; } + + /// + /// Get agent ID by name (searches all agent systems). + /// + private int GetAgentIdByName(string name) + { + // Check AgentVisual first (newest system) + foreach (var kvp in _agentVisuals) + { + if (kvp.Value.CurrentData?.name == name) + { + return kvp.Key; + } + } + + // Check AgentUI + foreach (var kvp in _agentUIs) + { + if (kvp.Value.CurrentData?.name == name) + { + return kvp.Key; + } + } + + // Check AgentController (legacy) + foreach (var kvp in _agents) + { + if (kvp.Value.CurrentData?.name == name) + { + return kvp.Key; + } + } + + return -1; + } #endregion #region UI Actions diff --git a/unity-client/Assets/Scripts/Models.cs b/unity-client/Assets/Scripts/Models.cs index b3da7ed..941833f 100644 --- a/unity-client/Assets/Scripts/Models.cs +++ b/unity-client/Assets/Scripts/Models.cs @@ -43,6 +43,11 @@ namespace TheIsland.Models public int energy; public string inventory; + // Mood system (Phase 3) + public int mood; + public string mood_state; // "happy", "neutral", "sad", "anxious" + public string social_tendency; // "introvert", "extrovert", "neutral" + public bool IsAlive => status == "Alive"; } @@ -99,6 +104,8 @@ namespace TheIsland.Models public int tick; public int day; public int alive_agents; + public string time_of_day; // "dawn", "day", "dusk", "night" + public string weather; // "Sunny", "Cloudy", "Rainy", etc. } /// @@ -129,6 +136,108 @@ namespace TheIsland.Models public int day_count; public string weather; public int resource_level; + public int current_tick_in_day; + public string time_of_day; // "dawn", "day", "dusk", "night" + } + + /// + /// Weather change event data. + /// + [Serializable] + public class WeatherChangeData + { + public string old_weather; + public string new_weather; + public string message; + } + + /// + /// Phase change event data (day/night cycle). + /// + [Serializable] + public class PhaseChangeData + { + public string old_phase; + public string new_phase; + public int day; + public string message; + } + + /// + /// Day change event data. + /// + [Serializable] + public class DayChangeData + { + public int day; + public string message; + } + + /// + /// Heal event data. + /// + [Serializable] + public class HealEventData + { + public string user; + public string agent_name; + public int hp_restored; + public int agent_hp; + public int user_gold; + public string message; + } + + /// + /// Encourage event data. + /// + [Serializable] + public class EncourageEventData + { + public string user; + public string agent_name; + public int mood_boost; + public int agent_mood; + public int user_gold; + public string message; + } + + /// + /// Talk event data. + /// + [Serializable] + public class TalkEventData + { + public string user; + public string agent_name; + public string topic; + public string response; + } + + /// + /// Revive event data. + /// + [Serializable] + public class ReviveEventData + { + public string user; + public string agent_name; + public int user_gold; + public string message; + } + + /// + /// Social interaction event data. + /// + [Serializable] + public class SocialInteractionData + { + public int initiator_id; + public string initiator_name; + public int target_id; + public string target_name; + public string interaction_type; // "chat", "share_food", "help", "argue", "comfort" + public string relationship_type; // "stranger", "friend", "rival", etc. + public string dialogue; } /// @@ -164,5 +273,25 @@ namespace TheIsland.Models public const string USER_UPDATE = "user_update"; public const string WORLD_UPDATE = "world_update"; public const string CHECK = "check"; + + // Day/Night cycle (Phase 2) + public const string TIME_UPDATE = "time_update"; + public const string PHASE_CHANGE = "phase_change"; + public const string DAY_CHANGE = "day_change"; + + // Weather system (Phase 3) + public const string WEATHER_CHANGE = "weather_change"; + public const string MOOD_UPDATE = "mood_update"; + + // New commands (Phase 4) + public const string HEAL = "heal"; + public const string TALK = "talk"; + public const string ENCOURAGE = "encourage"; + public const string REVIVE = "revive"; + + // Social system (Phase 5) + public const string SOCIAL_INTERACTION = "social_interaction"; + public const string RELATIONSHIP_CHANGE = "relationship_change"; + public const string AUTO_REVIVE = "auto_revive"; } } diff --git a/unity-client/Assets/Scripts/NetworkManager.cs b/unity-client/Assets/Scripts/NetworkManager.cs index 990e1f0..889c1be 100644 --- a/unity-client/Assets/Scripts/NetworkManager.cs +++ b/unity-client/Assets/Scripts/NetworkManager.cs @@ -56,6 +56,17 @@ namespace TheIsland.Network public event Action OnTick; public event Action OnSystemMessage; public event Action OnUserUpdate; + + // New Phase events + public event Action OnWeatherChange; + public event Action OnPhaseChange; + public event Action OnDayChange; + public event Action OnHeal; + public event Action OnEncourage; + public event Action OnTalk; + public event Action OnRevive; + public event Action OnSocialInteraction; + public event Action OnWorldUpdate; #endregion #region Private Fields @@ -286,6 +297,52 @@ namespace TheIsland.Network OnUserUpdate?.Invoke(userData); break; + case EventTypes.WORLD_UPDATE: + var worldData = JsonUtility.FromJson(dataJson); + OnWorldUpdate?.Invoke(worldData); + break; + + case EventTypes.WEATHER_CHANGE: + var weatherData = JsonUtility.FromJson(dataJson); + OnWeatherChange?.Invoke(weatherData); + break; + + case EventTypes.PHASE_CHANGE: + var phaseData = JsonUtility.FromJson(dataJson); + OnPhaseChange?.Invoke(phaseData); + break; + + case EventTypes.DAY_CHANGE: + var dayData = JsonUtility.FromJson(dataJson); + OnDayChange?.Invoke(dayData); + break; + + case EventTypes.HEAL: + var healData = JsonUtility.FromJson(dataJson); + OnHeal?.Invoke(healData); + break; + + case EventTypes.ENCOURAGE: + var encourageData = JsonUtility.FromJson(dataJson); + OnEncourage?.Invoke(encourageData); + break; + + case EventTypes.TALK: + var talkData = JsonUtility.FromJson(dataJson); + OnTalk?.Invoke(talkData); + break; + + case EventTypes.REVIVE: + case EventTypes.AUTO_REVIVE: + var reviveData = JsonUtility.FromJson(dataJson); + OnRevive?.Invoke(reviveData); + break; + + case EventTypes.SOCIAL_INTERACTION: + var socialData = JsonUtility.FromJson(dataJson); + OnSocialInteraction?.Invoke(socialData); + break; + case EventTypes.COMMENT: // Comments can be logged but typically not displayed in 3D Debug.Log($"[Chat] {json}"); @@ -423,6 +480,29 @@ namespace TheIsland.Network SendCommand($"feed {agentName}"); } + public void HealAgent(string agentName) + { + SendCommand($"heal {agentName}"); + } + + public void EncourageAgent(string agentName) + { + SendCommand($"encourage {agentName}"); + } + + public void TalkToAgent(string agentName, string topic = "") + { + string cmd = string.IsNullOrEmpty(topic) + ? $"talk {agentName}" + : $"talk {agentName} {topic}"; + SendCommand(cmd); + } + + public void ReviveAgent(string agentName) + { + SendCommand($"revive {agentName}"); + } + public void CheckStatus() { SendCommand("check"); diff --git a/unity-client/Assets/Scripts/SpeechBubble.cs b/unity-client/Assets/Scripts/SpeechBubble.cs index b5f8927..049bf88 100644 --- a/unity-client/Assets/Scripts/SpeechBubble.cs +++ b/unity-client/Assets/Scripts/SpeechBubble.cs @@ -84,6 +84,9 @@ namespace TheIsland.Visual // Add CanvasGroup for fading _canvasGroup = gameObject.AddComponent(); + // Create rounded rect sprite for bubble + Sprite roundedSprite = CreateRoundedBubbleSprite(32, 32, 10); + // Create outline (slightly larger background) var outlineObj = new GameObject("Outline"); outlineObj.transform.SetParent(transform); @@ -92,6 +95,8 @@ namespace TheIsland.Visual outlineObj.transform.localScale = Vector3.one; _bubbleOutline = outlineObj.AddComponent(); + _bubbleOutline.sprite = roundedSprite; + _bubbleOutline.type = Image.Type.Sliced; _bubbleOutline.color = outlineColor; var outlineRect = outlineObj.GetComponent(); outlineRect.anchorMin = Vector2.zero; @@ -107,6 +112,8 @@ namespace TheIsland.Visual bgObj.transform.localScale = Vector3.one; _bubbleBackground = bgObj.AddComponent(); + _bubbleBackground.sprite = roundedSprite; + _bubbleBackground.type = Image.Type.Sliced; _bubbleBackground.color = bubbleColor; var bgRect = bgObj.GetComponent(); bgRect.anchorMin = Vector2.zero; @@ -153,17 +160,88 @@ namespace TheIsland.Visual tailRect.anchoredPosition = new Vector2(0, 0); tailRect.sizeDelta = new Vector2(24, 16); - // Create a simple triangle using UI Image with a sprite - // For now, use a simple downward-pointing shape + // Create triangle sprite for tail var tailImage = tail.AddComponent(); + tailImage.sprite = CreateTriangleSprite(24, 16); tailImage.color = bubbleColor; - // Note: For a proper triangle, you'd use a custom sprite. - // This creates a simple rectangle as placeholder. - // In production, replace with a triangle sprite. - return tail; } + + private Sprite CreateRoundedBubbleSprite(int width, int height, int radius) + { + Texture2D tex = new Texture2D(width, height); + tex.filterMode = FilterMode.Bilinear; + + Color[] pixels = new Color[width * height]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + bool inRect = true; + + // Check corners for rounding + if (x < radius && y < radius) + { + inRect = Vector2.Distance(new Vector2(x, y), new Vector2(radius, radius)) <= radius; + } + else if (x >= width - radius && y < radius) + { + inRect = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, radius)) <= radius; + } + else if (x < radius && y >= height - radius) + { + inRect = Vector2.Distance(new Vector2(x, y), new Vector2(radius, height - radius - 1)) <= radius; + } + else if (x >= width - radius && y >= height - radius) + { + inRect = Vector2.Distance(new Vector2(x, y), new Vector2(width - radius - 1, height - radius - 1)) <= radius; + } + + pixels[y * width + x] = inRect ? Color.white : Color.clear; + } + } + + tex.SetPixels(pixels); + tex.Apply(); + + return Sprite.Create(tex, new Rect(0, 0, width, height), new Vector2(0.5f, 0.5f), 100f, + 0, SpriteMeshType.FullRect, new Vector4(radius, radius, radius, radius)); + } + + private Sprite CreateTriangleSprite(int width, int height) + { + Texture2D tex = new Texture2D(width, height); + tex.filterMode = FilterMode.Bilinear; + + Color[] pixels = new Color[width * height]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + // Triangle pointing down + float t = (float)y / height; + float halfWidth = (width / 2f) * (1 - t); + float center = width / 2f; + + if (x >= center - halfWidth && x <= center + halfWidth) + { + pixels[y * width + x] = Color.white; + } + else + { + pixels[y * width + x] = Color.clear; + } + } + } + + tex.SetPixels(pixels); + tex.Apply(); + + return Sprite.Create(tex, new Rect(0, 0, width, height), new Vector2(0.5f, 1f), 100f); + } #endregion #region Public API diff --git a/unity-client/Assets/Scripts/Visual.meta b/unity-client/Assets/Scripts/Visual.meta new file mode 100644 index 0000000..855e2d8 --- /dev/null +++ b/unity-client/Assets/Scripts/Visual.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ba6aed8ea8f684710867429092622258 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs b/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs new file mode 100644 index 0000000..e775839 --- /dev/null +++ b/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs @@ -0,0 +1,627 @@ +using UnityEngine; +using TheIsland.Core; +using TheIsland.Network; +using TheIsland.Models; + +namespace TheIsland.Visual +{ + /// + /// Manages the island environment visuals including sky, ground, water, and lighting. + /// Creates a beautiful dynamic background that responds to time of day and weather. + /// + public class EnvironmentManager : MonoBehaviour + { + #region Singleton + private static EnvironmentManager _instance; + public static EnvironmentManager Instance => _instance; + #endregion + + #region Sky Colors by Time of Day + [Header("Dawn Colors")] + [SerializeField] private Color dawnSkyTop = new Color(0.98f, 0.65f, 0.45f); + [SerializeField] private Color dawnSkyBottom = new Color(1f, 0.85f, 0.6f); + [SerializeField] private Color dawnAmbient = new Color(1f, 0.8f, 0.6f); + + [Header("Day Colors")] + [SerializeField] private Color daySkyTop = new Color(0.4f, 0.7f, 1f); + [SerializeField] private Color daySkyBottom = new Color(0.7f, 0.9f, 1f); + [SerializeField] private Color dayAmbient = new Color(1f, 1f, 0.95f); + + [Header("Dusk Colors")] + [SerializeField] private Color duskSkyTop = new Color(0.3f, 0.2f, 0.5f); + [SerializeField] private Color duskSkyBottom = new Color(1f, 0.5f, 0.3f); + [SerializeField] private Color duskAmbient = new Color(1f, 0.6f, 0.4f); + + [Header("Night Colors")] + [SerializeField] private Color nightSkyTop = new Color(0.05f, 0.05f, 0.15f); + [SerializeField] private Color nightSkyBottom = new Color(0.1f, 0.15f, 0.3f); + [SerializeField] private Color nightAmbient = new Color(0.3f, 0.35f, 0.5f); + #endregion + + #region Weather Modifiers + [Header("Weather Color Modifiers")] + [SerializeField] private Color cloudyTint = new Color(0.7f, 0.7f, 0.75f); + [SerializeField] private Color rainyTint = new Color(0.5f, 0.55f, 0.6f); + [SerializeField] private Color stormyTint = new Color(0.35f, 0.35f, 0.4f); + [SerializeField] private Color foggyTint = new Color(0.8f, 0.8f, 0.85f); + [SerializeField] private Color hotTint = new Color(1.1f, 0.95f, 0.85f); + #endregion + + #region Ground & Water + [Header("Ground Settings")] + [SerializeField] private Color sandColor = new Color(0.95f, 0.87f, 0.7f); + [SerializeField] private Color sandDarkColor = new Color(0.8f, 0.7f, 0.5f); + + [Header("Water Settings")] + [SerializeField] private Color waterShallowColor = new Color(0.3f, 0.8f, 0.9f, 0.8f); + [SerializeField] private Color waterDeepColor = new Color(0.1f, 0.4f, 0.6f, 0.9f); + [SerializeField] private float waveSpeed = 0.5f; + [SerializeField] private float waveAmplitude = 0.1f; + #endregion + + #region References + private Camera _mainCamera; + private Material _skyMaterial; + private GameObject _groundPlane; + private GameObject _waterPlane; + private Material _groundMaterial; + private Material _waterMaterial; + private Light _mainLight; + + // Current state + private string _currentTimeOfDay = "day"; + private string _currentWeather = "Sunny"; + private float _transitionProgress = 1f; + private Color _targetSkyTop, _targetSkyBottom; + private Color _currentSkyTop, _currentSkyBottom; + #endregion + + #region Unity Lifecycle + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + + _mainCamera = Camera.main; + CreateEnvironment(); + } + + private void Start() + { + // Subscribe to network events + var network = NetworkManager.Instance; + if (network != null) + { + network.OnPhaseChange += HandlePhaseChange; + network.OnWeatherChange += HandleWeatherChange; + network.OnTick += HandleTick; + } + + // Set initial sky + UpdateSkyColors(); + } + + private void Update() + { + // Smooth sky transition + if (_transitionProgress < 1f) + { + _transitionProgress += Time.deltaTime * 0.5f; + _currentSkyTop = Color.Lerp(_currentSkyTop, _targetSkyTop, _transitionProgress); + _currentSkyBottom = Color.Lerp(_currentSkyBottom, _targetSkyBottom, _transitionProgress); + UpdateSkyMaterial(); + } + + // Animate water + AnimateWater(); + } + + private void OnDestroy() + { + var network = NetworkManager.Instance; + if (network != null) + { + network.OnPhaseChange -= HandlePhaseChange; + network.OnWeatherChange -= HandleWeatherChange; + network.OnTick -= HandleTick; + } + } + #endregion + + #region Environment Creation + private void CreateEnvironment() + { + CreateSky(); + CreateGround(); + CreateWater(); + CreateLighting(); + CreateDecorations(); + } + + private void CreateSky() + { + // Create a gradient sky using a camera background shader + _skyMaterial = new Material(Shader.Find("Unlit/Color")); + + // Create sky quad that fills the background + var skyObj = GameObject.CreatePrimitive(PrimitiveType.Quad); + skyObj.name = "SkyBackground"; + skyObj.transform.SetParent(transform); + skyObj.transform.position = new Vector3(0, 5, 20); + skyObj.transform.localScale = new Vector3(60, 30, 1); + + // Remove collider + Destroy(skyObj.GetComponent()); + + // Create gradient material + _skyMaterial = CreateGradientMaterial(); + skyObj.GetComponent().material = _skyMaterial; + skyObj.GetComponent().sortingOrder = -100; + + // Set initial colors + _currentSkyTop = daySkyTop; + _currentSkyBottom = daySkyBottom; + _targetSkyTop = daySkyTop; + _targetSkyBottom = daySkyBottom; + UpdateSkyMaterial(); + } + + private Material CreateGradientMaterial() + { + // Create a simple shader for vertical gradient + string shaderCode = @" + Shader ""Custom/SkyGradient"" { + Properties { + _TopColor (""Top Color"", Color) = (0.4, 0.7, 1, 1) + _BottomColor (""Bottom Color"", Color) = (0.7, 0.9, 1, 1) + } + SubShader { + Tags { ""Queue""=""Background"" ""RenderType""=""Opaque"" } + Pass { + ZWrite Off + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + #include ""UnityCG.cginc"" + + fixed4 _TopColor; + fixed4 _BottomColor; + + struct v2f { + float4 pos : SV_POSITION; + float2 uv : TEXCOORD0; + }; + + v2f vert(appdata_base v) { + v2f o; + o.pos = UnityObjectToClipPos(v.vertex); + o.uv = v.texcoord; + return o; + } + + fixed4 frag(v2f i) : SV_Target { + return lerp(_BottomColor, _TopColor, i.uv.y); + } + ENDCG + } + } + }"; + + // Since we can't create shaders at runtime easily, use a texture-based approach + return CreateGradientTextureMaterial(); + } + + private Material CreateGradientTextureMaterial() + { + // Create gradient texture + Texture2D gradientTex = new Texture2D(1, 256); + gradientTex.wrapMode = TextureWrapMode.Clamp; + + for (int y = 0; y < 256; y++) + { + float t = y / 255f; + Color color = Color.Lerp(_currentSkyBottom, _currentSkyTop, t); + gradientTex.SetPixel(0, y, color); + } + gradientTex.Apply(); + + Material mat = new Material(Shader.Find("Unlit/Texture")); + mat.mainTexture = gradientTex; + return mat; + } + + private void UpdateSkyMaterial() + { + if (_skyMaterial == null || _skyMaterial.mainTexture == null) return; + + Texture2D tex = (Texture2D)_skyMaterial.mainTexture; + for (int y = 0; y < 256; y++) + { + float t = y / 255f; + Color color = Color.Lerp(_currentSkyBottom, _currentSkyTop, t); + tex.SetPixel(0, y, color); + } + tex.Apply(); + } + + private void CreateGround() + { + // Create sandy beach ground + _groundPlane = GameObject.CreatePrimitive(PrimitiveType.Quad); + _groundPlane.name = "GroundPlane"; + _groundPlane.transform.SetParent(transform); + _groundPlane.transform.position = new Vector3(0, -0.5f, 5); + _groundPlane.transform.rotation = Quaternion.Euler(90, 0, 0); + _groundPlane.transform.localScale = new Vector3(40, 20, 1); + + // Create sand texture + _groundMaterial = new Material(Shader.Find("Unlit/Texture")); + _groundMaterial.mainTexture = CreateSandTexture(); + _groundPlane.GetComponent().material = _groundMaterial; + _groundPlane.GetComponent().sortingOrder = -50; + + // Remove collider (we don't need physics) + Destroy(_groundPlane.GetComponent()); + } + + private Texture2D CreateSandTexture() + { + int size = 128; + Texture2D tex = new Texture2D(size, size); + tex.filterMode = FilterMode.Bilinear; + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + // Create sandy noise pattern + float noise = Mathf.PerlinNoise(x * 0.1f, y * 0.1f) * 0.3f; + float detail = Mathf.PerlinNoise(x * 0.3f, y * 0.3f) * 0.1f; + + Color baseColor = Color.Lerp(sandDarkColor, sandColor, 0.5f + noise + detail); + + // Add some sparkle/grain + if (Random.value > 0.95f) + { + baseColor = Color.Lerp(baseColor, Color.white, 0.3f); + } + + tex.SetPixel(x, y, baseColor); + } + } + tex.Apply(); + return tex; + } + + private void CreateWater() + { + // Create water plane at the horizon + _waterPlane = GameObject.CreatePrimitive(PrimitiveType.Quad); + _waterPlane.name = "WaterPlane"; + _waterPlane.transform.SetParent(transform); + _waterPlane.transform.position = new Vector3(0, -0.3f, 12); + _waterPlane.transform.rotation = Quaternion.Euler(90, 0, 0); + _waterPlane.transform.localScale = new Vector3(60, 15, 1); + + // Create water material + _waterMaterial = new Material(Shader.Find("Unlit/Transparent")); + _waterMaterial.mainTexture = CreateWaterTexture(); + _waterPlane.GetComponent().material = _waterMaterial; + _waterPlane.GetComponent().sortingOrder = -40; + + Destroy(_waterPlane.GetComponent()); + } + + private Texture2D CreateWaterTexture() + { + int size = 128; + Texture2D tex = new Texture2D(size, size); + tex.filterMode = FilterMode.Bilinear; + tex.wrapMode = TextureWrapMode.Repeat; + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + float t = (float)y / size; + Color baseColor = Color.Lerp(waterShallowColor, waterDeepColor, t); + + // Add wave highlights + float wave = Mathf.Sin(x * 0.2f + y * 0.1f) * 0.5f + 0.5f; + baseColor = Color.Lerp(baseColor, Color.white, wave * 0.1f); + + tex.SetPixel(x, y, baseColor); + } + } + tex.Apply(); + return tex; + } + + private void AnimateWater() + { + if (_waterMaterial == null) return; + + // Simple UV scrolling for wave effect + float offset = Time.time * waveSpeed * 0.1f; + _waterMaterial.mainTextureOffset = new Vector2(offset, offset * 0.5f); + } + + private void CreateLighting() + { + // Find or create main directional light + _mainLight = FindFirstObjectByType(); + if (_mainLight == null) + { + var lightObj = new GameObject("MainLight"); + lightObj.transform.SetParent(transform); + _mainLight = lightObj.AddComponent(); + _mainLight.type = LightType.Directional; + } + + _mainLight.transform.rotation = Quaternion.Euler(50, -30, 0); + _mainLight.intensity = 1f; + _mainLight.color = dayAmbient; + + // Set ambient light + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + RenderSettings.ambientLight = dayAmbient; + } + + private void CreateDecorations() + { + // Create palm tree silhouettes + CreatePalmTree(new Vector3(-8, 0, 8), 2.5f); + CreatePalmTree(new Vector3(-10, 0, 10), 3f); + CreatePalmTree(new Vector3(9, 0, 7), 2.2f); + CreatePalmTree(new Vector3(11, 0, 9), 2.8f); + + // Create rocks + CreateRock(new Vector3(-5, 0, 4), 0.5f); + CreateRock(new Vector3(6, 0, 5), 0.7f); + CreateRock(new Vector3(-7, 0, 6), 0.4f); + } + + private void CreatePalmTree(Vector3 position, float scale) + { + var treeObj = new GameObject("PalmTree"); + treeObj.transform.SetParent(transform); + treeObj.transform.position = position; + + // Create trunk (stretched capsule-ish shape using sprite) + var trunkSprite = new GameObject("Trunk"); + trunkSprite.transform.SetParent(treeObj.transform); + trunkSprite.transform.localPosition = new Vector3(0, scale * 0.5f, 0); + + var trunkRenderer = trunkSprite.AddComponent(); + trunkRenderer.sprite = CreateTreeSprite(); + trunkRenderer.sortingOrder = -20; + trunkSprite.transform.localScale = new Vector3(scale * 0.5f, scale, 1); + } + + private Sprite CreateTreeSprite() + { + int width = 64; + int height = 128; + Texture2D tex = new Texture2D(width, height); + + Color trunk = new Color(0.4f, 0.25f, 0.15f); + Color trunkDark = new Color(0.3f, 0.18f, 0.1f); + Color leaf = new Color(0.2f, 0.5f, 0.2f); + Color leafBright = new Color(0.3f, 0.65f, 0.25f); + + // Clear + Color[] pixels = new Color[width * height]; + for (int i = 0; i < pixels.Length; i++) pixels[i] = Color.clear; + + // Draw trunk + int trunkWidth = 8; + int trunkStart = width / 2 - trunkWidth / 2; + for (int y = 0; y < height * 0.6f; y++) + { + for (int x = trunkStart; x < trunkStart + trunkWidth; x++) + { + float noise = Mathf.PerlinNoise(x * 0.2f, y * 0.1f); + pixels[y * width + x] = Color.Lerp(trunkDark, trunk, noise); + } + } + + // Draw palm fronds + DrawPalmFronds(pixels, width, height, leaf, leafBright); + + tex.SetPixels(pixels); + tex.Apply(); + tex.filterMode = FilterMode.Point; + + return Sprite.Create(tex, new Rect(0, 0, width, height), new Vector2(0.5f, 0)); + } + + private void DrawPalmFronds(Color[] pixels, int width, int height, Color leaf, Color leafBright) + { + Vector2 center = new Vector2(width / 2, height * 0.65f); + + // Draw several fronds + float[] angles = { -60, -30, 0, 30, 60, -80, 80 }; + foreach (float angle in angles) + { + DrawFrond(pixels, width, height, center, angle, leaf, leafBright); + } + } + + private void DrawFrond(Color[] pixels, int width, int height, Vector2 start, float angle, Color leaf, Color leafBright) + { + float rad = angle * Mathf.Deg2Rad; + int length = 35; + + for (int i = 0; i < length; i++) + { + float t = i / (float)length; + float droop = t * t * 15; // Fronds droop more at the end + + int x = (int)(start.x + Mathf.Sin(rad) * i); + int y = (int)(start.y + Mathf.Cos(rad) * i - droop); + + // Draw thick frond + for (int dx = -2; dx <= 2; dx++) + { + for (int dy = -1; dy <= 1; dy++) + { + int px = x + dx; + int py = y + dy; + if (px >= 0 && px < width && py >= 0 && py < height) + { + float brightness = Mathf.PerlinNoise(px * 0.1f, py * 0.1f); + pixels[py * width + px] = Color.Lerp(leaf, leafBright, brightness); + } + } + } + } + } + + private void CreateRock(Vector3 position, float scale) + { + var rockObj = new GameObject("Rock"); + rockObj.transform.SetParent(transform); + rockObj.transform.position = position; + + var rockRenderer = rockObj.AddComponent(); + rockRenderer.sprite = CreateRockSprite(); + rockRenderer.sortingOrder = -15; + rockObj.transform.localScale = Vector3.one * scale; + } + + private Sprite CreateRockSprite() + { + int size = 32; + Texture2D tex = new Texture2D(size, size); + + Color rockDark = new Color(0.3f, 0.3f, 0.35f); + Color rockLight = new Color(0.5f, 0.5f, 0.55f); + + Color[] pixels = new Color[size * size]; + for (int i = 0; i < pixels.Length; i++) pixels[i] = Color.clear; + + // Draw rock shape + Vector2 center = new Vector2(size / 2, size / 3); + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + float dx = (x - center.x) / (size * 0.4f); + float dy = (y - center.y) / (size * 0.3f); + float dist = dx * dx + dy * dy; + + if (dist < 1 && y < size * 0.7f) + { + float noise = Mathf.PerlinNoise(x * 0.2f, y * 0.2f); + pixels[y * size + x] = Color.Lerp(rockDark, rockLight, noise); + } + } + } + + tex.SetPixels(pixels); + tex.Apply(); + tex.filterMode = FilterMode.Point; + + return Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0)); + } + #endregion + + #region Event Handlers + private void HandlePhaseChange(PhaseChangeData data) + { + _currentTimeOfDay = data.new_phase; + UpdateSkyColors(); + } + + private void HandleWeatherChange(WeatherChangeData data) + { + _currentWeather = data.new_weather; + UpdateSkyColors(); + } + + private void HandleTick(TickData data) + { + if (!string.IsNullOrEmpty(data.time_of_day) && data.time_of_day != _currentTimeOfDay) + { + _currentTimeOfDay = data.time_of_day; + UpdateSkyColors(); + } + if (!string.IsNullOrEmpty(data.weather) && data.weather != _currentWeather) + { + _currentWeather = data.weather; + UpdateSkyColors(); + } + } + + private void UpdateSkyColors() + { + // Get base colors for time of day + Color baseTop, baseBottom, ambient; + + switch (_currentTimeOfDay) + { + case "dawn": + baseTop = dawnSkyTop; + baseBottom = dawnSkyBottom; + ambient = dawnAmbient; + break; + case "dusk": + baseTop = duskSkyTop; + baseBottom = duskSkyBottom; + ambient = duskAmbient; + break; + case "night": + baseTop = nightSkyTop; + baseBottom = nightSkyBottom; + ambient = nightAmbient; + break; + default: // day + baseTop = daySkyTop; + baseBottom = daySkyBottom; + ambient = dayAmbient; + break; + } + + // Apply weather tint + Color weatherTint = Color.white; + switch (_currentWeather) + { + case "Cloudy": weatherTint = cloudyTint; break; + case "Rainy": weatherTint = rainyTint; break; + case "Stormy": weatherTint = stormyTint; break; + case "Foggy": weatherTint = foggyTint; break; + case "Hot": weatherTint = hotTint; break; + } + + _targetSkyTop = baseTop * weatherTint; + _targetSkyBottom = baseBottom * weatherTint; + _transitionProgress = 0f; + + // Update lighting + if (_mainLight != null) + { + _mainLight.color = ambient * weatherTint; + _mainLight.intensity = _currentTimeOfDay == "night" ? 0.3f : 1f; + } + + RenderSettings.ambientLight = ambient * weatherTint * 0.8f; + } + #endregion + + #region Public API + /// + /// Force update the environment to specific conditions. + /// + public void SetEnvironment(string timeOfDay, string weather) + { + _currentTimeOfDay = timeOfDay; + _currentWeather = weather; + UpdateSkyColors(); + } + #endregion + } +} diff --git a/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs.meta b/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs.meta new file mode 100644 index 0000000..2b2ba72 --- /dev/null +++ b/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6aa9102a04d7544619ec0187e065eda9 \ No newline at end of file diff --git a/unity-client/Assets/Scripts/Visual/SceneBootstrap.cs b/unity-client/Assets/Scripts/Visual/SceneBootstrap.cs new file mode 100644 index 0000000..dc9cd3c --- /dev/null +++ b/unity-client/Assets/Scripts/Visual/SceneBootstrap.cs @@ -0,0 +1,95 @@ +using UnityEngine; + +namespace TheIsland.Visual +{ + /// + /// Bootstraps the scene with all visual components. + /// Attach this to an empty GameObject in your scene to automatically + /// create the environment, weather effects, and other visual systems. + /// + public class SceneBootstrap : MonoBehaviour + { + [Header("Auto-Create Components")] + [SerializeField] private bool createEnvironment = true; + [SerializeField] private bool createWeatherEffects = true; + + [Header("Camera Settings")] + [SerializeField] private bool configureCamera = true; + [SerializeField] private Vector3 cameraPosition = new Vector3(0, 3, -8); + [SerializeField] private Vector3 cameraRotation = new Vector3(15, 0, 0); + [SerializeField] private float cameraFieldOfView = 60f; + + private void Awake() + { + // Configure camera + if (configureCamera) + { + ConfigureMainCamera(); + } + + // Create environment + if (createEnvironment && EnvironmentManager.Instance == null) + { + CreateEnvironmentManager(); + } + + // Create weather effects + if (createWeatherEffects && WeatherEffects.Instance == null) + { + CreateWeatherEffects(); + } + + Debug.Log("[SceneBootstrap] Visual systems initialized"); + } + + private void ConfigureMainCamera() + { + Camera mainCamera = Camera.main; + if (mainCamera == null) + { + var camObj = new GameObject("Main Camera"); + mainCamera = camObj.AddComponent(); + camObj.AddComponent(); + camObj.tag = "MainCamera"; + } + + mainCamera.transform.position = cameraPosition; + mainCamera.transform.rotation = Quaternion.Euler(cameraRotation); + mainCamera.fieldOfView = cameraFieldOfView; + mainCamera.clearFlags = CameraClearFlags.SolidColor; + mainCamera.backgroundColor = new Color(0.4f, 0.6f, 0.9f); // Fallback sky color + + Debug.Log("[SceneBootstrap] Camera configured"); + } + + private void CreateEnvironmentManager() + { + var envObj = new GameObject("EnvironmentManager"); + envObj.AddComponent(); + Debug.Log("[SceneBootstrap] EnvironmentManager created"); + } + + private void CreateWeatherEffects() + { + var weatherObj = new GameObject("WeatherEffects"); + weatherObj.AddComponent(); + Debug.Log("[SceneBootstrap] WeatherEffects created"); + } + + /// + /// Call this to manually refresh all visual systems. + /// + public void RefreshVisuals() + { + if (EnvironmentManager.Instance != null) + { + EnvironmentManager.Instance.SetEnvironment("day", "Sunny"); + } + + if (WeatherEffects.Instance != null) + { + WeatherEffects.Instance.SetWeather("Sunny"); + } + } + } +} diff --git a/unity-client/Assets/Scripts/Visual/SceneBootstrap.cs.meta b/unity-client/Assets/Scripts/Visual/SceneBootstrap.cs.meta new file mode 100644 index 0000000..2516c44 --- /dev/null +++ b/unity-client/Assets/Scripts/Visual/SceneBootstrap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 35dc7c6201b284023b5ab113ffab8add \ No newline at end of file diff --git a/unity-client/Assets/Scripts/Visual/WeatherEffects.cs b/unity-client/Assets/Scripts/Visual/WeatherEffects.cs new file mode 100644 index 0000000..745e46b --- /dev/null +++ b/unity-client/Assets/Scripts/Visual/WeatherEffects.cs @@ -0,0 +1,473 @@ +using UnityEngine; +using TheIsland.Network; +using TheIsland.Models; + +namespace TheIsland.Visual +{ + /// + /// Creates and manages weather particle effects. + /// Responds to weather changes from the server. + /// + public class WeatherEffects : MonoBehaviour + { + #region Singleton + private static WeatherEffects _instance; + public static WeatherEffects Instance => _instance; + #endregion + + #region Configuration + [Header("Rain Settings")] + [SerializeField] private int rainParticleCount = 500; + [SerializeField] private int stormParticleCount = 1000; + [SerializeField] private Color rainColor = new Color(0.7f, 0.8f, 0.9f, 0.6f); + + [Header("Sun Settings")] + [SerializeField] private int sunRayCount = 50; + [SerializeField] private Color sunRayColor = new Color(1f, 0.95f, 0.8f, 0.3f); + + [Header("Fog Settings")] + [SerializeField] private Color fogColor = new Color(0.85f, 0.85f, 0.9f, 0.5f); + + [Header("Hot Weather Settings")] + [SerializeField] private int heatWaveCount = 30; + [SerializeField] private Color heatColor = new Color(1f, 0.9f, 0.7f, 0.2f); + #endregion + + #region References + private ParticleSystem _rainSystem; + private ParticleSystem _sunRaySystem; + private ParticleSystem _fogSystem; + private ParticleSystem _heatSystem; + private ParticleSystem _cloudSystem; + + private string _currentWeather = "Sunny"; + #endregion + + #region Unity Lifecycle + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + + CreateAllEffects(); + } + + private void Start() + { + var network = NetworkManager.Instance; + if (network != null) + { + network.OnWeatherChange += HandleWeatherChange; + network.OnTick += HandleTick; + } + + // Start with sunny weather + SetWeather("Sunny"); + } + + private void OnDestroy() + { + var network = NetworkManager.Instance; + if (network != null) + { + network.OnWeatherChange -= HandleWeatherChange; + network.OnTick -= HandleTick; + } + } + #endregion + + #region Effect Creation + private void CreateAllEffects() + { + CreateRainEffect(); + CreateSunRayEffect(); + CreateFogEffect(); + CreateHeatEffect(); + CreateCloudEffect(); + } + + private void CreateRainEffect() + { + var rainObj = new GameObject("RainEffect"); + rainObj.transform.SetParent(transform); + rainObj.transform.position = new Vector3(0, 10, 5); + + _rainSystem = rainObj.AddComponent(); + var main = _rainSystem.main; + main.maxParticles = stormParticleCount; + main.startLifetime = 1.5f; + main.startSpeed = 15f; + main.startSize = 0.05f; + main.startColor = rainColor; + main.simulationSpace = ParticleSystemSimulationSpace.World; + main.gravityModifier = 1.5f; + + var emission = _rainSystem.emission; + emission.rateOverTime = rainParticleCount; + + var shape = _rainSystem.shape; + shape.shapeType = ParticleSystemShapeType.Box; + shape.scale = new Vector3(25, 0.1f, 15); + + // Renderer settings + var renderer = rainObj.GetComponent(); + renderer.material = CreateParticleMaterial(rainColor); + renderer.sortingOrder = 50; + + // Start stopped + _rainSystem.Stop(); + } + + private void CreateSunRayEffect() + { + var sunObj = new GameObject("SunRayEffect"); + sunObj.transform.SetParent(transform); + sunObj.transform.position = new Vector3(5, 8, 10); + sunObj.transform.rotation = Quaternion.Euler(45, -30, 0); + + _sunRaySystem = sunObj.AddComponent(); + var main = _sunRaySystem.main; + main.maxParticles = sunRayCount; + main.startLifetime = 3f; + main.startSpeed = 0.5f; + main.startSize = new ParticleSystem.MinMaxCurve(0.5f, 2f); + main.startColor = sunRayColor; + main.simulationSpace = ParticleSystemSimulationSpace.World; + + var emission = _sunRaySystem.emission; + emission.rateOverTime = 10; + + var shape = _sunRaySystem.shape; + shape.shapeType = ParticleSystemShapeType.Cone; + shape.angle = 15; + shape.radius = 3; + + var colorOverLifetime = _sunRaySystem.colorOverLifetime; + colorOverLifetime.enabled = true; + Gradient gradient = new Gradient(); + gradient.SetKeys( + new GradientColorKey[] { new GradientColorKey(Color.white, 0), new GradientColorKey(Color.white, 1) }, + new GradientAlphaKey[] { new GradientAlphaKey(0, 0), new GradientAlphaKey(0.3f, 0.3f), new GradientAlphaKey(0, 1) } + ); + colorOverLifetime.color = gradient; + + var sizeOverLifetime = _sunRaySystem.sizeOverLifetime; + sizeOverLifetime.enabled = true; + sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(1f, new AnimationCurve( + new Keyframe(0, 0.5f), new Keyframe(0.5f, 1f), new Keyframe(1, 1.5f))); + + var renderer = sunObj.GetComponent(); + renderer.material = CreateSunRayMaterial(); + renderer.sortingOrder = 40; + + _sunRaySystem.Stop(); + } + + private void CreateFogEffect() + { + var fogObj = new GameObject("FogEffect"); + fogObj.transform.SetParent(transform); + fogObj.transform.position = new Vector3(0, 1, 5); + + _fogSystem = fogObj.AddComponent(); + var main = _fogSystem.main; + main.maxParticles = 100; + main.startLifetime = 8f; + main.startSpeed = 0.3f; + main.startSize = new ParticleSystem.MinMaxCurve(3f, 6f); + main.startColor = fogColor; + main.simulationSpace = ParticleSystemSimulationSpace.World; + + var emission = _fogSystem.emission; + emission.rateOverTime = 5; + + var shape = _fogSystem.shape; + shape.shapeType = ParticleSystemShapeType.Box; + shape.scale = new Vector3(30, 2, 15); + + var velocityOverLifetime = _fogSystem.velocityOverLifetime; + velocityOverLifetime.enabled = true; + velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-0.2f, 0.2f); + velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(0.05f, 0.1f); + + var colorOverLifetime = _fogSystem.colorOverLifetime; + colorOverLifetime.enabled = true; + Gradient gradient = new Gradient(); + gradient.SetKeys( + new GradientColorKey[] { new GradientColorKey(Color.white, 0), new GradientColorKey(Color.white, 1) }, + new GradientAlphaKey[] { new GradientAlphaKey(0, 0), new GradientAlphaKey(0.5f, 0.3f), new GradientAlphaKey(0.5f, 0.7f), new GradientAlphaKey(0, 1) } + ); + colorOverLifetime.color = gradient; + + var renderer = fogObj.GetComponent(); + renderer.material = CreateFogMaterial(); + renderer.sortingOrder = 30; + + _fogSystem.Stop(); + } + + private void CreateHeatEffect() + { + var heatObj = new GameObject("HeatEffect"); + heatObj.transform.SetParent(transform); + heatObj.transform.position = new Vector3(0, 0, 5); + + _heatSystem = heatObj.AddComponent(); + var main = _heatSystem.main; + main.maxParticles = heatWaveCount; + main.startLifetime = 4f; + main.startSpeed = 0.8f; + main.startSize = new ParticleSystem.MinMaxCurve(1f, 3f); + main.startColor = heatColor; + main.simulationSpace = ParticleSystemSimulationSpace.World; + + var emission = _heatSystem.emission; + emission.rateOverTime = 8; + + var shape = _heatSystem.shape; + shape.shapeType = ParticleSystemShapeType.Box; + shape.scale = new Vector3(20, 0.1f, 10); + + var velocityOverLifetime = _heatSystem.velocityOverLifetime; + velocityOverLifetime.enabled = true; + velocityOverLifetime.y = 1f; + velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-0.3f, 0.3f); + + var colorOverLifetime = _heatSystem.colorOverLifetime; + colorOverLifetime.enabled = true; + Gradient gradient = new Gradient(); + gradient.SetKeys( + new GradientColorKey[] { new GradientColorKey(heatColor, 0), new GradientColorKey(heatColor, 1) }, + new GradientAlphaKey[] { new GradientAlphaKey(0, 0), new GradientAlphaKey(0.2f, 0.3f), new GradientAlphaKey(0, 1) } + ); + colorOverLifetime.color = gradient; + + var renderer = heatObj.GetComponent(); + renderer.material = CreateHeatMaterial(); + renderer.sortingOrder = 35; + + _heatSystem.Stop(); + } + + private void CreateCloudEffect() + { + var cloudObj = new GameObject("CloudEffect"); + cloudObj.transform.SetParent(transform); + cloudObj.transform.position = new Vector3(0, 8, 15); + + _cloudSystem = cloudObj.AddComponent(); + var main = _cloudSystem.main; + main.maxParticles = 30; + main.startLifetime = 20f; + main.startSpeed = 0.2f; + main.startSize = new ParticleSystem.MinMaxCurve(5f, 10f); + main.startColor = new Color(1, 1, 1, 0.7f); + main.simulationSpace = ParticleSystemSimulationSpace.World; + + var emission = _cloudSystem.emission; + emission.rateOverTime = 1; + + var shape = _cloudSystem.shape; + shape.shapeType = ParticleSystemShapeType.Box; + shape.scale = new Vector3(40, 2, 5); + + var velocityOverLifetime = _cloudSystem.velocityOverLifetime; + velocityOverLifetime.enabled = true; + velocityOverLifetime.x = 0.3f; + + var renderer = cloudObj.GetComponent(); + renderer.material = CreateCloudMaterial(); + renderer.sortingOrder = 25; + + _cloudSystem.Stop(); + } + #endregion + + #region Material Creation + private Material CreateParticleMaterial(Color color) + { + Material mat = new Material(Shader.Find("Particles/Standard Unlit")); + mat.SetColor("_Color", color); + mat.SetFloat("_Mode", 2); // Fade mode + + // Create simple white texture + Texture2D tex = new Texture2D(8, 8); + for (int i = 0; i < 64; i++) tex.SetPixel(i % 8, i / 8, Color.white); + tex.Apply(); + mat.mainTexture = tex; + + return mat; + } + + private Material CreateSunRayMaterial() + { + Material mat = new Material(Shader.Find("Particles/Standard Unlit")); + mat.SetColor("_Color", sunRayColor); + mat.SetFloat("_Mode", 1); // Additive + + // Create soft gradient texture + Texture2D tex = new Texture2D(32, 32); + Vector2 center = new Vector2(16, 16); + for (int y = 0; y < 32; y++) + { + for (int x = 0; x < 32; x++) + { + float dist = Vector2.Distance(new Vector2(x, y), center) / 16f; + float alpha = Mathf.Clamp01(1 - dist); + tex.SetPixel(x, y, new Color(1, 1, 1, alpha * alpha)); + } + } + tex.Apply(); + mat.mainTexture = tex; + + return mat; + } + + private Material CreateFogMaterial() + { + Material mat = new Material(Shader.Find("Particles/Standard Unlit")); + mat.SetColor("_Color", fogColor); + mat.SetFloat("_Mode", 2); // Fade + + // Create soft cloud texture + Texture2D tex = new Texture2D(64, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 64; x++) + { + float noise = Mathf.PerlinNoise(x * 0.1f, y * 0.1f); + float dist = Vector2.Distance(new Vector2(x, y), new Vector2(32, 32)) / 32f; + float alpha = Mathf.Clamp01((1 - dist) * noise); + tex.SetPixel(x, y, new Color(1, 1, 1, alpha)); + } + } + tex.Apply(); + mat.mainTexture = tex; + + return mat; + } + + private Material CreateHeatMaterial() + { + Material mat = new Material(Shader.Find("Particles/Standard Unlit")); + mat.SetColor("_Color", heatColor); + mat.SetFloat("_Mode", 1); // Additive + + // Create wavy heat texture + Texture2D tex = new Texture2D(32, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 32; x++) + { + float wave = Mathf.Sin((x + y * 0.3f) * 0.3f) * 0.5f + 0.5f; + float fade = 1 - Mathf.Abs(x - 16) / 16f; + float alpha = wave * fade * (1 - y / 64f); + tex.SetPixel(x, y, new Color(1, 1, 1, alpha * 0.3f)); + } + } + tex.Apply(); + mat.mainTexture = tex; + + return mat; + } + + private Material CreateCloudMaterial() + { + Material mat = new Material(Shader.Find("Particles/Standard Unlit")); + mat.SetColor("_Color", Color.white); + mat.SetFloat("_Mode", 2); // Fade + + // Create fluffy cloud texture + Texture2D tex = new Texture2D(64, 64); + for (int y = 0; y < 64; y++) + { + for (int x = 0; x < 64; x++) + { + float noise1 = Mathf.PerlinNoise(x * 0.08f, y * 0.08f); + float noise2 = Mathf.PerlinNoise(x * 0.15f + 100, y * 0.15f + 100) * 0.5f; + float dist = Vector2.Distance(new Vector2(x, y), new Vector2(32, 32)) / 32f; + float alpha = Mathf.Clamp01((noise1 + noise2) * (1 - dist * dist)); + tex.SetPixel(x, y, new Color(1, 1, 1, alpha * 0.8f)); + } + } + tex.Apply(); + tex.filterMode = FilterMode.Bilinear; + mat.mainTexture = tex; + + return mat; + } + #endregion + + #region Weather Control + private void HandleWeatherChange(WeatherChangeData data) + { + SetWeather(data.new_weather); + } + + private void HandleTick(TickData data) + { + if (!string.IsNullOrEmpty(data.weather) && data.weather != _currentWeather) + { + SetWeather(data.weather); + } + } + + public void SetWeather(string weather) + { + _currentWeather = weather; + + // Stop all effects first + _rainSystem?.Stop(); + _sunRaySystem?.Stop(); + _fogSystem?.Stop(); + _heatSystem?.Stop(); + _cloudSystem?.Stop(); + + // Enable appropriate effects + switch (weather) + { + case "Sunny": + _sunRaySystem?.Play(); + break; + + case "Cloudy": + _cloudSystem?.Play(); + break; + + case "Rainy": + _rainSystem?.Play(); + var rainMain = _rainSystem.main; + var rainEmission = _rainSystem.emission; + rainEmission.rateOverTime = rainParticleCount; + _cloudSystem?.Play(); + break; + + case "Stormy": + _rainSystem?.Play(); + var stormMain = _rainSystem.main; + var stormEmission = _rainSystem.emission; + stormEmission.rateOverTime = stormParticleCount; + stormMain.startSpeed = 20f; + _cloudSystem?.Play(); + // Could add lightning flashes here + break; + + case "Foggy": + _fogSystem?.Play(); + break; + + case "Hot": + _heatSystem?.Play(); + _sunRaySystem?.Play(); + break; + } + + Debug.Log($"[WeatherEffects] Weather set to: {weather}"); + } + #endregion + } +} diff --git a/unity-client/Assets/Scripts/Visual/WeatherEffects.cs.meta b/unity-client/Assets/Scripts/Visual/WeatherEffects.cs.meta new file mode 100644 index 0000000..5395b62 --- /dev/null +++ b/unity-client/Assets/Scripts/Visual/WeatherEffects.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d309e5fa265df414cba2779d11a0ed3c \ No newline at end of file