Compare commits

...

2 commits

7 changed files with 809 additions and 2 deletions

View file

@ -259,6 +259,30 @@ int PyVoxelGrid::set_greedy_meshing(PyVoxelGridObject* self, PyObject* value, vo
return 0;
}
// Visible property
PyObject* PyVoxelGrid::get_visible(PyVoxelGridObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return nullptr;
}
return PyBool_FromLong(self->data->isVisible());
}
int PyVoxelGrid::set_visible(PyVoxelGridObject* self, PyObject* value, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "VoxelGrid not initialized");
return -1;
}
if (!PyBool_Check(value)) {
PyErr_SetString(PyExc_TypeError, "visible must be a boolean");
return -1;
}
self->data->setVisible(value == Py_True);
return 0;
}
// =============================================================================
// Voxel access methods
// =============================================================================
@ -1267,5 +1291,7 @@ PyGetSetDef PyVoxelGrid::getsetters[] = {
"Y-axis rotation in degrees.", nullptr},
{"greedy_meshing", (getter)get_greedy_meshing, (setter)set_greedy_meshing,
"Enable greedy meshing optimization (reduces vertex count for uniform regions).", nullptr},
{"visible", (getter)get_visible, (setter)set_visible,
"Show or hide this voxel grid in rendering.", nullptr},
{nullptr} // Sentinel
};

View file

@ -52,6 +52,10 @@ public:
static PyObject* get_greedy_meshing(PyVoxelGridObject* self, void* closure);
static int set_greedy_meshing(PyVoxelGridObject* self, PyObject* value, void* closure);
// Properties - visibility
static PyObject* get_visible(PyVoxelGridObject* self, void* closure);
static int set_visible(PyVoxelGridObject* self, PyObject* value, void* closure);
// Voxel access methods
static PyObject* get(PyVoxelGridObject* self, PyObject* args);
static PyObject* set(PyVoxelGridObject* self, PyObject* args);

View file

@ -1021,6 +1021,9 @@ void Viewport3D::renderVoxelLayers(const mat4& view, const mat4& proj) {
for (auto& pair : sortedLayers) {
VoxelGrid* grid = pair.first;
// Skip invisible grids
if (!grid->isVisible()) continue;
// Get vertices (triggers rebuild if dirty)
const std::vector<MeshVertex>& vertices = grid->getVertices();
if (vertices.empty()) continue;

View file

@ -64,6 +64,7 @@ private:
mutable bool meshDirty_ = true;
mutable std::vector<MeshVertex> cachedVertices_;
bool greedyMeshing_ = false; // Use greedy meshing algorithm
bool visible_ = true; // Visibility toggle for rendering
// Index calculation (row-major: X varies fastest, then Y, then Z)
inline size_t index(int x, int y, int z) const {
@ -163,6 +164,10 @@ public:
void setGreedyMeshing(bool enabled) { greedyMeshing_ = enabled; markDirty(); }
bool isGreedyMeshingEnabled() const { return greedyMeshing_; }
/// Show/hide this voxel grid in rendering
void setVisible(bool v) { visible_ = v; }
bool isVisible() const { return visible_; }
// Memory info (for debugging)
size_t memoryUsageBytes() const {
return data_.size() + materials_.size() * sizeof(VoxelMaterial);

View file

@ -235,8 +235,10 @@ void VoxelMesher::generateGreedyMesh(const VoxelGrid& grid, std::vector<MeshVert
} else { // Z-facing
float faceZ = (dir > 0) ? (sliceIdx + 1) * cs : sliceIdx * cs;
corner = vec3(u * cs, v * cs, faceZ);
uAxis = vec3(rectW * cs, 0, 0);
vAxis = vec3(0, rectH * cs, 0);
// Note: axes swapped vs X/Y cases to maintain CCW winding
// (vAxis × uAxis must equal +Z for front faces)
uAxis = vec3(0, rectH * cs, 0);
vAxis = vec3(rectW * cs, 0, 0);
normal = vec3(0, 0, static_cast<float>(dir));
if (dir < 0) std::swap(uAxis, vAxis);
}

View file

@ -235,6 +235,60 @@
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Click-to-run overlay */
.game-panel {
position: relative;
}
.click-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10;
}
.click-overlay.hidden {
display: none;
}
.click-overlay h2 {
color: #e94560;
margin: 0 0 10px 0;
font-size: 32px;
}
.click-overlay p {
color: #888;
margin: 5px 15px;
text-align: center;
}
.click-overlay p a {
color: #667eea;
}
.click-overlay .start-btn {
margin-top: 20px;
padding: 15px 40px;
font-size: 18px;
background: #4ecca3;
color: #1a1a2e;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
transition: transform 0.2s, background 0.2s;
}
.click-overlay .start-btn:hover {
background: #3db892;
transform: scale(1.05);
}
.click-overlay .start-btn:disabled {
background: #666;
cursor: not-allowed;
transform: none;
}
@media (max-width: 900px) {
.main-content {
flex-direction: column;
@ -341,6 +395,14 @@
<div class="main-content">
<div class="game-panel">
<canvas class="emscripten" id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
<!-- Click-to-run overlay (visible on load, especially important for mobile) -->
<div id="clickOverlay" class="click-overlay">
<h2>McRogueFace Playground</h2>
<p>Code snippets run unmodified in <a href="https://github.com/jmccardle/McRogueFace" target="_blank">McRogueFace for Linux or Windows Desktop</a></p>
<p id="overlayStatus">Loading engine...</p>
<button class="start-btn" id="startBtn" disabled>Loading...</button>
</div>
</div>
<div class="repl-panel" id="replPanel">
@ -492,6 +554,9 @@
var clearBtn = document.getElementById('clearBtn');
var interpreterStatus = document.getElementById('interpreterStatus');
var sourceIndicator = document.getElementById('sourceIndicator');
var clickOverlay = document.getElementById('clickOverlay');
var startBtn = document.getElementById('startBtn');
var overlayStatus = document.getElementById('overlayStatus');
var shareModal = document.getElementById('shareModal');
var shareUrlInput = document.getElementById('shareUrl');
var shareSizeEl = document.getElementById('shareSize');
@ -676,6 +741,11 @@
appendToOutput('Python REPL ready. Enter code and click Run (or Ctrl+Enter).', 'success');
appendToOutput('Tip: Use Ctrl+Up/Down to navigate command history.', 'input');
// Enable the click overlay start button
overlayStatus.textContent = 'Engine ready!';
startBtn.textContent = 'Click to Run';
startBtn.disabled = false;
setTimeout(function() {
canvasElement.focus();
window.dispatchEvent(new Event('resize'));
@ -876,6 +946,15 @@
resetBtn.addEventListener('click', resetEnvironment);
clearBtn.addEventListener('click', clearOutput);
// Click overlay start button - runs editor code and dismisses overlay
startBtn.addEventListener('click', function() {
clickOverlay.classList.add('hidden');
canvasElement.focus();
if (window.runPython) {
runCode();
}
});
// ===========================================
// Initialize
// ===========================================

View file

@ -0,0 +1,688 @@
# village_demo.py - 3D Village Integration Demo
# Capstone demo combining: heightmap terrain + voxel buildings + animated entities
# + click-to-move pathfinding + FOV + camera follow + roof toggle
#
# Demonstrates all 3D milestones (9-14) working together in one scene.
import mcrfpy
import sys
import math
# ===========================================================================
# Constants
# ===========================================================================
GRID_W, GRID_D = 48, 36
Y_SCALE = 6.0
CELL_SIZE = 1.0
# Building definitions: (name, voxel_size, grid_pos, rotation, palette)
BUILDINGS = [
{
"name": "Stone House",
"size": (8, 5, 8),
"grid_pos": (10, 10), # (x, z) on nav grid
"rotation": 0.0,
"wall_color": (120, 120, 130),
"floor_color": (100, 80, 60),
"roof_color": (140, 60, 40),
"door_wall": "south", # which wall gets a doorway
},
{
"name": "Wooden Lodge",
"size": (10, 6, 8),
"grid_pos": (30, 8),
"rotation": 0.0,
"wall_color": (130, 90, 50),
"floor_color": (90, 70, 45),
"roof_color": (80, 55, 30),
"door_wall": "south",
},
{
"name": "Guard Tower",
"size": (7, 10, 7),
"grid_pos": (22, 26),
"rotation": 0.0,
"wall_color": (90, 90, 100),
"floor_color": (70, 65, 60),
"roof_color": (60, 60, 70),
"door_wall": "south",
},
]
# ===========================================================================
# Scene setup
# ===========================================================================
scene = mcrfpy.Scene("village_demo")
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(15, 15, 25))
scene.children.append(bg)
title = mcrfpy.Caption(text="3D Village Demo", pos=(20, 8))
title.fill_color = mcrfpy.Color(255, 255, 100)
scene.children.append(title)
subtitle = mcrfpy.Caption(text="Terrain + Voxel Buildings + Pathfinding + FOV + Animation", pos=(20, 28))
subtitle.fill_color = mcrfpy.Color(180, 180, 200)
scene.children.append(subtitle)
# ===========================================================================
# Viewport3D
# ===========================================================================
viewport = mcrfpy.Viewport3D(
pos=(10, 50),
size=(700, 500),
render_resolution=(350, 250),
fov=60.0,
camera_pos=(24.0, 20.0, 45.0),
camera_target=(24.0, 0.0, 18.0),
bg_color=mcrfpy.Color(120, 170, 220)
)
scene.children.append(viewport)
viewport.set_grid_size(GRID_W, GRID_D)
viewport.cell_size = CELL_SIZE
# ===========================================================================
# Phase B1: Terrain generation
# ===========================================================================
print("Generating terrain...")
hm = mcrfpy.HeightMap((GRID_W, GRID_D))
hm.mid_point_displacement(0.45, seed=12345)
hm.normalize(0.0, 1.0)
hm.rain_erosion(drops=2000, erosion=0.06, sedimentation=0.03, seed=54321)
hm.normalize(0.0, 1.0)
# Add hills at strategic locations (away from buildings)
# hm.add_hill() - check if it exists; if not, manually raise areas
# For now, manually raise a few areas
for x in range(3, 8):
for z in range(25, 32):
cx, cz = 5.5, 28.5
dist = math.sqrt((x - cx)**2 + (z - cz)**2)
if dist < 4.0:
bump = 0.3 * (1.0 - dist / 4.0)
hm[x, z] = min(1.0, hm[x, z] + bump)
for x in range(38, 46):
for z in range(20, 28):
cx, cz = 42.0, 24.0
dist = math.sqrt((x - cx)**2 + (z - cz)**2)
if dist < 5.0:
bump = 0.25 * (1.0 - dist / 5.0)
hm[x, z] = min(1.0, hm[x, z] + bump)
# Flatten building zones (set to a constant moderate height)
FLAT_HEIGHT = 0.35
for bldg in BUILDINGS:
bx, bz = bldg["grid_pos"]
bw = bldg["size"][0]
bd = bldg["size"][2]
# Flatten a zone slightly larger than the building footprint
for x in range(max(0, bx - 1), min(GRID_W, bx + bw + 1)):
for z in range(max(0, bz - 1), min(GRID_D, bz + bd + 1)):
hm[x, z] = FLAT_HEIGHT
hm.normalize(0.0, 1.0) # Re-normalize after modifications
# Build terrain mesh
vertex_count = viewport.build_terrain(
layer_name="terrain",
heightmap=hm,
y_scale=Y_SCALE,
cell_size=CELL_SIZE
)
print(f"Terrain: {vertex_count} vertices")
# Apply heightmap to navigation grid
viewport.apply_heightmap(hm, Y_SCALE)
# Color the terrain based on height + moisture
moisture = mcrfpy.HeightMap((GRID_W, GRID_D))
moisture.mid_point_displacement(0.6, seed=7777)
moisture.normalize(0.0, 1.0)
r_map = mcrfpy.HeightMap((GRID_W, GRID_D))
g_map = mcrfpy.HeightMap((GRID_W, GRID_D))
b_map = mcrfpy.HeightMap((GRID_W, GRID_D))
for z in range(GRID_D):
for x in range(GRID_W):
h = hm[x, z]
m = moisture[x, z]
if h < 0.15: # Water
r_map[x, z] = 0.15
g_map[x, z] = 0.3
b_map[x, z] = 0.65
elif h < 0.25: # Sand / shore
r_map[x, z] = 0.75
g_map[x, z] = 0.7
b_map[x, z] = 0.45
elif h < 0.6: # Grass / dirt
if m > 0.45:
# Lush grass
r_map[x, z] = 0.2 + h * 0.15
g_map[x, z] = 0.45 + m * 0.2
b_map[x, z] = 0.1
else:
# Dry grass / dirt
r_map[x, z] = 0.45
g_map[x, z] = 0.38
b_map[x, z] = 0.18
elif h < 0.8: # Rock
r_map[x, z] = 0.42
g_map[x, z] = 0.4
b_map[x, z] = 0.38
else: # Snow / high peaks
r_map[x, z] = 0.9
g_map[x, z] = 0.9
b_map[x, z] = 0.95
viewport.apply_terrain_colors("terrain", r_map, g_map, b_map)
# Mark water as unwalkable
viewport.apply_threshold(hm, 0.0, 0.15, False)
# Mark steep areas as costly
viewport.set_slope_cost(0.3, 3.0)
print("Terrain colored and nav grid configured")
# ===========================================================================
# Phase B2: Voxel Buildings
# ===========================================================================
print("Building voxel structures...")
building_data = [] # Store (body, roof, footprint) for each building
for bldg in BUILDINGS:
bw, bh, bd = bldg["size"]
gx, gz = bldg["grid_pos"]
# Get terrain height at building center
terrain_y = hm[gx + bw // 2, gz + bd // 2] * Y_SCALE
# --- Body VoxelGrid (floor + walls with doorway) ---
body = mcrfpy.VoxelGrid((bw, bh, bd), cell_size=CELL_SIZE)
floor_mat = body.add_material("floor", bldg["floor_color"])
wall_mat = body.add_material("wall", bldg["wall_color"], transparent=False)
window_mat = body.add_material("window", (180, 210, 240), transparent=True)
# Floor
body.fill_box((0, 0, 0), (bw - 1, 0, bd - 1), floor_mat)
# Walls (hollow box from y=1 to y=bh-2, leaving top for roof)
body.fill_box_hollow((0, 1, 0), (bw - 1, bh - 2, bd - 1), wall_mat)
# Doorway (south wall = z=0 face, centered)
door_cx = bw // 2
if bldg["door_wall"] == "south":
body.fill_box((door_cx - 1, 1, 0), (door_cx, 2, 0), 0) # 2-wide, 2-tall door
elif bldg["door_wall"] == "north":
body.fill_box((door_cx - 1, 1, bd - 1), (door_cx, 2, bd - 1), 0)
# Windows (east wall, if building is large enough)
if bw >= 7:
win_cx = bw - 1
win_cz = bd // 2
body.fill_box((win_cx, 2, win_cz - 1), (win_cx, 3, win_cz), window_mat)
# Windows (west wall)
if bw >= 7:
win_cz2 = bd // 2
body.fill_box((0, 2, win_cz2 - 1), (0, 3, win_cz2), window_mat)
# Position the body in world space
body.offset = (float(gx), terrain_y, float(gz))
body.rotation = bldg["rotation"]
body.greedy_meshing = True
# --- Roof VoxelGrid (single slab at top) ---
roof = mcrfpy.VoxelGrid((bw + 2, 1, bd + 2), cell_size=CELL_SIZE)
roof_mat = roof.add_material("roof", bldg["roof_color"])
roof.fill_box((0, 0, 0), (bw + 1, 0, bd + 1), roof_mat)
roof.offset = (float(gx - 1), terrain_y + (bh - 1) * CELL_SIZE, float(gz - 1))
roof.rotation = bldg["rotation"]
roof.greedy_meshing = True
# Add to viewport
viewport.add_voxel_layer(body, z_index=1)
viewport.add_voxel_layer(roof, z_index=2)
# Project body to nav grid (walls block movement)
viewport.project_voxel_to_nav(body, headroom=2)
building_data.append({
"name": bldg["name"],
"body": body,
"roof": roof,
"footprint": (gx, gz, bw, bd),
"terrain_y": terrain_y,
})
print(f" {bldg['name']}: {bw}x{bh}x{bd} at ({gx},{gz}), y={terrain_y:.1f}")
print(f"Built {len(building_data)} buildings")
# ===========================================================================
# Phase B3: Player Entity
# ===========================================================================
print("Setting up player...")
# Start player near the first building's door
start_x, start_z = BUILDINGS[0]["grid_pos"][0] + BUILDINGS[0]["size"][0] // 2, BUILDINGS[0]["grid_pos"][1] - 2
player = mcrfpy.Entity3D(
pos=(start_x, start_z),
scale=1.0,
color=mcrfpy.Color(100, 200, 255)
)
# Try to load animated model
try:
player_model = mcrfpy.Model3D("../assets/models/CesiumMan.glb")
if player_model.has_skeleton:
player.model = player_model
clips = player_model.animation_clips
if clips:
player.anim_clip = clips[0]
player.anim_loop = True
player.anim_speed = 1.0
player.auto_animate = True
# CesiumMan has a single clip - use it for both walk and idle
player.walk_clip = clips[0]
player.idle_clip = clips[0]
print(f" Player model loaded with {len(clips)} clip(s): {clips}")
else:
print(" Player model loaded (no skeleton)")
except Exception as e:
print(f" Could not load player model: {e}")
viewport.entities.append(player)
# ===========================================================================
# Phase B3 continued: NPCs
# ===========================================================================
print("Setting up NPCs...")
class NPCController:
"""Simple patrol NPC that walks between waypoints"""
def __init__(self, entity, waypoints, name="NPC"):
self.entity = entity
self.waypoints = waypoints
self.current_wp = 0
self.name = name
self.waiting = False
def update(self):
if self.entity.is_moving:
return
# Move to next waypoint
self.current_wp = (self.current_wp + 1) % len(self.waypoints)
wx, wz = self.waypoints[self.current_wp]
path = self.entity.path_to(wx, wz)
if path and len(path) > 0:
self.entity.follow_path(path)
npcs = []
# NPC 1: Guard patrolling between buildings
npc1 = mcrfpy.Entity3D(
pos=(15, 14),
scale=0.9,
color=mcrfpy.Color(255, 150, 80)
)
try:
npc1_model = mcrfpy.Model3D("../assets/models/CesiumMan.glb")
if npc1_model.has_skeleton:
npc1.model = npc1_model
clips = npc1_model.animation_clips
if clips:
npc1.anim_clip = clips[0]
npc1.anim_loop = True
npc1.auto_animate = True
npc1.walk_clip = clips[0]
npc1.idle_clip = clips[0]
except Exception:
pass
viewport.entities.append(npc1)
npc1_ctrl = NPCController(npc1, [
(15, 14), (28, 12), (35, 15), (28, 22), (15, 20)
], name="Guard")
npcs.append(npc1_ctrl)
# NPC 2: Villager near the lodge
npc2 = mcrfpy.Entity3D(
pos=(32, 18),
scale=0.85,
color=mcrfpy.Color(180, 255, 150)
)
try:
npc2_model = mcrfpy.Model3D("../assets/models/CesiumMan.glb")
if npc2_model.has_skeleton:
npc2.model = npc2_model
clips = npc2_model.animation_clips
if clips:
npc2.anim_clip = clips[0]
npc2.anim_loop = True
npc2.auto_animate = True
npc2.walk_clip = clips[0]
npc2.idle_clip = clips[0]
except Exception:
pass
viewport.entities.append(npc2)
npc2_ctrl = NPCController(npc2, [
(32, 18), (35, 12), (38, 18), (35, 24)
], name="Villager")
npcs.append(npc2_ctrl)
print(f" {len(npcs)} NPCs created")
# ===========================================================================
# Phase B5: Roof toggle tracking
# ===========================================================================
player_inside = [None] # Which building the player is inside (or None)
def check_building_interior(px, pz):
"""Check if player position is inside any building footprint"""
for bd in building_data:
gx, gz, bw, bdepth = bd["footprint"]
if gx <= px < gx + bw and gz <= pz < gz + bdepth:
return bd
return None
def update_roof_visibility(building_match):
"""Toggle roof visibility based on player position"""
old = player_inside[0]
if building_match is old:
return # No change
# Restore old building's roof
if old is not None:
old["roof"].visible = True
# Hide new building's roof
if building_match is not None:
building_match["roof"].visible = False
player_inside[0] = building_match
# ===========================================================================
# Phase B6: Camera + FOV
# ===========================================================================
# Initial FOV
fov_radius = 12
fov_visible = []
# FOV state: discovered cells persist
discovered = set()
def update_fov():
"""Recompute FOV from player position"""
global fov_visible
px, pz = player.pos
px, pz = int(px), int(pz)
fov_visible = viewport.compute_fov((px, pz), fov_radius)
# Mark cells as discovered
for cell_pos in fov_visible:
discovered.add((cell_pos[0], cell_pos[1]))
# Initial FOV computation
update_fov()
# Set up camera follow
viewport.follow(player, distance=14.0, height=10.0, smoothing=0.08)
# ===========================================================================
# Phase B4: Click-to-move handler
# ===========================================================================
def on_viewport_click(pos, button, action):
"""Handle click on viewport for movement"""
if action != mcrfpy.InputState.PRESSED:
return
if button != mcrfpy.MouseButton.LEFT:
return
# pos is absolute screen coords; convert to viewport-relative
# screen_to_world expects display pixel coords, NOT render resolution
local_x = pos.x - viewport.x
local_y = pos.y - viewport.y
result = viewport.screen_to_world(local_x, local_y)
# Update debug label
if result is not None:
debug_label.text = (
f"mouse=({pos.x:.0f},{pos.y:.0f}) "
f"local=({local_x:.0f},{local_y:.0f}) "
f"world=({result[0]:.1f},{result[1]:.1f},{result[2]:.1f}) "
f"grid=({int(result[0])},{int(result[2])})"
)
else:
debug_label.text = (
f"mouse=({pos.x:.0f},{pos.y:.0f}) "
f"local=({local_x:.0f},{local_y:.0f}) "
f"world=None"
)
return
# Convert world position to grid coordinates
target_gx = int(result[0])
target_gz = int(result[2])
# Clamp to grid bounds
target_gx = max(0, min(GRID_W - 1, target_gx))
target_gz = max(0, min(GRID_D - 1, target_gz))
# Check if target is walkable
cell = viewport.at(target_gx, target_gz)
if not cell.walkable:
status_text.text = f"Can't walk to ({target_gx}, {target_gz}) - blocked! h={cell.height:.1f}"
return
# Pathfind and move
px, pz = player.pos
path = player.path_to(target_gx, target_gz)
if path and len(path) > 0:
player.follow_path(path)
status_text.text = f"({int(px)},{int(pz)})->({target_gx},{target_gz}), {len(path)} steps"
else:
status_text.text = f"No path from ({int(px)},{int(pz)}) to ({target_gx},{target_gz})"
viewport.on_click = on_viewport_click
# ===========================================================================
# Keyboard handler (arrow keys for step movement)
# ===========================================================================
def on_key(key, state):
if state != mcrfpy.InputState.PRESSED:
return
px, pz = player.pos
px, pz = int(px), int(pz)
moved = False
if key == mcrfpy.Key.UP or key == mcrfpy.Key.W:
target = (px, pz - 1)
moved = True
elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S:
target = (px, pz + 1)
moved = True
elif key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A:
target = (px - 1, pz)
moved = True
elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D:
target = (px + 1, pz)
moved = True
elif key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
return
elif key == mcrfpy.Key.F:
# Toggle FOV visualization
update_fov()
status_text.text = f"FOV recomputed: {len(fov_visible)} visible cells"
return
if moved:
tx, tz = target
if 0 <= tx < GRID_W and 0 <= tz < GRID_D:
cell = viewport.at(tx, tz)
if cell.walkable:
path = player.path_to(tx, tz)
if path:
player.follow_path(path)
scene.on_key = on_key
# ===========================================================================
# Phase B7: UI Overlay
# ===========================================================================
# Info panel
info_panel = mcrfpy.Frame(
pos=(720, 50), size=(290, 320),
fill_color=mcrfpy.Color(25, 25, 35, 220),
outline_color=mcrfpy.Color(80, 80, 100),
outline=2.0
)
scene.children.append(info_panel)
info_title = mcrfpy.Caption(text="Village Info", pos=(10, 8))
info_title.fill_color = mcrfpy.Color(255, 255, 100)
info_panel.children.append(info_title)
pos_label = mcrfpy.Caption(text="Player: (?, ?)", pos=(10, 35))
pos_label.fill_color = mcrfpy.Color(180, 220, 255)
info_panel.children.append(pos_label)
building_label = mcrfpy.Caption(text="Location: Outside", pos=(10, 55))
building_label.fill_color = mcrfpy.Color(200, 200, 150)
info_panel.children.append(building_label)
entity_label = mcrfpy.Caption(text=f"Entities: {len(npcs) + 1}", pos=(10, 75))
entity_label.fill_color = mcrfpy.Color(180, 200, 180)
info_panel.children.append(entity_label)
fov_label = mcrfpy.Caption(text="FOV: ? cells", pos=(10, 95))
fov_label.fill_color = mcrfpy.Color(200, 180, 255)
info_panel.children.append(fov_label)
terrain_label = mcrfpy.Caption(text=f"Terrain: {GRID_W}x{GRID_D}, {vertex_count} verts", pos=(10, 115))
terrain_label.fill_color = mcrfpy.Color(150, 180, 150)
info_panel.children.append(terrain_label)
buildings_label = mcrfpy.Caption(text=f"Buildings: {len(building_data)}", pos=(10, 135))
buildings_label.fill_color = mcrfpy.Color(180, 150, 150)
info_panel.children.append(buildings_label)
# Building list
for i, bd in enumerate(building_data):
bl = mcrfpy.Caption(text=f" {bd['name']}", pos=(10, 155 + i * 18))
bl.fill_color = mcrfpy.Color(150, 150, 170)
info_panel.children.append(bl)
# Controls panel
controls_panel = mcrfpy.Frame(
pos=(720, 380), size=(290, 170),
fill_color=mcrfpy.Color(25, 25, 35, 220),
outline_color=mcrfpy.Color(80, 80, 100),
outline=2.0
)
scene.children.append(controls_panel)
ctrl_title = mcrfpy.Caption(text="Controls", pos=(10, 8))
ctrl_title.fill_color = mcrfpy.Color(255, 255, 100)
controls_panel.children.append(ctrl_title)
controls = [
"Click viewport: Move player",
"WASD/Arrows: Step movement",
"F: Recompute FOV",
"ESC: Quit",
"",
"Roofs hide when you enter",
"a building!",
]
for i, line in enumerate(controls):
cl = mcrfpy.Caption(text=line, pos=(10, 32 + i * 18))
cl.fill_color = mcrfpy.Color(160, 160, 180)
controls_panel.children.append(cl)
# Status bar
status_frame = mcrfpy.Frame(
pos=(10, 560), size=(700, 30),
fill_color=mcrfpy.Color(20, 20, 30, 220),
outline_color=mcrfpy.Color(60, 60, 80),
outline=1.0
)
scene.children.append(status_frame)
status_text = mcrfpy.Caption(text="Click the terrain to move. Enter buildings to see roofs toggle.", pos=(10, 6))
status_text.fill_color = mcrfpy.Color(150, 200, 150)
status_frame.children.append(status_text)
# Debug label for click coordinates
debug_frame = mcrfpy.Frame(
pos=(10, 595), size=(700, 25),
fill_color=mcrfpy.Color(30, 15, 15, 200),
outline_color=mcrfpy.Color(80, 40, 40),
outline=1.0
)
scene.children.append(debug_frame)
debug_label = mcrfpy.Caption(text="Click debug: (click viewport to see coordinates)", pos=(10, 4))
debug_label.fill_color = mcrfpy.Color(255, 180, 120)
debug_frame.children.append(debug_label)
# ===========================================================================
# Phase B8: Billboards (trees)
# ===========================================================================
# Note: Billboards require a texture. Check if kenney spritesheet works.
# For now, skip billboards if no suitable texture is available.
# Trees can be added once billboard texture support is confirmed.
# ===========================================================================
# Game update loop
# ===========================================================================
frame_count = [0]
def game_update(timer, runtime):
frame_count[0] += 1
# Update NPC patrol
for npc_ctrl in npcs:
npc_ctrl.update()
# Update player info
px, pz = player.pos
pos_label.text = f"Player: ({int(px)}, {int(pz)})"
# Check building interior
building_match = check_building_interior(int(px), int(pz))
update_roof_visibility(building_match)
if building_match is not None:
building_label.text = f"Location: {building_match['name']}"
else:
building_label.text = "Location: Outside"
# Update FOV periodically (every 30 frames to save CPU)
if frame_count[0] % 30 == 0:
update_fov()
fov_label.text = f"FOV: {len(fov_visible)} cells, {len(discovered)} discovered"
# Timer: ~60fps game update
game_timer = mcrfpy.Timer("village_update", game_update, 16)
# ===========================================================================
# Activate scene
# ===========================================================================
mcrfpy.current_scene = scene
print()
print("=== 3D Village Demo ===")
print(f"Terrain: {GRID_W}x{GRID_D} heightmap, {vertex_count} terrain vertices")
print(f"Buildings: {len(building_data)} voxel structures with toggleable roofs")
print(f"Entities: 1 player + {len(npcs)} NPCs with pathfinding")
print("Click terrain to move, enter buildings to see roof toggle!")
print()