commit 6c0c33f5d496894e4a3f80230083781bbf8230ea Author: Frank Tovar Date: Sun Apr 19 00:43:27 2026 +0200 ported from perforce diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f677870 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin +obj +.vs \ No newline at end of file diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 0000000..c6c0597 --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,35 @@ +# AGENTS + +## Context +- Game genre: roguelite strategy deckbuilder. +- Game fantasy: program a robot courier on a hex farmland delivering crate goods to sheds while glitches mutate instructions and patches improve the deck/program. +- Use this file to keep new work aligned with the existing design/implementation; see code under RobotAndDonkey.Game and tests under RobotAndDonkey.Tests. + +## Design document +- Detailed system design exists in the design.md file. +- UX design for a card row exists in the cardrow.md file. + +## Tech Design +- Deterministic core: GameState.GameState.CreateNew seeds Utils.SRandom; CoreLoop owns Board.Generate, glitch deck, robot, decks, and metrics (energy wasted, overspill, path length, visits). Balancing tunables live in Data/Balancing.cs. +- Deterministic finite automaton: CoreLoop.RunPhase (ERunPhase) feeds Requests (Execution/Requests/*) and Commands (Execution/Commands/*) via GameRuntime; EnterPreview seeds the first tape/hand; loop is DrawGlitch -> Improve -> (Gamble/BufferOverflow) -> ExecuteProgram/Scoring -> back to DrawGlitch. +- Entities/modifiers: cards/robots/cells derive from Modifiers.Entity holding modifiers. Modifier (Kind/Id/Duration) exposes OnAdded/Before/After/OnRemoved; DebuffSources disables effects. ModifierStack pushes robot -> cell -> card layers before each card; immunity/debuff rules are honored when executing intents. +- Intent pipeline: cards override Card.CreateIntents to enqueue intents (Intents/*). ModifierStack.Execute runs modifier Before hooks, validates Intent.IsValid (energy + debuffs), runs intents (mutate board/currency/add intents), then After hooks. Results records mirror deltas for UI sync. +- Board model: Board.Board stores ImmutableArray and TargetDeliveryAmount; Board.Generate builds axial hex maps sized by difficulty, sprinkling terrain/POIs with Balancing probabilities while preserving connectivity and POI reachability. Towers seed permanent Unreliable to neighbors; corrupt cells added by spread. FindCellIndex is currently O(n). +- Robots: Robots.* records define starting deck/program count; base Robot exposes Currency (energy/max carry/preview/tape length/hand size) derived from difficulty. Derived robots add modifiers (e.g., AnalystModifier) and deck tweaks. +- Cards/modifiers: Cards/Patches and Cards/Glitches are records; PatchCard/GlitchCard derive from Card with tooltip/modifier metadata. ModifyCardBase handles temporary modifier application; OccupiedSpace respects Efficient. Corruption-aware indexing uses Utils.CardExtensions.NextIndexConsideringCorruption. +- Frontend: HexBoard3D builds a 3D board with HexMeshBuilder multimeshes per cell type, billboarded POI sprites cached in dictionaries, highlight/selection meshes, camera-facing pivoted textures, optional debuff VFX from cell modifiers. Exported textures/materials/pivots enable art hookup. + +## Code Style & Patterns +- Indentation is done with 4 spaces per level. +- C# records and expression-bodied members; prefer immutability (ImmutableArray, init props) and explicit DeepClone including modifiers/debuff state. +- No modifier duplicates; always use AddModifier/InsertModifier/RemoveModifier so hooks fire. Respect DebuffSources when checking if effects apply. +- Intents/results are the only way to mutate state for UI; emit CurrencyResult/HandResult/TapeResult/PoiResult/etc. with a cloned state whenever state changes. +- Thread deterministic RNG through CoreLoop.Random (ref SRandom) instead of creating new Random instances; avoid hidden randomness. +- Balancing.Instance is the source of truth for costs/probabilities; avoid magic numbers in cards/intents/board generation. +- Tests live in RobotAndDonkey.Tests with builders (BoardBuilder, CoreLoopBuilder, TestRuntime, CardTestBase) and card-specific suites; mirror patterns for new content to keep coverage and determinism. +- Hex math uses axial coords (Utils.Hex) and EDirection; derive relative directions via helpers instead of manual math to respect corruption/orientation rules. Rendering caches meshes/sprites and small helper methods over monoliths. +- Variable names are full words and are not abbreviated (e.g. exception instead of ex, image instead of img). Structs have an S prefix, enums have an E prefix, fields have a m_ prefix, statics have a s_ prefix, members use UpperCamelCase, locals and arguments use lowerCamelCase. +- Inside of a class: statics appear first, then methods, then properties, and finally all other members. +- Always include method visibility modifiers, especially private. +- Don't use regions. +- Curly braces each belong in their own line. \ No newline at end of file diff --git a/DonkeysAndDroids.Godot/.editorconfig b/DonkeysAndDroids.Godot/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/DonkeysAndDroids.Godot/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/DonkeysAndDroids.Godot/.gitattributes b/DonkeysAndDroids.Godot/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/DonkeysAndDroids.Godot/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/DonkeysAndDroids.Godot/.gitignore b/DonkeysAndDroids.Godot/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/DonkeysAndDroids.Godot/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/DonkeysAndDroids.Godot/Background.cs b/DonkeysAndDroids.Godot/Background.cs new file mode 100644 index 0000000..ecd7352 --- /dev/null +++ b/DonkeysAndDroids.Godot/Background.cs @@ -0,0 +1,29 @@ +using Godot; + +public partial class Background : Control +{ + public override void _Ready() + { + var rect = GetNode("SwirlRect"); + m_SwirlMaterial = rect.Material as ShaderMaterial; + + m_Particles = GetNode("DustParticles"); + m_ParticlesProcess = m_Particles.ProcessMaterial as ParticleProcessMaterial; + } + + public override void _Process(double delta) + { + m_Particles.Position = Size / 2; + ((ParticleProcessMaterial)m_Particles.ProcessMaterial).EmissionBoxExtents = new(Size.X / 2, Size.Y / 2, 1); + Refresh(); + } + + public void Refresh() + { + } + + private GpuParticles2D m_Particles; + private ParticleProcessMaterial m_ParticlesProcess; + + private ShaderMaterial m_SwirlMaterial; +} \ No newline at end of file diff --git a/DonkeysAndDroids.Godot/Background.cs.uid b/DonkeysAndDroids.Godot/Background.cs.uid new file mode 100644 index 0000000..e7a3780 --- /dev/null +++ b/DonkeysAndDroids.Godot/Background.cs.uid @@ -0,0 +1 @@ +uid://bqii5k4sdtlnq diff --git a/DonkeysAndDroids.Godot/BoardCameraController.cs b/DonkeysAndDroids.Godot/BoardCameraController.cs new file mode 100644 index 0000000..5d163fc --- /dev/null +++ b/DonkeysAndDroids.Godot/BoardCameraController.cs @@ -0,0 +1,258 @@ +using Godot; + +public partial class BoardCameraController : Camera3D +{ + public override void _Ready() + { + InitBoardBounds(); + InitOrbit(); + } + + private void InitBoardBounds() + { + if (HexBoard != null && HexBoard.BoardBounds.Size != Vector2.Zero) + { + m_BoardRect = HexBoard.BoardBounds; + var center2D = m_BoardRect.Position + m_BoardRect.Size * 0.5f; + m_TargetPosition = new(center2D.X - 1, 0f, center2D.Y + 3); + } + else + { + m_BoardRect = new(-10, -10, 20, 20); + m_TargetPosition = new(-1, 0f, 3); + } + + var radius = Mathf.Max(m_BoardRect.Size.X, m_BoardRect.Size.Y) * 0.5f; + m_MinDistance = MinZoomDistance; + m_MaxDistance = Mathf.Max(m_MinDistance * 2f, radius * MaxZoomOutFactor); + } + + private void InitOrbit() + { + var startYawRad = GlobalTransform.Basis.GetEuler().Y; + var startYawDeg = Mathf.RadToDeg(startYawRad); + + m_RotationIndex = Mathf.PosMod(Mathf.RoundToInt(startYawDeg / 60f), 6); + m_YawDegrees = m_RotationIndex * 60f; + + m_Distance = (m_MaxDistance - m_MinDistance) * 0.75f + m_MinDistance; + UpdateTransformFromOrbit(); + + if (HexBoard != null) + HexBoard.SetCameraRotationIndex(m_RotationIndex); + } + + private void UpdateTransformFromOrbit() + { + var pitchRad = Mathf.DegToRad(PitchDegrees); + var yawRad = Mathf.DegToRad(m_YawDegrees); + + var horizontal = m_Distance * Mathf.Cos(pitchRad); + var height = m_Distance * Mathf.Sin(pitchRad); + + var offset = new Vector3(Mathf.Sin(yawRad) * horizontal, height, Mathf.Cos(yawRad) * horizontal); + + GlobalPosition = m_TargetPosition + offset; + LookAt(m_TargetPosition, Vector3.Up); + } + + public override void _Process(double delta) + { + var dt = (float)delta; + + HandleRotationInput(dt); + HandleMovementInput(dt); + HandleZoomInput(dt); + } + + public override void _UnhandledInput(InputEvent @event) + { + if (@event is InputEventMouseButton mb && mb.Pressed) + { + if (mb.ButtonIndex == MouseButton.WheelUp) + m_PendingWheelSteps -= 1f; + else if (mb.ButtonIndex == MouseButton.WheelDown) + m_PendingWheelSteps += 1f; + } + } + + private void HandleMovementInput(float delta) + { + var input = Vector2.Zero; + + input.Y -= Input.GetActionStrength("camera_move_forward"); + input.Y += Input.GetActionStrength("camera_move_back"); + input.X -= Input.GetActionStrength("camera_move_left"); + input.X += Input.GetActionStrength("camera_move_right"); + + if (input.LengthSquared() < 0.0001f) + return; + + var yawRad = Mathf.DegToRad(m_YawDegrees); + var forward = new Vector2(Mathf.Sin(yawRad), Mathf.Cos(yawRad)); + var right = new Vector2(forward.Y, -forward.X); + + var worldDir = right * input.X + forward * input.Y; + var deltaPos = worldDir * MoveSpeed * delta; + + m_TargetPosition.X += deltaPos.X; + m_TargetPosition.Z += deltaPos.Y; + + ClampTargetToBoard(); + UpdateTransformFromOrbit(); + } + + private void HandleZoomInput(float delta) + { + var zoomDelta = 0f; + + var axis = Input.GetActionStrength("camera_zoom_out") - Input.GetActionStrength("camera_zoom_in"); + if (Mathf.Abs(axis) > 0.001f) + zoomDelta += axis * PadZoomSpeed * delta; + + if (Mathf.Abs(m_PendingWheelSteps) > 0.001f) + { + zoomDelta += m_PendingWheelSteps * WheelZoomStep; + m_PendingWheelSteps = 0f; + } + + if (Mathf.Abs(zoomDelta) < 0.0001f) + return; + + m_Distance = Mathf.Clamp(m_Distance + zoomDelta, m_MinDistance, m_MaxDistance); + UpdateTransformFromOrbit(); + } + + private void HandleRotationInput(float delta) + { + if (Input.IsActionJustPressed("camera_rotate_left")) + QueueRotate(-1); + if (Input.IsActionJustPressed("camera_rotate_right")) + QueueRotate(+1); + + var axis = Input.GetActionStrength("camera_rotate_right") - Input.GetActionStrength("camera_rotate_left"); + var axisDir = 0; + if (axis > GamepadDeadZone) + axisDir = +1; + else if (axis < -GamepadDeadZone) + axisDir = -1; + + if (axisDir != 0) + { + if (m_RotateAxisDir != axisDir) + { + m_RotateAxisDir = axisDir; + m_RotateAxisTimer = 0f; + QueueRotate(axisDir); + } + else + { + m_RotateAxisTimer += delta; + if (m_RotateAxisTimer >= GamepadRotateRepeatDelay) + { + m_RotateAxisTimer = 0f; + QueueRotate(axisDir); + } + } + } + else + { + m_RotateAxisDir = 0; + m_RotateAxisTimer = 0f; + } + } + + private void QueueRotate(int steps) + { + if (steps == 0 || m_RotationTween != null) + return; + + m_RotationIndex = Mathf.PosMod(m_RotationIndex + steps, 6); + var targetYaw = m_RotationIndex * 60f; + + var current = m_YawDegrees; + var deltaYaw = Mathf.Wrap(targetYaw - current, -180f, 180f); + targetYaw = current + deltaYaw; + + HexBoard?.SetCameraRotationIndex(m_RotationIndex); + + m_RotationTween = CreateTween(); + m_RotationTween.SetTrans(Tween.TransitionType.Back); + m_RotationTween.SetEase(Tween.EaseType.Out); + m_RotationTween.TweenProperty(this, nameof(YawDegrees), targetYaw, RotationDuration); + m_RotationTween.TweenCallback(Callable.From(() => + { + YawDegrees = m_RotationIndex * 60f; + m_RotationTween = null; + })); + } + + private void ClampTargetToBoard() + { + if (m_BoardRect.Size == Vector2.Zero) + return; + + var minX = m_BoardRect.Position.X; + var maxX = m_BoardRect.Position.X + m_BoardRect.Size.X; + var minZ = m_BoardRect.Position.Y; + var maxZ = m_BoardRect.Position.Y + m_BoardRect.Size.Y; + + m_TargetPosition.X = Mathf.Clamp(m_TargetPosition.X, minX, maxX); + m_TargetPosition.Z = Mathf.Clamp(m_TargetPosition.Z, minZ, maxZ); + } + + public float YawDegrees + { + get => m_YawDegrees; + set + { + m_YawDegrees = value; + UpdateTransformFromOrbit(); + } + } + + [Export] + public HexBoard3D HexBoard { get; set; } + + [Export] + public float GamepadDeadZone = 0.4f; + + [Export] + public float GamepadRotateRepeatDelay = 1.0f; + + [Export] + public float MaxZoomOutFactor = 2.2f; + + [ExportCategory("Zoom")] + [Export] + public float MinZoomDistance = 8f; + + [ExportCategory("Movement")] + [Export] + public float MoveSpeed = 10f; + + [Export] + public float PadZoomSpeed = 40f; + + [ExportCategory("Rotation")] + [Export] + public float PitchDegrees = 60f; + + [Export] + public float RotationDuration = 0.35f; + + [Export] + public float WheelZoomStep = 3f; + + private Rect2 m_BoardRect; + private float m_Distance; + private float m_MaxDistance; + private float m_MinDistance; + private float m_PendingWheelSteps; + private int m_RotateAxisDir; + private float m_RotateAxisTimer; + private int m_RotationIndex; + private Tween m_RotationTween; + private Vector3 m_TargetPosition = new(-1, 0f, 3); + private float m_YawDegrees; +} \ No newline at end of file diff --git a/DonkeysAndDroids.Godot/BoardCameraController.cs.uid b/DonkeysAndDroids.Godot/BoardCameraController.cs.uid new file mode 100644 index 0000000..16045f4 --- /dev/null +++ b/DonkeysAndDroids.Godot/BoardCameraController.cs.uid @@ -0,0 +1 @@ +uid://cs6idwp164gov diff --git a/DonkeysAndDroids.Godot/BoardNode.cs b/DonkeysAndDroids.Godot/BoardNode.cs new file mode 100644 index 0000000..a0601dc --- /dev/null +++ b/DonkeysAndDroids.Godot/BoardNode.cs @@ -0,0 +1,50 @@ +using Godot; +using RobotAndDonkey.Game.Board; +using RobotAndDonkey.Game.Execution.Results; +using RobotAndDonkey.Game.Pois; +using RobotAndDonkey.Game.Utils; +using System; + +public partial class BoardNode : Node3D +{ + public override void _Ready() + { + m_HexBoard3D = GetNode("HexBoard3D"); + } + + public override void _Process(double delta) + { + } + + public void Configure(Board board) + { + m_HexBoard3D.Configure(board); + } + + public void Configure(Result result, Tween tween) + { + switch (result) + { + case CellTypeResult cellResult: + { + var cell = cellResult.Board.Cells[cellResult.Board.FindCellIndex(cellResult.Hex)]; + tween.TweenCallback(Callable.From(() => m_HexBoard3D.Configure(cell))); + break; + } + case ModifyCellResult cellResult: + { + var cell = cellResult.Board.Cells[cellResult.Board.FindCellIndex(cellResult.Hex)]; + m_HexBoard3D.ModifyCell(cell, cellResult.Modifier, tween); + break; + } + case PoiResult cellResult: + { + var cell = cellResult.Board.Cells[cellResult.Board.FindCellIndex(cellResult.Hex)]; + m_HexBoard3D.ModifyPoi(cell, cellResult.Poi, tween); + break; + } + } + } + + private HexBoard3D m_HexBoard3D; +} diff --git a/DonkeysAndDroids.Godot/BoardNode.cs.uid b/DonkeysAndDroids.Godot/BoardNode.cs.uid new file mode 100644 index 0000000..597ba01 --- /dev/null +++ b/DonkeysAndDroids.Godot/BoardNode.cs.uid @@ -0,0 +1 @@ +uid://b5ehrdnu2ovli diff --git a/DonkeysAndDroids.Godot/BufferOverflow.cs b/DonkeysAndDroids.Godot/BufferOverflow.cs new file mode 100644 index 0000000..820c7ec --- /dev/null +++ b/DonkeysAndDroids.Godot/BufferOverflow.cs @@ -0,0 +1,91 @@ +using DonkeysAndDroids; +using Godot; +using RobotAndDonkey.Game.Execution.Commands; +using RobotAndDonkey.Game.Execution.Results; + +public partial class BufferOverflow : Control, IScreen +{ + public override void _Ready() + { + m_Hand = GetNode("%Hand"); + m_Remove = GetNode