Spaces:
Running
Running
import streamlit as st | |
import asyncio | |
import websockets | |
import uuid | |
from datetime import datetime | |
import os | |
import random | |
import hashlib | |
import base64 | |
import edge_tts | |
import nest_asyncio | |
import re | |
import threading | |
import time | |
import json | |
import streamlit.components.v1 as components | |
from gradio_client import Client | |
from streamlit_marquee import streamlit_marquee | |
import folium | |
from streamlit_folium import folium_static | |
# Patch asyncio for nesting | |
nest_asyncio.apply() | |
# Page Config | |
st.set_page_config( | |
layout="wide", | |
page_title="Rocky Mountain Quest ๐๏ธ๐ฎ", | |
page_icon="๐ฆ" | |
) | |
# Game Config | |
GAME_NAME = "Rocky Mountain Quest ๐๏ธ๐ฎ" | |
START_LOCATION = "Trailhead Camp โบ" | |
CHARACTERS = { | |
"Trailblazer Tim ๐": {"voice": "en-US-GuyNeural", "desc": "Fearless hiker seeking epic trails!", "color": 0x00ff00}, | |
"Meme Queen Mia ๐": {"voice": "en-US-JennyNeural", "desc": "Spreads laughs with wild memes!", "color": 0xff00ff}, | |
"Elk Whisperer Eve ๐ฆ": {"voice": "en-GB-SoniaNeural", "desc": "Talks to wildlife, loves nature!", "color": 0x0000ff}, | |
"Tech Titan Tara ๐พ": {"voice": "en-AU-NatashaNeural", "desc": "Codes her way through the Rockies!", "color": 0xffff00}, | |
"Ski Guru Sam โท๏ธ": {"voice": "en-CA-ClaraNeural", "desc": "Shreds slopes, lives for snow!", "color": 0xffa500}, | |
"Cosmic Camper Cal ๐ ": {"voice": "en-US-AriaNeural", "desc": "Stargazes and tells epic tales!", "color": 0x800080}, | |
"Rasta Ranger Rick ๐": {"voice": "en-GB-RyanNeural", "desc": "Chills with natureโs vibes!", "color": 0x00ffff}, | |
"Boulder Bro Ben ๐ชจ": {"voice": "en-AU-WilliamNeural", "desc": "Climbs rocks, bro-style!", "color": 0xff4500} | |
} | |
FILE_EMOJIS = {"md": "๐", "mp3": "๐ต"} | |
# Prairie Simulator Locations | |
PRAIRIE_LOCATIONS = { | |
"Deadwood, SD": (44.3769, -103.7298), | |
"Wind Cave National Park": (43.6047, -103.4798), | |
"Wyoming Spring Creek": (41.6666, -106.6666) | |
} | |
# Directories | |
for d in ["chat_logs", "audio_logs"]: | |
os.makedirs(d, exist_ok=True) | |
CHAT_DIR = "chat_logs" | |
AUDIO_DIR = "audio_logs" | |
STATE_FILE = "user_state.txt" | |
CHAT_FILE = os.path.join(CHAT_DIR, "quest_log.md") | |
# Helpers | |
def format_timestamp(username=""): | |
now = datetime.now().strftime("%Y%m%d_%H%M%S") | |
return f"{now}-by-{username}" | |
def clean_text_for_tts(text): | |
return re.sub(r'[#*!\[\]]+', '', ' '.join(text.split()))[:200] or "No text" | |
def generate_filename(prompt, username, file_type="md"): | |
timestamp = format_timestamp(username) | |
hash_val = hashlib.md5(prompt.encode()).hexdigest()[:8] | |
return f"{timestamp}-{hash_val}.{file_type}" | |
def create_file(prompt, username, file_type="md"): | |
filename = generate_filename(prompt, username, file_type) | |
with open(filename, 'w', encoding='utf-8') as f: | |
f.write(prompt) | |
return filename | |
def get_download_link(file, file_type="mp3"): | |
with open(file, "rb") as f: | |
b64 = base64.b64encode(f.read()).decode() | |
mime_types = {"mp3": "audio/mpeg", "md": "text/markdown"} | |
return f'<a href="data:{mime_types.get(file_type, "application/octet-stream")};base64,{b64}" download="{os.path.basename(file)}">{FILE_EMOJIS.get(file_type, "๐ฅ")} {os.path.basename(file)}</a>' | |
def save_username(username): | |
with open(STATE_FILE, 'w') as f: | |
f.write(username) | |
def load_username(): | |
if os.path.exists(STATE_FILE): | |
with open(STATE_FILE, 'r') as f: | |
return f.read().strip() | |
return None | |
# Audio Processing | |
async def async_edge_tts_generate(text, voice, username): | |
cache_key = f"{text[:100]}_{voice}" | |
if cache_key in st.session_state['audio_cache']: | |
return st.session_state['audio_cache'][cache_key] | |
text = clean_text_for_tts(text) | |
filename = f"{format_timestamp(username)}-{hashlib.md5(text.encode()).hexdigest()[:8]}.mp3" | |
communicate = edge_tts.Communicate(text, voice) | |
await communicate.save(filename) | |
if os.path.exists(filename) and os.path.getsize(filename) > 0: | |
st.session_state['audio_cache'][cache_key] = filename | |
return filename | |
return None | |
def play_and_download_audio(file_path): | |
if file_path and os.path.exists(file_path): | |
st.audio(file_path) | |
st.markdown(get_download_link(file_path), unsafe_allow_html=True) | |
# WebSocket Broadcast | |
async def broadcast_message(message, room_id): | |
if room_id in st.session_state.active_connections: | |
disconnected = [] | |
for client_id, ws in st.session_state.active_connections[room_id].items(): | |
try: | |
await ws.send(message) | |
except websockets.ConnectionClosed: | |
disconnected.append(client_id) | |
for client_id in disconnected: | |
if client_id in st.session_state.active_connections[room_id]: | |
del st.session_state.active_connections[room_id][client_id] | |
# Chat and Quest Log | |
async def save_chat_entry(username, message, voice, is_markdown=False): | |
if not message.strip() or message == st.session_state.last_transcript: | |
return None, None | |
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
entry = f"[{timestamp}] {username}: {message}" if not is_markdown else f"[{timestamp}] {username}:\n```markdown\n{message}\n```" | |
md_file = create_file(entry, username, "md") | |
with open(CHAT_FILE, 'a') as f: | |
f.write(f"{entry}\n") | |
audio_file = await async_edge_tts_generate(message, voice, username) | |
await broadcast_message(f"{username}|{message}", "quest") | |
st.session_state.chat_history.append(entry) | |
st.session_state.last_transcript = message | |
st.session_state.score += 10 | |
st.session_state.treasures += 1 | |
return md_file, audio_file | |
async def load_chat(): | |
if not os.path.exists(CHAT_FILE): | |
with open(CHAT_FILE, 'a') as f: | |
f.write(f"# {GAME_NAME} Log\n\nThe adventure begins at {START_LOCATION}! ๐๏ธ\n") | |
with open(CHAT_FILE, 'r') as f: | |
content = f.read().strip() | |
return content.split('\n') | |
# Session State Init | |
def init_session_state(): | |
defaults = { | |
'server_running': False, 'server_task': None, 'active_connections': {}, | |
'chat_history': [], 'audio_cache': {}, 'last_transcript': "", | |
'username': None, 'score': 0, 'treasures': 0, 'location': START_LOCATION, | |
'speech_processed': False, 'players': {}, 'last_update': time.time(), | |
'update_interval': 20, 'x_pos': 0, 'z_pos': 0, 'move_left': False, | |
'move_right': False, 'move_up': False, 'move_down': False, | |
'prairie_players': {} | |
} | |
for k, v in defaults.items(): | |
if k not in st.session_state: | |
st.session_state[k] = v | |
if st.session_state.username is None: | |
saved_username = load_username() | |
if saved_username and saved_username in CHARACTERS: | |
st.session_state.username = saved_username | |
else: | |
st.session_state.username = random.choice(list(CHARACTERS.keys())) | |
asyncio.run(save_chat_entry(st.session_state.username, "๐บ๏ธ Begins the Rocky Mountain Quest!", CHARACTERS[st.session_state.username]["voice"])) | |
save_username(st.session_state.username) | |
init_session_state() | |
# ArXiv Integration | |
async def perform_arxiv_search(query, username): | |
gradio_client = Client("awacke1/Arxiv-Paper-Search-And-QA-RAG-Pattern") | |
refs = gradio_client.predict( | |
query, 5, "Semantic Search", "mistralai/Mixtral-8x7B-Instruct-v0.1", api_name="/update_with_rag_md" | |
)[0] | |
result = f"๐ Ancient Rocky Knowledge:\n{refs}" | |
voice = CHARACTERS[username]["voice"] | |
md_file, audio_file = await save_chat_entry(username, result, voice, True) | |
return md_file, audio_file | |
# WebSocket for Multiplayer with Map Updates | |
async def websocket_handler(websocket, path): | |
client_id = str(uuid.uuid4()) | |
room_id = "quest" | |
if room_id not in st.session_state.active_connections: | |
st.session_state.active_connections[room_id] = {} | |
st.session_state.active_connections[room_id][client_id] = websocket | |
username = st.session_state.username | |
if "prairie" in path: | |
st.session_state.prairie_players[client_id] = { | |
"username": username, | |
"animal": "prairie_dog", | |
"location": PRAIRIE_LOCATIONS["Deadwood, SD"], | |
"color": CHARACTERS[username]["color"] | |
} | |
else: | |
st.session_state.players[client_id] = { | |
"username": username, | |
"x": random.uniform(-20, 20), | |
"z": random.uniform(-50, 50), | |
"color": CHARACTERS[username]["color"] | |
} | |
await save_chat_entry(username, f"๐บ๏ธ Joins the quest at {START_LOCATION}!", CHARACTERS[username]["voice"]) | |
try: | |
async for message in websocket: | |
if '|' in message: | |
username, content = message.split('|', 1) | |
voice = CHARACTERS.get(username, {"voice": "en-US-AriaNeural"})["voice"] | |
if content.startswith("MOVE:"): | |
_, x, z = content.split(":") | |
st.session_state.players[client_id]["x"] = float(x) | |
st.session_state.players[client_id]["z"] = float(z) | |
elif content.startswith("PRAIRIE:"): | |
action, value = content.split(":", 1) | |
if action == "ANIMAL": | |
st.session_state.prairie_players[client_id]["animal"] = value | |
elif action == "MOVE": | |
target = PRAIRIE_LOCATIONS.get(value, PRAIRIE_LOCATIONS["Deadwood, SD"]) | |
.ADDRESSst.session_state.prairie_players[client_id]["location"] = target | |
action_msg = f"{username} ({st.session_state.prairie_players[client_id]['animal']}) moves to {value}" | |
await save_chat_entry(username, action_msg, voice) | |
else: | |
await save_chat_entry(username, content, voice) | |
await perform_arxiv_search(content, username) | |
except websockets.ConnectionClosed: | |
await save_chat_entry(username, "๐ Leaves the quest!", CHARACTERS[username]["voice"]) | |
if client_id in st.session_state.players: | |
del st.session_state.players[client_id] | |
if client_id in st.session_state.prairie_players: | |
del st.session_state.prairie_players[client_id] | |
finally: | |
if room_id in st.session_state.active_connections and client_id in st.session_state.active_connections[room_id]: | |
del st.session_state.active_connections[room_id][client_id] | |
async def periodic_update(): | |
while True: | |
if st.session_state.active_connections.get("quest"): | |
player_list = ", ".join([p["username"] for p in st.session_state.players.values()]) or "No adventurers yet!" | |
message = f"๐ข Quest Update: Active Adventurers - {player_list}" | |
player_data = json.dumps(list(st.session_state.players.values())) | |
await broadcast_message(f"System|{message}", "quest") | |
await broadcast_message(f"MAP_UPDATE:{player_data}", "quest") | |
prairie_list = ", ".join([f"{p['username']} ({p['animal']})" for p in st.session_state.prairie_players.values()]) or "No animals yet!" | |
prairie_message = f"๐พ Prairie Update: {prairie_list}" | |
prairie_data = json.dumps(list(st.session_state.prairie_players.values())) | |
await broadcast_message(f"System|{prairie_message}", "quest") | |
await broadcast_message(f"PRAIRIE_UPDATE:{prairie_data}", "quest") | |
await save_chat_entry("System", f"{message}\n{prairie_message}", "en-US-AriaNeural") | |
await asyncio.sleep(st.session_state.update_interval) | |
async def run_websocket_server(): | |
if not st.session_state.get('server_running', False): | |
server = await websockets.serve(websocket_handler, '0.0.0.0', 8765) | |
st.session_state['server_running'] = True | |
asyncio.create_task(periodic_update()) | |
await server.wait_closed() | |
def start_websocket_server(): | |
loop = asyncio.new_event_loop() | |
asyncio.set_event_loop(loop) | |
loop.run_until_complete(run_websocket_server()) | |
# Rocky Mountain Quest Map HTML | |
rocky_map_html = f""" | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Rocky Mountain Quest Map</title> | |
<style> | |
body {{ margin: 0; overflow: hidden; font-family: Arial, sans-serif; background: #000; }} | |
#gameContainer {{ width: 800px; height: 600px; position: relative; }} | |
canvas {{ width: 100%; height: 100%; display: block; }} | |
#chatBox {{ | |
position: absolute; bottom: 10px; left: 10px; width: 300px; height: 150px; | |
background: rgba(0, 0, 0, 0.7); color: white; padding: 10px; | |
border-radius: 5px; overflow-y: auto; | |
}} | |
#status {{ | |
position: absolute; top: 10px; left: 10px; color: white; | |
background: rgba(0, 0, 0, 0.5); padding: 5px; border-radius: 5px; | |
}} | |
</style> | |
</head> | |
<body> | |
<div id="gameContainer"> | |
<div id="status">Players: 1</div> | |
<div id="chatBox"></div> | |
</div> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
<script> | |
const playerName = "{st.session_state.username}"; | |
let ws = new WebSocket('ws://localhost:8765'); | |
const scene = new THREE.Scene(); | |
const camera = new THREE.PerspectiveCamera(75, 800 / 600, 0.1, 1000); | |
camera.position.set(0, 50, 50); | |
camera.lookAt(0, 0, 0); | |
const renderer = new THREE.WebGLRenderer({{ antialias: true }}); | |
renderer.setSize(800, 600); | |
document.getElementById('gameContainer').appendChild(renderer.domElement); | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); | |
scene.add(ambientLight); | |
const sunLight = new THREE.DirectionalLight(0xffddaa, 1); | |
sunLight.position.set(50, 50, 50); | |
scene.add(sunLight); | |
const groundGeometry = new THREE.PlaneGeometry(100, 100); | |
const groundMaterial = new THREE.MeshStandardMaterial({{ color: 0x228B22 }}); | |
const ground = new THREE.Mesh(groundGeometry, groundMaterial); | |
ground.rotation.x = -Math.PI / 2; | |
scene.add(ground); | |
let players = {{}}; | |
const playerMeshes = {{}}; | |
let xPos = 0; | |
let zPos = 0; | |
function updatePlayers(playerData) {{ | |
playerData.forEach(player => {{ | |
if (!playerMeshes[player.username]) {{ | |
const geometry = new THREE.BoxGeometry(2, 2, 2); | |
const material = new THREE.MeshPhongMaterial({{ color: player.color }}); | |
const mesh = new THREE.Mesh(geometry, material); | |
scene.add(mesh); | |
playerMeshes[player.username] = mesh; | |
}} | |
const mesh = playerMeshes[player.username]; | |
mesh.position.set(player.x, 1, player.z); | |
}}); | |
document.getElementById('status').textContent = `Players: ${{Object.keys(playerMeshes).length}}`; | |
}} | |
document.addEventListener('keydown', (event) => {{ | |
const speed = 2; | |
switch (event.code) {{ | |
case 'ArrowLeft': case 'KeyA': | |
xPos -= speed; | |
ws.send(`${{playerName}}|MOVE:${{xPos}}:${{zPos}}`); | |
break; | |
case 'ArrowRight': case 'KeyD': | |
xPos += speed; | |
ws.send(`${{playerName}}|MOVE:${{xPos}}:${{zPos}}`); | |
break; | |
case 'ArrowUp': case 'KeyW': | |
zPos -= speed; | |
ws.send(`${{playerName}}|MOVE:${{xPos}}:${{zPos}}`); | |
break; | |
case 'ArrowDown': case 'KeyS': | |
zPos += speed; | |
ws.send(`${{playerName}}|MOVE:${{xPos}}:${{zPos}}`); | |
break; | |
}} | |
}}); | |
ws.onmessage = function(event) {{ | |
const data = event.data; | |
if (data.startsWith('MAP_UPDATE:')) {{ | |
const playerData = JSON.parse(data.split('MAP_UPDATE:')[1]); | |
updatePlayers(playerData); | |
}} else if (!data.startsWith('PRAIRIE_UPDATE:')) {{ | |
const [sender, message] = data.split('|'); | |
const chatBox = document.getElementById('chatBox'); | |
chatBox.innerHTML += `<p>${{sender}}: ${{message}}</p>`; | |
chatBox.scrollTop = chatBox.scrollHeight; | |
}} | |
}}; | |
function animate() {{ | |
requestAnimationFrame(animate); | |
renderer.render(scene, camera); | |
}} | |
animate(); | |
</script> | |
</body> | |
</html> | |
""" | |
# Prairie Simulator HTML (Corrected) | |
prairie_simulator_html = """ | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Prairie Simulator</title> | |
<style> | |
body {{ margin: 0; font-family: Arial, sans-serif; background: #f0f0f0; }} | |
#simContainer {{ width: 100%; height: 600px; position: relative; }} | |
#map {{ width: 100%; height: 400px; }} | |
#controls {{ padding: 10px; background: #fff; border-radius: 5px; }} | |
#status {{ color: #333; padding: 5px; }} | |
#chatBox {{ height: 100px; overflow-y: auto; background: #fff; border: 1px solid #ccc; padding: 5px; }} | |
</style> | |
</head> | |
<body> | |
<div id="simContainer"> | |
<div id="map"></div> | |
<div id="controls"> | |
<label>Animal: </label> | |
<select id="animal" onchange="updateAnimal()"> | |
<option value="prairie_dog">Prairie Dog</option> | |
<option value="deer">Deer</option> | |
<option value="sheep">Sheep</option> | |
<option value="groundhog">Groundhog</option> | |
</select> | |
<label>Move to: </label> | |
<select id="location"> | |
<option value="Deadwood, SD">Deadwood, SD</option> | |
<option value="Wind Cave National Park">Wind Cave National Park</option> | |
<option value="Wyoming Spring Creek">Wyoming Spring Creek</option> | |
</select> | |
<button onclick="move()">Move</button> | |
<div id="status">Players: 0</div> | |
<div id="chatBox"></div> | |
</div> | |
</div> | |
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script> | |
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" /> | |
<script> | |
const playerName = "{username}"; | |
let ws = new WebSocket('ws://localhost:8765/prairie'); | |
const map = L.map('map').setView([44.0, -103.0], 7); | |
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{ | |
attribution: 'ยฉ OpenStreetMap contributors' | |
}}).addTo(map); | |
const locations = {{ | |
"Deadwood, SD": [44.3769, -103.7298], | |
"Wind Cave National Park": [43.6047, -103.4798], | |
"Wyoming Spring Creek": [41.6666, -106.6666] | |
}}; | |
for (let loc in locations) {{ | |
L.marker(locations[loc]).addTo(map).bindPopup(loc); | |
}} | |
let players = {{}}; | |
const markers = {{}}; | |
function updatePlayers(playerData) {{ | |
playerData.forEach(player => {{ | |
if (!markers[player.username]) {{ | |
markers[player.username] = L.circleMarker(player.location, {{ | |
color: `#${{player.color.toString(16).padStart(6, '0')}}`, | |
radius: 5 | |
}}).addTo(map).bindPopup(`${{player.username}} (${{player.animal}})`); | |
}} else {{ | |
markers[player.username].setLatLng(player.location); | |
}} | |
}}); | |
document.getElementById('status').textContent = `Players: ${{Object.keys(markers).length}}`; | |
}} | |
function updateAnimal() {{ | |
const animal = document.getElementById('animal').value; | |
ws.send(`${{playerName}}|PRAIRIE:ANIMAL:${{animal}}`); | |
}} | |
function move() {{ | |
const location = document.getElementById('location').value; | |
ws.send(`${{playerName}}|PRAIRIE:MOVE:${{location}}`); | |
}} | |
ws.onmessage = function(event) {{ | |
const data = event.data; | |
if (data.startsWith('PRAIRIE_UPDATE:')) {{ | |
const playerData = JSON.parse(data.split('PRAIRIE_UPDATE:')[1]); | |
updatePlayers(playerData); | |
}} else if (!data.startsWith('MAP_UPDATE:')) {{ | |
const [sender, message] = data.split('|'); | |
const chatBox = document.getElementById('chatBox'); | |
chatBox.innerHTML += `<p>${{sender}}: ${{message}}</p>`; | |
chatBox.scrollTop = chatBox.scrollHeight; | |
}} | |
}}; | |
</script> | |
</body> | |
</html> | |
""".format(username=st.session_state.username) | |
# Main Game Loop | |
def main(): | |
# Sidebar Titles and HUD | |
st.sidebar.title(f"๐ฎ {GAME_NAME}") | |
st.sidebar.subheader(f"๐ {st.session_state.username}โs Adventure - Score: {st.session_state.score} ๐") | |
st.sidebar.write(f"๐ {CHARACTERS[st.session_state.username]['desc']}") | |
st.sidebar.write(f"๐ Location: {st.session_state.location}") | |
st.sidebar.write(f"๐ Score: {st.session_state.score}") | |
st.sidebar.write(f"๐ต Treasures: {st.session_state.treasures}") | |
st.sidebar.write(f"๐ฅ Players: {', '.join([p['username'] for p in st.session_state.players.values()]) or 'None'}") | |
new_username = st.sidebar.selectbox("๐งโโ๏ธ Choose Your Hero", list(CHARACTERS.keys()), index=list(CHARACTERS.keys()).index(st.session_state.username)) | |
if new_username != st.session_state.username: | |
asyncio.run(save_chat_entry(st.session_state.username, f"๐ Transforms into {new_username}!", CHARACTERS[st.session_state.username]["voice"])) | |
st.session_state.username = new_username | |
save_username(st.session_state.username) | |
st.rerun() | |
# Two-column layout | |
left_col, right_col = st.columns([2, 1]) | |
# Left Column: Rocky Mountain Quest | |
with left_col: | |
components.html(rocky_map_html, width=800, height=600) | |
chat_content = asyncio.run(load_chat()) | |
st.text_area("๐ Quest Log", "\n".join(chat_content[-10:]), height=200, disabled=True) | |
message = st.text_input(f"๐จ๏ธ {st.session_state.username} says:", placeholder="Speak or type to chat! ๐ฒ") | |
if st.button("๐ Send & Chat ๐ค"): | |
if message: | |
voice = CHARACTERS[st.session_state.username]["voice"] | |
md_file, audio_file = asyncio.run(save_chat_entry(st.session_state.username, message, voice)) | |
if audio_file: | |
play_and_download_audio(audio_file) | |
st.success(f"๐ +10 points! New Score: {st.session_state.score}") | |
mycomponent = components.declare_component("speech_component", path="./speech_component") | |
val = mycomponent(my_input_value="", key=f"speech_{st.session_state.get('speech_processed', False)}") | |
if val and val != st.session_state.last_transcript: | |
val_stripped = val.strip().replace('\n', ' ') | |
if val_stripped: | |
voice = CHARACTERS.get(st.session_state.username, {"voice": "en-US-AriaNeural"})["voice"] | |
st.session_state['speech_processed'] = True | |
md_file, audio_file = asyncio.run(save_chat_entry(st.session_state.username, val_stripped, voice)) | |
if audio_file: | |
play_and_download_audio(audio_file) | |
st.rerun() | |
# Right Column: Prairie Simulator | |
with right_col: | |
st.subheader("๐พ Prairie Simulator") | |
components.html(prairie_simulator_html, width=600, height=600) | |
# Countdown Timer | |
elapsed = time.time() - st.session_state.last_update | |
remaining = max(0, st.session_state.update_interval - elapsed) | |
st.sidebar.markdown(f"โณ Next Update in: {int(remaining)}s") | |
if remaining <= 0: | |
st.session_state.last_update = time.time() | |
st.rerun() | |
if not st.session_state.get('server_running', False): | |
st.session_state.server_task = threading.Thread(target=start_websocket_server, daemon=True) | |
st.session_state.server_task.start() | |
if __name__ == "__main__": | |
main() |