diff --git a/Assets/Scenes/MainScene.unity b/Assets/Scenes/MainScene.unity index ff30c91..cc285dd 100644 --- a/Assets/Scenes/MainScene.unity +++ b/Assets/Scenes/MainScene.unity @@ -279,7 +279,7 @@ MonoBehaviour: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 20140502} - m_Enabled: 0 + m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} m_Name: @@ -292,7 +292,7 @@ MonoBehaviour: m_OnCullStateChanged: m_PersistentCalls: m_Calls: [] - m_Sprite: {fileID: 21300000, guid: 6065d1c1eb6981444bb481613c4cd11c, type: 3} + m_Sprite: {fileID: 21300000, guid: 627f05383f6b58640ac9b1ee9dddbbdc, type: 3} m_Type: 0 m_PreserveAspect: 0 m_FillCenter: 1 @@ -1136,7 +1136,7 @@ MonoBehaviour: m_OnCullStateChanged: m_PersistentCalls: m_Calls: [] - m_Sprite: {fileID: 21300000, guid: 32d65bdbcbb545d468277d0fb39d3c3f, type: 3} + m_Sprite: {fileID: 21300000, guid: fedb7ced60395164f8d062e2b67c1270, type: 3} m_Type: 0 m_PreserveAspect: 0 m_FillCenter: 1 @@ -1456,6 +1456,21 @@ Canvas: m_SortingLayerID: 2066495187 m_SortingOrder: 0 m_TargetDisplay: 0 +--- !u!114 &900200000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 931804142} + m_Enabled: 0 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a07b7ea3dd0eddb18928dbc7b6a75f84, type: 3} + m_Name: + m_EditorClassIdentifier: + Icons: + - AbilityName: Glamour + Icon: {fileID: 3093879478420113611, guid: 34805f69b368dbc3c97b5a7e82e46bd5, type: 3} --- !u!1 &931804142 GameObject: m_ObjectHideFlags: 0 @@ -1468,8 +1483,7 @@ GameObject: - component: {fileID: 931804143} - component: {fileID: 931804145} - component: {fileID: 931804144} - - component: - fileID: 900200000 + - component: {fileID: 900200000} m_Layer: 0 m_Name: GameManager m_TagString: Untagged @@ -1550,7 +1564,6 @@ MonoBehaviour: - {fileID: 1053176256474194, guid: 22b65ec108270314abb7a703065d9fec, type: 3} - {fileID: 1413321323189438, guid: 96c834e23d6b0cc46b4b05cabcf89b2e, type: 3} - {fileID: 1565667635693750, guid: ee4f02036f9c2554e9e9c3a84eb7a806, type: 3} - - {fileID: 1389379551073354, guid: 134b637b0dd9d2247b45c7340e74828a, type: 3} BridgeBottomH: - {fileID: 1517841448877270, guid: eec8b44ff369b324a9761878dfe42824, type: 3} ItemPrefabs: @@ -1558,6 +1571,8 @@ MonoBehaviour: - {fileID: 5477247290407607681, guid: f183d5d53b8ad3c4492f606db0653bc2, type: 3} - {fileID: 5477247290407607681, guid: 1e2e2c97a1aac214c9d3146f36032a6c, type: 3} - {fileID: 5477247290407607681, guid: 4e536bffa16801d45937752cf461f46b, type: 3} + BossPrefabs: + - {fileID: 1389379551073354, guid: 134b637b0dd9d2247b45c7340e74828a, type: 3} XPText: {fileID: 688187632} BloodfillText: {fileID: 1762789916} TimeOfDayText: {fileID: 1890321003} @@ -2186,6 +2201,11 @@ PrefabInstance: propertyPath: MausoleumH.Array.size value: 1 objectReference: {fileID: 0} + - target: {fileID: 114616681878518094, guid: f3bbbc2a9aa494c40ba34e989909e805, + type: 2} + propertyPath: BossPrefabs.Array.size + value: 1 + objectReference: {fileID: 0} - target: {fileID: 114616681878518094, guid: f3bbbc2a9aa494c40ba34e989909e805, type: 2} propertyPath: ItemPrefabs.Array.size @@ -2194,7 +2214,7 @@ PrefabInstance: - target: {fileID: 114616681878518094, guid: f3bbbc2a9aa494c40ba34e989909e805, type: 2} propertyPath: BloodPrefabs.Array.size - value: 5 + value: 4 objectReference: {fileID: 0} - target: {fileID: 114616681878518094, guid: f3bbbc2a9aa494c40ba34e989909e805, type: 2} @@ -2773,28 +2793,3 @@ SceneRoots: - {fileID: 1338412496} - {fileID: 72271895} - {fileID: 1212246474} ---- !u!114 &900200000 -MonoBehaviour: - Icons: - - AbilityName: Glamour - Icon: - fileID: 3093879478420113611 - guid: 34805f69b368dbc3c97b5a7e82e46bd5 - type: 3 - m_CorrespondingSourceObject: - fileID: 0 - m_EditorClassIdentifier: '' - m_EditorHideFlags: !!int '0' - m_Enabled: !!int '1' - m_GameObject: - fileID: 931804142 - m_Name: '' - m_ObjectHideFlags: !!int '0' - m_PrefabAsset: - fileID: 0 - m_PrefabInstance: - fileID: 0 - m_Script: - fileID: 11500000 - guid: a07b7ea3dd0eddb18928dbc7b6a75f84 - type: 3 diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs new file mode 100644 index 0000000..45ed067 --- /dev/null +++ b/Assets/Scripts/Blood/Boss.cs @@ -0,0 +1,228 @@ +namespace VampireDrama +{ + using UnityEngine; + + // A vampire-hunter boss. Reuses the Human body (collider, rigidbody, + // animations) but replaces the wandering brain with an aggressive, + // keep-distance ranged fighter that self-heals and rains telegraphed + // volleys of arrows on the player (see BossAttackController). + // + // The boss is defeated by draining it: GetResistance always keeps the + // player on the melee-drain path (never the instant-kill feed path) so its + // blood pool is chipped down over several hits. When drained it reports its + // defeat to the level, which opens the sealed exit and grants a reward. + public class Boss : Human + { + public float HealInterval = 1.5f; + public float HealAmount = 1f; + + // Movement: the boss holds a spot for a randomized dwell time, then + // takes a step. Most steps hunt the player (it is trying to kill them); + // the rest are random so it is not perfectly predictable. It never flees. + public float HoldMin = 0.8f; + public float HoldMax = 2.0f; + public float HuntChance = 0.7f; + + // The arena band the boss is confined to (inclusive y range). It stays + // put until the player enters, and never leaves the band — including up + // through the exit gap at the top. + public float ArenaMinY; + public float ArenaMaxY; + + // True once the player has entered the arena and the fight has begun. + public bool Engaged { get; private set; } + + // Set by BossAttackController while it is aiming/firing: the boss plants + // and holds position (and its facing) so it never shoots mid-step. + public bool IsAttacking; + + // Intro cutscene: the boss walks straight down to IntroTargetY without + // hunting or shooting, then holds until BeginFight() starts the fight. + public bool InIntro; + public int IntroTargetY; + public float IntroStepInterval = 0.22f; + public bool IntroArrived { get; private set; } + + private const float DeathThreshold = 1f; + + // Data-driven tuning (arena, dialogue, attack) for this encounter. Set + // before Start; the attack controller reads it from here too. + public BossDefinition Definition; + + private float lastMove; + private float moveDelay = 1f; + private float lastHealTime; + private bool defeated; + + protected override void Start() + { + base.Start(); + + if (Definition != null) + { + MaxBlood = Definition.MaxBlood; + HealAmount = Definition.HealAmount; + HealInterval = Definition.HealInterval; + HuntChance = Definition.HuntChance; + } + else + { + MaxBlood = 40f; + } + + LitresOfBlood = MaxBlood; + Suspicion = 100; // always aware of the vampire + Intoxication = 0; + Darkness = 0; + + lastMove = Time.time; + lastHealTime = Time.time; + defeated = false; + + if (GetComponent() == null) + { + gameObject.AddComponent(); + } + } + + public override float GetResistance() + { + return defeated ? 0f : 1f; + } + + public override void LoseBlood(float hitStrength, Vector3 attackerPosition) + { + base.LoseBlood(hitStrength, attackerPosition); + + if (!defeated && LitresOfBlood <= DeathThreshold) + { + defeated = true; + + var level = GameManager.GetCurrentLevel(); + if (level != null) + { + level.BossDefeated(this); + } + } + } + + // Ends the intro and switches the boss into its normal combat brain. + public void BeginFight() + { + InIntro = false; + IntroArrived = true; + Engaged = true; + } + + protected override void Brain() + { + if (defeated) return; + + if (InIntro) + { + IntroWalk(); + return; + } + + var level = GameManager.GetCurrentLevel(); + if (level == null) return; + + Vector3 me = transform.position; + Vector3 playerPos = level.GetPlayerPosition(); + + if (!Engaged) + { + // Guard the arena; only wake up once the player steps into it. + if (playerPos.y >= ArenaMinY - 0.5f) + { + Engaged = true; + } + else + { + return; + } + } + + float now = Time.time; + + SelfHeal(now); + + // hold still (and keep facing the player) while aiming/firing + if (IsAttacking) return; + + // hold the current spot for a randomized dwell, then take one step + if (now - lastMove < moveDelay) return; + lastMove = now; + moveDelay = HoldMin + Random.value * (HoldMax - HoldMin); + + Vector3 diff = playerPos - me; + + int dx = 0, dy = 0; + + // Mostly hunt the player (step toward them on the dominant axis); + // occasionally take a random step so it isn't perfectly predictable. + if (Random.value < HuntChance) + { + if (System.Math.Abs(diff.x) >= System.Math.Abs(diff.y)) + { + dx = diff.x >= 0 ? 1 : -1; + } + else + { + dy = diff.y >= 0 ? 1 : -1; + } + } + else + { + if (Random.value < 0.5f) + { + dx = Random.value < 0.5f ? -1 : 1; + } + else + { + dy = Random.value < 0.5f ? -1 : 1; + } + } + + // never leave the arena band: don't wander down into the city, and + // don't slip up through the exit gap off the top of the map + if (dy < 0 && me.y + dy < ArenaMinY) dy = 0; + if (dy > 0 && me.y + dy > ArenaMaxY) dy = 0; + + if (dx == 0 && dy == 0) return; + + RaycastHit2D hit; + Move(dx, dy, out hit); + } + + private void IntroWalk() + { + if (IntroArrived) return; + + if (Mathf.RoundToInt(transform.position.y) <= IntroTargetY) + { + IntroArrived = true; + return; + } + + float now = Time.time; + if (now - lastMove < IntroStepInterval) return; + lastMove = now; + + RaycastHit2D hit; + Move(0, -1, out hit); + } + + private void SelfHeal(float now) + { + if (defeated) return; + if (now - lastHealTime < HealInterval) return; + lastHealTime = now; + + if (LitresOfBlood < MaxBlood) + { + LitresOfBlood = System.Math.Min(MaxBlood, LitresOfBlood + HealAmount); + } + } + } +} diff --git a/Assets/Scripts/Blood/Boss.cs.meta b/Assets/Scripts/Blood/Boss.cs.meta new file mode 100644 index 0000000..fc30591 --- /dev/null +++ b/Assets/Scripts/Blood/Boss.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 27a2fc1e5c1d98c37a2d91c84637fb20 \ No newline at end of file diff --git a/Assets/Scripts/Blood/BossAttackController.cs b/Assets/Scripts/Blood/BossAttackController.cs new file mode 100644 index 0000000..16c64c3 --- /dev/null +++ b/Assets/Scripts/Blood/BossAttackController.cs @@ -0,0 +1,215 @@ +namespace VampireDrama +{ + using System.Collections.Generic; + using UnityEngine; + + // Drives the boss's area-denial attack: it aims a straight line of tiles + // toward the player, flashes a warning overlay on those tiles for + // TelegraphDelay seconds, then fires a projectile down that line. Standing + // on the line when the projectile arrives hurts a lot. Swap the projectile + // look per boss by changing the sprite in BossProjectile. + public class BossAttackController : MonoBehaviour + { + public float AttackCooldown = 3f; + public float TelegraphDelay = 0.9f; + public int Damage = 3; + public int LineLength = 6; + public float ProjectileSpeed = 8f; + + // The attack is purely ranged: it does not threaten tiles within melee + // range of the boss, so a player standing point-blank is never hit. + public int MeleeRange = 1; + + private enum Phase { Waiting, Telegraphing } + + private Phase phase = Phase.Waiting; + private float phaseStart; + private int fireX, fireY; + private Vector3 fireOrigin; + private readonly List telegraphTiles = new List(); + + private Boss boss; + + private static Sprite squareSprite; + + private void Start() + { + phaseStart = Time.time; + boss = GetComponent(); + + if (boss != null && boss.Definition != null) + { + var d = boss.Definition; + Damage = d.ArrowDamage; + AttackCooldown = d.AttackCooldown; + TelegraphDelay = d.TelegraphDelay; + LineLength = d.LineLength; + ProjectileSpeed = d.ProjectileSpeed; + MeleeRange = d.MeleeRange; + } + } + + private void Update() + { + float now = Time.time; + + if (phase == Phase.Waiting) + { + // Only attack once engaged, and never mid-step: the boss must be + // standing still so it can plant, turn, and aim (no shooting while + // it is walking away to keep its distance). + if (boss != null && (!boss.Engaged || boss.isMoving)) return; + + if (now - phaseStart >= AttackCooldown) + { + BeginTelegraph(); + } + } + else if (phase == Phase.Telegraphing) + { + PulseTelegraph(now); + + if (now - phaseStart >= TelegraphDelay) + { + Fire(); + } + } + } + + private void BeginTelegraph() + { + var level = GameManager.GetCurrentLevel(); + if (level == null) return; + + Vector3 me = transform.position; + Vector3 diff = level.GetPlayerPosition() - me; + + if (Mathf.Abs(diff.x) >= Mathf.Abs(diff.y)) + { + fireX = diff.x >= 0 ? 1 : -1; + fireY = 0; + } + else + { + fireX = 0; + fireY = diff.y >= 0 ? 1 : -1; + } + + fireOrigin = new Vector3(Mathf.Round(me.x), Mathf.Round(me.y), 0f); + + // Turn to face the player and hold position while winding up the shot. + if (boss != null) + { + boss.lastDirection = new Direction(fireX, fireY); + boss.IsAttacking = true; + } + + // only warn (and later hit) tiles beyond melee range + ClearTelegraph(); + for (int i = MeleeRange + 1; i <= LineLength; i++) + { + Vector3 tilePos = fireOrigin + new Vector3(fireX * i, fireY * i, 0f); + telegraphTiles.Add(CreateTelegraphTile(tilePos)); + } + + phase = Phase.Telegraphing; + phaseStart = Time.time; + } + + private void Fire() + { + ClearTelegraph(); + + var projObj = new GameObject("BossProjectile"); + projObj.transform.position = fireOrigin + new Vector3(fireX, fireY, 0f); + + // spawns adjacent to the boss but only arms past melee range, so it + // flies harmlessly over a point-blank player + var proj = projObj.AddComponent(); + proj.Configure(new Vector3(fireX, fireY, 0f), ProjectileSpeed, Damage, LineLength, MeleeRange); + + // shot is away; let the boss reposition again + if (boss != null) boss.IsAttacking = false; + + phase = Phase.Waiting; + phaseStart = Time.time; + } + + private void PulseTelegraph(float now) + { + float t = Mathf.Clamp01((now - phaseStart) / TelegraphDelay); + // flash faster and brighter as the shot approaches + float alpha = 0.25f + 0.45f * Mathf.Abs(Mathf.Sin(t * Mathf.PI * 5f)) * (0.5f + 0.5f * t); + + foreach (var tile in telegraphTiles) + { + if (tile == null) continue; + var sr = tile.GetComponent(); + if (sr != null) + { + var c = sr.color; + sr.color = new Color(c.r, c.g, c.b, alpha); + } + } + } + + private GameObject CreateTelegraphTile(Vector3 pos) + { + var obj = new GameObject("BossTelegraph"); + obj.transform.position = new Vector3(pos.x, pos.y, -0.05f); + + var sr = obj.AddComponent(); + sr.sprite = GetSquareSprite(); + sr.color = new Color(1f, 0.15f, 0.1f, 0.3f); + sr.material = new Material(GetUnlitShader()); + sr.sortingLayerName = "Units"; + sr.sortingOrder = 9; + + return obj; + } + + private void ClearTelegraph() + { + foreach (var tile in telegraphTiles) + { + if (tile != null) Destroy(tile); + } + telegraphTiles.Clear(); + } + + private void OnDestroy() + { + ClearTelegraph(); + } + + private static Shader GetUnlitShader() + { + var shader = Shader.Find("Universal Render Pipeline/2D/Sprite-Unlit-Default"); + if (shader == null) shader = Shader.Find("Sprites/Default"); + return shader; + } + + private static Sprite GetSquareSprite() + { + if (squareSprite != null) return squareSprite; + + int size = 16; + var texture = new Texture2D(size, size, TextureFormat.RGBA32, false); + texture.filterMode = FilterMode.Point; + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + bool border = (x < 2 || x >= size - 2 || y < 2 || y >= size - 2); + float a = border ? 1f : 0.5f; + texture.SetPixel(x, y, new Color(1f, 1f, 1f, a)); + } + } + + texture.Apply(); + squareSprite = Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), size); + return squareSprite; + } + } +} diff --git a/Assets/Scripts/Blood/BossAttackController.cs.meta b/Assets/Scripts/Blood/BossAttackController.cs.meta new file mode 100644 index 0000000..f27eff9 --- /dev/null +++ b/Assets/Scripts/Blood/BossAttackController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bf55bba9098bec8e487c40aa335a9d35 \ No newline at end of file diff --git a/Assets/Scripts/Blood/BossDefinition.cs b/Assets/Scripts/Blood/BossDefinition.cs new file mode 100644 index 0000000..65a7823 --- /dev/null +++ b/Assets/Scripts/Blood/BossDefinition.cs @@ -0,0 +1,35 @@ +namespace VampireDrama +{ + // Data describing a single boss encounter: the arena it is fought in, what + // it says on entry, and its combat/attack tuning. Plain data (no Unity + // dependency) so encounters can be authored as simple entries; see + // BossRoster for the level rosters. + public class BossDefinition + { + public string Name; + public string[] Dialog; + + // Name of the body prefab to use (matched against LevelConstruction's + // BloodPrefabs by GameObject name, e.g. "Human2"). Empty = random. + public string BodyPrefab; + + // 12x12 arena template appended on top of the city. Row 0 is nearest the + // exit; 'B' marks the boss's walk-in target. Keep the centre column + // (index 6) clear so the level stays traversable. + public string[] ArenaTemplate; + + // Boss body + public float MaxBlood = 40f; + public float HealAmount = 1f; + public float HealInterval = 1.5f; + public float HuntChance = 0.7f; + + // Area-denial (ranged) attack + public int ArrowDamage = 3; + public float AttackCooldown = 3f; + public float TelegraphDelay = 0.9f; + public int LineLength = 6; + public float ProjectileSpeed = 8f; + public int MeleeRange = 1; + } +} diff --git a/Assets/Scripts/Blood/BossDefinition.cs.meta b/Assets/Scripts/Blood/BossDefinition.cs.meta new file mode 100644 index 0000000..4a9faf2 --- /dev/null +++ b/Assets/Scripts/Blood/BossDefinition.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: db983c8a5810108c2967f7322b1a413e \ No newline at end of file diff --git a/Assets/Scripts/Blood/BossProjectile.cs b/Assets/Scripts/Blood/BossProjectile.cs new file mode 100644 index 0000000..395d632 --- /dev/null +++ b/Assets/Scripts/Blood/BossProjectile.cs @@ -0,0 +1,127 @@ +namespace VampireDrama +{ + using UnityEngine; + + // A single boss projectile (an arrow for the vampire hunter). Travels in a + // straight line and deals heavy damage to the player on contact, then dies. + // Also dies after covering its configured range so it never lingers. + public class BossProjectile : MonoBehaviour + { + private Vector3 dir; + private float speed; + private int damage; + private float maxDistance; + private float armDistance; + private Vector3 startPos; + private bool hasHit; + + private const float HitRadius = 0.5f; + + private static Sprite arrowSprite; + + public void Configure(Vector3 direction, float projectileSpeed, int projDamage, float range, float arm) + { + dir = direction.normalized; + speed = projectileSpeed; + damage = projDamage; + maxDistance = range; + armDistance = arm; + startPos = transform.position; + hasHit = false; + + var sr = gameObject.AddComponent(); + sr.sprite = GetArrowSprite(); + sr.color = new Color(0.95f, 0.9f, 0.55f, 1f); + sr.material = new Material(GetUnlitShader()); + sr.sortingLayerName = "Units"; + sr.sortingOrder = 11; + + float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg; + transform.rotation = Quaternion.Euler(0f, 0f, angle); + transform.localScale = new Vector3(0.7f, 0.7f, 1f); + } + + private void Update() + { + if (hasHit) return; + + transform.position += dir * speed * Time.deltaTime; + + // Explicitly ranged: the arrow only becomes lethal past the boss's + // melee range, so a player standing point-blank is never hit. + bool armed = (transform.position - startPos).magnitude >= armDistance; + + var level = GameManager.GetCurrentLevel(); + if (armed && level != null) + { + Vector3 playerPos = level.GetPlayerPosition(); + if ((transform.position - playerPos).sqrMagnitude <= HitRadius * HitRadius) + { + var player = level.GetPlayer(); + if (player != null) + { + player.ReceivePunch(damage); + } + + hasHit = true; + Destroy(gameObject); + return; + } + } + + if ((transform.position - startPos).magnitude >= maxDistance) + { + Destroy(gameObject); + } + } + + private static Shader GetUnlitShader() + { + var shader = Shader.Find("Universal Render Pipeline/2D/Sprite-Unlit-Default"); + if (shader == null) shader = Shader.Find("Sprites/Default"); + return shader; + } + + // A simple arrow shape pointing +X (rotated at spawn to match travel). + private static Sprite GetArrowSprite() + { + if (arrowSprite != null) return arrowSprite; + + int size = 16; + var texture = new Texture2D(size, size, TextureFormat.RGBA32, false); + texture.filterMode = FilterMode.Point; + + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + texture.SetPixel(x, y, new Color(0f, 0f, 0f, 0f)); + } + } + + int mid = size / 2; + // shaft + for (int x = 2; x < size - 4; x++) + { + texture.SetPixel(x, mid, Color.white); + texture.SetPixel(x, mid - 1, Color.white); + } + // arrowhead + for (int i = 0; i < 5; i++) + { + int x = size - 5 + i; + for (int y = mid - (4 - i); y <= mid - 1 + (4 - i); y++) + { + if (x >= 0 && x < size && y >= 0 && y < size) + { + texture.SetPixel(x, y, Color.white); + } + } + } + + texture.Apply(); + arrowSprite = Sprite.Create(texture, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f), size); + return arrowSprite; + } + } +} diff --git a/Assets/Scripts/Blood/BossProjectile.cs.meta b/Assets/Scripts/Blood/BossProjectile.cs.meta new file mode 100644 index 0000000..490d8a5 --- /dev/null +++ b/Assets/Scripts/Blood/BossProjectile.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e363d8317e60a1e878ec8a20cd306cc9 \ No newline at end of file diff --git a/Assets/Scripts/Blood/BossRoster.cs b/Assets/Scripts/Blood/BossRoster.cs new file mode 100644 index 0000000..27da3ab --- /dev/null +++ b/Assets/Scripts/Blood/BossRoster.cs @@ -0,0 +1,83 @@ +namespace VampireDrama +{ + using System.Collections.Generic; + using UnityEngine; + + // Rosters of boss encounters per level, and a random picker. Add new bosses + // by adding BossDefinition entries; PickForLevel chooses one at random. + public static class BossRoster + { + // A safe default arena: enclosed, with a centre entrance/exit gap and + // two lit pillars. 'B' is the boss's walk-in target. + private static readonly string[] StandardArena = new string[] + { + "====== =====", + "| |", + "| B |", + "| |", + "| = = |", + "| |", + "| |", + "| |", + "| = = |", + "| |", + "| |", + "====== =====", + }; + + private static readonly List Level1 = new List + { + new BossDefinition + { + Name = "Vampire Hunter", + BodyPrefab = "Human5", + Dialog = new string[] + { + "Vampire! You cannot hide from me. Prepare to be put back into the ground!" + }, + ArenaTemplate = StandardArena, + }, + new BossDefinition + { + Name = "Zealous Priest", + BodyPrefab = "Human5", + Dialog = new string[] + { + "Unholy thing! The Lord's light will burn the night from you.", + "Kneel, and be cleansed." + }, + ArenaTemplate = StandardArena, + MaxBlood = 34f, + ArrowDamage = 2, + AttackCooldown = 2.2f, + TelegraphDelay = 0.7f, + }, + new BossDefinition + { + Name = "Old Stalker", + BodyPrefab = "Human5", + Dialog = new string[] + { + "I have hunted your kind for forty years.", + "You will be no different." + }, + ArenaTemplate = StandardArena, + MaxBlood = 48f, + AttackCooldown = 3.6f, + LineLength = 7, + HuntChance = 0.85f, + }, + }; + + public static BossDefinition PickForLevel(int level) + { + // Only a level-1 roster for now; extend with per-level lists later. + var list = Level1; + if (list.Count == 0) return null; + + int idx = (int)(Random.value * list.Count); + if (idx >= list.Count) idx = list.Count - 1; + return list[idx]; + } + } +} diff --git a/Assets/Scripts/Blood/BossRoster.cs.meta b/Assets/Scripts/Blood/BossRoster.cs.meta new file mode 100644 index 0000000..bde6033 --- /dev/null +++ b/Assets/Scripts/Blood/BossRoster.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c025e27554c73e79d8340ff40b26fa90 \ No newline at end of file diff --git a/Assets/Scripts/Blood/Human.cs b/Assets/Scripts/Blood/Human.cs index 3d955e3..208cd07 100644 --- a/Assets/Scripts/Blood/Human.cs +++ b/Assets/Scripts/Blood/Human.cs @@ -48,7 +48,7 @@ public bool ForceMove(int xDir, int yDir) return Move(xDir, yDir, out hit); } - public float GetResistance() + public virtual float GetResistance() { // edge case, max draining by attack is until 1litre of blood, this human will not be able to fight back, trust me if (LitresOfBlood == 1) return 0f; @@ -85,7 +85,7 @@ public bool IsAlerted() return KnowsWhatsUp(); } - public void LoseBlood(float hitStrength, Vector3 attackerPosition) + public virtual void LoseBlood(float hitStrength, Vector3 attackerPosition) { LitresOfBlood = System.Math.Max(1, LitresOfBlood - (hitStrength * (1 + (Intoxication / 100f)))); @@ -375,6 +375,11 @@ public override void Update() base.Update(); if (isMoving) return; + Brain(); + } + + protected virtual void Brain() + { var timeNow = Time.time; if (timeNow - lastMovement < getMovementTime()) { diff --git a/Assets/Scripts/Game/AbilityUI.cs b/Assets/Scripts/Game/AbilityUI.cs index 406a0a0..91d8e48 100644 --- a/Assets/Scripts/Game/AbilityUI.cs +++ b/Assets/Scripts/Game/AbilityUI.cs @@ -14,7 +14,7 @@ public class AbilityUI : MonoBehaviour public void Start() { - var canvas = FindObjectOfType(); + var canvas = FindFirstObjectByType(); if (canvas == null) return; var font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); @@ -169,7 +169,7 @@ public void Update() if (selected.Name != currentAbilityName) { currentAbilityName = selected.Name; - if (iconSet == null) iconSet = FindObjectOfType(); + if (iconSet == null) iconSet = FindFirstObjectByType(); if (iconSet != null) { abilityIcon.sprite = iconSet.GetIcon(currentAbilityName); diff --git a/Assets/Scripts/Game/BossDialogUI.cs b/Assets/Scripts/Game/BossDialogUI.cs new file mode 100644 index 0000000..88ee47d --- /dev/null +++ b/Assets/Scripts/Game/BossDialogUI.cs @@ -0,0 +1,153 @@ +namespace VampireDrama +{ + using UnityEngine; + using UnityEngine.UI; + + // A simple bottom-of-screen dialogue box, built in code (same approach as + // AbilityUI). Show it with one or more lines; the player advances through + // them with Confirm, and Done flips true once the last line is dismissed. + public class BossDialogUI : MonoBehaviour + { + private GameObject panel; + private Text speakerText; + private Text lineText; + private Text hintText; + + private string[] lines; + private int index; + private float shownAt; + private bool showing; + + // small delay before Confirm is accepted, so a press left over from + // gameplay doesn't instantly skip a line + private const float AdvanceDebounce = 0.3f; + + public bool Done { get; private set; } + + public void Show(string speaker, string[] dialogLines) + { + if (panel == null) Build(); + if (panel == null) return; + + lines = dialogLines; + index = 0; + Done = false; + showing = true; + shownAt = Time.time; + + panel.SetActive(true); + speakerText.text = speaker; + lineText.text = (lines != null && lines.Length > 0) ? lines[0] : ""; + } + + public void Hide() + { + showing = false; + if (panel != null) panel.SetActive(false); + } + + private void Update() + { + if (!showing) return; + if (Time.time - shownAt < AdvanceDebounce) return; + + if (GameInput.GetInstance().ConfirmPressed()) + { + index++; + if (lines == null || index >= lines.Length) + { + Done = true; + showing = false; + } + else + { + lineText.text = lines[index]; + shownAt = Time.time; + } + } + } + + private void Build() + { + var canvas = FindFirstObjectByType(); + if (canvas == null) return; + + var font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + if (font == null) font = Resources.GetBuiltinResource("Arial.ttf"); + var existingText = canvas.GetComponentInChildren(); + if (existingText != null && existingText.font != null) font = existingText.font; + + // Panel anchored across the bottom of the screen + panel = CreateUIObject("BossDialogPanel", canvas.transform); + var panelRT = panel.GetComponent(); + panelRT.anchorMin = new Vector2(0.5f, 0f); + panelRT.anchorMax = new Vector2(0.5f, 0f); + panelRT.pivot = new Vector2(0.5f, 0f); + panelRT.anchoredPosition = new Vector2(0, 20); + panelRT.sizeDelta = new Vector2(560, 90); + + var panelImg = panel.AddComponent(); + panelImg.color = new Color(0f, 0f, 0f, 0.75f); + panelImg.raycastTarget = false; + + // Speaker name + var nameObj = CreateUIObject("BossDialogSpeaker", panel.transform); + var nameRT = nameObj.GetComponent(); + nameRT.anchorMin = new Vector2(0, 1); + nameRT.anchorMax = new Vector2(1, 1); + nameRT.pivot = new Vector2(0, 1); + nameRT.offsetMin = new Vector2(12, -26); + nameRT.offsetMax = new Vector2(-12, -4); + + speakerText = nameObj.AddComponent(); + speakerText.font = font; + speakerText.fontSize = 16; + speakerText.fontStyle = FontStyle.Bold; + speakerText.color = new Color(0.9f, 0.2f, 0.2f, 1f); + speakerText.alignment = TextAnchor.MiddleLeft; + speakerText.raycastTarget = false; + + // Dialogue line + var lineObj = CreateUIObject("BossDialogLine", panel.transform); + var lineRT = lineObj.GetComponent(); + lineRT.anchorMin = new Vector2(0, 0); + lineRT.anchorMax = new Vector2(1, 1); + lineRT.offsetMin = new Vector2(12, 22); + lineRT.offsetMax = new Vector2(-12, -28); + + lineText = lineObj.AddComponent(); + lineText.font = font; + lineText.fontSize = 15; + lineText.color = Color.white; + lineText.alignment = TextAnchor.UpperLeft; + lineText.horizontalOverflow = HorizontalWrapMode.Wrap; + lineText.verticalOverflow = VerticalWrapMode.Overflow; + lineText.raycastTarget = false; + + // Continue hint + var hintObj = CreateUIObject("BossDialogHint", panel.transform); + var hintRT = hintObj.GetComponent(); + hintRT.anchorMin = new Vector2(1, 0); + hintRT.anchorMax = new Vector2(1, 0); + hintRT.pivot = new Vector2(1, 0); + hintRT.offsetMin = new Vector2(-160, 4); + hintRT.offsetMax = new Vector2(-8, 20); + + hintText = hintObj.AddComponent(); + hintText.font = font; + hintText.fontSize = 11; + hintText.color = new Color(0.7f, 0.7f, 0.7f, 1f); + hintText.alignment = TextAnchor.MiddleRight; + hintText.text = "Ctrl to continue"; + hintText.raycastTarget = false; + } + + private GameObject CreateUIObject(string name, Transform parent) + { + var obj = new GameObject(name, typeof(RectTransform)); + obj.transform.SetParent(parent, false); + obj.layer = 5; // UI layer + return obj; + } + } +} diff --git a/Assets/Scripts/Game/BossDialogUI.cs.meta b/Assets/Scripts/Game/BossDialogUI.cs.meta new file mode 100644 index 0000000..3215614 --- /dev/null +++ b/Assets/Scripts/Game/BossDialogUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d66e23d26533c45deb87a2c526dfbaf7 \ No newline at end of file diff --git a/Assets/Scripts/Game/GameInput.cs b/Assets/Scripts/Game/GameInput.cs index 3157c18..4e4dcd2 100644 --- a/Assets/Scripts/Game/GameInput.cs +++ b/Assets/Scripts/Game/GameInput.cs @@ -14,6 +14,10 @@ public class GameInput private InputAction useAbilityAction; private InputAction confirmAction; + // When locked (e.g. during the boss intro cutscene) gameplay input is + // suppressed, but Confirm stays live so dialogue can be advanced. + public bool Locked; + private GameInput() { moveAction = new InputAction("Move", InputActionType.Value); @@ -69,26 +73,31 @@ public static GameInput GetInstance() public Vector2 GetMoveInput() { + if (Locked) return Vector2.zero; return moveAction.ReadValue(); } public bool JumpPressed() { + if (Locked) return false; return jumpAction.WasPressedThisFrame(); } public bool InteractPressed() { + if (Locked) return false; return interactAction.WasPressedThisFrame(); } public bool CycleAbilityPressed() { + if (Locked) return false; return cycleAbilityAction.WasPressedThisFrame(); } public bool UseAbilityPressed() { + if (Locked) return false; return useAbilityAction.WasPressedThisFrame(); } diff --git a/Assets/Scripts/Map/ChunkPresets.cs b/Assets/Scripts/Map/ChunkPresets.cs index e396067..5af117b 100644 --- a/Assets/Scripts/Map/ChunkPresets.cs +++ b/Assets/Scripts/Map/ChunkPresets.cs @@ -89,6 +89,16 @@ public ConstructionChunk GetRandomChunk12x12Template() return All12x12Templates[idxTemplate]; } + // Builds a 12x12 boss-arena chunk from text rows (supplied by a + // BossDefinition). Built in code so it does not depend on Resources + // being present. The arena is appended on top of the full-size city, so + // row 0 sits just below the exit and the bottom row opens onto the city. + // Keep the centre column (index 6) clear so the map stays traversable. + public ConstructionChunk BuildArena(string[] rows) + { + return getFromTextPreset(rows); + } + private ConstructionChunk getFromTextPreset(string[] textpreset) { var preset = new ConstructionChunk(textpreset.Length); diff --git a/Assets/Scripts/Map/ConstructionTypes.cs b/Assets/Scripts/Map/ConstructionTypes.cs index 21a1d8f..6c08b25 100644 --- a/Assets/Scripts/Map/ConstructionTypes.cs +++ b/Assets/Scripts/Map/ConstructionTypes.cs @@ -22,7 +22,8 @@ public enum ConstructionType Mansion = 8, Nightclub = 9, BridgeBottom = 10, - PoliceDepartment = 11 + PoliceDepartment = 11, + BossSpawn = 12 } public class PossibleConstruct @@ -102,6 +103,7 @@ private void Populate() all.Add(new PossibleConstruct { Ascii = 'C', Id = ConstructionType.Church, Passable = false, HasLightSource = true, Direction = ConstructHVDirection.Horizontal, IsRandomHumanSpawner = false, IsSpecialSpawner = false }); all.Add(new PossibleConstruct { Ascii = 'X', Id = ConstructionType.Dumpster, Passable = true, HasLightSource = false, Direction = ConstructHVDirection.Horizontal, IsRandomHumanSpawner = false, IsSpecialSpawner = false }); all.Add(new PossibleConstruct { Ascii = 'P', Id = ConstructionType.PoliceDepartment, Passable = false, HasLightSource = true, Direction = ConstructHVDirection.Horizontal, IsRandomHumanSpawner = false, IsSpecialSpawner = true, SpawnerCooldown = 20 }); + all.Add(new PossibleConstruct { Ascii = 'B', Id = ConstructionType.BossSpawn, Passable = true, HasLightSource = false, Direction = ConstructHVDirection.None, IsRandomHumanSpawner = false, IsSpecialSpawner = false }); } } } \ No newline at end of file diff --git a/Assets/Scripts/Map/Map.cs b/Assets/Scripts/Map/Map.cs index fa3de24..ca87f58 100644 --- a/Assets/Scripts/Map/Map.cs +++ b/Assets/Scripts/Map/Map.cs @@ -46,6 +46,12 @@ public class Map private int templateHeight = 12; private ChunkTemplates possibleTemplates; + // When set, the first chunk band generated (which ends up at the top of + // the level, next to the exit) uses the boss arena built from + // BossArenaTemplate. + public bool BossArenaAtTop = false; + public string[] BossArenaTemplate; + public Map() { possibleTemplates = new ChunkTemplates(); @@ -96,7 +102,14 @@ public void GenerateMapWithChunks() { if (line % templateHeight == 0) { - InitNewTemplatesToUse(possibleTemplates); + if (line == 0 && BossArenaAtTop) + { + InitBossArenaTemplate(possibleTemplates); + } + else + { + InitNewTemplatesToUse(possibleTemplates); + } } ConstructLineFromTemplate(); @@ -109,6 +122,12 @@ private void InitNewTemplatesToUse(ChunkTemplates templates) TemplatesToUse.Add(templates.GetRandomChunk12x12Template()); } + private void InitBossArenaTemplate(ChunkTemplates templates) + { + TemplatesToUse.Clear(); + TemplatesToUse.Add(templates.BuildArena(BossArenaTemplate)); + } + public void StartNewDynamicMap() { Clear(); diff --git a/Assets/Scripts/Map/MapExit.cs b/Assets/Scripts/Map/MapExit.cs index 8d9286b..7014674 100644 --- a/Assets/Scripts/Map/MapExit.cs +++ b/Assets/Scripts/Map/MapExit.cs @@ -9,6 +9,13 @@ public void OnTriggerEnter2D(Collider2D item) var player = item.gameObject.GetComponent(); if (player != null) { + var level = GameManager.GetCurrentLevel(); + if (level != null && level.IsExitLocked()) + { + Debug.Log("The exit is sealed until the hunter falls..."); + return; + } + Debug.Log("MapExit Triggered"); GameManager.instance.LevelComplete(); } diff --git a/Assets/Scripts/Player/VampirePlayer.cs b/Assets/Scripts/Player/VampirePlayer.cs index 40b9026..2744a12 100644 --- a/Assets/Scripts/Player/VampirePlayer.cs +++ b/Assets/Scripts/Player/VampirePlayer.cs @@ -151,15 +151,35 @@ public override void Update() nextMoveIsJump = false; } - if ((ver == 0) && (hor != 0)) - { - hitSomething = !Move(hor, ver, out hit); - lastInput = timeNow; - } - else if ((hor == 0) && (ver != 0)) + bool isJumpMove = (System.Math.Abs(hor) > 1) || (System.Math.Abs(ver) > 1); + + if (((ver == 0) && (hor != 0)) || ((hor == 0) && (ver != 0))) { - hitSomething = !Move(hor, ver, out hit); lastInput = timeNow; + + var level = GameManager.GetCurrentLevel(); + + // Grid-based melee: connect with whatever is on the tile ahead, + // even if it was mid-step, instead of relying on a point-raycast + // that whiffs on sliding colliders. + Human target = null; + if (!isJumpMove && level != null) + { + Vector3 targetTile = new Vector3( + Mathf.Round(transform.position.x) + hor, + Mathf.Round(transform.position.y) + ver, + 0f); + target = level.GetAttackTargetAt(targetTile); + } + + if (target != null) + { + Fight(target, target.gameObject, hor, ver); + } + else + { + hitSomething = !Move(hor, ver, out hit); + } } if (hitSomething) @@ -167,11 +187,11 @@ public override void Update() Transform objectHit = hit.transform; GameObject gameObjHit = objectHit.gameObject; + // Fallback melee (e.g. a jump landing on a target). No longer + // bails out when the target is moving. Human sheep = gameObjHit.GetComponent(); if (sheep != null) { - if (sheep.isMoving) return; - Fight(sheep, gameObjHit, hor, ver); } @@ -265,6 +285,9 @@ public bool Fight(Human target, GameObject obj, int hor, int ver) public void Burn(int strength) { + // no damage while frozen by a cutscene (can't move out of an aura) + if (GameInput.GetInstance().Locked) return; + Stats.Bloodfill = System.Math.Max(0, Stats.Bloodfill - strength); if (Stats.Bloodfill == 0) { @@ -276,6 +299,9 @@ public void Burn(int strength) public void ReceivePunch(int strength) { + // no damage while frozen by a cutscene (can't dodge or flee) + if (GameInput.GetInstance().Locked) return; + var defense = GetTotalDefense(); if (Random.value < defense / maxDefense) { diff --git a/Assets/Scripts/Scene/GameManager.cs b/Assets/Scripts/Scene/GameManager.cs index e200c20..126d5a6 100644 --- a/Assets/Scripts/Scene/GameManager.cs +++ b/Assets/Scripts/Scene/GameManager.cs @@ -32,7 +32,7 @@ private void InitGame() sceneScript.InitScene(level); TriggerInventoryUI(); - foreach (var old in FindObjectsOfType()) + foreach (var old in FindObjectsByType(FindObjectsSortMode.None)) { Destroy(old); } diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index 63c7783..24a74c8 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -30,6 +30,9 @@ public class LevelConstruction : MonoBehaviour public GameObject[] BloodPrefabs; public GameObject[] BridgeBottomH; public GameObject[] ItemPrefabs; + // dedicated boss body prefabs (assigned in the inspector), kept separate + // from the civilian BloodPrefabs pool + public GameObject[] BossPrefabs; private bool itemAdded; private bool canAddItemToMap; @@ -40,10 +43,18 @@ public class LevelConstruction : MonoBehaviour protected List allObjects; protected GameObject exitInstance; + protected GameObject bossInstance; + protected Vector3 bossSpawnPosition; + protected bool hasBossSpawn; + protected bool bossDefeated; + protected BossDefinition bossDefinition; + protected float tileSize = 1.0f; public Map currentMap; protected ConstructionChunk fullMap; protected int lineCount; + protected int cityHeight; + protected const int BossArenaRows = 12; protected int startAndExit; protected string startOfRandomState; @@ -67,14 +78,26 @@ public virtual void InitScene(int level) humans = new List(); var config = MapConfiguration.getInstance(); - config.Height = (level + 1) * 12; + // Full-size city, plus one extra 12-row band on top for the boss arena. + cityHeight = (level + 1) * 12; + config.Height = cityHeight + BossArenaRows; lineCount = config.Height; startAndExit = 6; + Debug.Log("[Level] level=" + level + " city=" + cityHeight + " arena=" + BossArenaRows + " total(rows)=" + config.Height); + mapWidth = config.Width; mapHeight = config.Height; + // pick which boss (and thus arena + dialogue + tuning) this level uses + bossDefinition = BossRoster.PickForLevel(level); + currentMap = new Map(); + currentMap.BossArenaAtTop = bossDefinition != null && bossDefinition.ArenaTemplate != null; + currentMap.BossArenaTemplate = (bossDefinition != null) ? bossDefinition.ArenaTemplate : null; + hasBossSpawn = false; + bossDefeated = false; + bossInstance = null; //int seed = 1; //Random.InitState(seed); @@ -97,7 +120,7 @@ public virtual void InitScene(int level) //if (Random.value >= 0.5) { canAddItemToMap = true; - shouldAddItemAtLineIdx = (int)(Random.value * lineCount); + shouldAddItemAtLineIdx = (int)(Random.value * cityHeight); } Player = Instantiate(PlayerPrefab, new Vector3(5f, 0f, 0f), Quaternion.identity) as GameObject; @@ -114,6 +137,10 @@ public virtual void InitScene(int level) { AddHuman(); } + + // The boss is not placed up front: it walks in from the exit during + // the intro cutscene (see SceneManager), triggered when the player + // reaches the arena. } public ConstructionChunk GetFullMap() @@ -128,7 +155,7 @@ public Vector2 GetExitPosition() private int getHumanCountForLevel(int level) { - return (lineCount / 6) + ((level - 1) * 2) + LevelState.GetExtraLawEnforcementCount(); + return (cityHeight / 6) + ((level - 1) * 2) + LevelState.GetExtraLawEnforcementCount(); } private GameObject GetRandomHumanTemplate() @@ -137,6 +164,37 @@ private GameObject GetRandomHumanTemplate() return BloodPrefabs[nextPick]; } + private GameObject FindPrefabByName(GameObject[] prefabs, string name) + { + if (prefabs == null) return null; + + foreach (var prefab in prefabs) + { + if (prefab != null && prefab.name == name) + { + return prefab; + } + } + + return null; + } + + // Resolves the boss's body prefab from its definition (by name): dedicated + // BossPrefabs first, then the civilian BloodPrefabs, else a random human. + private GameObject GetBossBodyTemplate() + { + if (bossDefinition != null && !string.IsNullOrEmpty(bossDefinition.BodyPrefab)) + { + var fromBossPrefabs = FindPrefabByName(BossPrefabs, bossDefinition.BodyPrefab); + if (fromBossPrefabs != null) return fromBossPrefabs; + + var fromBloodPrefabs = FindPrefabByName(BloodPrefabs, bossDefinition.BodyPrefab); + if (fromBloodPrefabs != null) return fromBloodPrefabs; + } + + return GetRandomHumanTemplate(); + } + protected bool IsSortOfTheSamePosition(Vector3 a, Vector3 b) { return ((Mathf.Abs(a.x - b.x) <= Mathf.Epsilon) && @@ -213,7 +271,8 @@ protected bool IsAreaOkForHuman(int x, int y) private Vector3 GetRandomV3() { var config = MapConfiguration.getInstance(); - return new Vector3((int)(Random.value * config.Width), (int)(Random.value * config.Height), 0); + // keep spawns in the city, not the sealed boss arena on top + return new Vector3((int)(Random.value * config.Width), (int)(Random.value * cityHeight), 0); } private void AddHuman() @@ -270,7 +329,11 @@ public float GetHeatMapAverageAtPoint(int x, int y) private GameObject GetTemplateGameObjectForConstruct(Construct construct) { - if (construct.Template.Id == ConstructionType.Road && construct.Template.Direction == ConstructHVDirection.Vertical) + if (construct.Template.Id == ConstructionType.BossSpawn) + { + return RoadCrossing[0]; + } + else if (construct.Template.Id == ConstructionType.Road && construct.Template.Direction == ConstructHVDirection.Vertical) { return RoadV[0]; } @@ -395,6 +458,12 @@ private void RenderLine(int lineIdx) var x = 0; foreach (var construct in line) { + if (construct.Template.Id == ConstructionType.BossSpawn) + { + bossSpawnPosition = new Vector3(x * tileSize, lineIdx * tileSize, 0f); + hasBossSpawn = true; + } + GameObject templateGameObject = GetTemplateGameObjectForConstruct(construct); if (templateGameObject != null) @@ -434,6 +503,12 @@ protected void ClearScene() { Destroy(Player); + if (bossInstance != null) + { + Destroy(bossInstance); + bossInstance = null; + } + foreach (var obj in humans) { Destroy(obj); @@ -485,6 +560,40 @@ public List GetHumansInRadius(Vector3 center, float radius, LayerMask blo return result; } + // Finds a meleeable target (human or boss) occupying the given tile. + // A moving target occupies the single tile it is currently closest to + // (its interpolated position rounded), so melee connects once it is at + // least halfway onto a tile and stops connecting on a tile it has + // mostly vacated. This is more forgiving than a point-raycast on the + // collider centre without letting you hit a target that has moved on. + public Human GetAttackTargetAt(Vector3 tile) + { + int tx = Mathf.RoundToInt(tile.x); + int ty = Mathf.RoundToInt(tile.y); + + // the boss is not kept in the humans list + if (bossInstance != null && OccupiesTile(bossInstance, tx, ty)) + { + return bossInstance.GetComponent(); + } + + foreach (var obj in humans) + { + if (OccupiesTile(obj, tx, ty)) + { + return obj.GetComponent(); + } + } + + return null; + } + + private bool OccupiesTile(GameObject obj, int tx, int ty) + { + return Mathf.RoundToInt(obj.transform.position.x) == tx + && Mathf.RoundToInt(obj.transform.position.y) == ty; + } + public Human GetHumanFacing(Vector3 position, int dirX, int dirY) { Vector3 target = position + new Vector3(dirX, dirY, 0); @@ -526,6 +635,111 @@ public RoyT.AStar.Position[] GetPathToPlayer(Vector3 from) } } + // Spawns the boss by reusing a normal human prefab (for its collider, + // rigidbody and animation), stripping the Human brain and dropping in + // the Boss brain in its place. Avoids needing a dedicated boss prefab. + protected Boss SpawnBossAt(Vector3 pos) + { + var template = GetBossBodyTemplate(); + var obj = Instantiate(template, pos, Quaternion.identity) as GameObject; + + LayerMask blocking = 0; + float speed = 1f; + + var human = obj.GetComponent(); + if (human != null) + { + blocking = human.blockingLayer; + speed = human.baseMoveSpeed; + DestroyImmediate(human); + } + + var boss = obj.AddComponent(); + boss.blockingLayer = blocking; + boss.baseMoveSpeed = speed; + // arena is the top 12-row band; keep the boss inside it + boss.ArenaMinY = cityHeight; + boss.ArenaMaxY = cityHeight + BossArenaRows - 1; + + bossInstance = obj; + return boss; + } + + // Spawns the boss up at the exit and starts its walk-in, stopping a bit + // above the middle of the arena. Called by the intro cutscene. + public Boss SpawnBossForIntro() + { + if (!hasBossSpawn || bossInstance != null) return null; + + Vector2 exit = GetExitPosition(); + var boss = SpawnBossAt(new Vector3(exit.x, exit.y, 0f)); + boss.Definition = bossDefinition; + boss.InIntro = true; + // walk down to wherever the 'B' marker sits in the arena template + boss.IntroTargetY = Mathf.RoundToInt(bossSpawnPosition.y); + return boss; + } + + public bool IsExitLocked() + { + return hasBossSpawn && !bossDefeated; + } + + public Vector3 GetPlayerPosition() + { + if (Player == null) return Vector3.zero; + return Player.transform.position; + } + + public VampirePlayer GetPlayer() + { + if (Player == null) return null; + return Player.GetComponent(); + } + + // Called by the Boss when it has been fully drained. Opens the sealed + // exit and rewards the player with the Recruit Ghoul ability. + public void BossDefeated(Boss boss) + { + if (bossDefeated) return; + bossDefeated = true; + + Vector3 spot = boss.transform.position; + + var abilities = GameGlobals.GetInstance().PlayerStats.Abilities; + bool alreadyHas = false; + foreach (var ability in abilities.Abilities) + { + if (ability is RecruitGhoulAbility) + { + alreadyHas = true; + break; + } + } + + if (!alreadyHas) + { + abilities.Unlock(new RecruitGhoulAbility()); + Debug.Log("[Boss] The hunter falls. Unlocked ability: Recruit Ghoul. The exit is open."); + } + else + { + Debug.Log("[Boss] The hunter falls. The exit is open."); + } + + if (bossInstance != null) + { + Destroy(bossInstance); + bossInstance = null; + } + + if (Bloodstain != null && Bloodstain.Length > 0) + { + var bloodstain = Instantiate(Bloodstain[0], spot, Quaternion.identity) as GameObject; + allObjects.Add(bloodstain); + } + } + public bool AddItem(ItemStats stats, Vector3 position) { foreach (var prefabItem in ItemPrefabs) diff --git a/Assets/Scripts/Scene/SceneManager.cs b/Assets/Scripts/Scene/SceneManager.cs index c778d15..fc4941b 100644 --- a/Assets/Scripts/Scene/SceneManager.cs +++ b/Assets/Scripts/Scene/SceneManager.cs @@ -1,9 +1,14 @@ -using UnityEngine; +using System.Collections; +using UnityEngine; using UnityEngine.UI; using VampireDrama; public class SceneManager : LevelConstruction { + private bool bossIntroStarted; + private bool cameraOverride; + private BossDialogUI dialogUI; + public Text XPText; public Text BloodfillText; public Text TimeOfDayText; @@ -46,6 +51,10 @@ public override void InitScene(int level) { base.InitScene(level); + bossIntroStarted = false; + cameraOverride = false; + GameInput.GetInstance().Locked = false; + XPRollover = new UiRollover(); XPRollover.transitionTxt = XPText; @@ -73,8 +82,19 @@ public void Update() var cameras = Camera.allCameras; if ((Player != null) && (cameras.Length > 0)) { - //cameras[0].transform.position = new Vector3(6, Player.transform.position.y, -15f); - cameras[0].transform.position = new Vector3(6 + (Player.transform.position.x - 6), Player.transform.position.y, -15f); + // the intro cutscene takes over the camera while it plays + if (!cameraOverride) + { + //cameras[0].transform.position = new Vector3(6, Player.transform.position.y, -15f); + cameras[0].transform.position = new Vector3(6 + (Player.transform.position.x - 6), Player.transform.position.y, -15f); + } + + // kick off the boss intro once the player steps into the arena + if (!bossIntroStarted && hasBossSpawn && Player.transform.position.y >= cityHeight - 0.5f) + { + bossIntroStarted = true; + StartCoroutine(BossIntroRoutine()); + } DisplayPlayerStats(); @@ -192,6 +212,13 @@ private void DisplayPlayerStats() public void Kill(Human target, GameObject obj) { + var boss = target as Boss; + if (boss != null) + { + BossDefeated(boss); + return; + } + Vector3 killspot = obj.transform.position; humans.Remove(obj); Destroy(obj); @@ -243,4 +270,71 @@ public void ApplyAuraEffectEverywhere(AuraEffect effect) var vamp = Player.GetComponent(); if (vamp != null) effect.Affect(vamp); } + + // Boss intro cutscene: pan up to the arena, walk the boss in from the exit, + // show its line, then hand control back and start the fight. + private IEnumerator BossIntroRoutine() + { + var input = GameInput.GetInstance(); + input.Locked = true; + cameraOverride = true; + + var cameras = Camera.allCameras; + var cam = (cameras.Length > 0) ? cameras[0] : null; + + // pan up so the arena is in view + Vector3 arenaCenter = new Vector3(6f, cityHeight + (BossArenaRows / 2f), -15f); + yield return PanCamera(cam, arenaCenter, 1.0f); + + // the boss walks in from the exit (bounded, so a blocked path can't hang) + var boss = SpawnBossForIntro(); + float walkTimeout = 0f; + while (boss != null && !boss.IntroArrived && walkTimeout < 10f) + { + walkTimeout += Time.deltaTime; + yield return null; + } + + // its line(s), from the chosen boss definition + if (dialogUI == null) dialogUI = gameObject.AddComponent(); + string speaker = (bossDefinition != null) ? bossDefinition.Name : "Boss"; + string[] dialog = (bossDefinition != null && bossDefinition.Dialog != null) + ? bossDefinition.Dialog + : new string[] { "..." }; + dialogUI.Show(speaker, dialog); + while (!dialogUI.Done) + { + yield return null; + } + dialogUI.Hide(); + + // hand off to the fight + if (boss != null) boss.BeginFight(); + + // pan back to the player and return control + if (cam != null && Player != null) + { + Vector3 back = new Vector3(6 + (Player.transform.position.x - 6), Player.transform.position.y, -15f); + yield return PanCamera(cam, back, 0.6f); + } + + cameraOverride = false; + input.Locked = false; + } + + private IEnumerator PanCamera(Camera cam, Vector3 target, float duration) + { + if (cam == null) yield break; + + Vector3 start = cam.transform.position; + float t = 0f; + while (t < duration) + { + t += Time.deltaTime; + float k = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(t / duration)); + cam.transform.position = Vector3.Lerp(start, target, k); + yield return null; + } + cam.transform.position = target; + } } diff --git a/Packages/manifest.json b/Packages/manifest.json index 41e4771..01b0491 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -10,10 +10,10 @@ "com.unity.ide.vscode": "1.2.4", "com.unity.inputsystem": "1.14.0", "com.unity.multiplayer.center": "1.0.1", - "com.unity.sdk.linux-x86_64": "1.0.2", + "com.unity.sdk.linux-x86_64": "1.1.0", "com.unity.test-framework": "1.6.0", "com.unity.timeline": "1.8.10", - "com.unity.toolchain.linux-x86_64-linux": "1.0.2", + "com.unity.toolchain.linux-x86_64-linux": "1.1.0", "com.unity.ugui": "2.0.0", "com.unity.xr.legacyinputhelpers": "2.1.13", "com.unity.modules.accessibility": "1.0.0", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 44d48a3..a972dc0 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -89,16 +89,16 @@ } }, "com.unity.sdk.linux-x86_64": { - "version": "1.0.2", + "version": "1.1.0", "depth": 0, "source": "registry", "dependencies": { - "com.unity.sysroot.base": "1.0.2" + "com.unity.sysroot.base": "1.1.0" }, "url": "https://packages.unity.com" }, "com.unity.sysroot.base": { - "version": "1.0.2", + "version": "1.1.0", "depth": 1, "source": "registry", "dependencies": {}, @@ -127,11 +127,11 @@ "url": "https://packages.unity.com" }, "com.unity.toolchain.linux-x86_64-linux": { - "version": "1.0.2", + "version": "1.1.0", "depth": 0, "source": "registry", "dependencies": { - "com.unity.sysroot.base": "1.0.2" + "com.unity.sysroot.base": "1.1.0" }, "url": "https://packages.unity.com" }, diff --git a/docs/mechanics.md b/docs/mechanics.md index cf21f88..f6309df 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -96,11 +96,21 @@ Per-level stats that affect gameplay: - Traversability validated via A* — regenerates if no path exists from south to north - Light sources on certain constructs reduce darkness values +## Boss Encounters + +- **Data-driven** (`BossDefinition` + `BossRoster`): each encounter is a plain-data entry with a name, body prefab (by name, resolved against the dedicated `BossPrefabs` array first, then `BloodPrefabs`, else random), dialogue lines, arena template, and combat/attack tuning (blood, heal, hunt chance, arrow damage, cooldown, telegraph, line length, projectile speed, melee range). `BossRoster.PickForLevel` chooses a **random** boss from the level's list (currently a level-1 roster: Vampire Hunter, Zealous Priest, Old Stalker). Add bosses by adding entries. +- Every level gets a fixed 12x12 **boss arena** (from the chosen boss's template) appended as an extra band **on top of the full-size city** (total height = `(level+1)*12` city + 12 arena). The arena is enclosed with a central entrance/exit gap and marked with `B` for the boss spawn (see `ConstructionType.BossSpawn`). Humans and items only spawn in the city, not the sealed arena. +- **Intro cutscene** (`SceneManager.BossIntroRoutine`): the boss is not pre-placed. When the player steps into the arena, player input is locked (`GameInput.Locked`), the camera pans up to frame the arena, the boss walks in from the exit down to the arena's `B` marker tile (`Boss.InIntro` walk), then delivers a line via `BossDialogUI` (advance with Confirm). On dismissal the boss's `BeginFight()` engages it, the camera returns to the player, and input unlocks. The player takes **no damage while input is locked** (`GameInput.Locked`), so a nearby aura (e.g. a church's holy aura at the arena entrance) can't kill a player who can't move. +- The boss is a **vampire hunter** (`Boss : Human`): reuses a human prefab's body but replaces the wandering brain. It has a large blood pool (40), **self-heals** ~1 blood every 1.5s, and **hunts** the player (mostly steps toward them, holds each spot ~0.8-2s) while planting and facing the player to fire. +- **Area-denial attack** (`BossAttackController` + `BossProjectile`): aims a straight line of tiles at the player, flashes a red warning overlay for ~0.9s, then fires an arrow down that line. A hit deals 3 damage via `ReceivePunch` (player starts each level with 10 bloodfill). It is **purely ranged**: tiles within `MeleeRange` (1) of the boss are not telegraphed and the arrow only arms past that distance, so a player standing point-blank is never hit — close in to drain it safely. Change the projectile sprite per boss to reskin the volley. +- **Defeat**: drain the boss with melee. `GetResistance` keeps the player on the drain path (never instant-kill) until the pool is emptied; `LoseBlood` then reports defeat to the level. +- The level **exit is sealed** (`IsExitLocked`) until the boss falls. Defeating it opens the exit and **unlocks the Recruit Ghoul ability** (granted once). + ## Not Yet Implemented (from GitHub issues) - Batform ability (needs sprites/animations) - Feeding restrictions (witness proximity) -- Boss encounters / arenas +- Rival-vampire / priest boss variants; arena gimmicks (hostages, trigger tiles) - Music / audio system - Environmental storytelling (books, posters, overheard conversations) - Shadow mechanics (buildings blocking sunlight)