using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Godot; using Godot.Collections; using RobotAndDonkey.Game; using RobotAndDonkey.Game.Board; using RobotAndDonkey.Game.Modifiers; using RobotAndDonkey.Game.Pois; using RobotAndDonkey.Game.Utils; public partial class HexBoard3D : Node3D { public override void _Ready() { if (m_Board == null) { var random = new SRandom(241); var board = Board.Generate(ref random, EDifficulty.Easy); Configure(board); } } public void Configure(Cell cell) { m_CellByHex[cell.Hex] = cell; RebuildCellVisual(cell); } public void ModifyCell(Cell cell, EModifierId modifier, Tween tween) { m_CellByHex[cell.Hex] = cell; tween.TweenCallback(Callable.From(() => RebuildCellVisual(cell))); tween.Parallel(); SpawnCellUpdateVfx(cell, tween); tween.Parallel().TweenCallback(Callable.From(() => Main.Instance.Music.Play(modifier))); tween.TweenInterval(TotalAnimationDuration); } public void ModifyPoi(Cell cell, Poi poi, Tween tween) { m_CellByHex[cell.Hex] = cell; tween.TweenCallback(Callable.From(() => RebuildCellVisual(cell))); tween.Parallel(); if (poi != null) { tween.Parallel().TweenCallback(Callable.From(() => { Main.Instance.Music.Play(poi); })); tween.TweenInterval(poi is Donkey ? 2.0 : TotalAnimationDuration); } SpawnCellUpdateVfx(cell, tween); } public void Configure(Board board) { var boardChanged = !ReferenceEquals(m_Board, board); m_Board = board; if (m_Board == null) { ClearBoard(); return; } if (boardChanged) BuildBoard(); } private void BuildBoard() { ClearBoard(); if (m_Board == null || m_Board.Cells.IsDefaultOrEmpty) { BoardBounds = new(); return; } m_CellByHex.Clear(); m_CellNodes.Clear(); var minX = float.MaxValue; var maxX = float.MinValue; var minZ = float.MaxValue; var maxZ = float.MinValue; var hasAnyCell = false; foreach (var cell in m_Board.Cells) { m_CellByHex[cell.Hex] = cell; var world = cell.Hex.ToWorld(); BuildCellNode(cell); var x = world.X; var z = world.Y; if (x < minX) minX = x; if (x > maxX) maxX = x; if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; hasAnyCell = true; } if (hasAnyCell) BoardBounds = new(new(minX, minZ), new(maxX - minX, maxZ - minZ)); else BoardBounds = new(); CreateHighlightMesh(); CreateSelectedMesh(); } private void ClearBoard() { foreach (var child in GetChildren()) child.QueueFree(); m_PoiSprites.Clear(); m_AvatarSprites.Clear(); m_DonkeySprites.Clear(); m_CellByHex.Clear(); m_CellNodes.Clear(); m_HighlightInstance = null; m_SelectedInstance = null; HighlightedCell = null; SelectedCell = null; } public override void _Process(double delta) { var camera = GetViewport()?.GetCamera3D(); if (camera == null) return; var camPos = camera.GlobalPosition; foreach (var sprite in m_PoiSprites) { if (!IsInstanceValid(sprite)) continue; var pos = sprite.GlobalPosition; var target = new Vector3(camPos.X, pos.Y, camPos.Z); sprite.LookAtFromPosition(sprite.GlobalPosition, target, Vector3.Up, true); } if (!m_UseMouseHighlight) UpdateHighlight(camera); } public override void _UnhandledInput(InputEvent @event) { if (Input.IsActionJustPressed("cell_select") && HighlightedCell != null) SelectCell(HighlightedCell); switch (@event) { case InputEventMouseMotion: case InputEventMouseButton: { m_UseMouseHighlight = true; var camera = GetViewport()?.GetCamera3D(); if (camera != null) UpdateHighlight(camera); break; } case InputEventJoypadMotion: case InputEventJoypadButton: { m_UseMouseHighlight = false; break; } } } private void CreateHighlightMesh() { if (m_HighlightInstance != null) return; m_HighlightInstance = new() { Mesh = m_HexMesh, Name = "HighlightHex", Visible = false }; if (HighlightMaterial != null) m_HighlightInstance.MaterialOverride = HighlightMaterial; else { var mat = new StandardMaterial3D { ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, Transparency = BaseMaterial3D.TransparencyEnum.Alpha, AlbedoColor = new(1f, 1f, 1f, 0.4f), NoDepthTest = true }; m_HighlightInstance.MaterialOverride = mat; } AddChild(m_HighlightInstance); } [MemberNotNull(nameof(m_SelectedInstance))] private void CreateSelectedMesh() { if (m_SelectedInstance != null) return; m_SelectedInstance = new() { Mesh = m_HexMesh, Name = "SelectedHex", Visible = false }; if (SelectedMaterial != null) m_SelectedInstance.MaterialOverride = SelectedMaterial; else { var mat = new StandardMaterial3D { ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, Transparency = BaseMaterial3D.TransparencyEnum.Alpha, AlbedoColor = new(1f, 1f, 1f, 0.6f), NoDepthTest = true }; m_SelectedInstance.MaterialOverride = mat; } AddChild(m_SelectedInstance); } private void UpdateHighlight(Camera3D camera) { if (m_Board == null || m_Board.Cells.IsDefaultOrEmpty) return; var viewport = GetViewport(); if (viewport == null) return; Vector2 screenPos; if (m_UseMouseHighlight) screenPos = viewport.GetMousePosition(); else { var rect = viewport.GetVisibleRect(); screenPos = rect.Size * 0.5f; } var origin = camera.ProjectRayOrigin(screenPos); var dir = camera.ProjectRayNormal(screenPos); if (Mathf.Abs(dir.Y) < 0.0001f) { HideHighlight(); return; } var t = -origin.Y / dir.Y; if (t < 0) { HideHighlight(); return; } var hit = origin + dir * t; var hex = Hex.FromWorld(new(hit.X, hit.Z)); if (!m_CellByHex.TryGetValue(hex, out var cell)) { HideHighlight(); return; } if (HighlightedCell != null && HighlightedCell.Hex.Equals(hex)) return; HighlightedCell = cell; if (m_HighlightInstance != null) { var world = cell.Hex.ToWorld(); var pos = new Vector3(world.X, HighlightHeight, world.Y); m_HighlightInstance.Transform = new(Basis.Identity, pos); m_HighlightInstance.Visible = true; } } private void SelectCell(Cell cell) { if (cell == null) return; SelectedCell = cell; if (m_SelectedInstance == null) CreateSelectedMesh(); var world = cell.Hex.ToWorld(); var pos = new Vector3(world.X, SelectedHeight, world.Y); m_SelectedInstance.Transform = new(Basis.Identity, pos); m_SelectedInstance.Visible = true; Tooltip.Instance?.Describe(cell); } private void HideHighlight() { HighlightedCell = null; if (m_HighlightInstance != null) m_HighlightInstance.Visible = false; } private void BuildCellNode(Cell cell) { var hex = cell.Hex; var cellNode = new Node3D { Name = $"Cell_{hex}" }; cellNode.Transform = BuildCellTransform(cell); var meshInstance = new MeshInstance3D { Mesh = m_HexMesh, Name = "HexMesh" }; var mat = GetMaterialForType(cell.Type); if (mat != null) meshInstance.MaterialOverride = mat; cellNode.AddChild(meshInstance); if (DebuffScene != null) { foreach (var modifier in cell.Modifiers) { if (modifier is not UnreliableModifierBase && modifier is not CorruptModifierBase) continue; if (modifier.DebuffSources.Count > 0) continue; var area = DebuffScene.Instantiate(); area.Color = modifier is UnreliableModifierBase ? InterferenceColor : CorruptColor; area.Position = new(0f, 0.1f, 0f); cellNode.AddChild(area); } } if (cell.Poi is not null) { var (tex, pivot) = GetTextureAndPivotForPoi(cell.Poi); if (tex != null) { var sprite = new Sprite3D { Texture = tex, Billboard = BaseMaterial3D.BillboardModeEnum.Enabled, Centered = true, NoDepthTest = true, PixelSize = 0.01f, Name = $"{cell.Poi.GetType().Name}_Sprite" }; ApplyPivot(sprite, pivot); sprite.Position = new(0f, PoiHeight, 0f); cellNode.AddChild(sprite); m_PoiSprites.Add(sprite); if (cell.Poi is Avatar avatar) m_AvatarSprites[avatar] = sprite; if (cell.Poi is Donkey donkey) m_DonkeySprites[donkey] = sprite; } var infoNode = BuildPoiInfoNode(cell); if (infoNode != null) { infoNode.Position = new(0f, PoiInfoHeight, 0f); cellNode.AddChild(infoNode); } } AddChild(cellNode); m_CellNodes[hex] = cellNode; } private void RebuildCellVisual(Cell cell) { if (m_CellNodes.TryGetValue(cell.Hex, out var oldNode) && IsInstanceValid(oldNode)) oldNode.QueueFree(); BuildCellNode(cell); } private Transform3D BuildCellTransform(Cell cell) { var world = cell.Hex.ToWorld(); var position = new Vector3(world.X, 0f, world.Y); var rotation = Basis.Rotated(new(0, 1, 0), MathF.PI * (60 * m_CameraRotationIndex) / 180f); return new(rotation, position); } private void UpdateCellTransforms() { if (m_Board == null || m_Board.Cells.IsDefaultOrEmpty) return; foreach (var cell in m_Board.Cells) { if (!m_CellNodes.TryGetValue(cell.Hex, out var node) || !IsInstanceValid(node)) continue; node.Transform = BuildCellTransform(cell); } } private Material GetMaterialForType(ECellType type) { if (CellTypeMaterials != null && CellTypeMaterials.Count > (int)type && CellTypeMaterials[(int)type] != null) return CellTypeMaterials[(int)type]; var mat = new StandardMaterial3D { ShadingMode = BaseMaterial3D.ShadingModeEnum.PerPixel, }; return mat; } private (Texture2D texture, Vector2 pivot) GetTextureAndPivotForPoi(Poi poi) { return poi switch { Crate => (CrateTexture, CratePivot), Shed => (ShedTexture, ShedPivot), Tower => (TowerTexture, TowerPivot), Donkey d => (GetDonkeyTexture(d.Direction), DonkeyPivot), Avatar a => (GetAvatarTexture(a.Direction), AvatarPivot), _ => (null, default) }; } private Node3D BuildPoiInfoNode(Cell cell) { if (cell.Poi is null) return null; switch (cell.Poi) { case Crate crate: { if (crate.Amount > 0) { var label = new Label3D { Text = crate.Amount.ToString(), FontSize = 72, Name = "Crate_Amount", Billboard = BaseMaterial3D.BillboardModeEnum.Enabled }; return label; } var check = CreateCheckmarkSprite(); if (check != null) check.Name = "Crate_Checkmark"; return check; } case Shed shed: { var remaining = shed.Remaining; if (remaining > 0) { var label = new Label3D { Text = $"-{remaining}", FontSize = 72, Name = "Shed_Remaining", Billboard = BaseMaterial3D.BillboardModeEnum.Enabled }; return label; } var check = CreateCheckmarkSprite(); if (check != null) check.Name = "Shed_Checkmark"; return check; } } return null; } private Sprite3D CreateCheckmarkSprite() { if (CheckmarkTexture == null) return null; var sprite = new Sprite3D { Texture = CheckmarkTexture, Billboard = BaseMaterial3D.BillboardModeEnum.Enabled, Centered = true, NoDepthTest = true, PixelSize = 0.01f, Name = "CheckmarkSprite" }; return sprite; } private void ApplyPivot(Sprite3D sprite, Vector2 pivotNormalized) { if (sprite.Texture is not { } tex) return; var sizeI = tex.GetSize(); var size = new Vector2(sizeI.X, sizeI.Y); var pivotFromCenter = pivotNormalized - new Vector2(0.5f, 0.5f); var offset = pivotFromCenter * size; sprite.Offset = offset; } public void SetCameraRotationIndex(int rotationIndex) { rotationIndex = Mathf.PosMod(rotationIndex, 6); if (m_CameraRotationIndex == rotationIndex) return; m_CameraRotationIndex = rotationIndex; UpdateCellTransforms(); foreach (var kvp in m_AvatarSprites) { var avatar = kvp.Key; var sprite = kvp.Value; if (!IsInstanceValid(sprite)) continue; sprite.Texture = GetAvatarTexture(avatar.Direction); ApplyPivot(sprite, AvatarPivot); } foreach (var kvp in m_DonkeySprites) { var donkey = kvp.Key; var sprite = kvp.Value; if (!IsInstanceValid(sprite)) continue; sprite.Texture = GetDonkeyTexture(donkey.Direction); ApplyPivot(sprite, DonkeyPivot); } } private Texture2D GetAvatarTexture(EDirection worldDirection) { var dirIndex = (int)worldDirection; var relative = Mathf.PosMod(dirIndex - m_CameraRotationIndex, 6); return (EDirection)relative switch { EDirection.Right => AvatarRightTexture, EDirection.TopRight => AvatarTopRightTexture, EDirection.TopLeft => AvatarTopLeftTexture, EDirection.Left => AvatarLeftTexture, EDirection.BottomLeft => AvatarBottomLeftTexture, EDirection.BottomRight => AvatarBottomRightTexture, _ => throw new UnreachableException() }; } private Texture2D GetDonkeyTexture(EDirection worldDirection) { var dirIndex = (int)worldDirection; var relative = Mathf.PosMod(dirIndex - m_CameraRotationIndex, 6); return (EDirection)relative switch { EDirection.Right => DonkeyRightTexture, EDirection.TopRight => DonkeyTopRightTexture, EDirection.TopLeft => DonkeyTopLeftTexture, EDirection.Left => DonkeyLeftTexture, EDirection.BottomLeft => DonkeyBottomLeftTexture, EDirection.BottomRight => DonkeyBottomRightTexture, _ => throw new UnreachableException() }; } private void SpawnCellUpdateVfx(Cell cell, Tween tween) { var mesh1 = new MeshInstance3D() { Mesh = m_HexMesh, Visible = false }; var mesh2 = new MeshInstance3D() { Mesh = m_HexMesh, Visible = false }; var mat1 = new StandardMaterial3D { ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, Transparency = BaseMaterial3D.TransparencyEnum.Alpha, AlbedoColor = new(1f, 1f, 1f, 0.4f), NoDepthTest = true }; var mat2 = new StandardMaterial3D { ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, Transparency = BaseMaterial3D.TransparencyEnum.Alpha, AlbedoColor = new(1f, 1f, 1f, 0.4f), NoDepthTest = true }; mesh1.MaterialOverride = mat1; mesh2.MaterialOverride = mat2; var world = cell.Hex.ToWorld(); var position = new Vector3(world.X, 0.1f, world.Y); mesh1.Position = position; mesh2.Position = position; tween.TweenCallback(Callable.From(() => { AddChild(mesh1); AddChild(mesh2); })); tween.TweenProperty(mesh1, "scale", new Vector3(2, 1, 2), TotalAnimationDuration); tween.Parallel().TweenProperty(mesh2, "position", position + new Vector3(0, 1, 0), TotalAnimationDuration); tween.Parallel().TweenProperty(mat1, "albedo_color", new Color(1, 1, 1, 0.0f), TotalAnimationDuration); tween.Parallel().TweenProperty(mat2, "albedo_color", new Color(1, 1, 1, 0.0f), TotalAnimationDuration); tween.TweenCallback(Callable.From(() => { mesh1.QueueFree(); mesh2.QueueFree(); })); } [ExportGroup("POI Pivots (normalized, 0–1)")] [Export] public Vector2 CratePivot { get; set; } = new(0.5f, 1.0f); [Export] public Vector2 ShedPivot { get; set; } = new(0.5f, 1.0f); [Export] public Vector2 TowerPivot { get; set; } = new(0.5f, 1.0f); [Export] public Vector2 DonkeyPivot { get; set; } = new(0.5f, 1.0f); [Export] public Vector2 AvatarPivot { get; set; } = new(0.5f, 1.0f); [Export] public Array CellTypeMaterials { get; set; } = new(); [ExportGroup("POI Textures")] [Export] public Texture2D CrateTexture { get; set; } [Export] public Texture2D ShedTexture { get; set; } [Export] public Texture2D TowerTexture { get; set; } [Export] public Texture2D AvatarRightTexture { get; set; } [Export] public Texture2D AvatarTopRightTexture { get; set; } [Export] public Texture2D AvatarTopLeftTexture { get; set; } [Export] public Texture2D AvatarLeftTexture { get; set; } [Export] public Texture2D AvatarBottomLeftTexture { get; set; } [Export] public Texture2D AvatarBottomRightTexture { get; set; } [Export] public Texture2D DonkeyRightTexture { get; set; } [Export] public Texture2D DonkeyTopRightTexture { get; set; } [Export] public Texture2D DonkeyTopLeftTexture { get; set; } [Export] public Texture2D DonkeyLeftTexture { get; set; } [Export] public Texture2D DonkeyBottomLeftTexture { get; set; } [Export] public Texture2D DonkeyBottomRightTexture { get; set; } [Export] public Texture2D CheckmarkTexture { get; set; } [ExportGroup("Cell effects")] [Export] public PackedScene DebuffScene { get; set; } [Export] public Color CorruptColor { get; set; } = new(0.6f, 0.1f, 0.8f, 0.45f); [Export] public Color InterferenceColor { get; set; } = new(0.1f, 0.8f, 0.6f, 0.45f); [ExportGroup("Highlight")] [Export] public Material HighlightMaterial { get; set; } [Export] public float HighlightHeight { get; set; } = 0.02f; [Export] public Material SelectedMaterial { get; set; } [Export] public float SelectedHeight { get; set; } = 0.03f; public float TotalAnimationDuration => 0.375f / SettingsManager.Instance.GameSpeed; public Cell HighlightedCell { get; private set; } public Cell SelectedCell { get; private set; } public Rect2 BoardBounds { get; private set; } private const float PoiHeight = 0.15f; private const float PoiInfoHeight = 0.25f; private readonly System.Collections.Generic.Dictionary m_AvatarSprites = new(); private readonly System.Collections.Generic.Dictionary m_CellByHex = new(); private readonly System.Collections.Generic.Dictionary m_CellNodes = new(); private readonly System.Collections.Generic.Dictionary m_DonkeySprites = new(); private readonly ArrayMesh m_HexMesh = HexMeshBuilder.CreateFlatHexMesh(); private readonly List m_PoiSprites = new(); private Board m_Board; private int m_CameraRotationIndex; private MeshInstance3D m_HighlightInstance; private MeshInstance3D m_SelectedInstance; private bool m_UseMouseHighlight = true; }