Spaces:
Sleeping
Sleeping
| import streamlit as st # ๐ Streamlit magic | |
| import streamlit.components.v1 as components # ๐ผ๏ธ Embed custom HTML/JS | |
| import os # ๐ File operations | |
| import json # ๐ JSON encoding/decoding | |
| import pandas as pd # ๐ DataFrame handling | |
| import uuid # ๐ Unique IDs | |
| import math # โ Math utils | |
| import time # โณ Time utilities | |
| from gamestate import GameState # ๐ผ Shared game-state singleton | |
| # ๐ Page setup | |
| st.set_page_config(page_title="Infinite World Builder", layout="wide") | |
| # ๐ Constants for world dimensions & CSV schema | |
| SAVE_DIR = "saved_worlds" | |
| PLOT_WIDTH = 50.0 # โ๏ธ Plot width in world units | |
| PLOT_DEPTH = 50.0 # โ๏ธ Plot depth in world units | |
| CSV_COLUMNS = [ | |
| 'obj_id', 'type', | |
| 'pos_x', 'pos_y', 'pos_z', | |
| 'rot_x', 'rot_y', 'rot_z', 'rot_order' | |
| ] | |
| # ๐จ Model categories and types | |
| MODEL_CATEGORIES = { | |
| "None": ["None"], | |
| "Buildings": [ | |
| "Simple House", | |
| "Cyberpunk Wall Panel", | |
| "Modular Hab Block", | |
| "MegaCorp Skyscraper", | |
| "Castle Wall Section", | |
| "Wooden Door", | |
| "House Roof Section", | |
| "Concrete Bunker Wall", | |
| "Damaged House Facade" | |
| ], | |
| "Nature": [ | |
| "Tree", | |
| "Rock", | |
| "Pine Tree", | |
| "Boulder", | |
| "Alien Plant", | |
| "Floating Rock Platform", | |
| "Rubble Pile" | |
| ], | |
| "Props": [ | |
| "Fence Post", | |
| "Rooftop AC Unit", | |
| "Holographic Window Display", | |
| "Jersey Barrier", | |
| "Oil Drum", | |
| "Canned Food", | |
| "Treasure Chest", | |
| "Wall Torch", | |
| "Bone Pile" | |
| ], | |
| "Characters": [ | |
| "King Figure", | |
| "Soldier Figure", | |
| "Mage Figure", | |
| "Zombie Figure", | |
| "Survivor Figure", | |
| "Dwarf Miner Figure", | |
| "Undead Knight Figure", | |
| "Hero Figure" | |
| ], | |
| "Vehicles": [ | |
| "Wooden Cart", | |
| "Ballista", | |
| "Siege Tower", | |
| "Buggy Frame", | |
| "Motorbike", | |
| "Hover Bike", | |
| "APC", | |
| "Sand Boat" | |
| ], | |
| "Weapons": [ | |
| "Makeshift Machete", | |
| "Pistol Body", | |
| "Scope Attachment", | |
| "Laser Pistol", | |
| "Energy Sword", | |
| "Dwarven Axe", | |
| "Magic Staff" | |
| ], | |
| "Effects": [ | |
| "Candle Flame", | |
| "Dust Cloud", | |
| "Blood Splat Decal", | |
| "Burning Barrel Fire", | |
| "Warp Tunnel Effect", | |
| "Laser Beam", | |
| "Gold Sparkle", | |
| "Steam Vent" | |
| ] | |
| } | |
| # ๐๏ธ Ensure directory for plots exists | |
| os.makedirs(SAVE_DIR, exist_ok=True) | |
| # ๐ Cache for 1h | |
| def load_plot_metadata(): | |
| # ๐ Scan SAVE_DIR for plot CSVs | |
| try: | |
| plot_files = [f for f in os.listdir(SAVE_DIR) | |
| if f.endswith(".csv") and f.startswith("plot_X")] | |
| except FileNotFoundError: | |
| st.error(f"Folder '{SAVE_DIR}' missing! ๐จ") | |
| return [] | |
| except Exception as e: | |
| st.error(f"Error reading '{SAVE_DIR}': {e}") | |
| return [] | |
| parsed = [] | |
| for fn in plot_files: | |
| try: | |
| parts = fn[:-4].split('_') # strip .csv | |
| gx = int(parts[1][1:]) # X index | |
| gz = int(parts[2][1:]) # Z index | |
| name = " ".join(parts[3:]) if len(parts)>3 else f"Plot({gx},{gz})" | |
| parsed.append({ | |
| 'id': fn[:-4], | |
| 'filename': fn, | |
| 'grid_x': gx, | |
| 'grid_z': gz, | |
| 'name': name, | |
| 'x_offset': gx * PLOT_WIDTH, | |
| 'z_offset': gz * PLOT_DEPTH | |
| }) | |
| except Exception: | |
| st.warning(f"Skip invalid file: {fn}") | |
| parsed.sort(key=lambda p: (p['grid_x'], p['grid_z'])) | |
| return parsed | |
| def load_plot_objects(filename, x_offset, z_offset): | |
| # ๐ฅ Load objects from a plot CSV and shift by offsets | |
| path = os.path.join(SAVE_DIR, filename) | |
| try: | |
| df = pd.read_csv(path) | |
| # ๐ก๏ธ Ensure essentials exist | |
| if not all(c in df.columns for c in ['type','pos_x','pos_y','pos_z']): | |
| st.warning(f"Missing cols in {filename} ๐ง") | |
| return [] | |
| # ๐ Guarantee obj_id | |
| df['obj_id'] = df.get('obj_id', pd.Series([str(uuid.uuid4()) for _ in df.index])) | |
| # ๐ Fill missing rotation | |
| for col, default in [('rot_x',0.0),('rot_y',0.0),('rot_z',0.0),('rot_order','XYZ')]: | |
| if col not in df.columns: | |
| df[col] = default | |
| objs = [] | |
| for _, row in df.iterrows(): | |
| o = row.to_dict() | |
| o['pos_x'] += x_offset | |
| o['pos_z'] += z_offset | |
| objs.append(o) | |
| return objs | |
| except FileNotFoundError: | |
| st.error(f"CSV not found: {filename}") | |
| return [] | |
| except pd.errors.EmptyDataError: | |
| return [] | |
| except Exception as e: | |
| st.error(f"Load error {filename}: {e}") | |
| return [] | |
| def save_plot_data(filename, objects_list, px, pz): | |
| # ๐พ Save list of new objects relative to plot origin | |
| path = os.path.join(SAVE_DIR, filename) | |
| if not isinstance(objects_list, list): | |
| st.error("๐ Invalid data format for save") | |
| return False | |
| rel = [] | |
| for o in objects_list: | |
| pos = o.get('position', {}) | |
| rot = o.get('rotation', {}) | |
| typ = o.get('type','Unknown') | |
| oid = o.get('obj_id', str(uuid.uuid4())) | |
| # ๐ Skip bad objects | |
| if not all(k in pos for k in ['x','y','z']) or typ=='Unknown': | |
| continue | |
| rel.append({ | |
| 'obj_id': oid, 'type': typ, | |
| 'pos_x': pos['x']-px, 'pos_y': pos['y'], 'pos_z': pos['z']-pz, | |
| 'rot_x': rot.get('_x',0.0), 'rot_y': rot.get('_y',0.0), | |
| 'rot_z': rot.get('_z',0.0), 'rot_order': rot.get('_order','XYZ') | |
| }) | |
| try: | |
| pd.DataFrame(rel, columns=CSV_COLUMNS).to_csv(path, index=False) | |
| st.success(f"๐ Saved {len(rel)} to {filename}") | |
| return True | |
| except Exception as e: | |
| st.error(f"Save failed: {e}") | |
| return False | |
| # ๐ Singleton for global world state | |
| def get_game_state(): | |
| return GameState(save_dir=SAVE_DIR, csv_filename="world_state.csv") | |
| game_state = get_game_state() | |
| # ๐ง Session state defaults | |
| st.session_state.setdefault('selected_object','None') | |
| st.session_state.setdefault('selected_category','None') | |
| st.session_state.setdefault('new_plot_name','') | |
| st.session_state.setdefault('js_save_data_result',None) | |
| st.session_state.setdefault('custom_scale', 1.0) | |
| st.session_state.setdefault('custom_rotation_y', 0) | |
| # ๐ Load everything | |
| plots_metadata = load_plot_metadata() | |
| all_initial_objects = [] | |
| for p in plots_metadata: | |
| all_initial_objects += load_plot_objects(p['filename'], p['x_offset'], p['z_offset']) | |
| # ๐ฅ๏ธ Sidebar UI | |
| with st.sidebar: | |
| st.title("๐๏ธ World Controls") | |
| st.header("๐ Navigate Plots") | |
| cols = st.columns(2) | |
| i = 0 | |
| for p in sorted(plots_metadata, key=lambda x:(x['grid_x'],x['grid_z'])): | |
| label = f"โก๏ธ {p['name']} ({p['grid_x']},{p['grid_z']})" | |
| if cols[i].button(label, key=f"nav_{p['id']}"): | |
| try: | |
| from streamlit_js_eval import streamlit_js_eval | |
| js = f"teleportPlayer({p['x_offset']+PLOT_WIDTH/2},{p['z_offset']+PLOT_DEPTH/2});" | |
| streamlit_js_eval(js_code=js, key=f"tp_{p['id']}") | |
| except Exception as e: | |
| st.error(f"TP fail: {e}") | |
| i = (i+1)%2 | |
| st.markdown("---") | |
| st.header("๐ฒ Place Objects") | |
| # ๐ Category selector | |
| category_list = list(MODEL_CATEGORIES.keys()) | |
| selected_category = st.selectbox( | |
| "Category:", | |
| category_list, | |
| index=category_list.index(st.session_state.selected_category) if st.session_state.selected_category in category_list else 0, | |
| key="selected_category_widget" | |
| ) | |
| if selected_category != st.session_state.selected_category: | |
| st.session_state.selected_category = selected_category | |
| if st.session_state.selected_category != "None": | |
| # Default to first item in the new category | |
| st.session_state.selected_object = MODEL_CATEGORIES[selected_category][0] | |
| # ๐ผ๏ธ Object selector within the category | |
| if selected_category in MODEL_CATEGORIES: | |
| object_list = MODEL_CATEGORIES[selected_category] | |
| current_object = st.session_state.selected_object | |
| if current_object not in object_list: | |
| current_object = object_list[0] | |
| selected_object = st.selectbox( | |
| "Select:", | |
| object_list, | |
| index=object_list.index(current_object) if current_object in object_list else 0, | |
| key="selected_object_widget" | |
| ) | |
| if selected_object != st.session_state.selected_object: | |
| st.session_state.selected_object = selected_object | |
| # โ๏ธ Scale slider (not shown for the None selection) | |
| if st.session_state.selected_object != "None": | |
| st.session_state.custom_scale = st.slider( | |
| "Scale:", | |
| min_value=0.2, | |
| max_value=3.0, | |
| value=st.session_state.custom_scale, | |
| step=0.1, | |
| key="scale_slider" | |
| ) | |
| # ๐ Rotation slider (Y-axis) | |
| st.session_state.custom_rotation_y = st.slider( | |
| "Rotation:", | |
| min_value=0, | |
| max_value=359, | |
| value=st.session_state.custom_rotation_y, | |
| step=15, | |
| key="rotation_slider" | |
| ) | |
| st.markdown("---") | |
| st.header("๐พ Save Work") | |
| if st.button("๐พ Save Current Work", key="save_button"): | |
| from streamlit_js_eval import streamlit_js_eval | |
| streamlit_js_eval(js_code="getSaveDataAndPosition();", key="js_save_processor") | |
| st.rerun() | |
| # ๐ New plot naming | |
| with st.expander("Create Named Plot"): | |
| st.text_input("New Plot Name:", key="new_plot_name") | |
| if st.button("Create at Current Position"): | |
| if st.session_state.new_plot_name.strip(): | |
| from streamlit_js_eval import streamlit_js_eval | |
| streamlit_js_eval(js_code="getSaveDataForNamedPlot();", key="named_plot_processor") | |
| st.rerun() | |
| # ๐จ Handle incoming save data | |
| raw = st.session_state.get("js_save_processor") | |
| if raw: | |
| st.info("๐ฌ Got save data!") | |
| ok=False | |
| try: | |
| pay = json.loads(raw) if isinstance(raw,str) else raw | |
| pos, objs = pay.get('playerPosition'), pay.get('objectsToSave') | |
| if isinstance(objs,list) and pos: | |
| gx, gz = math.floor(pos['x']/PLOT_WIDTH), math.floor(pos['z']/PLOT_DEPTH) | |
| fn = f"plot_X{gx}_Z{gz}.csv" | |
| if save_plot_data(fn, objs, gx*PLOT_WIDTH, gz*PLOT_DEPTH): | |
| load_plot_metadata.clear() | |
| try: | |
| from streamlit_js_eval import streamlit_js_eval | |
| streamlit_js_eval(js_code="resetNewlyPlacedObjects();", key="reset_js") | |
| except: | |
| pass | |
| game_state.update_state(objs) | |
| ok=True | |
| if not ok: | |
| st.error("โ Save error") | |
| except Exception as e: | |
| st.error(f"Err: {e}") | |
| st.session_state.js_save_processor=None | |
| if ok: st.rerun() | |
| # ๐จ Handle named plot data | |
| named_raw = st.session_state.get("named_plot_processor") | |
| if named_raw: | |
| st.info("๐ฌ Got data for named plot!") | |
| ok=False | |
| try: | |
| pay = json.loads(named_raw) if isinstance(named_raw,str) else named_raw | |
| pos, objs = pay.get('playerPosition'), pay.get('objectsToSave') | |
| if isinstance(objs,list) and pos and st.session_state.new_plot_name.strip(): | |
| gx, gz = math.floor(pos['x']/PLOT_WIDTH), math.floor(pos['z']/PLOT_DEPTH) | |
| name = st.session_state.new_plot_name.strip().replace(' ', '_') | |
| fn = f"plot_X{gx}_Z{gz}_{name}.csv" | |
| if save_plot_data(fn, objs, gx*PLOT_WIDTH, gz*PLOT_DEPTH): | |
| load_plot_metadata.clear() | |
| try: | |
| from streamlit_js_eval import streamlit_js_eval | |
| streamlit_js_eval(js_code="resetNewlyPlacedObjects();", key="reset_js") | |
| except: | |
| pass | |
| game_state.update_state(objs) | |
| ok=True | |
| st.session_state.new_plot_name = "" | |
| if not ok: | |
| st.error("โ Named plot save error") | |
| except Exception as e: | |
| st.error(f"Named plot err: {e}") | |
| st.session_state.named_plot_processor=None | |
| if ok: st.rerun() | |
| # ๐ Main view | |
| st.header("๐ Infinite Shared 3D World") | |
| st.caption("โก๏ธ Explore, click to build, ๐พ to save!") | |
| # ๐ Inject state into JS | |
| state = { | |
| "ALL_INITIAL_OBJECTS": all_initial_objects, | |
| "PLOTS_METADATA": plots_metadata, | |
| "SELECTED_OBJECT_TYPE": st.session_state.selected_object, | |
| "CUSTOM_SCALE": st.session_state.custom_scale, | |
| "CUSTOM_ROTATION_Y": st.session_state.custom_rotation_y * (math.pi / 180), # Convert to radians | |
| "PLOT_WIDTH": PLOT_WIDTH, | |
| "PLOT_DEPTH": PLOT_DEPTH, | |
| "GAME_STATE": game_state.get_state() | |
| } | |
| try: | |
| with open('index.html','r',encoding='utf-8') as f: | |
| html = f.read() | |
| script = f""" | |
| <script> | |
| window.ALL_INITIAL_OBJECTS = {json.dumps(state['ALL_INITIAL_OBJECTS'])}; | |
| window.PLOTS_METADATA = {json.dumps(state['PLOTS_METADATA'])}; | |
| window.SELECTED_OBJECT_TYPE = {json.dumps(state['SELECTED_OBJECT_TYPE'])}; | |
| window.CUSTOM_SCALE = {json.dumps(state['CUSTOM_SCALE'])}; | |
| window.CUSTOM_ROTATION_Y = {json.dumps(state['CUSTOM_ROTATION_Y'])}; | |
| window.PLOT_WIDTH = {json.dumps(state['PLOT_WIDTH'])}; | |
| window.PLOT_DEPTH = {json.dumps(state['PLOT_DEPTH'])}; | |
| window.GAME_STATE = {json.dumps(state['GAME_STATE'])}; | |
| console.log('๐ State injected!', {{ | |
| objs: window.ALL_INITIAL_OBJECTS.length, | |
| plots: window.PLOTS_METADATA.length, | |
| gs: window.GAME_STATE.length, | |
| scale: window.CUSTOM_SCALE, | |
| rotation: window.CUSTOM_ROTATION_Y | |
| }}); | |
| </script> | |
| """ | |
| html = html.replace('</head>', script + '\n</head>', 1) | |
| components.html(html, height=750, scrolling=False) | |
| except FileNotFoundError: | |
| st.error("โ index.html missing!") | |
| except Exception as e: | |
| st.error(f"๐ฑ HTML inject failed: {e}") |