Basic terrain editing

main
copygirl 2 months ago
parent c5e9e5aab1
commit 1d5c91a6de
  1. 49
      addons/receive-input-in-editor/ReceiveInputInEditorPlugin.cs
  2. 38
      addons/receive-input-in-editor/ReceiveInputInEditorPlugin.gd
  3. 7
      addons/receive-input-in-editor/plugin.cfg
  4. 65
      addons/terrain/TerrainPlugin.cs
  5. 7
      addons/terrain/plugin.cfg
  6. 2
      assets/models/trees/tree_cone.glb.import
  7. 2
      assets/models/trees/tree_default.glb.import
  8. 2
      assets/models/trees/tree_oak.glb.import
  9. 62
      level.tscn
  10. 2
      project.godot
  11. 133
      terrain/Terrain+Editing.cs
  12. 184
      terrain/Terrain.cs

@ -0,0 +1,49 @@
#if TOOLS
[Tool]
public partial class ReceiveInputInEditorPlugin
: EditorPlugin
{
public override bool _Handles(GodotObject obj)
=> true;
public override int _Forward3DGuiInput(Camera3D camera, InputEvent ev)
{
var root = (Node3D)EditorInterface.Singleton.GetEditedSceneRoot();
var viewport = root.GetViewport();
// Don't know about any negative effect of changing these and leaving them like that.
viewport.GuiDisableInput = false; // Re-enable input, required for `PushInput` to work at all.
viewport.HandleInputLocally = true; // Let sub-viewport handle its own input, makes `SetInputAsHandled` work as expected?
// This will propagate input events into the edited scene,
// including regular, GUI, shortcut, unhandled key and unhandled.
viewport.PushInput(ev);
// Enabling `PhysicsObjectPicking` causes `IsInputHandled()` to always
// return true, and object picking isn't immediate anyway, so let's just
// do it ourselves. This doesn't support touch input, though.
if (!viewport.IsInputHandled() && (ev is InputEventMouse { Position: var mouse })) {
// Ray is cast from the editor camera's view.
var from = camera.ProjectRayOrigin(mouse);
var to = from + camera.ProjectRayNormal(mouse) * camera.Far;
// Actual collision is done in the edited scene though.
var space = root.GetWorld3D().DirectSpaceState;
var query = PhysicsRayQueryParameters3D.Create(from, to);
var result = space.IntersectRay(query);
// The collider object isn't necessarily a `CollisionObject3D`.
var collider = (GodotObject)result.GetValueOrDefault("collider");
if (collider is CollisionObject3D { Visible: true } obj) {
var pos = (Vector3)result["position"];
var normal = (Vector3)result["normal"];
var shape = (int)result["shape"];
obj._InputEvent(camera, ev, pos, normal, shape);
}
}
// If any node calls `SetInputAsHandled()`, the event is not passed on to other editor gizmos / plugins.
return viewport.IsInputHandled() ? (int)AfterGuiInput.Stop : (int)AfterGuiInput.Pass;
}
}
#endif

@ -0,0 +1,38 @@
@tool
extends EditorPlugin
func _handles(obj: Object) -> bool:
return true
func _forward_3d_gui_input(camera: Camera3D, event: InputEvent) -> int:
var root := EditorInterface.get_edited_scene_root() as Node3D
var viewport := root.get_viewport()
# Don't know about any negative effect of changing these and leaving them like that.
viewport.gui_disable_input = false # Re-enable input, required for `push_input` to work at all.
viewport.handle_input_locally = true # Let sub-viewport handle its own input, makes `set_input_as_handled` work as expected?
# This will propagate input events into the edited scene,
# including regular, GUI, shortcut, unhandled key and unhandled.
viewport.push_input(event)
# Enabling `physics_object_picking` causes `is_input_handled()` to always
# return true, and object picking isn't immediate anyway, so let's just
# do it ourselves. This doesn't support touch input, though.
if !viewport.is_input_handled() and event is InputEventMouse:
# Ray is cast from the editor camera's view.
var from := camera.project_ray_origin(event.position)
var to := from + camera.project_ray_normal(event.position) * camera.far
# Actual collision is done in the edited scene though.
var space := root.get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(from, to)
var result := space.intersect_ray(query)
# The collider object isn't necessarily a `CollisionObject3D`.
var obj := result.get("collider") as CollisionObject3D
if obj and obj.visible:
obj._input_event(camera, event, result["position"], result["normal"], result["shape"])
# If any node calls `set_input_as_handled()`, the event is not passed on to other editor gizmos / plugins.
return AfterGUIInput.AFTER_GUI_INPUT_STOP if viewport.is_input_handled() else AfterGUIInput.AFTER_GUI_INPUT_PASS

@ -0,0 +1,7 @@
[plugin]
name="Receive Input in Editor"
description=""
author="copygirl"
version=""
script="ReceiveInputInEditorPlugin.cs"

@ -1,65 +0,0 @@
#if TOOLS
using System.Security.Cryptography.X509Certificates;
[Tool]
public partial class TerrainPlugin
: EditorPlugin
{
public override bool _Handles(GodotObject @object)
=> @object is Terrain;
public override void _EnterTree()
{
// Initialization of the plugin goes here.
}
public override void _ExitTree()
{
// Clean-up of the plugin goes here.
}
Terrain _previousTerrain = null;
public override int _Forward3DGuiInput(Camera3D viewportCamera, InputEvent @event)
{
if (@event is not InputEventMouse) return (int)AfterGuiInput.Pass;
if (!IsInstanceValid(_previousTerrain))
_previousTerrain = null;
if (RayCastTerrain(viewportCamera) is var (terrain, pos, normal, shape)) {
if (_previousTerrain != terrain) {
_previousTerrain?._MouseExit();
terrain._MouseEnter();
_previousTerrain = terrain;
}
terrain._InputEvent(viewportCamera, @event, pos, normal, shape);
return viewportCamera.GetViewport().IsInputHandled()
? (int)AfterGuiInput.Stop : (int)AfterGuiInput.Pass;
} else {
_previousTerrain?._MouseExit();
_previousTerrain = null;
return (int)AfterGuiInput.Pass;
}
}
(Terrain Terrain, Vector3 Position, Vector3 Normal, int Shape)? RayCastTerrain(Camera3D camera)
{
var viewport = camera.GetViewport();
var mouse = viewport.GetMousePosition();
if (!viewport.GetVisibleRect().HasPoint(mouse)) return null;
var from = camera.ProjectRayOrigin(mouse);
var to = from + camera.ProjectRayNormal(mouse) * camera.Far;
var space = camera.GetWorld3D().DirectSpaceState;
var query = new PhysicsRayQueryParameters3D { From = from, To = to };
var result = space.IntersectRay(query);
if (result.Count == 0) return null;
if ((GodotObject)result["collider"] is not Terrain terrain) return null;
return (terrain, (Vector3)result["position"], (Vector3)result["normal"], (int)result["shape"]);
}
}
#endif

@ -1,7 +0,0 @@
[plugin]
name="Terrain Plugin"
description=""
author="copygirl"
version=""
script="TerrainPlugin.cs"

@ -17,6 +17,7 @@ nodes/root_type=""
nodes/root_name="" nodes/root_name=""
nodes/apply_root_scale=true nodes/apply_root_scale=true
nodes/root_scale=1.0 nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
meshes/ensure_tangents=true meshes/ensure_tangents=true
meshes/generate_lods=true meshes/generate_lods=true
meshes/create_shadow_meshes=true meshes/create_shadow_meshes=true
@ -28,6 +29,7 @@ animation/import=true
animation/fps=30 animation/fps=30
animation/trimming=false animation/trimming=false
animation/remove_immutable_tracks=true animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path="" import_script/path=""
_subresources={} _subresources={}
gltf/naming_version=1 gltf/naming_version=1

@ -17,6 +17,7 @@ nodes/root_type=""
nodes/root_name="" nodes/root_name=""
nodes/apply_root_scale=true nodes/apply_root_scale=true
nodes/root_scale=1.0 nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
meshes/ensure_tangents=true meshes/ensure_tangents=true
meshes/generate_lods=true meshes/generate_lods=true
meshes/create_shadow_meshes=true meshes/create_shadow_meshes=true
@ -28,6 +29,7 @@ animation/import=true
animation/fps=30 animation/fps=30
animation/trimming=false animation/trimming=false
animation/remove_immutable_tracks=true animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path="" import_script/path=""
_subresources={} _subresources={}
gltf/naming_version=1 gltf/naming_version=1

@ -17,6 +17,7 @@ nodes/root_type=""
nodes/root_name="" nodes/root_name=""
nodes/apply_root_scale=true nodes/apply_root_scale=true
nodes/root_scale=1.0 nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
meshes/ensure_tangents=true meshes/ensure_tangents=true
meshes/generate_lods=true meshes/generate_lods=true
meshes/create_shadow_meshes=true meshes/create_shadow_meshes=true
@ -28,6 +29,7 @@ animation/import=true
animation/fps=30 animation/fps=30
animation/trimming=false animation/trimming=false
animation/remove_immutable_tracks=true animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path="" import_script/path=""
_subresources={} _subresources={}
gltf/naming_version=1 gltf/naming_version=1

File diff suppressed because one or more lines are too long

@ -21,7 +21,7 @@ project/assembly_name="SlimeDream"
[editor_plugins] [editor_plugins]
enabled=PackedStringArray("res://addons/terrain/plugin.cfg") enabled=PackedStringArray("res://addons/receive-input-in-editor/plugin.cfg")
[input] [input]

@ -0,0 +1,133 @@
[Tool]
public partial class Terrain
{
bool _unhandledMouseMotion = false; // Used to detect when mouse moves off the terrain.
Vector2I? _tileHover = null; // Position of currently hovered tile.
bool _isSelecting = false; // Whether left mouse is held down to select tiles.
(Vector2I Start, Vector2I End)? _selection = null;
public override void _Input(InputEvent ev)
{
if (!IsEditing()) return;
if (_isSelecting && ev is InputEventMouseButton { ButtonIndex: MouseButton.Left, Pressed: false }) {
_isSelecting = false;
GetViewport().SetInputAsHandled();
}
if ((ev is InputEventMouseButton { ButtonIndex: var wheel, ShiftPressed: true })
&& (wheel is MouseButton.WheelUp or MouseButton.WheelDown))
{
const float AdjustHeight = 0.5f;
var value = (wheel == MouseButton.WheelUp) ? 1.0f : -1.0f;
foreach (var tile in GetSelectedTiles())
AdjustTileHeight(tile, value * AdjustHeight);
if (_selection != null)
UpdateMeshAndShape();
NotifyPropertyListChanged();
GetViewport().SetInputAsHandled();
}
if (ev is InputEventMouseMotion)
_unhandledMouseMotion = true;
}
public override void _InputEvent(Camera3D camera, InputEvent ev, Vector3 position, Vector3 normal, int shapeIdx)
{
if (!IsEditing()) return;
var localPos = ToLocal(position);
var tilePos = ToTilePos(localPos);
if (ev is InputEventMouseButton { ButtonIndex: MouseButton.Left, Pressed: true }) {
_selection = (tilePos, tilePos);
_isSelecting = true;
GetViewport().SetInputAsHandled();
}
if (ev is InputEventMouseMotion) {
_unhandledMouseMotion = false;
_tileHover = tilePos;
if (_isSelecting) _selection = _selection.Value with { End = tilePos };
}
}
public override void _Process(double delta)
{
if (!IsEditing()) {
_tileHover = null;
_selection = null;
_isSelecting = false;
}
if (_unhandledMouseMotion)
_tileHover = null;
if ((_tileHover != null) || (_selection != null)) {
var mesh = GetOrCreateMesh("EditToolMesh");
mesh.ClearSurfaces();
mesh.SurfaceBegin(Mesh.PrimitiveType.Lines);
void AddLine(Vector3 start, Vector3 end) {
mesh.SurfaceAddVertex(start);
mesh.SurfaceAddVertex(end);
}
void AddQuad(Vector3 topLeft, Vector3 topRight,
Vector3 bottomLeft, Vector3 bottomRight) {
AddLine(topLeft , topRight );
AddLine(topRight , bottomRight);
AddLine(bottomRight, bottomLeft );
AddLine(bottomLeft , topLeft );
}
if (_tileHover is Vector2I hover) {
var corners = GetGridCorners(hover);
var margin = 0.1f;
mesh.SurfaceSetColor(Colors.Black);
AddQuad(
corners.TopLeft + new Vector3(-margin, 0, -margin),
corners.TopRight + new Vector3(+margin, 0, -margin),
corners.BottomLeft + new Vector3(-margin, 0, +margin),
corners.BottomRight + new Vector3(+margin, 0, +margin)
);
}
mesh.SurfaceSetColor(Colors.Blue);
foreach (var tilePos in GetSelectedTiles()) {
var corners = GetGridCorners(tilePos);
AddQuad(corners.TopLeft, corners.TopRight, corners.BottomLeft, corners.BottomRight);
}
mesh.SurfaceEnd();
mesh.SurfaceSetMaterial(0, _editToolMaterial);
} else {
var meshInstance = (MeshInstance3D)GetNodeOrNull("EditToolMesh");
var mesh = (ImmediateMesh)meshInstance?.Mesh;
mesh?.ClearSurfaces();
}
}
bool IsEditing()
{
if (Engine.IsEditorHint()) {
var selection = EditorInterface.Singleton.GetSelection();
return selection.GetSelectedNodes().Contains(this);
} else {
return false;
}
}
IEnumerable<Vector2I> GetSelectedTiles()
{
if (_selection is not (Vector2I start, Vector2I end)) yield break;
// Ensure start.X/Y is smaller than end.X/Y.
(start, end) = (new(Min(start.X, end.X), Min(start.Y, end.Y)),
new(Max(start.X, end.X), Max(start.Y, end.Y)));
// Go over all tiles in the range and yield each one.
for (var x = start.X; x <= end.X; x++)
for (var y = start.Y; y <= end.Y; y++)
yield return new(x, y);
}
}

@ -1,4 +1,3 @@
[Tool]
public partial class Terrain public partial class Terrain
: StaticBody3D : StaticBody3D
{ {
@ -7,12 +6,11 @@ public partial class Terrain
[Export] public Godot.Collections.Array<Texture2D> Textures { get; set; } [Export] public Godot.Collections.Array<Texture2D> Textures { get; set; }
public bool IsEditing { get; } = Engine.IsEditorHint(); // If value at position non-existant => [ 0, 0, 0, 0 ]
// If value at position is float => [ v, v, v, v ]
// If value at position is float[] => value
[Export] public Godot.Collections.Dictionary<Vector2I, Variant> Tiles { get; set; }
Vector2I? _gridHover = null;
Vector2I? _gridSelectionStart = null;
Vector2I? _gridSelectionEnd = null;
bool _isSelecting = false;
Material _editToolMaterial; Material _editToolMaterial;
Material _terrainMaterial; Material _terrainMaterial;
@ -33,29 +31,93 @@ public partial class Terrain
} }
Vector2I ToGridPos(Vector3 localPos) /// <summary> Returns if this terrain grid contains the specified tile position. </summary>
public bool ContainsTilePos(Vector2I tilePos)
=> (tilePos.X >= 0) && (tilePos.X < Size.X)
&& (tilePos.Y >= 0) && (tilePos.Y < Size.Y);
/// <summary> Transforms a 3D position local to the equivalent tile position. </summary>
public Vector2I ToTilePos(Vector3 localPos)
=> new(RoundToInt(localPos.X / TileSize + Size.X / 2.0f), => new(RoundToInt(localPos.X / TileSize + Size.X / 2.0f),
RoundToInt(localPos.Z / TileSize + Size.Y / 2.0f)); RoundToInt(localPos.Z / TileSize + Size.Y / 2.0f));
float GetCornerHeight(Vector2I gridPos, Corner corner) public float GetCornerHeight(Vector2I tilePos, Corner corner)
=> GetTileRaw(tilePos) switch {
{ VariantType: Variant.Type.Nil } => 0,
{ VariantType: Variant.Type.Float } num => (float)num,
{ VariantType: Variant.Type.PackedFloat32Array } nums => ((float[])nums)[(int)corner],
_ => throw new Exception("Invalid type in Points dictionary"),
};
public void SetCornerHeight(Vector2I tilePos, Corner corner, float value)
{ {
return 0.0f; if (!ContainsTilePos(tilePos)) return;
var existing = GetTileRaw(tilePos) switch {
{ VariantType: Variant.Type.Nil } => [ 0, 0, 0, 0 ],
{ VariantType: Variant.Type.Float } num => [ (float)num, (float)num, (float)num, (float)num ],
{ VariantType: Variant.Type.PackedFloat32Array } nums => (float[])nums,
_ => throw new Exception("Invalid type in Points dictionary"),
};
existing[(int)corner] = value;
if (existing.All(IsZeroApprox))
Tiles?.Remove(tilePos);
if (IsEqualApprox(existing[0], existing[1])
&& IsEqualApprox(existing[0], existing[2])
&& IsEqualApprox(existing[0], existing[3]))
SetTileRaw(tilePos, existing[0]);
else
SetTileRaw(tilePos, existing);
}
public void SetTileHeight(Vector2I tilePos, float value)
{
if (!ContainsTilePos(tilePos)) return;
if (IsZeroApprox(value)) Tiles?.Remove(tilePos);
else SetTileRaw(tilePos, value);
}
public void AdjustTileHeight(Vector2I tilePos, float relative)
{
if (relative == 0) return;
if (!ContainsTilePos(tilePos)) return;
switch (GetTileRaw(tilePos)) {
case { VariantType: Variant.Type.Nil }:
SetTileRaw(tilePos, relative);
break;
case { VariantType: Variant.Type.Float } num:
var newNum = (float)num + relative;
if (IsZeroApprox(newNum)) Tiles?.Remove(tilePos);
else SetTileRaw(tilePos, newNum);
break;
case { VariantType: Variant.Type.PackedFloat32Array } nums:
var newNums = (float[])nums;
for (var i = 0; i < 4; i++) newNums[i] += relative;
SetTileRaw(tilePos, newNums);
break;
default: throw new Exception("Invalid type in Points dictionary");
};
} }
GridCorners GetGridCorners(Vector2I gridPos) public GridCorners GetGridCorners(Vector2I tilePos)
{ {
var halfSize = TileSize / 2; var halfSize = TileSize / 2;
var vx = (gridPos.X - Size.X / 2.0f) * TileSize; var vx = (tilePos.X - Size.X / 2.0f) * TileSize;
var vz = (gridPos.Y - Size.Y / 2.0f) * TileSize; var vz = (tilePos.Y - Size.Y / 2.0f) * TileSize;
return new() { return new() {
TopLeft = new(vx - halfSize, GetCornerHeight(gridPos, Corner.TopLeft ), vz - halfSize), TopLeft = new(vx - halfSize, GetCornerHeight(tilePos, Corner.TopLeft ), vz - halfSize),
TopRight = new(vx + halfSize, GetCornerHeight(gridPos, Corner.TopRight ), vz - halfSize), TopRight = new(vx + halfSize, GetCornerHeight(tilePos, Corner.TopRight ), vz - halfSize),
BottomLeft = new(vx - halfSize, GetCornerHeight(gridPos, Corner.BottomLeft ), vz + halfSize), BottomLeft = new(vx - halfSize, GetCornerHeight(tilePos, Corner.BottomLeft ), vz + halfSize),
BottomRight = new(vx + halfSize, GetCornerHeight(gridPos, Corner.BottomRight), vz + halfSize), BottomRight = new(vx + halfSize, GetCornerHeight(tilePos, Corner.BottomRight), vz + halfSize),
}; };
} }
Variant GetTileRaw(Vector2I tilePos)
=> (Tiles?.TryGetValue(tilePos, out var result) == true) ? result : default;
void SetTileRaw(Vector2I tilePos, Variant value)
=> (Tiles ??= [])[tilePos] = value;
void UpdateMeshAndShape() void UpdateMeshAndShape()
{ {
@ -101,91 +163,6 @@ public partial class Terrain
} }
public override void _MouseEnter()
{
}
public override void _MouseExit()
{
_gridHover = null;
}
public override void _InputEvent(Camera3D camera, InputEvent @event, Vector3 position, Vector3 normal, int shapeIdx)
{
if (!IsEditing) return;
var localPos = ToLocal(position);
var gridPos = ToGridPos(localPos);
if (@event is InputEventMouseButton { ButtonIndex: MouseButton.Left, Pressed: var pressed }) {
if (pressed) _gridSelectionStart = _gridSelectionEnd = gridPos;
_isSelecting = pressed;
camera.GetViewport().SetInputAsHandled();
}
if (@event is InputEventMouseMotion) {
_gridHover = gridPos;
if (_isSelecting) _gridSelectionEnd = gridPos;
}
}
public override void _Process(double delta)
{
if (!IsEditing) _gridHover = _gridSelectionStart = _gridSelectionEnd = null;
if ((_gridHover != null) || (_gridSelectionStart != null)) {
var mesh = GetOrCreateMesh("EditToolMesh");
mesh.ClearSurfaces();
mesh.SurfaceBegin(Mesh.PrimitiveType.Lines);
void AddLine(Vector3 start, Vector3 end) {
mesh.SurfaceAddVertex(start);
mesh.SurfaceAddVertex(end);
}
void AddQuad(Vector3 topLeft, Vector3 topRight,
Vector3 bottomLeft, Vector3 bottomRight) {
AddLine(topLeft , topRight );
AddLine(topRight , bottomRight);
AddLine(bottomRight, bottomLeft );
AddLine(bottomLeft , topLeft );
}
if (_gridHover is Vector2I hover) {
var corners = GetGridCorners(hover);
var margin = 0.1f;
mesh.SurfaceSetColor(Colors.Black);
AddQuad(
corners.TopLeft + new Vector3(-margin, 0, -margin),
corners.TopRight + new Vector3(+margin, 0, -margin),
corners.BottomLeft + new Vector3(-margin, 0, +margin),
corners.BottomRight + new Vector3(+margin, 0, +margin)
);
}
if (_gridSelectionStart is Vector2I start) {
var end = _gridSelectionEnd ?? start;
(start, end) = (new(Min(start.X, end.X), Min(start.Y, end.Y)),
new(Max(start.X, end.X), Max(start.Y, end.Y)));
mesh.SurfaceSetColor(Colors.Blue);
for (var x = start.X; x <= end.X; x++)
for (var y = start.Y; y <= end.Y; y++) {
var corners = GetGridCorners(new(x, y));
AddQuad(corners.TopLeft, corners.TopRight, corners.BottomLeft, corners.BottomRight);
}
}
mesh.SurfaceEnd();
mesh.SurfaceSetMaterial(0, _editToolMaterial);
} else {
var meshInstance = (MeshInstance3D)GetNodeOrNull("EditToolMesh");
var mesh = (ImmediateMesh)meshInstance?.Mesh;
mesh?.ClearSurfaces();
}
}
ImmediateMesh GetOrCreateMesh(string name) ImmediateMesh GetOrCreateMesh(string name)
{ {
var meshInstance = (MeshInstance3D)GetNodeOrNull(name); var meshInstance = (MeshInstance3D)GetNodeOrNull(name);
@ -208,7 +185,8 @@ public partial class Terrain
return (ConcavePolygonShape3D)collisionShape.Shape; return (ConcavePolygonShape3D)collisionShape.Shape;
} }
enum Corner
public enum Corner
{ {
TopLeft, TopLeft,
TopRight, TopRight,
@ -216,7 +194,7 @@ public partial class Terrain
BottomRight, BottomRight,
} }
readonly struct GridCorners public readonly struct GridCorners
{ {
public Vector3 TopLeft { get; init; } public Vector3 TopLeft { get; init; }
public Vector3 TopRight { get; init; } public Vector3 TopRight { get; init; }

Loading…
Cancel
Save