Initial commit: Terrafier v0.1.0
- Core world model (World, Dimension, Tile, Terrain, Platform) - NBT reader/writer with full tag support - Anvil region (.mca) format reader with chunk parsing - Block palette compression and export - Biome database with Minecraft 1.21 biomes - CLI commands: new, import, export, info, render - GUI application with egui (viewport, tools, undo/redo) - Noise-based heightmap generation and editing operations License: GPL-3.0-or-later
This commit is contained in:
commit
91835402b3
94 changed files with 12372 additions and 0 deletions
16
gui/Cargo.toml
Executable file
16
gui/Cargo.toml
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "terrafier-gui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Native GUI for Terrafier — interactive world painting with GPU rendering"
|
||||
|
||||
[dependencies]
|
||||
terrafier-core = { path = "../core" }
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
|
||||
egui = "0.31"
|
||||
eframe = "0.31"
|
||||
rfd = "0.15"
|
||||
189
gui/src/app.rs
Executable file
189
gui/src/app.rs
Executable file
|
|
@ -0,0 +1,189 @@
|
|||
use std::collections::VecDeque;
|
||||
|
||||
use terrafier_core::model::terrain::Terrain;
|
||||
use terrafier_core::ops::operations::Operation;
|
||||
use terrafier_core::World;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum ToolMode {
|
||||
Raise,
|
||||
Lower,
|
||||
Smooth,
|
||||
Flatten,
|
||||
Paint,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
impl ToolMode {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ToolMode::Raise => "Raise",
|
||||
ToolMode::Lower => "Lower",
|
||||
ToolMode::Smooth => "Smooth",
|
||||
ToolMode::Flatten => "Flatten",
|
||||
ToolMode::Paint => "Paint",
|
||||
ToolMode::Inspect => "Inspect",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TerrafierApp {
|
||||
pub world: Option<World>,
|
||||
pub selected_tile: Option<(i32, i32)>,
|
||||
pub tool_mode: ToolMode,
|
||||
pub brush_radius: u32,
|
||||
pub brush_strength: f64,
|
||||
pub selected_terrain: Terrain,
|
||||
pub target_height: i16,
|
||||
pub smooth_iterations: u32,
|
||||
pub undo_stack: VecDeque<Box<dyn Operation>>,
|
||||
pub redo_stack: Vec<Box<dyn Operation>>,
|
||||
pub brush_local_x: Option<u32>,
|
||||
pub brush_local_z: Option<u32>,
|
||||
pub show_heightmap: bool,
|
||||
pub zoom: f32,
|
||||
pub view_offset: (f32, f32),
|
||||
pub show_new_world: bool,
|
||||
pub show_export: bool,
|
||||
pub status_message: String,
|
||||
pub world_name: String,
|
||||
pub world_seed: String,
|
||||
pub export_path: String,
|
||||
}
|
||||
|
||||
impl TerrafierApp {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
world: None,
|
||||
selected_tile: None,
|
||||
tool_mode: ToolMode::Raise,
|
||||
brush_radius: 16,
|
||||
brush_strength: 0.5,
|
||||
selected_terrain: Terrain::Grass,
|
||||
target_height: 64,
|
||||
smooth_iterations: 3,
|
||||
undo_stack: VecDeque::new(),
|
||||
redo_stack: Vec::new(),
|
||||
brush_local_x: None,
|
||||
brush_local_z: None,
|
||||
show_heightmap: false,
|
||||
zoom: 1.0,
|
||||
view_offset: (0.0, 0.0),
|
||||
show_new_world: false,
|
||||
show_export: false,
|
||||
status_message: "Ready".to_string(),
|
||||
world_name: String::new(),
|
||||
world_seed: String::new(),
|
||||
export_path: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_for_undo(&mut self, op: Box<dyn Operation>) {
|
||||
self.undo_stack.push_back(op);
|
||||
if self.undo_stack.len() > 50 {
|
||||
self.undo_stack.pop_front();
|
||||
}
|
||||
self.redo_stack.clear();
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) {
|
||||
if let Some(op) = self.undo_stack.pop_back() {
|
||||
if let Some(ref mut world) = self.world {
|
||||
let dim = world.overworld_mut().expect("world has no overworld");
|
||||
let inv = op.inverse();
|
||||
if let Err(e) = inv.apply(dim) {
|
||||
self.status_message = format!("Undo error: {:?}", e);
|
||||
self.undo_stack.push_back(op);
|
||||
return;
|
||||
}
|
||||
self.redo_stack.push(op);
|
||||
self.status_message = "Undo".to_string();
|
||||
}
|
||||
} else {
|
||||
self.status_message = "Nothing to undo".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) {
|
||||
if let Some(op) = self.redo_stack.pop() {
|
||||
if let Some(ref mut world) = self.world {
|
||||
let dim = world.overworld_mut().expect("world has no overworld");
|
||||
if let Err(e) = op.apply(dim) {
|
||||
self.status_message = format!("Redo error: {:?}", e);
|
||||
self.redo_stack.push(op);
|
||||
return;
|
||||
}
|
||||
self.undo_stack.push_back(op);
|
||||
self.status_message = "Redo".to_string();
|
||||
}
|
||||
} else {
|
||||
self.status_message = "Nothing to redo".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for TerrafierApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
egui::TopBottomPanel::top("menu").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("New World").clicked() {
|
||||
self.show_new_world = true;
|
||||
}
|
||||
if ui.button("Open").clicked() {
|
||||
if let Some(path) = rfd::FileDialog::new().pick_folder() {
|
||||
match terrafier_core::io::import::import(&path) {
|
||||
Ok(world) => {
|
||||
self.world = Some(world);
|
||||
self.selected_tile = None;
|
||||
self.undo_stack.clear();
|
||||
self.redo_stack.clear();
|
||||
self.status_message = format!("Opened world from {}", path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
self.status_message = format!("Open error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.button("Export").clicked() {
|
||||
self.show_export = true;
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Undo").clicked() {
|
||||
self.undo();
|
||||
}
|
||||
if ui.button("Redo").clicked() {
|
||||
self.redo();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
egui::SidePanel::left("tools")
|
||||
.resizable(false)
|
||||
.default_width(200.0)
|
||||
.show(ctx, |ui| {
|
||||
crate::tools::show_tools_panel(ui, self);
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
crate::view::show_viewport(ui, self);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(&self.status_message);
|
||||
if let Some((tx, tz)) = self.selected_tile {
|
||||
ui.separator();
|
||||
ui.label(format!("Tile ({}, {})", tx, tz));
|
||||
}
|
||||
if self.world.is_some() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label("World loaded");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
crate::dialogs::handle_dialogs(self, ctx);
|
||||
}
|
||||
}
|
||||
55
gui/src/dialogs/export.rs
Executable file
55
gui/src/dialogs/export.rs
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
pub fn show(app: &mut crate::app::TerrafierApp, ctx: &egui::Context) {
|
||||
if !app.show_export {
|
||||
return;
|
||||
}
|
||||
let mut keep_open = true;
|
||||
egui::Window::new("Export").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Output path:");
|
||||
ui.text_edit_singleline(&mut app.export_path);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button("Export as Save").clicked() {
|
||||
let Some(ref world) = app.world else {
|
||||
app.status_message = "No world to export".to_string();
|
||||
return;
|
||||
};
|
||||
let path = std::path::Path::new(&app.export_path);
|
||||
match terrafier_core::io::export::export_to_save(world, path) {
|
||||
Ok(()) => {
|
||||
app.status_message = format!("Exported to {}", app.export_path);
|
||||
keep_open = false;
|
||||
}
|
||||
Err(e) => {
|
||||
app.status_message = format!("Export error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Render as PNG").clicked() {
|
||||
let Some(ref world) = app.world else {
|
||||
app.status_message = "No world to render".to_string();
|
||||
return;
|
||||
};
|
||||
let path = std::path::Path::new(&app.export_path);
|
||||
match terrafier_core::io::export::render_to_image(world, path, 2) {
|
||||
Ok(()) => {
|
||||
app.status_message = format!("Rendered to {}", app.export_path);
|
||||
keep_open = false;
|
||||
}
|
||||
Err(e) => {
|
||||
app.status_message = format!("Render error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Cancel").clicked() {
|
||||
keep_open = false;
|
||||
}
|
||||
});
|
||||
if !keep_open {
|
||||
app.show_export = false;
|
||||
}
|
||||
}
|
||||
11
gui/src/dialogs/mod.rs
Executable file
11
gui/src/dialogs/mod.rs
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
mod export;
|
||||
mod new_world;
|
||||
|
||||
pub fn handle_dialogs(app: &mut crate::app::TerrafierApp, ctx: &egui::Context) {
|
||||
if app.show_new_world {
|
||||
new_world::show(app, ctx);
|
||||
}
|
||||
if app.show_export {
|
||||
export::show(app, ctx);
|
||||
}
|
||||
}
|
||||
43
gui/src/dialogs/new_world.rs
Executable file
43
gui/src/dialogs/new_world.rs
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
use terrafier_core::World;
|
||||
|
||||
pub fn show(app: &mut crate::app::TerrafierApp, ctx: &egui::Context) {
|
||||
if !app.show_new_world {
|
||||
return;
|
||||
}
|
||||
let mut keep_open = true;
|
||||
egui::Window::new("New World").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
ui.text_edit_singleline(&mut app.world_name);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Seed:");
|
||||
ui.text_edit_singleline(&mut app.world_seed);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button("Create").clicked() {
|
||||
let name = if app.world_name.is_empty() {
|
||||
"World"
|
||||
} else {
|
||||
&app.world_name
|
||||
};
|
||||
let seed = app.world_seed.parse::<u64>().unwrap_or(0);
|
||||
app.world = Some(World::new(name, seed));
|
||||
app.selected_tile = None;
|
||||
app.undo_stack.clear();
|
||||
app.redo_stack.clear();
|
||||
app.status_message = format!("Created world '{}' with seed {}", name, seed);
|
||||
keep_open = false;
|
||||
}
|
||||
|
||||
if ui.button("Cancel").clicked() {
|
||||
keep_open = false;
|
||||
}
|
||||
});
|
||||
if !keep_open {
|
||||
app.show_new_world = false;
|
||||
}
|
||||
}
|
||||
15
gui/src/main.rs
Executable file
15
gui/src/main.rs
Executable file
|
|
@ -0,0 +1,15 @@
|
|||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::init();
|
||||
let native_options = eframe::NativeOptions::default();
|
||||
eframe::run_native(
|
||||
"Terrafier",
|
||||
native_options,
|
||||
Box::new(|_cc| Ok(Box::new(app::TerrafierApp::new()))),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod app;
|
||||
mod dialogs;
|
||||
mod tools;
|
||||
mod view;
|
||||
190
gui/src/tools.rs
Executable file
190
gui/src/tools.rs
Executable file
|
|
@ -0,0 +1,190 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use terrafier_core::model::brush::SymmetricBrush;
|
||||
use terrafier_core::model::tile::TILE_SIZE;
|
||||
use terrafier_core::ops::operations::{
|
||||
FlattenOperation, HeightOperation, MultiTileOperation, Operation, PaintOperation,
|
||||
SmoothOperation,
|
||||
};
|
||||
use terrafier_core::Terrain;
|
||||
|
||||
use crate::app::{TerrafierApp, ToolMode};
|
||||
|
||||
pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) {
|
||||
ui.heading("Tools");
|
||||
ui.separator();
|
||||
|
||||
ui.label("Mode:");
|
||||
ui.radio_value(&mut app.tool_mode, ToolMode::Raise, "Raise");
|
||||
ui.radio_value(&mut app.tool_mode, ToolMode::Lower, "Lower");
|
||||
ui.radio_value(&mut app.tool_mode, ToolMode::Smooth, "Smooth");
|
||||
ui.radio_value(&mut app.tool_mode, ToolMode::Flatten, "Flatten");
|
||||
ui.radio_value(&mut app.tool_mode, ToolMode::Paint, "Paint");
|
||||
ui.radio_value(&mut app.tool_mode, ToolMode::Inspect, "Inspect");
|
||||
|
||||
ui.separator();
|
||||
ui.add(egui::Slider::new(&mut app.brush_radius, 1..=64).text("Radius"));
|
||||
ui.add(egui::Slider::new(&mut app.brush_strength, 0.0..=1.0).text("Strength"));
|
||||
|
||||
if app.tool_mode == ToolMode::Smooth {
|
||||
ui.add(egui::Slider::new(&mut app.smooth_iterations, 1..=20).text("Iterations"));
|
||||
}
|
||||
|
||||
if app.tool_mode == ToolMode::Flatten {
|
||||
ui.add(egui::Slider::new(&mut app.target_height, -64..=320).text("Height"));
|
||||
}
|
||||
|
||||
if app.tool_mode == ToolMode::Paint {
|
||||
ui.label("Terrain:");
|
||||
egui::ComboBox::from_label("Type")
|
||||
.selected_text(app.selected_terrain.name())
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut app.selected_terrain, Terrain::Desert, "Desert");
|
||||
ui.selectable_value(&mut app.selected_terrain, Terrain::Grass, "Grass");
|
||||
ui.selectable_value(&mut app.selected_terrain, Terrain::Forest, "Forest");
|
||||
ui.selectable_value(&mut app.selected_terrain, Terrain::Rock, "Rock");
|
||||
ui.selectable_value(&mut app.selected_terrain, Terrain::Sand, "Sand");
|
||||
ui.selectable_value(&mut app.selected_terrain, Terrain::Swamp, "Swamp");
|
||||
ui.selectable_value(&mut app.selected_terrain, Terrain::Water, "Water");
|
||||
});
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.checkbox(&mut app.show_heightmap, "Heightmap view");
|
||||
|
||||
ui.separator();
|
||||
if ui.button("Apply to selected tile").clicked() {
|
||||
apply_tool(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_tool(app: &mut TerrafierApp) {
|
||||
let Some((tx, tz)) = app.selected_tile else {
|
||||
app.status_message = "No tile selected".to_string();
|
||||
return;
|
||||
};
|
||||
if app.world.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ref mut world) = app.world else {
|
||||
return;
|
||||
};
|
||||
let dim = world.overworld_mut().expect("no overworld");
|
||||
|
||||
let center = app.brush_local_x.unwrap_or(64);
|
||||
let center_z = app.brush_local_z.unwrap_or(64);
|
||||
|
||||
// Handle Inspect separately — it doesn't modify the world
|
||||
if app.tool_mode == ToolMode::Inspect {
|
||||
if let Some(tile) = dim.tiles.get(&(tx, tz)) {
|
||||
let count = tile.terrain.iter().filter(|&&t| t != 0).count();
|
||||
let h_min = tile.heightmap.iter().min().unwrap_or(&0);
|
||||
let h_max = tile.heightmap.iter().max().unwrap_or(&0);
|
||||
app.status_message = format!(
|
||||
"Tile ({},{}): {} blocks, height {}-{}",
|
||||
tx, tz, count, h_min, h_max
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the operation — always use MultiTileOperation for consistency
|
||||
let radius = app.brush_radius as i32;
|
||||
let global_cx = tx * TILE_SIZE as i32 + center as i32;
|
||||
let global_cz = tz * TILE_SIZE as i32 + center_z as i32;
|
||||
|
||||
let min_tx = ((global_cx - radius) >> 7).min(tx);
|
||||
let max_tx = ((global_cx + radius) >> 7).max(tx);
|
||||
let min_tz = ((global_cz - radius) >> 7).min(tz);
|
||||
let max_tz = ((global_cz + radius) >> 7).max(tz);
|
||||
|
||||
let mut ops: Vec<Box<dyn Operation>> = Vec::new();
|
||||
|
||||
for otx in min_tx..=max_tx {
|
||||
for otz in min_tz..=max_tz {
|
||||
let local_cx = (global_cx - otx * TILE_SIZE as i32).clamp(0, TILE_SIZE as i32 - 1) as u32;
|
||||
let local_cz = (global_cz - otz * TILE_SIZE as i32).clamp(0, TILE_SIZE as i32 - 1) as u32;
|
||||
|
||||
if dim.tiles.contains_key(&(otx, otz)) {
|
||||
let brush = Arc::new(SymmetricBrush::new(app.brush_radius as f64));
|
||||
let op: Box<dyn Operation> = match app.tool_mode {
|
||||
ToolMode::Raise => Box::new(HeightOperation {
|
||||
tile_x: otx,
|
||||
tile_z: otz,
|
||||
center_x: local_cx,
|
||||
center_z: local_cz,
|
||||
radius: app.brush_radius,
|
||||
delta: 5,
|
||||
brush,
|
||||
before_snapshot: Default::default(),
|
||||
}),
|
||||
ToolMode::Lower => Box::new(HeightOperation {
|
||||
tile_x: otx,
|
||||
tile_z: otz,
|
||||
center_x: local_cx,
|
||||
center_z: local_cz,
|
||||
radius: app.brush_radius,
|
||||
delta: -5,
|
||||
brush,
|
||||
before_snapshot: Default::default(),
|
||||
}),
|
||||
ToolMode::Smooth => Box::new(SmoothOperation {
|
||||
tile_x: otx,
|
||||
tile_z: otz,
|
||||
center_x: local_cx,
|
||||
center_z: local_cz,
|
||||
radius: app.brush_radius,
|
||||
iterations: app.smooth_iterations,
|
||||
brush,
|
||||
before_snapshot: Default::default(),
|
||||
}),
|
||||
ToolMode::Flatten => Box::new(FlattenOperation {
|
||||
tile_x: otx,
|
||||
tile_z: otz,
|
||||
center_x: local_cx,
|
||||
center_z: local_cz,
|
||||
radius: app.brush_radius,
|
||||
target_height: app.target_height,
|
||||
brush,
|
||||
before_snapshot: Default::default(),
|
||||
}),
|
||||
ToolMode::Paint => Box::new(PaintOperation {
|
||||
tile_x: otx,
|
||||
tile_z: otz,
|
||||
center_x: local_cx,
|
||||
center_z: local_cz,
|
||||
radius: app.brush_radius,
|
||||
terrain: app.selected_terrain,
|
||||
brush,
|
||||
before_snapshot: Default::default(),
|
||||
}),
|
||||
ToolMode::Inspect => unreachable!(),
|
||||
};
|
||||
ops.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ops.is_empty() {
|
||||
app.status_message = "No tiles found in brush range".to_string();
|
||||
return;
|
||||
}
|
||||
|
||||
let multi_op = MultiTileOperation { operations: ops };
|
||||
|
||||
// Apply the operation first (fills before_snapshot for Flatten/Paint/Smooth)
|
||||
let result = multi_op.apply(dim);
|
||||
|
||||
// Save for undo after applying (op now contains the pre-apply snapshot)
|
||||
app.save_for_undo(Box::new(multi_op));
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
app.status_message = format!("Applied {} to ({},{})", app.tool_mode.name(), tx, tz);
|
||||
}
|
||||
Err(e) => {
|
||||
app.status_message = format!("Error: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
158
gui/src/view.rs
Executable file
158
gui/src/view.rs
Executable file
|
|
@ -0,0 +1,158 @@
|
|||
use egui::{Color32, ColorImage, TextureOptions, Vec2};
|
||||
|
||||
use terrafier_core::model::tile::TILE_SIZE;
|
||||
|
||||
const TERRAIN_COLORS: [Color32; 7] = [
|
||||
Color32::from_rgb(194, 178, 128), // Desert
|
||||
Color32::from_rgb(124, 189, 107), // Grass
|
||||
Color32::from_rgb(86, 140, 74), // Forest
|
||||
Color32::from_rgb(128, 128, 128), // Rock
|
||||
Color32::from_rgb(227, 212, 160), // Sand
|
||||
Color32::from_rgb(72, 107, 75), // Swamp
|
||||
Color32::from_rgb(64, 128, 255), // Water
|
||||
];
|
||||
|
||||
pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
|
||||
let Some(world) = &app.world else {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.heading("No world loaded. Click 'New World' to begin.");
|
||||
});
|
||||
return;
|
||||
};
|
||||
let dim = &world.dimensions[0];
|
||||
|
||||
let mut min_tx = i32::MAX;
|
||||
let mut max_tx = i32::MIN;
|
||||
let mut min_tz = i32::MAX;
|
||||
let mut max_tz = i32::MIN;
|
||||
for &(tx, tz) in dim.tiles.keys() {
|
||||
min_tx = min_tx.min(tx);
|
||||
max_tx = max_tx.max(tx);
|
||||
min_tz = min_tz.min(tz);
|
||||
max_tz = max_tz.max(tz);
|
||||
}
|
||||
|
||||
let display_size = 64u32;
|
||||
let grid_w = ((max_tx - min_tx + 1) * display_size as i32) as usize;
|
||||
let grid_h = ((max_tz - min_tz + 1) * display_size as i32) as usize;
|
||||
|
||||
let mut img_data = vec![0u8; grid_w * grid_h * 4];
|
||||
|
||||
for (&(tx, tz), tile) in &dim.tiles {
|
||||
let px = ((tx - min_tx) * display_size as i32) as usize;
|
||||
let pz = ((tz - min_tz) * display_size as i32) as usize;
|
||||
|
||||
for ly in 0..display_size as usize {
|
||||
for lx in 0..display_size as usize {
|
||||
let sx = (lx * TILE_SIZE / display_size as usize).min(TILE_SIZE - 1);
|
||||
let sz = (ly * TILE_SIZE / display_size as usize).min(TILE_SIZE - 1);
|
||||
let idx = sz * TILE_SIZE + sx;
|
||||
|
||||
let terrain_type = tile.terrain[idx] as usize;
|
||||
let base = TERRAIN_COLORS[terrain_type.min(6)];
|
||||
|
||||
let h = tile.heightmap[idx];
|
||||
let height_factor = 0.7 + 0.3 * ((h + 64) as f32 / 384.0);
|
||||
|
||||
let (r, g, b) = if app.show_heightmap {
|
||||
// Heat map: map height to color gradient
|
||||
let norm = ((h + 64) as f32 / 384.0).clamp(0.0, 1.0);
|
||||
if norm < 0.25 {
|
||||
// Blue (low) -> Cyan
|
||||
let t = norm / 0.25;
|
||||
((t * 255.0) as u8, (t * 128.0) as u8, 255)
|
||||
} else if norm < 0.5 {
|
||||
// Cyan -> Green
|
||||
let t = (norm - 0.25) / 0.25;
|
||||
(0, (128.0 + t * 127.0) as u8, ((1.0 - t) * 255.0) as u8)
|
||||
} else if norm < 0.75 {
|
||||
// Green -> Yellow
|
||||
let t = (norm - 0.5) / 0.25;
|
||||
((t * 255.0) as u8, 255, 0)
|
||||
} else {
|
||||
// Yellow -> Red
|
||||
let t = (norm - 0.75) / 0.25;
|
||||
(255, ((1.0 - t) * 255.0) as u8, 0)
|
||||
}
|
||||
} else {
|
||||
let r = (base.r() as f32 * height_factor).min(255.0) as u8;
|
||||
let g = (base.g() as f32 * height_factor).min(255.0) as u8;
|
||||
let b = (base.b() as f32 * height_factor).min(255.0) as u8;
|
||||
(r, g, b)
|
||||
};
|
||||
|
||||
let di = (pz + ly) * grid_w + (px + lx);
|
||||
img_data[di * 4] = r;
|
||||
img_data[di * 4 + 1] = g;
|
||||
img_data[di * 4 + 2] = b;
|
||||
img_data[di * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let color_image = ColorImage::from_rgba_unmultiplied([grid_w, grid_h], &img_data);
|
||||
let texture_id = ui
|
||||
.ctx()
|
||||
.load_texture("world_map", color_image, TextureOptions::LINEAR);
|
||||
|
||||
let desired_size = Vec2::new(grid_w as f32, grid_h as f32);
|
||||
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click());
|
||||
|
||||
let rect = response.rect;
|
||||
painter.image(
|
||||
texture_id.id(),
|
||||
rect,
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
Color32::WHITE,
|
||||
);
|
||||
|
||||
// Draw selection border
|
||||
if let Some((sel_tx, sel_tz)) = app.selected_tile {
|
||||
if sel_tx >= min_tx && sel_tx <= max_tx && sel_tz >= min_tz && sel_tz <= max_tz {
|
||||
let bx = rect.min.x + (sel_tx - min_tx) as f32 * display_size as f32;
|
||||
let bz = rect.min.y + (sel_tz - min_tz) as f32 * display_size as f32;
|
||||
let border_rect =
|
||||
egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(display_size as f32));
|
||||
painter.rect_stroke(
|
||||
border_rect,
|
||||
0.0,
|
||||
egui::Stroke::new(3.0, Color32::WHITE),
|
||||
egui::StrokeKind::Middle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw brush position marker
|
||||
if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) = (app.selected_tile, app.brush_local_x, app.brush_local_z) {
|
||||
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size as f32;
|
||||
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size as f32;
|
||||
let brush_x = rect.min.x + tile_x_in_pixels + (bx as f32 * display_size as f32 / TILE_SIZE as f32);
|
||||
let brush_z = rect.min.y + tile_z_in_pixels + (bz as f32 * display_size as f32 / TILE_SIZE as f32);
|
||||
let brush_radius_px = app.brush_radius as f32 * display_size as f32 / TILE_SIZE as f32;
|
||||
painter.circle_stroke(
|
||||
egui::pos2(brush_x, brush_z),
|
||||
brush_radius_px.max(2.0),
|
||||
egui::Stroke::new(1.5, Color32::YELLOW),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle click to select tile
|
||||
if response.clicked() {
|
||||
if let Some(pos) = response.interact_pointer_pos() {
|
||||
let lx = pos.x - rect.min.x;
|
||||
let lz = pos.y - rect.min.y;
|
||||
if lx >= 0.0 && lz >= 0.0 {
|
||||
let tx = (lx / display_size as f32).floor() as i32 + min_tx;
|
||||
let tz = (lz / display_size as f32).floor() as i32 + min_tz;
|
||||
if dim.tiles.contains_key(&(tx, tz)) {
|
||||
app.selected_tile = Some((tx, tz));
|
||||
let local_x = ((lx as u32 % display_size) * TILE_SIZE as u32 / display_size).min(127);
|
||||
let local_z = ((lz as u32 % display_size) * TILE_SIZE as u32 / display_size).min(127);
|
||||
app.brush_local_x = Some(local_x);
|
||||
app.brush_local_z = Some(local_z);
|
||||
app.status_message = format!("Selected tile ({}, {}) at local ({}, {})", tx, tz, local_x, local_z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue