From 0187c5ecbe4b651b0c949fdb5d515dfe0f769e79 Mon Sep 17 00:00:00 2001 From: empty Date: Fri, 2 Jan 2026 00:23:13 +0800 Subject: [PATCH] feat: Phase 19-C/D - sprite loading, transparency, and animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add runtime sprite loading from Characters.png and Environment.png - Implement ProcessTransparency for chroma-key white background removal - Add AgentAnimator for procedural idle/movement animations - Add Billboard component support for 2.5D perspective - Normalize sprite scales based on world units - Fix SetMovement parameter mismatch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- unity-client/Assets/Scripts/AgentVisual.cs | 105 +++++++++++---- .../Assets/Scripts/Visual/AgentAnimator.cs | 123 ++++++++++++++++++ .../Scripts/Visual/AgentAnimator.cs.meta | 2 + .../Scripts/Visual/EnvironmentManager.cs | 62 ++++++--- 4 files changed, 246 insertions(+), 46 deletions(-) create mode 100644 unity-client/Assets/Scripts/Visual/AgentAnimator.cs create mode 100644 unity-client/Assets/Scripts/Visual/AgentAnimator.cs.meta diff --git a/unity-client/Assets/Scripts/AgentVisual.cs b/unity-client/Assets/Scripts/AgentVisual.cs index 0ddf994..8c63d40 100644 --- a/unity-client/Assets/Scripts/AgentVisual.cs +++ b/unity-client/Assets/Scripts/AgentVisual.cs @@ -115,45 +115,77 @@ namespace TheIsland.Visual { if (!IsAlive) return; + // Phase 19-D: Apply soft-repulsion to prevent crowding + Vector3 repulsion = CalculateRepulsion(); + // Handle Movement if (_isMoving) { - transform.position = Vector3.MoveTowards(transform.position, _targetPosition, _moveSpeed * Time.deltaTime); + // Simple steering toward target + Vector3 moveDir = (_targetPosition - transform.position).normalized; + Vector3 finalVelocity = (moveDir * _moveSpeed) + repulsion; + + transform.position += finalVelocity * Time.deltaTime; // Flip sprite based on direction - if (_spriteRenderer != null) + if (_spriteRenderer != null && Mathf.Abs(moveDir.x) > 0.01f) { - float dx = _targetPosition.x - transform.position.x; - if (Mathf.Abs(dx) > 0.1f) - { - // FlipX = true means face Left (assuming sprite faces Right by default) - // If sprite faces Front, we might need a different approach, but FlipX is standard for 2D. - _spriteRenderer.flipX = dx < 0; - } + _spriteRenderer.flipX = moveDir.x < 0; } - if (Vector3.Distance(transform.position, _targetPosition) < 0.05f) + if (Vector3.Distance(transform.position, _targetPosition) < 0.1f) { _isMoving = false; } } - - if (_isMoving) + else if (repulsion.sqrMagnitude > 0.001f) { - MoveTowardsTarget(); + // Push away even when idle + transform.position += repulsion * Time.deltaTime; } - // Phase 19-B: Use AgentAnimator for procedural movement/idle + // Phase 19-D: Dynamic Z-Sorting + if (_spriteRenderer != null) + { + // In world space, higher Z (further) should have lower sorting order + // Z typically ranges from -10 to 10 on the island + _spriteRenderer.sortingOrder = Mathf.RoundToInt(-transform.position.z * 100); + } + + // Phase 19-B/D: Use AgentAnimator if (_animator != null) { - float velocity = _isMoving ? _moveSpeed : 0; - _animator.SetMovement(velocity, _moveSpeed); + float currentSpeed = _isMoving ? _moveSpeed : 0; + _animator.SetMovement(currentSpeed, _moveSpeed); } // Phase 19: Smooth UI Bar Transitions UpdateSmoothBars(); } + private Vector3 CalculateRepulsion() + { + Vector3 force = Vector3.zero; + float radius = 1.2f; // Social distancing radius + float strength = 1.5f; + + var allAgents = FindObjectsByType(FindObjectsSortMode.None); + foreach (var other in allAgents) + { + if (other == this || !other.IsAlive) continue; + + Vector3 diff = transform.position - other.transform.position; + float dist = diff.magnitude; + + if (dist < radius && dist > 0.01f) + { + // Linear falloff repulsion + force += diff.normalized * (1.0f - (dist / radius)) * strength; + } + } + return force; + } + private void UpdateSmoothBars() { float lerpSpeed = 5f * Time.deltaTime; @@ -288,11 +320,11 @@ namespace TheIsland.Visual if (!System.IO.File.Exists(path)) yield break; byte[] fileData = System.IO.File.ReadAllBytes(path); - Texture2D tex = new Texture2D(2, 2); - tex.LoadImage(fileData); + Texture2D sourceTex = new Texture2D(2, 2); + sourceTex.LoadImage(fileData); - // Phase 19-B: Fix white background transparency - ProcessTransparency(tex); + // Phase 19-C: Fix black/white background with robust transcoding + Texture2D tex = ProcessTransparency(sourceTex); // Slice the 1x3 collection (3 characters in a row) int charIndex = id % 3; @@ -304,23 +336,44 @@ namespace TheIsland.Visual { _spriteRenderer.sprite = characterSprite; _spriteRenderer.color = Color.white; + + // Phase 19-C: Normalize scale. Target height approx 2.0 units. + float spriteHeightUnits = characterSprite.rect.height / characterSprite.pixelsPerUnit; + float normScale = 2.0f / spriteHeightUnits; // Desired height is 2.0 units + _spriteRenderer.transform.localScale = new Vector3(normScale, normScale, 1); + + // Update original scale for animator + _originalSpriteScale = _spriteRenderer.transform.localScale; } } - private void ProcessTransparency(Texture2D tex) + private Texture2D ProcessTransparency(Texture2D source) { - if (tex == null) return; - Color[] pixels = tex.GetPixels(); + if (source == null) return null; + + // Create a new texture with Alpha channel + Texture2D tex = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false); + Color[] pixels = source.GetPixels(); + for (int i = 0; i < pixels.Length; i++) { - // If the pixel is very close to white, make it transparent - if (pixels[i].r > 0.95f && pixels[i].g > 0.95f && pixels[i].b > 0.95f) + Color p = pixels[i]; + // Chroma-key: If pixel is very close to white, make it transparent + // Using 0.9f as threshold to catch almost-white artifacts + if (p.r > 0.9f && p.g > 0.9f && p.b > 0.9f) { - pixels[i] = Color.clear; + pixels[i] = new Color(0, 0, 0, 0); + } + else + { + // Ensure full opacity for others + pixels[i] = new Color(p.r, p.g, p.b, 1.0f); } } + tex.SetPixels(pixels); tex.Apply(); + return tex; } private void ApplyAgentColor(int agentId) diff --git a/unity-client/Assets/Scripts/Visual/AgentAnimator.cs b/unity-client/Assets/Scripts/Visual/AgentAnimator.cs new file mode 100644 index 0000000..a422177 --- /dev/null +++ b/unity-client/Assets/Scripts/Visual/AgentAnimator.cs @@ -0,0 +1,123 @@ +using UnityEngine; +using System.Collections; + +namespace TheIsland +{ + /// + /// Procedural 2D animator for agents. + /// Handles idle breathing, movement bopping, and action-based squash/stretch. + /// + public class AgentAnimator : MonoBehaviour + { + [Header("Animation Settings")] + public float idleSpeed = 2f; + public float idleAmount = 0.05f; + public float moveBopSpeed = 12f; + public float moveBopAmount = 0.12f; + public float moveTiltAmount = 8f; + + private SpriteRenderer _spriteRenderer; + private Vector3 _originalScale; + private Vector3 _targetLocalPos; + private Quaternion _targetLocalRot; + private Vector3 _targetScale; + + private float _velocityPercentage; // 0 to 1 + private bool _isMoving; + + private void Awake() + { + // Find in children if not on this object + _spriteRenderer = GetComponentInChildren(); + } + + private void Start() + { + if (_spriteRenderer != null) + { + _originalScale = _spriteRenderer.transform.localScale; + } + else + { + _originalScale = Vector3.one; + } + } + + public void SetMovement(float currentVelocity, float maxVelocity) + { + _velocityPercentage = Mathf.Clamp01(currentVelocity / Mathf.Max(0.1f, maxVelocity)); + _isMoving = _velocityPercentage > 0.05f; + } + + public void TriggerActionEffect() + { + StopAllCoroutines(); + StartCoroutine(ActionPulseRoutine()); + } + + private void Update() + { + if (_spriteRenderer == null) return; + + if (_isMoving) + { + AnimateMove(); + } + else + { + AnimateIdle(); + } + + // Smoothly apply transforms + _spriteRenderer.transform.localPosition = Vector3.Lerp(_spriteRenderer.transform.localPosition, _targetLocalPos, Time.deltaTime * 10f); + _spriteRenderer.transform.localRotation = Quaternion.Slerp(_spriteRenderer.transform.localRotation, _targetLocalRot, Time.deltaTime * 10f); + _spriteRenderer.transform.localScale = Vector3.Lerp(_spriteRenderer.transform.localScale, _targetScale, Time.deltaTime * 10f); + } + + private void AnimateIdle() + { + // Idle "Breathing" + float breathe = Mathf.Sin(Time.time * idleSpeed) * idleAmount; + + _targetScale = new Vector3(_originalScale.x, _originalScale.y * (1f + breathe), _originalScale.z); + _targetLocalPos = Vector3.zero; + _targetLocalRot = Quaternion.identity; + } + + private void AnimateMove() + { + // Movement "Bopping" - Sin wave for vertical bounce + float cycle = Time.time * moveBopSpeed; + float bop = Mathf.Abs(Mathf.Sin(cycle)) * moveBopAmount * _velocityPercentage; + + // Tilt based on the cycle to give a "walking" feel + float tilt = Mathf.Sin(cycle) * moveTiltAmount * _velocityPercentage; + + _targetLocalPos = new Vector3(0, bop, 0); + _targetLocalRot = Quaternion.Euler(0, 0, tilt); + + // Squash and stretch during the bop + float stretch = 1f + bop; + float squash = 1f / stretch; + _targetScale = new Vector3(_originalScale.x * stretch, _originalScale.y * squash, _originalScale.z); + } + + private IEnumerator ActionPulseRoutine() + { + float elapsed = 0; + float duration = 0.25f; + while (elapsed < duration) + { + elapsed += Time.deltaTime; + float t = elapsed / duration; + // Double pulse or overshoot + float scale = 1.0f + Mathf.Sin(t * Mathf.PI) * 0.4f; + + // Override target scale briefly + _spriteRenderer.transform.localScale = new Vector3(_originalScale.x * scale, _originalScale.y * (2f - scale), _originalScale.z); + yield return null; + } + _targetScale = _originalScale; + } + } +} \ No newline at end of file diff --git a/unity-client/Assets/Scripts/Visual/AgentAnimator.cs.meta b/unity-client/Assets/Scripts/Visual/AgentAnimator.cs.meta new file mode 100644 index 0000000..89c35f6 --- /dev/null +++ b/unity-client/Assets/Scripts/Visual/AgentAnimator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4b9e1cbf8a16c41ccb4b5f197e3ade72 \ No newline at end of file diff --git a/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs b/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs index c73a8f9..5948d0c 100644 --- a/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs +++ b/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs @@ -437,8 +437,17 @@ namespace TheIsland.Visual var trunkRenderer = trunkSprite.AddComponent(); trunkRenderer.sprite = CreateTreeSprite(); trunkRenderer.sortingOrder = -20; - // Phase 19-B: Uniform scale to avoid distortion - trunkSprite.transform.localScale = new Vector3(scale, scale, 1); + + // Phase 19-C: Add Billboard for 2.5D perspective + trunkSprite.AddComponent(); + + // Phase 19-C: Normalize scale based on world units. + // If the sprite is large, we want it to fit the intended 'scale' height. + // A typical tree sprite at 100 PPU might be 10 units high. + // We want it to be 'scale' units high (e.g. 3 units). + float spriteHeightUnits = trunkRenderer.sprite.rect.height / trunkRenderer.sprite.pixelsPerUnit; + float normScale = scale / spriteHeightUnits; + trunkSprite.transform.localScale = new Vector3(normScale, normScale, 1); } private Texture2D _envTexture; @@ -449,33 +458,39 @@ namespace TheIsland.Visual if (System.IO.File.Exists(path)) { byte[] data = System.IO.File.ReadAllBytes(path); - _envTexture = new Texture2D(2, 2); - _envTexture.LoadImage(data); + Texture2D sourceTex = new Texture2D(2, 2); + sourceTex.LoadImage(data); - // Phase 19-B: Fix white background transparency - ProcessTransparency(_envTexture); + // Phase 19-C: Robust transparency transcoding + _envTexture = ProcessTransparency(sourceTex); } } - private void ProcessTransparency(Texture2D tex) + private Texture2D ProcessTransparency(Texture2D source) { - if (tex == null) return; - Color[] pixels = tex.GetPixels(); - bool changed = false; + if (source == null) return null; + + // Create a new texture with Alpha channel + Texture2D tex = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false); + Color[] pixels = source.GetPixels(); + for (int i = 0; i < pixels.Length; i++) { - // If the pixel is very close to white, make it transparent - if (pixels[i].r > 0.92f && pixels[i].g > 0.92f && pixels[i].b > 0.92f) + Color p = pixels[i]; + // Chroma-key: If pixel is very close to white, make it transparent + if (p.r > 0.9f && p.g > 0.9f && p.b > 0.9f) { - pixels[i] = Color.clear; - changed = true; + pixels[i] = new Color(0, 0, 0, 0); + } + else + { + pixels[i] = new Color(p.r, p.g, p.b, 1.0f); } } - if (changed) - { - tex.SetPixels(pixels); - tex.Apply(); - } + + tex.SetPixels(pixels); + tex.Apply(); + return tex; } private Sprite CreateTreeSprite() @@ -606,7 +621,14 @@ namespace TheIsland.Visual var rockRenderer = rockObj.AddComponent(); rockRenderer.sprite = CreateRockSprite(); rockRenderer.sortingOrder = -15; - rockObj.transform.localScale = Vector3.one * scale; + + // Phase 19-C: Add Billboard + rockObj.AddComponent(); + + // Phase 19-C: Normalize scale + float spriteWidthUnits = rockRenderer.sprite.rect.width / rockRenderer.sprite.pixelsPerUnit; + float normScale = scale / spriteWidthUnits; + rockObj.transform.localScale = Vector3.one * normScale; } private Sprite CreateRockSprite()