diff --git a/src/3d/Entity3D.cpp b/src/3d/Entity3D.cpp index 246e32b..fe15720 100644 --- a/src/3d/Entity3D.cpp +++ b/src/3d/Entity3D.cpp @@ -1121,6 +1121,47 @@ PyObject* Entity3D::py_animate(PyEntity3DObject* self, PyObject* args, PyObject* return NULL; } +PyObject* Entity3D::py_follow_path(PyEntity3DObject* self, PyObject* args) +{ + PyObject* path_list; + if (!PyArg_ParseTuple(args, "O", &path_list)) { + return NULL; + } + + if (!PyList_Check(path_list)) { + PyErr_SetString(PyExc_TypeError, "follow_path() requires a list of (x, z) tuples"); + return NULL; + } + + std::vector> path; + Py_ssize_t len = PyList_Size(path_list); + for (Py_ssize_t i = 0; i < len; ++i) { + PyObject* item = PyList_GetItem(path_list, i); + if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) { + PyErr_SetString(PyExc_TypeError, "Each path element must be (x, z) tuple"); + return NULL; + } + int x = static_cast(PyLong_AsLong(PyTuple_GetItem(item, 0))); + int z = static_cast(PyLong_AsLong(PyTuple_GetItem(item, 1))); + if (PyErr_Occurred()) return NULL; + path.emplace_back(x, z); + } + + self->data->followPath(path); + Py_RETURN_NONE; +} + +PyObject* Entity3D::py_clear_path(PyEntity3DObject* self, PyObject* args) +{ + self->data->clearPath(); + Py_RETURN_NONE; +} + +PyObject* Entity3D::get_is_moving(PyEntity3DObject* self, void* closure) +{ + return PyBool_FromLong(self->data->isMoving() ? 1 : 0); +} + // Method and GetSet tables PyMethodDef Entity3D::methods[] = { @@ -1141,6 +1182,14 @@ PyMethodDef Entity3D::methods[] = { {"animate", (PyCFunction)Entity3D::py_animate, METH_VARARGS | METH_KEYWORDS, "animate(property, target, duration, easing=None, callback=None)\n\n" "Animate a property over time. (Not yet implemented)"}, + {"follow_path", (PyCFunction)Entity3D::py_follow_path, METH_VARARGS, + "follow_path(path)\n\n" + "Queue path positions for smooth movement.\n\n" + "Args:\n" + " path: List of (x, z) tuples (as returned by path_to())"}, + {"clear_path", (PyCFunction)Entity3D::py_clear_path, METH_NOARGS, + "clear_path()\n\n" + "Clear the movement queue and stop at current position."}, {NULL} // Sentinel }; @@ -1185,6 +1234,8 @@ PyGetSetDef Entity3D::getsetters[] = { "Animation clip to play when entity is moving.", NULL}, {"idle_clip", (getter)Entity3D::get_idle_clip, (setter)Entity3D::set_idle_clip, "Animation clip to play when entity is stationary.", NULL}, + {"is_moving", (getter)Entity3D::get_is_moving, NULL, + "Whether entity is currently moving (read-only).", NULL}, {NULL} // Sentinel }; diff --git a/src/3d/Entity3D.h b/src/3d/Entity3D.h index 6401ffd..ce1d69a 100644 --- a/src/3d/Entity3D.h +++ b/src/3d/Entity3D.h @@ -272,6 +272,9 @@ public: static PyObject* py_at(PyEntity3DObject* self, PyObject* args, PyObject* kwds); static PyObject* py_update_visibility(PyEntity3DObject* self, PyObject* args); static PyObject* py_animate(PyEntity3DObject* self, PyObject* args, PyObject* kwds); + static PyObject* py_follow_path(PyEntity3DObject* self, PyObject* args); + static PyObject* py_clear_path(PyEntity3DObject* self, PyObject* args); + static PyObject* get_is_moving(PyEntity3DObject* self, void* closure); static PyMethodDef methods[]; static PyGetSetDef getsetters[]; diff --git a/src/3d/Viewport3D.cpp b/src/3d/Viewport3D.cpp index 240753d..b5985b8 100644 --- a/src/3d/Viewport3D.cpp +++ b/src/3d/Viewport3D.cpp @@ -185,6 +185,67 @@ void Viewport3D::orbitCamera(float angle, float distance, float height) { camera_.setTarget(vec3(0, 0, 0)); } +vec3 Viewport3D::screenToWorld(float screenX, float screenY) { + // Convert screen coordinates to normalized device coordinates (-1 to 1) + // screenX/Y are relative to the viewport position + float ndcX = (2.0f * screenX / size_.x) - 1.0f; + float ndcY = 1.0f - (2.0f * screenY / size_.y); // Flip Y for OpenGL + + // Get inverse matrices + mat4 proj = camera_.getProjectionMatrix(); + mat4 view = camera_.getViewMatrix(); + mat4 invProj = proj.inverse(); + mat4 invView = view.inverse(); + + // Unproject near plane point to get ray direction + vec4 rayClip(ndcX, ndcY, -1.0f, 1.0f); + vec4 rayEye = invProj * rayClip; + rayEye = vec4(rayEye.x, rayEye.y, -1.0f, 0.0f); // Direction in eye space + + vec4 rayWorld4 = invView * rayEye; + vec3 rayDir = vec3(rayWorld4.x, rayWorld4.y, rayWorld4.z).normalized(); + vec3 rayOrigin = camera_.getPosition(); + + // Intersect with Y=0 plane (ground level) + // This is a simplification - for hilly terrain, you'd want ray-marching + if (std::abs(rayDir.y) > 0.0001f) { + float t = -rayOrigin.y / rayDir.y; + if (t > 0) { + return rayOrigin + rayDir * t; + } + } + + // Ray parallel to ground or pointing away - return invalid position + return vec3(-1.0f, -1.0f, -1.0f); +} + +void Viewport3D::followEntity(std::shared_ptr entity, float distance, float height, float smoothing) { + if (!entity) return; + + // Get entity's world position + vec3 entityPos = entity->getWorldPos(); + + // Calculate desired camera position behind and above entity + float entityRotation = radians(entity->getRotation()); + float camX = entityPos.x - std::sin(entityRotation) * distance; + float camZ = entityPos.z - std::cos(entityRotation) * distance; + float camY = entityPos.y + height; + + vec3 desiredPos(camX, camY, camZ); + vec3 currentPos = camera_.getPosition(); + + // Smooth interpolation (smoothing is 0-1, where 1 = instant) + if (smoothing >= 1.0f) { + camera_.setPosition(desiredPos); + } else { + vec3 newPos = vec3::lerp(currentPos, desiredPos, smoothing); + camera_.setPosition(newPos); + } + + // Look at entity (slightly above ground) + camera_.setTarget(vec3(entityPos.x, entityPos.y + 0.5f, entityPos.z)); +} + // ============================================================================= // Mesh Layer Management // ============================================================================= @@ -2114,6 +2175,59 @@ static PyObject* Viewport3D_billboard_count(PyViewport3DObject* self, PyObject* return PyLong_FromLong(static_cast(billboards->size())); } +// ============================================================================= +// Camera & Input Methods (Milestone 8) +// ============================================================================= + +static PyObject* Viewport3D_screen_to_world(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"x", "y", NULL}; + + float x = 0.0f, y = 0.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "ff", const_cast(kwlist), &x, &y)) { + return NULL; + } + + // Adjust for viewport position (user passes screen coords relative to viewport) + vec3 worldPos = self->data->screenToWorld(x, y); + + // Return None if no intersection (ray parallel to ground or invalid) + if (worldPos.x < 0 && worldPos.y < 0 && worldPos.z < 0) { + Py_RETURN_NONE; + } + + return Py_BuildValue("(fff)", worldPos.x, worldPos.y, worldPos.z); +} + +static PyObject* Viewport3D_follow(PyViewport3DObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"entity", "distance", "height", "smoothing", NULL}; + + PyObject* entityObj = nullptr; + float distance = 10.0f; + float height = 5.0f; + float smoothing = 1.0f; // Default to instant (for single-call positioning) + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|fff", const_cast(kwlist), + &entityObj, &distance, &height, &smoothing)) { + return NULL; + } + + // Check if it's an Entity3D object + if (!PyObject_IsInstance(entityObj, (PyObject*)&mcrfpydef::PyEntity3DType)) { + PyErr_SetString(PyExc_TypeError, "Expected an Entity3D object"); + return NULL; + } + + PyEntity3DObject* entObj = (PyEntity3DObject*)entityObj; + if (!entObj->data) { + PyErr_SetString(PyExc_ValueError, "Invalid Entity3D object"); + return NULL; + } + + self->data->followEntity(entObj->data, distance, height, smoothing); + Py_RETURN_NONE; +} + } // namespace mcrf // Methods array - outside namespace but PyObjectType still in scope via typedef @@ -2275,5 +2389,23 @@ PyMethodDef Viewport3D_methods[] = { "Get the number of billboards.\n\n" "Returns:\n" " Number of billboards in the viewport"}, + + // Camera & Input methods (Milestone 8) + {"screen_to_world", (PyCFunction)mcrf::Viewport3D_screen_to_world, METH_VARARGS | METH_KEYWORDS, + "screen_to_world(x, y) -> tuple or None\n\n" + "Convert screen coordinates to world position via ray casting.\n\n" + "Args:\n" + " x: Screen X coordinate relative to viewport\n" + " y: Screen Y coordinate relative to viewport\n\n" + "Returns:\n" + " (x, y, z) world position tuple, or None if no intersection with ground plane"}, + {"follow", (PyCFunction)mcrf::Viewport3D_follow, METH_VARARGS | METH_KEYWORDS, + "follow(entity, distance=10, height=5, smoothing=1.0)\n\n" + "Position camera to follow an entity.\n\n" + "Args:\n" + " entity: Entity3D to follow\n" + " distance: Distance behind entity\n" + " height: Camera height above entity\n" + " smoothing: Interpolation factor (0-1). 1 = instant, lower = smoother"}, {NULL} // Sentinel }; diff --git a/src/3d/Viewport3D.h b/src/3d/Viewport3D.h index 0ab5e17..780c1ee 100644 --- a/src/3d/Viewport3D.h +++ b/src/3d/Viewport3D.h @@ -85,6 +85,19 @@ public: // Camera orbit helper for demos void orbitCamera(float angle, float distance, float height); + /// Convert screen coordinates to world position via ray casting + /// @param screenX X position relative to viewport + /// @param screenY Y position relative to viewport + /// @return World position on Y=0 plane, or (-1,-1,-1) if no intersection + vec3 screenToWorld(float screenX, float screenY); + + /// Position camera to follow an entity + /// @param entity Entity to follow + /// @param distance Distance behind entity + /// @param height Height above entity + /// @param smoothing Interpolation factor (0-1, where 1 = instant) + void followEntity(std::shared_ptr entity, float distance, float height, float smoothing = 1.0f); + // ========================================================================= // Mesh Layer Management // ========================================================================= diff --git a/tests/demo/screens/integration_demo.py b/tests/demo/screens/integration_demo.py new file mode 100644 index 0000000..9d3b1ce --- /dev/null +++ b/tests/demo/screens/integration_demo.py @@ -0,0 +1,462 @@ +# integration_demo.py - Milestone 8 Integration Demo +# Showcases all 3D features: terrain, entities, pathfinding, FOV, billboards, UI, input + +import mcrfpy +import math +import random + +DEMO_NAME = "3D Integration Demo" +DEMO_DESCRIPTION = """Complete 3D demo with terrain, player, NPC, FOV, and UI overlay. + +Controls: + Arrow keys: Move player + Click: Move to clicked position + ESC: Quit +""" + +# Create the main scene +scene = mcrfpy.Scene("integration_demo") + +# ============================================================================= +# Constants +# ============================================================================= +GRID_WIDTH = 32 +GRID_DEPTH = 32 +CELL_SIZE = 1.0 +TERRAIN_Y_SCALE = 3.0 +FOV_RADIUS = 10 + +# ============================================================================= +# 3D Viewport +# ============================================================================= +viewport = mcrfpy.Viewport3D( + pos=(10, 10), + size=(700, 550), + render_resolution=(350, 275), + fov=60.0, + camera_pos=(16.0, 15.0, 25.0), + camera_target=(16.0, 0.0, 16.0), + bg_color=mcrfpy.Color(40, 60, 100) +) +viewport.enable_fog = True +viewport.fog_near = 10.0 +viewport.fog_far = 40.0 +viewport.fog_color = mcrfpy.Color(40, 60, 100) +scene.children.append(viewport) + +# Set up navigation grid +viewport.set_grid_size(GRID_WIDTH, GRID_DEPTH) + +# ============================================================================= +# Terrain Generation +# ============================================================================= +print("Generating terrain...") + +# Create heightmap with hills +hm = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH)) +hm.mid_point_displacement(roughness=0.5) +hm.normalize(0.0, 1.0) + +# Build terrain mesh +viewport.build_terrain( + layer_name="terrain", + heightmap=hm, + y_scale=TERRAIN_Y_SCALE, + cell_size=CELL_SIZE +) + +# Apply heightmap to navigation grid +viewport.apply_heightmap(hm, TERRAIN_Y_SCALE) + +# Mark steep slopes and water as unwalkable +viewport.apply_threshold(hm, 0.0, 0.12, False) # Low areas = water (unwalkable) +viewport.set_slope_cost(0.4, 2.0) + +# Create base terrain colors (green/brown based on height) +r_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH)) +g_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH)) +b_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH)) + +# Storage for base colors (for FOV dimming) +base_colors = [] + +for z in range(GRID_DEPTH): + row = [] + for x in range(GRID_WIDTH): + h = hm[x, z] + if h < 0.12: # Water + r, g, b = 0.1, 0.2, 0.4 + elif h < 0.25: # Sand/beach + r, g, b = 0.6, 0.5, 0.3 + elif h < 0.6: # Grass + r, g, b = 0.2 + random.random() * 0.1, 0.4 + random.random() * 0.15, 0.15 + else: # Rock/mountain + r, g, b = 0.4, 0.35, 0.3 + + r_map[x, z] = r + g_map[x, z] = g + b_map[x, z] = b + row.append((r, g, b)) + base_colors.append(row) + +viewport.apply_terrain_colors("terrain", r_map, g_map, b_map) + +# ============================================================================= +# Find walkable starting positions +# ============================================================================= +def find_walkable_pos(): + """Find a random walkable position""" + for _ in range(100): + x = random.randint(2, GRID_WIDTH - 3) + z = random.randint(2, GRID_DEPTH - 3) + cell = viewport.at(x, z) + if cell.walkable: + return (x, z) + return (GRID_WIDTH // 2, GRID_DEPTH // 2) + +# ============================================================================= +# Player Entity +# ============================================================================= +player_start = find_walkable_pos() +player = mcrfpy.Entity3D(pos=player_start, scale=0.8, color=mcrfpy.Color(50, 150, 255)) +viewport.entities.append(player) +print(f"Player at {player_start}") + +# Track discovered cells +discovered = set() +discovered.add(player_start) + +# ============================================================================= +# NPC Entity with Patrol AI +# ============================================================================= +npc_start = find_walkable_pos() +while abs(npc_start[0] - player_start[0]) < 5 and abs(npc_start[1] - player_start[1]) < 5: + npc_start = find_walkable_pos() + +npc = mcrfpy.Entity3D(pos=npc_start, scale=0.7, color=mcrfpy.Color(255, 100, 100)) +viewport.entities.append(npc) +print(f"NPC at {npc_start}") + +# NPC patrol system +class NPCController: + def __init__(self, entity, waypoints): + self.entity = entity + self.waypoints = waypoints + self.current_wp = 0 + self.path = [] + self.path_index = 0 + + def update(self): + if self.entity.is_moving: + return + + # If we have a path, follow it + if self.path_index < len(self.path): + next_pos = self.path[self.path_index] + self.entity.pos = next_pos + self.path_index += 1 + return + + # Reached waypoint, go to next + self.current_wp = (self.current_wp + 1) % len(self.waypoints) + target = self.waypoints[self.current_wp] + + # Compute path to next waypoint + self.path = self.entity.path_to(target[0], target[1]) + self.path_index = 0 + +# Create patrol waypoints +npc_waypoints = [] +for _ in range(4): + wp = find_walkable_pos() + npc_waypoints.append(wp) + +npc_controller = NPCController(npc, npc_waypoints) + +# ============================================================================= +# FOV Visualization +# ============================================================================= +def update_fov_colors(): + """Update terrain colors based on FOV""" + # Compute FOV from player position + visible_cells = viewport.compute_fov((player.pos[0], player.pos[1]), FOV_RADIUS) + visible_set = set((c[0], c[1]) for c in visible_cells) + + # Update discovered + discovered.update(visible_set) + + # Update terrain colors + r_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH)) + g_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH)) + b_map = mcrfpy.HeightMap((GRID_WIDTH, GRID_DEPTH)) + + for z in range(GRID_DEPTH): + for x in range(GRID_WIDTH): + base_r, base_g, base_b = base_colors[z][x] + + if (x, z) in visible_set: + # Fully visible + r_map[x, z] = base_r + g_map[x, z] = base_g + b_map[x, z] = base_b + elif (x, z) in discovered: + # Discovered but not visible - dim + r_map[x, z] = base_r * 0.4 + g_map[x, z] = base_g * 0.4 + b_map[x, z] = base_b * 0.4 + else: + # Never seen - very dark + r_map[x, z] = base_r * 0.1 + g_map[x, z] = base_g * 0.1 + b_map[x, z] = base_b * 0.1 + + viewport.apply_terrain_colors("terrain", r_map, g_map, b_map) + +# Initial FOV update +update_fov_colors() + +# ============================================================================= +# UI Overlay +# ============================================================================= +ui_frame = mcrfpy.Frame( + pos=(720, 10), + size=(260, 200), + fill_color=mcrfpy.Color(20, 20, 30, 220), + outline_color=mcrfpy.Color(80, 80, 120), + outline=2.0 +) +scene.children.append(ui_frame) + +title_label = mcrfpy.Caption(text="3D Integration Demo", pos=(740, 20)) +title_label.fill_color = mcrfpy.Color(255, 255, 150) +scene.children.append(title_label) + +status_label = mcrfpy.Caption(text="Status: Idle", pos=(740, 50)) +status_label.fill_color = mcrfpy.Color(150, 255, 150) +scene.children.append(status_label) + +player_pos_label = mcrfpy.Caption(text="Player: (0, 0)", pos=(740, 75)) +player_pos_label.fill_color = mcrfpy.Color(100, 200, 255) +scene.children.append(player_pos_label) + +npc_pos_label = mcrfpy.Caption(text="NPC: (0, 0)", pos=(740, 100)) +npc_pos_label.fill_color = mcrfpy.Color(255, 150, 150) +scene.children.append(npc_pos_label) + +fps_label = mcrfpy.Caption(text="FPS: --", pos=(740, 125)) +fps_label.fill_color = mcrfpy.Color(200, 200, 200) +scene.children.append(fps_label) + +discovered_label = mcrfpy.Caption(text="Discovered: 0", pos=(740, 150)) +discovered_label.fill_color = mcrfpy.Color(180, 180, 100) +scene.children.append(discovered_label) + +# Controls info +controls_frame = mcrfpy.Frame( + pos=(720, 220), + size=(260, 120), + fill_color=mcrfpy.Color(20, 20, 30, 200), + outline_color=mcrfpy.Color(60, 60, 80), + outline=1.0 +) +scene.children.append(controls_frame) + +ctrl_title = mcrfpy.Caption(text="Controls:", pos=(740, 230)) +ctrl_title.fill_color = mcrfpy.Color(200, 200, 100) +scene.children.append(ctrl_title) + +ctrl_lines = [ + "Arrow keys: Move", + "Click: Pathfind", + "F: Toggle follow cam", + "ESC: Quit" +] +for i, line in enumerate(ctrl_lines): + cap = mcrfpy.Caption(text=line, pos=(740, 255 + i * 20)) + cap.fill_color = mcrfpy.Color(150, 150, 150) + scene.children.append(cap) + +# ============================================================================= +# Game State +# ============================================================================= +follow_camera = True +frame_count = 0 +fps_update_time = 0 + +# ============================================================================= +# Update Function +# ============================================================================= +def game_update(timer, runtime): + global frame_count, fps_update_time + + try: + # Calculate FPS + frame_count += 1 + if runtime - fps_update_time >= 1000: # Update FPS every second + fps = frame_count + fps_label.text = f"FPS: {fps}" + frame_count = 0 + fps_update_time = runtime + + # Update NPC patrol + npc_controller.update() + + # Update UI labels + px, pz = player.pos + player_pos_label.text = f"Player: ({px}, {pz})" + nx, nz = npc.pos + npc_pos_label.text = f"NPC: ({nx}, {nz})" + discovered_label.text = f"Discovered: {len(discovered)}" + + # Camera follow + if follow_camera: + viewport.follow(player, distance=12.0, height=8.0, smoothing=0.1) + + # Update status based on player state + if player.is_moving: + status_label.text = "Status: Moving" + status_label.fill_color = mcrfpy.Color(255, 255, 100) + else: + status_label.text = "Status: Idle" + status_label.fill_color = mcrfpy.Color(150, 255, 150) + except Exception as e: + print(f"Update error: {e}") + +# ============================================================================= +# Input Handling +# ============================================================================= +def try_move_player(dx, dz): + """Try to move player in direction""" + new_x = player.pos[0] + dx + new_z = player.pos[1] + dz + + if not viewport.is_in_fov(new_x, new_z): + # Allow moving into discovered cells even if not currently visible + if (new_x, new_z) not in discovered: + return False + + if new_x < 0 or new_x >= GRID_WIDTH or new_z < 0 or new_z >= GRID_DEPTH: + return False + + cell = viewport.at(new_x, new_z) + if not cell.walkable: + return False + + player.pos = (new_x, new_z) + update_fov_colors() + return True + +def on_key(key, state): + global follow_camera + + if state != mcrfpy.InputState.PRESSED: + return + + if player.is_moving: + return # Don't accept input while moving + + dx, dz = 0, 0 + if key == mcrfpy.Key.UP: + dz = -1 + elif key == mcrfpy.Key.DOWN: + dz = 1 + elif key == mcrfpy.Key.LEFT: + dx = -1 + elif key == mcrfpy.Key.RIGHT: + dx = 1 + elif key == mcrfpy.Key.F: + follow_camera = not follow_camera + status_label.text = f"Camera: {'Follow' if follow_camera else 'Free'}" + return + elif key == mcrfpy.Key.ESCAPE: + mcrfpy.exit() + return + + if dx != 0 or dz != 0: + try_move_player(dx, dz) + +# Click-to-move handling +def on_click(pos, button, state): + if button != mcrfpy.MouseButton.LEFT or state != mcrfpy.InputState.PRESSED: + return + + if player.is_moving: + return + + # Convert click position to viewport-relative coordinates + vp_x = pos.x - viewport.x + vp_y = pos.y - viewport.y + + # Check if click is within viewport + if vp_x < 0 or vp_x >= viewport.w or vp_y < 0 or vp_y >= viewport.h: + return + + # Convert to world position + world_pos = viewport.screen_to_world(vp_x, vp_y) + if world_pos is None: + return + + # Convert to grid position + grid_x = int(world_pos[0] / CELL_SIZE) + grid_z = int(world_pos[2] / CELL_SIZE) + + # Validate grid position + if grid_x < 0 or grid_x >= GRID_WIDTH or grid_z < 0 or grid_z >= GRID_DEPTH: + return + + cell = viewport.at(grid_x, grid_z) + if not cell.walkable: + status_label.text = "Status: Can't walk there!" + status_label.fill_color = mcrfpy.Color(255, 100, 100) + return + + # Find path + path = player.path_to(grid_x, grid_z) + if not path: + status_label.text = "Status: No path!" + status_label.fill_color = mcrfpy.Color(255, 100, 100) + return + + # Follow path (limited to FOV_RADIUS steps) + limited_path = path[:FOV_RADIUS] + player.follow_path(limited_path) + status_label.text = f"Status: Moving ({len(limited_path)} steps)" + status_label.fill_color = mcrfpy.Color(255, 255, 100) + + # Schedule FOV update after movement completes + fov_update_timer = None + + def update_fov_after_move(*args): + # Accept any number of args since timer may pass (runtime) or (timer, runtime) + nonlocal fov_update_timer + if not player.is_moving: + update_fov_colors() + if fov_update_timer: + fov_update_timer.stop() + + fov_update_timer = mcrfpy.Timer("fov_update", update_fov_after_move, 100) + +scene.on_key = on_key +viewport.on_click = on_click + +# ============================================================================= +# Start Game +# ============================================================================= +timer = mcrfpy.Timer("game_update", game_update, 16) # ~60 FPS + +mcrfpy.current_scene = scene + +print() +print("=" * 60) +print("3D Integration Demo Loaded!") +print("=" * 60) +print(f" Terrain: {GRID_WIDTH}x{GRID_DEPTH} cells") +print(f" Player starts at: {player_start}") +print(f" NPC patrolling {len(npc_waypoints)} waypoints") +print() +print("Controls:") +print(" Arrow keys: Move player") +print(" Click: Pathfind to location") +print(" F: Toggle camera follow") +print(" ESC: Quit") +print("=" * 60)