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

# --- Constants ---
SAVE_DIR = "saved_worlds"
WORLD_MAP_FILE = os.path.join(SAVE_DIR, "world_map.json")
DEFAULT_SPACE_SIZE = 50 # Matches ground plane size in JS (approx)
MINIMAP_CELL_SIZE = 10 # Pixels per cell in minimap image

# --- Ensure Save Directory Exists ---
os.makedirs(SAVE_DIR, exist_ok=True)

# --- Helper Functions for Persistence ---

def load_world_map():
    """Loads the world map metadata."""
    if os.path.exists(WORLD_MAP_FILE):
        try:
            with open(WORLD_MAP_FILE, 'r') as f:
                return json.load(f)
        except json.JSONDecodeError:
            st.error("Error reading world map file. Starting fresh.")
            return {"spaces": {}} # Return empty if corrupt
    return {"spaces": {}} # SpaceID -> {"grid_x": int, "grid_y": int, "name": str}

def save_world_map(world_data):
    """Saves the world map metadata."""
    try:
        with open(WORLD_MAP_FILE, 'w') as f:
            json.dump(world_data, f, indent=4)
    except Exception as e:
        st.error(f"Error saving world map: {e}")


def save_space_data(space_id, objects_data):
    """Saves the object data for a specific space."""
    file_path = os.path.join(SAVE_DIR, f"{space_id}.json")
    try:
        with open(file_path, 'w') as f:
            # Store objects directly, could add metadata later
            json.dump({"objects": objects_data}, f, indent=4)
    except Exception as e:
        st.error(f"Error saving space data for {space_id}: {e}")


def load_space_data(space_id):
    """Loads object data for a specific space."""
    file_path = os.path.join(SAVE_DIR, f"{space_id}.json")
    if os.path.exists(file_path):
        try:
            with open(file_path, 'r') as f:
                data = json.load(f)
                return data.get("objects", []) # Return objects list or empty
        except json.JSONDecodeError:
            st.error(f"Error reading space file {space_id}.json.")
            return []
        except Exception as e:
            st.error(f"Error loading space data for {space_id}: {e}")
            return []
    return [] # Return empty list if file doesn't exist

def find_next_available_grid_slot(world_data):
    """Finds the next empty slot in a spiral pattern (simple version)."""
    occupied = set((d["grid_x"], d["grid_y"]) for d in world_data.get("spaces", {}).values())
    x, y = 0, 0
    dx, dy = 0, -1
    limit = 1 # How many steps in current direction
    steps = 0 # Steps taken in current direction
    leg = 0   # Current leg of the spiral (0=right, 1=up, 2=left, 3=down)

    while True:
        if (x, y) not in occupied:
            return x, y

        # Move
        x, y = x + dx, y + dy
        steps += 1

        # Check if direction needs changing
        if steps == limit:
            steps = 0
            leg = (leg + 1) % 4
            if leg == 0: # Right
                dx, dy = 1, 0
            elif leg == 1: # Up
                dx, dy = 0, 1
                limit += 1 # Increase steps after moving up
            elif leg == 2: # Left
                dx, dy = -1, 0
            elif leg == 3: # Down
                dx, dy = 0, -1
                limit += 1 # Increase steps after moving down

        # Safety break (increase if expecting huge maps)
        if limit > 100:
             st.error("Could not find empty grid slot easily! Map too full?")
             return None, None


# --- Minimap Generation ---
def generate_minimap(world_data, current_space_id=None):
    spaces = world_data.get("spaces", {})
    if not spaces:
        return None # No map if no spaces saved

    try:
        coords = [(d["grid_x"], d["grid_y"]) for d in spaces.values()]
        # Handle case where coords might be empty if spaces dict is malformed
        if not coords: return None

        min_x = min(c[0] for c in coords)
        max_x = max(c[0] for c in coords)
        min_y = min(c[1] for c in coords)
        max_y = max(c[1] for c in coords)

        # Add padding around the edges
        padding = 1
        img_width = (max_x - min_x + 1 + 2 * padding) * MINIMAP_CELL_SIZE
        img_height = (max_y - min_y + 1 + 2 * padding) * MINIMAP_CELL_SIZE

        img = Image.new('RGB', (img_width, img_height), color = 'lightgrey')
        draw = ImageDraw.Draw(img)

        for space_id, data in spaces.items():
            # Calculate position including padding offset
            cell_x = (data["grid_x"] - min_x + padding) * MINIMAP_CELL_SIZE
            cell_y = (data["grid_y"] - min_y + padding) * MINIMAP_CELL_SIZE
            color = "blue"
            if space_id == current_space_id:
                color = "red" # Highlight current space

            draw.rectangle(
                [cell_x, cell_y, cell_x + MINIMAP_CELL_SIZE -1, cell_y + MINIMAP_CELL_SIZE -1],
                fill=color, outline="black"
            )
            # Optional: Draw space name/ID lightly
            # draw.text((cell_x + 1, cell_y + 1), data.get("name", sid[:2]), fill="white", font_size=8)


        return img
    except Exception as e:
        st.error(f"Error generating minimap: {e}")
        return None


# --- Page Config ---
st.set_page_config(
    page_title="Multiplayer World Builder",
    layout="wide"
)

# --- Initialize Session State ---
if 'selected_object' not in st.session_state:
    st.session_state.selected_object = 'None'
if 'current_space_id' not in st.session_state:
    st.session_state.current_space_id = None # Will be set when loading/creating
if 'space_name' not in st.session_state:
     st.session_state.space_name = ""
if 'initial_objects' not in st.session_state:
     st.session_state.initial_objects = [] # Objects to load into JS
if 'trigger_rerun' not in st.session_state:
     st.session_state.trigger_rerun = 0 # Counter to force reruns cleanly


# --- Load initial world data ---
world_data = load_world_map()

# --- Handle Save Data from JS (Query Param Workaround) ---
query_params = st.query_params.to_dict()
save_data_encoded = query_params.get("save_data")


if save_data_encoded:
    st.session_state.trigger_rerun += 1 # Increment to signal change
    save_successful = False
    try:
        save_data_json = urllib.parse.unquote(save_data_encoded[0]) # Get first value if list
        objects_to_save = json.loads(save_data_json)

        # Get ID/Name from URL or fall back to session state if not in URL (e.g., if redirect failed partially)
        space_id_to_save = query_params.get("space_id", [st.session_state.current_space_id])[0]
        space_name_to_save = urllib.parse.unquote(query_params.get("space_name", [st.session_state.space_name])[0])


        if not space_id_to_save or space_id_to_save == 'null': # JS might send 'null' string
            space_id_to_save = str(uuid.uuid4()) # Create new ID
            is_new_space = True
            st.session_state.current_space_id = space_id_to_save # Update state
        else:
            is_new_space = False

        # Save the actual object data
        save_space_data(space_id_to_save, objects_to_save)

        # Update world map only if needed (new space or name change)
        map_updated = False
        world_spaces = world_data.setdefault("spaces", {})

        if is_new_space:
            grid_x, grid_y = find_next_available_grid_slot(world_data)
            if grid_x is not None:
                 world_spaces[space_id_to_save] = {
                     "grid_x": grid_x,
                     "grid_y": grid_y,
                     "name": space_name_to_save or f"Space_{space_id_to_save[:6]}"
                 }
                 map_updated = True
            else:
                 st.error("Failed to assign grid position!")
        elif space_id_to_save in world_spaces and world_spaces[space_id_to_save].get("name") != space_name_to_save:
             # Update name if it changed for existing space
             world_spaces[space_id_to_save]["name"] = space_name_to_save
             map_updated = True

        if map_updated:
             save_world_map(world_data)

        st.session_state.space_name = space_name_to_save # Ensure state matches saved name

        st.success(f"Space '{space_name_to_save or space_id_to_save}' saved successfully!")
        save_successful = True


    except Exception as e:
        st.error(f"Error processing save data: {e}")
        st.exception(e) # Show full traceback for debugging

    # IMPORTANT: Clear query param to prevent resave on refresh/rerun
    st.query_params.clear()

    # Force a rerun IF save was processed, to reload objects and clear URL state visually
    # Do this AFTER clearing query_params
    if save_successful:
        # No need to explicitly call st.rerun() here, Streamlit's flow after
        # clearing query params and updating state often handles it.
        # If visual updates lag, uncommenting st.rerun() might be necessary,
        # but can sometimes cause double reruns.
        # st.rerun()
        pass


# --- Load Space Data if ID is set ---
# This runs on every script execution, including after save/load actions
if st.session_state.current_space_id:
     st.session_state.initial_objects = load_space_data(st.session_state.current_space_id)
     # Ensure name is loaded if space ID exists but name state is empty
     if not st.session_state.space_name and st.session_state.current_space_id in world_data.get("spaces", {}):
          st.session_state.space_name = world_data["spaces"][st.session_state.current_space_id].get("name", "")


# --- Sidebar Controls ---
with st.sidebar:
    st.title("🏗️ World Controls")

    # --- Space Management ---
    st.subheader("Manage Spaces")
    saved_spaces = list(world_data.get("spaces", {}).items()) # List of (id, data) tuples
    space_options = {sid: data.get("name", f"Unnamed ({sid[:6]}...)") for sid, data in saved_spaces}
    options_list = ["_new_"] + list(space_options.keys())

    # Determine current index for selectbox
    current_selection_index = 0 # Default to "Create New"
    if st.session_state.current_space_id in space_options:
        try:
            current_selection_index = options_list.index(st.session_state.current_space_id)
        except ValueError:
            current_selection_index = 0 # Fallback if ID somehow not in list

    selected_space_option = st.selectbox(
        "Load or Create Space:",
        options = options_list,
        format_func = lambda x: space_options.get(x, "Select...") if x != "_new_" else "✨ Create New Space ✨",
        index=current_selection_index,
        key="space_selection_widget" # Use a dedicated key for the widget
    )

    # --- Handle Load/Create Logic ---
    # Compare widget state to session state to detect user change
    if selected_space_option != st.session_state.get('_last_selected_space', None):
        st.session_state._last_selected_space = selected_space_option # Track selection change
        if selected_space_option == "_new_":
            # User explicitly selected "Create New"
            if st.session_state.current_space_id is not None: # Check if switching *from* an existing space
                st.session_state.current_space_id = None
                st.session_state.initial_objects = []
                st.session_state.space_name = ""
                st.rerun() # Rerun to clear the space
        else:
            # User selected an existing space
            if st.session_state.current_space_id != selected_space_option:
                st.session_state.current_space_id = selected_space_option
                # Loading data and name happens naturally at top of script now
                st.rerun() # Rerun to reflect the load

    # --- Name Input ---
    current_name = st.text_input(
         "Current Space Name:",
         value=st.session_state.space_name, # Reflect current loaded/new name state
         key="current_space_name_input"
    )
    # Update session state if user types a new name
    if current_name != st.session_state.space_name:
        st.session_state.space_name = current_name
        # Note: The name is only saved to world_map.json when the JS save is triggered

    st.info(f"Current Space ID: {st.session_state.current_space_id or 'None (New)'}")
    st.caption("Click 'Save Work' in the 3D view to save changes.")
    st.markdown("---")


    # --- Object Placement Controls ---
    st.subheader("Place Objects")
    object_types = ["None", "Simple House", "Tree", "Rock", "Fence Post"]
    # Ensure selectbox reflects current state
    current_object_index = 0
    try:
        current_object_index = object_types.index(st.session_state.selected_object)
    except ValueError:
        st.session_state.selected_object = "None" # Reset if invalid

    selected_object_type_widget = st.selectbox(
        "Select Object to Place:",
        options=object_types,
        index=current_object_index,
        key="selected_object_widget"
    )
    # Update state based on widget interaction
    if selected_object_type_widget != st.session_state.selected_object:
         st.session_state.selected_object = selected_object_type_widget
         st.rerun() # Rerun to update JS injection

    st.markdown("---")

    # --- Minimap ---
    st.subheader("World Minimap")
    # Regenerate map data fresh each time
    current_world_data_for_map = load_world_map()
    minimap_img = generate_minimap(current_world_data_for_map, st.session_state.current_space_id)
    if minimap_img:
        st.image(minimap_img, caption="Blue: Saved Spaces, Red: Current", use_column_width=True)
    else:
        st.caption("No spaces saved yet.")


# --- Main Area ---
st.header("3D Space Editor")
st.caption(f"Editing: {st.session_state.space_name or 'New Space'}")

# --- Load and Prepare HTML ---
html_file_path = 'index.html'
html_content_with_state = None # Initialize to None

try:
    # --- Read the HTML template file ---
    with open(html_file_path, 'r', encoding='utf-8') as f:
        html_template = f.read()

    # --- Prepare JavaScript code to inject state ---
    # Ensure all state variables are correctly serialized as JSON
    js_injection_script = f"""
<script>
    // Set global variables BEFORE the main script runs
    window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
    window.INITIAL_OBJECTS = {json.dumps(st.session_state.initial_objects)};
    window.CURRENT_SPACE_ID = {json.dumps(st.session_state.current_space_id)};
    window.CURRENT_SPACE_NAME = {json.dumps(st.session_state.space_name)};
    // Basic logging to verify state in browser console
    console.log("Streamlit State Injected:", {{
        selectedObject: window.SELECTED_OBJECT_TYPE,
        initialObjectsCount: window.INITIAL_OBJECTS ? window.INITIAL_OBJECTS.length : 0,
        spaceId: window.CURRENT_SPACE_ID,
        spaceName: window.CURRENT_SPACE_NAME
    }});
</script>
"""
    # --- Inject the script into the HTML template ---
    # Replacing just before </head> is generally safe
    html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)

    # --- Embed HTML Component (ONLY if HTML loading and preparation succeeded) ---
    components.html(
        html_content_with_state,
        height=750, # Adjust height as needed
        scrolling=False
    )

# --- Error Handling ---
except FileNotFoundError:
    st.error(f"CRITICAL ERROR: Could not find the file '{html_file_path}'.")
    st.warning(f"Please make sure `{html_file_path}` is in the same directory as `app.py` and that the `{SAVE_DIR}` directory exists.")
except Exception as e:
    st.error(f"An critical error occurred during HTML preparation or component rendering: {e}")
    st.exception(e) # Show full traceback for debugging
    # Do NOT attempt to render components.html if html_content_with_state is not defined