Initial commit: Tiny Tackle Heroes Unity project

This commit is contained in:
2026-09-15 17:37:42 +09:00
commit 591fa9a826
1623 changed files with 182420 additions and 0 deletions
@@ -0,0 +1,540 @@
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.IO;
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.Spawning;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;
namespace BumpCombat.PlayModeTests
{
public sealed class StatusVisualIntegrationTests
{
private const string IgniteVisualName = "Ignite Status Visual";
private const string ShockVisualName = "Shock Status Visual";
private const string CycloneGhostName = "Cyclone Move Afterimage";
[UnityTest]
public IEnumerator CrowdControlSpirals_FollowHeadsRotatePauseAndReplaceGroggyPips()
{
yield return LoadCombatPrototype();
var director = Object.FindAnyObjectByType<SpawnDirector>();
director.enabled = false;
foreach (var old in Object.FindObjectsByType<EnemyController>(FindObjectsSortMode.None))
old.gameObject.SetActive(false);
var player = Object.FindAnyObjectByType<PlayerController>();
player.GetComponent<Rigidbody2D>().position = new Vector2(8f, -5f);
var prefabs = new List<EnemyController>();
foreach (string field in new[] { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs" })
prefabs.AddRange((EnemyController[])typeof(SpawnDirector).GetField(field, BindingFlags.NonPublic | BindingFlags.Instance).GetValue(director));
var actors = new List<EnemyController>();
var roles = new[] { RunTimedEvent.Elite, RunTimedEvent.MidBoss, RunTimedEvent.FinalBoss };
var kinds = new[] { EnemyKind.Slime, EnemyKind.Bat,
director.GetEventEnemyTuning(roles[0]).PrefabKind,
director.GetEventEnemyTuning(roles[1]).PrefabKind,
director.GetEventEnemyTuning(roles[2]).PrefabKind };
for (int i = 0; i < kinds.Length; i++)
{
var prefab = prefabs.Find(p => p != null && p.Definition.Kind == kinds[i]);
Assert.That(prefab, Is.Not.Null);
actors.Add(Object.Instantiate(prefab, new Vector3((i - 2) * 2.6f, 0f, 0f), Quaternion.identity));
if (i >= 2) actors[i].ConfigureRunEventEnemy(roles[i - 2], director.GetEventEnemyTuning(roles[i - 2]));
}
yield return new WaitForSeconds(1.5f);
for (int i = 0; i < actors.Count; i++) actors[i].TryReposition(new Vector2((i - 2) * 2.6f, 0f));
Assert.That(actors[0].ApplyStun(3f), Is.True);
Assert.That(actors[1].ApplyStun(3f), Is.True);
for (int i = 2; i < actors.Count; i++)
{
for (int contact = 0; contact < 10 && !actors[i].IsGroggy; contact++)
actors[i].RegisterArtifactContact(actors[i].ShieldColor, 987654 + contact);
Assert.That(actors[i].IsGroggy, Is.True, kinds[i].ToString());
}
Assert.That(actors[2].IsGroggy, Is.True);
yield return null;
var marker = actors[0].transform.Find("Stun Runes").GetComponent<SpriteRenderer>();
Sprite first = marker.sprite;
yield return new WaitForSeconds(.075f);
Assert.That(marker.sprite, Is.Not.SameAs(first));
actors[0].TryReposition(new Vector2(-5.4f, .1f));
yield return null;
for (int i = 0; i < actors.Count; i++)
{
var actor = actors[i];
var body = actor.GetComponent<SpriteRenderer>();
var cc = actor.transform.Find(i >= 2 ? "Groggy Spiral" : "Stun Runes").GetComponent<SpriteRenderer>();
Assert.That(cc.enabled, Is.True);
Assert.That(cc.transform.lossyScale.x, Is.EqualTo(.5f * body.transform.lossyScale.x).Within(.0001f));
Assert.That(cc.transform.lossyScale.y, Is.EqualTo(.5f * body.transform.lossyScale.y).Within(.0001f));
Assert.That(cc.sprite.texture.name, Does.Contain(i >= 2 ? "Groggy" : "Stun"));
Assert.That(Vector3.Distance(cc.transform.position, CrowdControlMarkerArt.MarkerWorldPosition(body)), Is.LessThan(.02f));
Assert.That(cc.sortingOrder, Is.EqualTo(body.sortingOrder + 100));
Assert.That(actor.transform.Find("Stagger Pips"), Is.Null);
Assert.That(actor.transform.Find("Groggy Star 0"), Is.Null);
}
Time.timeScale = 0f;
yield return null;
Sprite paused = marker.sprite;
yield return new WaitForSecondsRealtime(.12f);
Assert.That(marker.sprite, Is.SameAs(paused));
CaptureCrowdControlReview();
Time.timeScale = 1f;
actors[0].gameObject.SetActive(false);
yield return null;
Assert.That(marker == null || !marker.enabled || !marker.gameObject.activeInHierarchy, Is.True);
LogAssert.NoUnexpectedReceived();
}
private static void CaptureCrowdControlReview()
{
var host = new GameObject("CC Review Camera");
var camera = host.AddComponent<Camera>();
camera.enabled = false;
camera.orthographic = true;
camera.orthographicSize = 3.8f;
camera.aspect = 1440f / 720f;
camera.transform.position = new Vector3(0f, .3f, -10f);
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color32(31, 34, 40, 255);
camera.cullingMask = ~(1 << 5);
var renderTarget = new RenderTexture(1440, 720, 24);
renderTarget.Create();
camera.targetTexture = renderTarget;
camera.Render();
var previous = RenderTexture.active;
RenderTexture.active = renderTarget;
var snapshot = new Texture2D(1440, 720, TextureFormat.RGB24, false);
snapshot.ReadPixels(new Rect(0, 0, 1440, 720), 0, 0);
snapshot.Apply();
string folder = Path.GetFullPath(Path.Combine(Application.dataPath, "../../../../output/cc-proportional-guard-v3"));
// The optional image is local QA output; CI without this workspace only runs assertions.
if (Directory.Exists(folder)) File.WriteAllBytes(Path.Combine(folder, "runtime-review.png"), snapshot.EncodeToPNG());
RenderTexture.active = previous;
camera.targetTexture = null;
renderTarget.Release();
Object.Destroy(snapshot);
Object.Destroy(renderTarget);
Object.Destroy(host);
}
[SetUp]
public void SetUp()
{
RunManager.ForceProductionModeForTests = false;
ArtifactRewardController.GrantCatalogForTests = true;
Time.timeScale = 1f;
}
[TearDown]
public void TearDown()
{
RunManager.ForceProductionModeForTests = false;
ArtifactRewardController.GrantCatalogForTests = false;
Time.timeScale = 1f;
}
[UnityTest]
public IEnumerator EnemyStatusVisual_LoadsBothSheetsAndSharesGroundAnchor()
{
yield return LoadCombatPrototype();
EnemyController enemy = FindOrSpawnEnemy();
Assert.That(enemy == null, Is.False);
SpriteRenderer source = enemy.GetComponent<SpriteRenderer>();
Assert.That(source == null, Is.False);
enemy.transform.localScale = Vector3.one * 0.8f;
source.flipX = true;
source.flipY = false;
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
Assert.That(enemy.ApplyShock(3f, 0.2f), Is.True);
Assert.That(enemy.ApplyStun(2f), Is.True);
yield return null;
yield return null;
SpriteRenderer ignite = FindChildRenderer(enemy, IgniteVisualName);
SpriteRenderer shock = FindChildRenderer(enemy, ShockVisualName);
Assert.That(ignite == null, Is.False);
Assert.That(shock == null, Is.False);
Assert.That(ignite.sprite == null, Is.False);
Assert.That(shock.sprite == null, Is.False);
Assert.That(ignite.sprite.rect.width, Is.EqualTo(64f).Within(0.01f));
Assert.That(ignite.sprite.rect.height, Is.EqualTo(64f).Within(0.01f));
Assert.That(shock.sprite.rect.width, Is.EqualTo(64f).Within(0.01f));
Assert.That(shock.sprite.rect.height, Is.EqualTo(64f).Within(0.01f));
Assert.That(ignite.sprite.pixelsPerUnit, Is.EqualTo(32f).Within(0.01f));
Assert.That(shock.sprite.pixelsPerUnit, Is.EqualTo(32f).Within(0.01f));
Assert.That(
Vector3.Distance(
ignite.transform.position,
(Vector3)enemy.GroundAnchorPosition),
Is.LessThan(0.0001f));
Assert.That(
Vector3.Distance(
shock.transform.position,
(Vector3)enemy.GroundAnchorPosition),
Is.LessThan(0.0001f));
Assert.That(ignite.flipX, Is.EqualTo(source.flipX));
Assert.That(shock.flipX, Is.EqualTo(source.flipX));
Assert.That(ignite.sortingLayerID, Is.EqualTo(source.sortingLayerID));
Assert.That(shock.sortingLayerID, Is.EqualTo(source.sortingLayerID));
Assert.That(
ignite.sortingOrder,
Is.EqualTo(source.sortingOrder + ArtifactStatusVisual.StatusSortingOffset));
Assert.That(
shock.sortingOrder,
Is.EqualTo(source.sortingOrder + ArtifactStatusVisual.StatusSortingOffset));
Assert.That(
ignite.transform.lossyScale.x,
Is.EqualTo(source.transform.lossyScale.x).Within(0.001f));
Assert.That(
shock.transform.lossyScale.x,
Is.EqualTo(source.transform.lossyScale.x).Within(0.001f));
Assert.That(FindChildRendererCount(enemy, IgniteVisualName), Is.EqualTo(1));
Assert.That(FindChildRendererCount(enemy, ShockVisualName), Is.EqualTo(1));
Assert.That(enemy.transform.Find("Stun Runes") == null, Is.False);
}
[UnityTest]
public IEnumerator EnemyStatusVisual_FreezesDuringPauseAndAdvancesAfterResume()
{
yield return LoadCombatPrototype();
EnemyController enemy = FindOrSpawnEnemy();
Assert.That(enemy == null, Is.False);
Assert.That(enemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
yield return null;
SpriteRenderer ignite = FindChildRenderer(enemy, IgniteVisualName);
Assert.That(ignite == null, Is.False);
Sprite frameBeforePause = ignite.sprite;
Time.timeScale = 0f;
for (int i = 0; i < 8; i++)
{
yield return null;
}
Assert.That(ignite == null, Is.False);
Assert.That(ignite.sprite == frameBeforePause, Is.True);
Time.timeScale = 1f;
yield return new WaitForSeconds(0.19f);
Assert.That(ignite.sprite == frameBeforePause, Is.False);
}
[UnityTest]
public IEnumerator EnemyStatusVisual_ClearsOnExpiryDeathAndDisable()
{
yield return LoadCombatPrototype();
EnemyController expiringEnemy = FindOrSpawnEnemy();
Assert.That(expiringEnemy == null, Is.False);
Assert.That(expiringEnemy.ApplyIgnite(0.12f, 0.5f, 0.01f), Is.True);
Assert.That(expiringEnemy.ApplyShock(0.12f, 0.2f), Is.True);
yield return new WaitForSeconds(0.3f);
Assert.That(FindChildRenderer(expiringEnemy, IgniteVisualName) == null, Is.True);
Assert.That(FindChildRenderer(expiringEnemy, ShockVisualName) == null, Is.True);
EnemyController dyingEnemy = FindOrSpawnEnemy();
Assert.That(dyingEnemy == null, Is.False);
Assert.That(dyingEnemy.ApplyIgnite(3f, 0.5f, 0.01f), Is.True);
yield return null;
Assert.That(FindChildRenderer(dyingEnemy, IgniteVisualName) == null, Is.False);
Assert.That(
dyingEnemy.TryTakeDamage(
dyingEnemy.CurrentHealth + 1f,
Vector2.zero,
0f),
Is.True);
Assert.That(dyingEnemy.IsDead, Is.True);
yield return null;
Assert.That(FindChildRenderer(dyingEnemy, IgniteVisualName) == null, Is.True);
EnemyController disabledEnemy = FindOrSpawnEnemy();
Assert.That(disabledEnemy == null, Is.False);
Assert.That(disabledEnemy.ApplyShock(3f, 0.2f), Is.True);
yield return null;
disabledEnemy.gameObject.SetActive(false);
yield return null;
Assert.That(FindChildRenderer(disabledEnemy, ShockVisualName) == null, Is.True);
}
[UnityTest]
public IEnumerator CycloneGhosts_CopyPlayerSpriteAndRespectMovementPauseAndExpiry()
{
yield return LoadCombatPrototype();
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
Assert.That(player == null, Is.False);
PlayerStats stats = player.GetComponent<PlayerStats>();
ActiveArtifactController artifacts =
player.GetComponent<ActiveArtifactController>();
CombatFeedback feedback = player.GetComponent<CombatFeedback>();
SpriteRenderer playerRenderer = player.GetComponent<SpriteRenderer>();
Animator playerAnimator = player.GetComponent<Animator>();
Assert.That(stats == null, Is.False);
Assert.That(artifacts == null, Is.False);
Assert.That(feedback == null, Is.False);
Assert.That(playerRenderer == null, Is.False);
Assert.That(playerAnimator == null, Is.False);
playerAnimator.enabled = false;
Sprite expectedGhostSprite = playerRenderer.sprite;
Assert.That(expectedGhostSprite == null, Is.False);
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
ActiveArtifactDefinition cyclone = artifacts.CurrentArtifact;
FieldInfo artifactIdField = typeof(ActiveArtifactDefinition).GetField(
"artifactId",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(artifactIdField, Is.Not.Null);
string originalArtifactId = cyclone.ArtifactId;
const string testArtifactId = "cyclone-afterimage-test";
bool used;
try
{
artifactIdField.SetValue(cyclone, testArtifactId);
used = artifacts.TryUseCurrent(false);
}
finally
{
artifactIdField.SetValue(cyclone, originalArtifactId);
}
Assert.That(used, Is.True);
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.True);
Assert.That(
stats.GetStackCount(
testArtifactId + ".move-speed",
CharacterStat.MoveSpeed),
Is.GreaterThan(0));
for (int i = 0; i < 12; i++)
{
player.transform.position += Vector3.right * 0.02f;
yield return new WaitForSeconds(0.02f);
}
SpriteRenderer ghost = FindFirstRenderer(CycloneGhostName);
Assert.That(ghost == null, Is.False);
Assert.That(CountRenderers(CycloneGhostName), Is.LessThanOrEqualTo(4));
Assert.That(ghost.sprite == expectedGhostSprite, Is.True);
Assert.That(ghost.flipX, Is.EqualTo(playerRenderer.flipX));
Assert.That(ghost.flipY, Is.EqualTo(playerRenderer.flipY));
Assert.That(ghost.sortingLayerID, Is.EqualTo(playerRenderer.sortingLayerID));
Assert.That(ghost.sortingOrder, Is.EqualTo(playerRenderer.sortingOrder - 1));
Assert.That(
ghost.transform.lossyScale.x,
Is.EqualTo(player.transform.lossyScale.x).Within(0.001f));
int countBeforePause = CountRenderers(CycloneGhostName);
Time.timeScale = 0f;
player.transform.position += Vector3.right * 0.5f;
for (int i = 0; i < 8; i++)
{
yield return null;
}
Assert.That(CountRenderers(CycloneGhostName), Is.EqualTo(countBeforePause));
Time.timeScale = 1f;
int countBeforeStop = CountRenderers(CycloneGhostName);
yield return new WaitForSeconds(0.12f);
Assert.That(CountRenderers(CycloneGhostName), Is.LessThanOrEqualTo(countBeforeStop));
yield return new WaitForSeconds(3.2f);
Assert.That(CountRenderers(CycloneGhostName), Is.EqualTo(0));
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.False);
Assert.That(
stats.GetStackCount(
testArtifactId + ".move-speed",
CharacterStat.MoveSpeed),
Is.Zero);
}
[UnityTest]
public IEnumerator CycloneGhosts_ClearWhenPlayerDiesOrDisables()
{
yield return LoadCombatPrototype();
PlayerController player = Object.FindAnyObjectByType<PlayerController>();
Assert.That(player == null, Is.False);
PlayerStats stats = player.GetComponent<PlayerStats>();
ActiveArtifactController artifacts =
player.GetComponent<ActiveArtifactController>();
SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone);
Assert.That(artifacts.TryUseCurrent(false), Is.True);
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.True);
player.transform.position += Vector3.right * 0.2f;
yield return null;
Assert.That(CountRenderers(CycloneGhostName), Is.GreaterThan(0));
player.GetComponent<PlayerHealth>().TryTakeDamage(
player.GetComponent<PlayerHealth>().CurrentHealth + 1f,
Vector2.zero,
0f);
yield return null;
Assert.That(CountRenderers(CycloneGhostName), Is.EqualTo(0));
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.False);
Assert.That(stats == null, Is.False);
Assert.That(artifacts == null, Is.False);
player.gameObject.SetActive(false);
yield return null;
Assert.That(CountRenderers(CycloneGhostName), Is.EqualTo(0));
Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.False);
}
private static IEnumerator LoadCombatPrototype()
{
AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype");
while (!load.isDone)
{
yield return null;
}
yield return null;
yield return null;
yield return null;
}
private static EnemyController FindOrSpawnEnemy()
{
EnemyController[] enemies = Object.FindObjectsByType<EnemyController>(
FindObjectsInactive.Exclude,
FindObjectsSortMode.InstanceID);
foreach (EnemyController enemy in enemies)
{
if (enemy != null && !enemy.IsDead && enemy.gameObject.activeInHierarchy)
{
return enemy;
}
}
SpawnDirector director = Object.FindAnyObjectByType<SpawnDirector>();
if (director == null)
{
return null;
}
director.DebugSpawnImmediate(1);
enemies = Object.FindObjectsByType<EnemyController>(
FindObjectsInactive.Exclude,
FindObjectsSortMode.InstanceID);
foreach (EnemyController enemy in enemies)
{
if (enemy != null && !enemy.IsDead && enemy.gameObject.activeInHierarchy)
{
return enemy;
}
}
return null;
}
private static SpriteRenderer FindChildRenderer(
EnemyController enemy,
string objectName)
{
if (enemy == null)
{
return null;
}
SpriteRenderer[] renderers =
enemy.GetComponentsInChildren<SpriteRenderer>(true);
foreach (SpriteRenderer renderer in renderers)
{
if (renderer != null && renderer.gameObject.name == objectName)
{
return renderer;
}
}
return null;
}
private static int FindChildRendererCount(
EnemyController enemy,
string objectName)
{
if (enemy == null)
{
return 0;
}
int count = 0;
SpriteRenderer[] renderers =
enemy.GetComponentsInChildren<SpriteRenderer>(true);
foreach (SpriteRenderer renderer in renderers)
{
if (renderer != null && renderer.gameObject.name == objectName)
{
count++;
}
}
return count;
}
private static SpriteRenderer FindFirstRenderer(string objectName)
{
SpriteRenderer[] renderers =
Object.FindObjectsByType<SpriteRenderer>(
FindObjectsInactive.Include,
FindObjectsSortMode.None);
foreach (SpriteRenderer renderer in renderers)
{
if (renderer != null && renderer.gameObject.name == objectName)
{
return renderer;
}
}
return null;
}
private static int CountRenderers(string objectName)
{
int count = 0;
SpriteRenderer[] renderers =
Object.FindObjectsByType<SpriteRenderer>(
FindObjectsInactive.Include,
FindObjectsSortMode.None);
foreach (SpriteRenderer renderer in renderers)
{
if (renderer != null && renderer.gameObject.name == objectName)
{
count++;
}
}
return count;
}
private static void SelectArtifact(
ActiveArtifactController artifacts,
ActiveArtifactEffect effect)
{
Assert.That(artifacts.OwnedArtifactCount, Is.GreaterThanOrEqualTo(2));
for (int i = 0; i < artifacts.OwnedArtifactCount; i++)
{
if (artifacts.CurrentArtifact != null
&& artifacts.CurrentArtifact.Effect == effect)
{
return;
}
Assert.That(artifacts.SelectNext(), Is.True);
}
Assert.Fail($"Artifact {effect} was not found in the debug catalog.");
}
}
}