From 0a854c4474f1607e8178ac8d8d83329077c763ae Mon Sep 17 00:00:00 2001 From: Partouf Date: Mon, 13 Jul 2026 23:38:14 +0200 Subject: [PATCH 01/22] add vampire-hunter boss encounters with telegraphed area-denial attacks Every level now ends in a fixed 12x12 boss arena (top chunk band, marked with a new 'B' BossSpawn tile). The boss is a vampire hunter that reuses a human prefab body but swaps in a ranged, keep-distance brain: it self-heals, holds ~3 tiles away, and fires telegraphed volleys of arrows. A red warning overlay flashes on the target line, then a projectile deals heavy damage on hit. The boss is defeated by draining it (GetResistance keeps the player on the melee-drain path until the pool empties). Defeat seals-then-opens the level exit and unlocks the Recruit Ghoul ability. - Boss/BossAttackController/BossProjectile: boss brain, telegraph, projectiles - Human: GetResistance/LoseBlood made virtual; AI extracted into Brain() - ConstructionTypes/ChunkPresets/Map: BossSpawn tile + forced arena template - LevelConstruction/SceneManager: spawn boss, lock exit, reward on defeat - MapExit: respect sealed exit Arena is kept fully passable: MapTest probes the map edges at mid-height, which lands on the arena's bottom row at level 1, so edge walls there would hang generation. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs | 137 ++++++++++++++ Assets/Scripts/Blood/BossAttackController.cs | 179 +++++++++++++++++++ Assets/Scripts/Blood/BossProjectile.cs | 121 +++++++++++++ Assets/Scripts/Blood/Human.cs | 9 +- Assets/Scripts/Map/ChunkPresets.cs | 31 ++++ Assets/Scripts/Map/ConstructionTypes.cs | 4 +- Assets/Scripts/Map/Map.cs | 19 +- Assets/Scripts/Map/MapExit.cs | 7 + Assets/Scripts/Scene/LevelConstruction.cs | 118 +++++++++++- Assets/Scripts/Scene/SceneManager.cs | 7 + docs/mechanics.md | 10 +- 11 files changed, 636 insertions(+), 6 deletions(-) create mode 100644 Assets/Scripts/Blood/Boss.cs create mode 100644 Assets/Scripts/Blood/BossAttackController.cs create mode 100644 Assets/Scripts/Blood/BossProjectile.cs diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs new file mode 100644 index 0000000..3cdc966 --- /dev/null +++ b/Assets/Scripts/Blood/Boss.cs @@ -0,0 +1,137 @@ +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; + public float PreferredRange = 3f; + public float MoveInterval = 0.6f; + + private const float DeathThreshold = 1f; + + private float lastMove; + private float lastHealTime; + private bool defeated; + + protected override void Start() + { + base.Start(); + + MaxBlood = 40f; + LitresOfBlood = 40f; + 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); + } + } + } + + protected override void Brain() + { + float now = Time.time; + + SelfHeal(now); + + if (defeated) return; + if (now - lastMove < MoveInterval) return; + lastMove = now; + + var level = GameManager.GetCurrentLevel(); + if (level == null) return; + + Vector3 me = transform.position; + Vector3 diff = level.GetPlayerPosition() - me; + float dist = diff.magnitude; + + bool moveToward = dist > PreferredRange + 0.5f; + bool moveAway = dist < PreferredRange - 0.5f; + if (!moveToward && !moveAway) return; + + int sign = moveToward ? 1 : -1; + + int dx = 0, dy = 0; + if (System.Math.Abs(diff.x) >= System.Math.Abs(diff.y)) + { + dx = (diff.x >= 0 ? 1 : -1) * sign; + } + else + { + dy = (diff.y >= 0 ? 1 : -1) * sign; + } + + if (dx == 0 && dy == 0) return; + + RaycastHit2D hit; + if (!Move(dx, dy, out hit)) + { + // primary axis blocked, try the other one + if (dx != 0) + { + dx = 0; + dy = (diff.y >= 0 ? 1 : -1) * sign; + } + else + { + dy = 0; + dx = (diff.x >= 0 ? 1 : -1) * sign; + } + + if (dx != 0 || dy != 0) + { + Move(dx, dy, 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/BossAttackController.cs b/Assets/Scripts/Blood/BossAttackController.cs new file mode 100644 index 0000000..b93d2da --- /dev/null +++ b/Assets/Scripts/Blood/BossAttackController.cs @@ -0,0 +1,179 @@ +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 = 6; + public int LineLength = 6; + public float ProjectileSpeed = 8f; + + 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 static Sprite squareSprite; + + private void Start() + { + phaseStart = Time.time; + } + + private void Update() + { + float now = Time.time; + + if (phase == Phase.Waiting) + { + 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); + + ClearTelegraph(); + for (int i = 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); + + var proj = projObj.AddComponent(); + proj.Configure(new Vector3(fireX, fireY, 0f), ProjectileSpeed, Damage, LineLength); + + 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/BossProjectile.cs b/Assets/Scripts/Blood/BossProjectile.cs new file mode 100644 index 0000000..d428d00 --- /dev/null +++ b/Assets/Scripts/Blood/BossProjectile.cs @@ -0,0 +1,121 @@ +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 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) + { + dir = direction.normalized; + speed = projectileSpeed; + damage = projDamage; + maxDistance = range; + 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; + + var level = GameManager.GetCurrentLevel(); + if (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/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/Map/ChunkPresets.cs b/Assets/Scripts/Map/ChunkPresets.cs index e396067..8f83707 100644 --- a/Assets/Scripts/Map/ChunkPresets.cs +++ b/Assets/Scripts/Map/ChunkPresets.cs @@ -89,6 +89,37 @@ public ConstructionChunk GetRandomChunk12x12Template() return All12x12Templates[idxTemplate]; } + // A fixed 12x12 boss arena, built in code so it does not depend on + // Resources being present (keeps out-of-Unity map tooling working). + // Row 0 is nearest the level exit (top); the player enters from the + // bottom. 'B' marks where the boss spawns. + // + // The plaza is kept fully passable on purpose: MapTest probes the west + // and east map edges at mid-height, which lands inside this band on the + // first level, so any wall on the edge columns there would make the + // level untraversable and hang generation. An open plaza also reads + // well for the telegraphed area-denial attacks (room to dodge). + public ConstructionChunk GetBossArenaTemplate() + { + var arena = new string[] + { + " ", + " ", + " B ", + " ", + " = = ", + " ", + " ", + " ", + " = = ", + " ", + " ", + " ", + }; + + return getFromTextPreset(arena); + } + 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..77f63a1 100644 --- a/Assets/Scripts/Map/Map.cs +++ b/Assets/Scripts/Map/Map.cs @@ -46,6 +46,10 @@ 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) is replaced with the fixed boss arena. + public bool BossArenaAtTop = false; + public Map() { possibleTemplates = new ChunkTemplates(); @@ -96,7 +100,14 @@ public void GenerateMapWithChunks() { if (line % templateHeight == 0) { - InitNewTemplatesToUse(possibleTemplates); + if (line == 0 && BossArenaAtTop) + { + InitBossArenaTemplate(possibleTemplates); + } + else + { + InitNewTemplatesToUse(possibleTemplates); + } } ConstructLineFromTemplate(); @@ -109,6 +120,12 @@ private void InitNewTemplatesToUse(ChunkTemplates templates) TemplatesToUse.Add(templates.GetRandomChunk12x12Template()); } + private void InitBossArenaTemplate(ChunkTemplates templates) + { + TemplatesToUse.Clear(); + TemplatesToUse.Add(templates.GetBossArenaTemplate()); + } + 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/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index 63c7783..9cfaf9c 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -40,6 +40,11 @@ public class LevelConstruction : MonoBehaviour protected List allObjects; protected GameObject exitInstance; + protected GameObject bossInstance; + protected Vector3 bossSpawnPosition; + protected bool hasBossSpawn; + protected bool bossDefeated; + protected float tileSize = 1.0f; public Map currentMap; protected ConstructionChunk fullMap; @@ -75,6 +80,10 @@ public virtual void InitScene(int level) mapHeight = config.Height; currentMap = new Map(); + currentMap.BossArenaAtTop = true; + hasBossSpawn = false; + bossDefeated = false; + bossInstance = null; //int seed = 1; //Random.InitState(seed); @@ -114,6 +123,11 @@ public virtual void InitScene(int level) { AddHuman(); } + + if (hasBossSpawn) + { + SpawnBoss(bossSpawnPosition); + } } public ConstructionChunk GetFullMap() @@ -270,7 +284,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 +413,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 +458,12 @@ protected void ClearScene() { Destroy(Player); + if (bossInstance != null) + { + Destroy(bossInstance); + bossInstance = null; + } + foreach (var obj in humans) { Destroy(obj); @@ -526,6 +556,92 @@ 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 void SpawnBoss(Vector3 pos) + { + var template = GetRandomHumanTemplate(); + 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; + + bossInstance = obj; + } + + 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..0103c80 100644 --- a/Assets/Scripts/Scene/SceneManager.cs +++ b/Assets/Scripts/Scene/SceneManager.cs @@ -192,6 +192,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); diff --git a/docs/mechanics.md b/docs/mechanics.md index cf21f88..35f3ed9 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -96,11 +96,19 @@ 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 + +- Every level ends in a fixed 12x12 **boss arena** placed as the top chunk band (an open plaza with a few lit pillars, marked with `B` for the boss spawn — see `ConstructionType.BossSpawn`). +- 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 keeps its distance (~3 tiles) as a ranged fighter. +- **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 heavy damage (6) via `ReceivePunch`. 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) From e5692bada711d56316c603ee0d89ebe4171f302a Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:08:08 +0200 Subject: [PATCH 02/22] confine boss to its arena instead of chasing across the level The boss AI moved toward the player from level load, so it walked down out of the top arena band and met the player mid-city (~8 tiles in) instead of guarding the end of the level. Now the boss stays idle at its spawn until the player steps into the arena band (tracked via ArenaMinY, set to the top 12-row band), then engages. Its movement is clamped so it never descends below the arena, and it holds fire until the fight has begun. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs | 40 +++++++++++++++++--- Assets/Scripts/Blood/BossAttackController.cs | 6 +++ Assets/Scripts/Scene/LevelConstruction.cs | 2 + 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs index 3cdc966..07bda4f 100644 --- a/Assets/Scripts/Blood/Boss.cs +++ b/Assets/Scripts/Blood/Boss.cs @@ -18,6 +18,13 @@ public class Boss : Human public float PreferredRange = 3f; public float MoveInterval = 0.6f; + // Bottom edge (y) of the arena band. The boss guards this band: it stays + // put until the player steps into it, and never wanders below it. + public float ArenaMinY; + + // True once the player has entered the arena and the fight has begun. + public bool Engaged { get; private set; } + private const float DeathThreshold = 1f; private float lastMove; @@ -67,19 +74,35 @@ public override void LoseBlood(float hitStrength, Vector3 attackerPosition) protected override void Brain() { + if (defeated) 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); - if (defeated) return; if (now - lastMove < MoveInterval) return; lastMove = now; - var level = GameManager.GetCurrentLevel(); - if (level == null) return; - - Vector3 me = transform.position; - Vector3 diff = level.GetPlayerPosition() - me; + Vector3 diff = playerPos - me; float dist = diff.magnitude; bool moveToward = dist > PreferredRange + 0.5f; @@ -98,6 +121,9 @@ protected override void Brain() dy = (diff.y >= 0 ? 1 : -1) * sign; } + // never leave the arena band (don't chase the player back down the city) + if (dy < 0 && me.y + dy < ArenaMinY) dy = 0; + if (dx == 0 && dy == 0) return; RaycastHit2D hit; @@ -115,6 +141,8 @@ protected override void Brain() dx = (diff.x >= 0 ? 1 : -1) * sign; } + if (dy < 0 && me.y + dy < ArenaMinY) dy = 0; + if (dx != 0 || dy != 0) { Move(dx, dy, out hit); diff --git a/Assets/Scripts/Blood/BossAttackController.cs b/Assets/Scripts/Blood/BossAttackController.cs index b93d2da..6e10be2 100644 --- a/Assets/Scripts/Blood/BossAttackController.cs +++ b/Assets/Scripts/Blood/BossAttackController.cs @@ -24,11 +24,14 @@ private enum Phase { Waiting, Telegraphing } private Vector3 fireOrigin; private readonly List telegraphTiles = new List(); + private Boss boss; + private static Sprite squareSprite; private void Start() { phaseStart = Time.time; + boss = GetComponent(); } private void Update() @@ -37,6 +40,9 @@ private void Update() if (phase == Phase.Waiting) { + // only fight once the player has entered the arena + if (boss != null && !boss.Engaged) return; + if (now - phaseStart >= AttackCooldown) { BeginTelegraph(); diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index 9cfaf9c..c65dd11 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -578,6 +578,8 @@ protected void SpawnBoss(Vector3 pos) var boss = obj.AddComponent(); boss.blockingLayer = blocking; boss.baseMoveSpeed = speed; + // arena is the top 12-row band; keep the boss inside it + boss.ArenaMinY = lineCount - 12; bossInstance = obj; } From 20d2d638e582beda92d45e1de9b4274783067d02 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:22:41 +0200 Subject: [PATCH 03/22] append boss arena on top of the full city instead of replacing a band The arena was overwriting the top 12-row city band, so the playable city shrank by half (level 1 became 12 city rows instead of 24). Now the arena is an ADDITIVE extra band: total height = (level+1)*12 city + 12 arena. Level 1 is 24 city rows + 12 arena = 36 total, exit above everything. - LevelConstruction: cityHeight vs total height; humans/items/difficulty keyed to cityHeight so the sealed arena stays clean and difficulty is unchanged - ChunkPresets: arena is now enclosed (walls + central entrance/exit gap); safe because the mid-height traversability probe now lands in the city - boss ArenaMinY = cityHeight Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Map/ChunkPresets.cs | 39 ++++++++++++----------- Assets/Scripts/Scene/LevelConstruction.cs | 17 +++++++--- docs/mechanics.md | 2 +- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/Assets/Scripts/Map/ChunkPresets.cs b/Assets/Scripts/Map/ChunkPresets.cs index 8f83707..d35a03d 100644 --- a/Assets/Scripts/Map/ChunkPresets.cs +++ b/Assets/Scripts/Map/ChunkPresets.cs @@ -91,30 +91,31 @@ public ConstructionChunk GetRandomChunk12x12Template() // A fixed 12x12 boss arena, built in code so it does not depend on // Resources being present (keeps out-of-Unity map tooling working). - // Row 0 is nearest the level exit (top); the player enters from the - // bottom. 'B' marks where the boss spawns. + // It is appended as an extra band on top of the full-size city (it does + // not replace any city), so row 0 sits just below the level exit and + // row 11 opens onto the city the player climbs up from. 'B' marks the + // boss spawn. // - // The plaza is kept fully passable on purpose: MapTest probes the west - // and east map edges at mid-height, which lands inside this band on the - // first level, so any wall on the edge columns there would make the - // level untraversable and hang generation. An open plaza also reads - // well for the telegraphed area-denial attacks (room to dodge). + // Enclosed on all four sides with a one-tile gap on the centre column + // (index 6): the top gap leads to the exit, the bottom gap is the + // entrance from the city. The centre column is clear the whole way so + // the map stays traversable to the exit. public ConstructionChunk GetBossArenaTemplate() { var arena = new string[] { - " ", - " ", - " B ", - " ", - " = = ", - " ", - " ", - " ", - " = = ", - " ", - " ", - " ", + "====== =====", + "| |", + "| B |", + "| |", + "| = = |", + "| |", + "| |", + "| |", + "| = = |", + "| |", + "| |", + "====== =====", }; return getFromTextPreset(arena); diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index c65dd11..a5922bd 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -49,6 +49,8 @@ public class LevelConstruction : MonoBehaviour public Map currentMap; protected ConstructionChunk fullMap; protected int lineCount; + protected int cityHeight; + protected const int BossArenaRows = 12; protected int startAndExit; protected string startOfRandomState; @@ -72,10 +74,14 @@ 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; @@ -106,7 +112,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; @@ -142,7 +148,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() @@ -227,7 +233,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() @@ -579,7 +586,7 @@ protected void SpawnBoss(Vector3 pos) boss.blockingLayer = blocking; boss.baseMoveSpeed = speed; // arena is the top 12-row band; keep the boss inside it - boss.ArenaMinY = lineCount - 12; + boss.ArenaMinY = cityHeight; bossInstance = obj; } diff --git a/docs/mechanics.md b/docs/mechanics.md index 35f3ed9..14be8c3 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -98,7 +98,7 @@ Per-level stats that affect gameplay: ## Boss Encounters -- Every level ends in a fixed 12x12 **boss arena** placed as the top chunk band (an open plaza with a few lit pillars, marked with `B` for the boss spawn — see `ConstructionType.BossSpawn`). +- Every level gets a fixed 12x12 **boss arena** 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. - 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 keeps its distance (~3 tiles) as a ranged fighter. - **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 heavy damage (6) via `ReceivePunch`. 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. From 789bfb67a7a855c2239b652134723dcb4005d5f2 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:27:54 +0200 Subject: [PATCH 04/22] boss plants and faces the player before shooting The boss could fire while walking away to keep its distance, and shots came out regardless of which way it faced. Now the attack only begins when the boss is engaged and standing still; at that point it turns to face the player (sets facing to the shot direction) and holds position for the whole telegraph + shot via a new IsAttacking flag that suppresses movement. Once the shot is loosed it resumes repositioning. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs | 7 +++++++ Assets/Scripts/Blood/BossAttackController.cs | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs index 07bda4f..696e1a8 100644 --- a/Assets/Scripts/Blood/Boss.cs +++ b/Assets/Scripts/Blood/Boss.cs @@ -25,6 +25,10 @@ public class Boss : Human // 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; + private const float DeathThreshold = 1f; private float lastMove; @@ -99,6 +103,9 @@ protected override void Brain() SelfHeal(now); + // hold still (and keep facing the player) while aiming/firing + if (IsAttacking) return; + if (now - lastMove < MoveInterval) return; lastMove = now; diff --git a/Assets/Scripts/Blood/BossAttackController.cs b/Assets/Scripts/Blood/BossAttackController.cs index 6e10be2..ddfcc53 100644 --- a/Assets/Scripts/Blood/BossAttackController.cs +++ b/Assets/Scripts/Blood/BossAttackController.cs @@ -40,8 +40,10 @@ private void Update() if (phase == Phase.Waiting) { - // only fight once the player has entered the arena - if (boss != null && !boss.Engaged) return; + // 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) { @@ -80,6 +82,13 @@ private void BeginTelegraph() 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; + } + ClearTelegraph(); for (int i = 1; i <= LineLength; i++) { @@ -101,6 +110,9 @@ private void Fire() var proj = projObj.AddComponent(); proj.Configure(new Vector3(fireX, fireY, 0f), ProjectileSpeed, Damage, LineLength); + // shot is away; let the boss reposition again + if (boss != null) boss.IsAttacking = false; + phase = Phase.Waiting; phaseStart = Time.time; } From bda242f4524865db25a5f61ce0d51233cffdbccb Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:31:49 +0200 Subject: [PATCH 05/22] clamp boss to the top of the arena so it can't escape through the exit The boss was only clamped at the arena's bottom edge, so when it chased the player upward it walked straight through the centre exit gap and off the top of the map. Added ArenaMaxY (top row of the arena band) and clamp upward movement to it, matching the existing bottom clamp. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs | 11 ++++++++--- Assets/Scripts/Scene/LevelConstruction.cs | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs index 696e1a8..6a4a9a7 100644 --- a/Assets/Scripts/Blood/Boss.cs +++ b/Assets/Scripts/Blood/Boss.cs @@ -18,9 +18,11 @@ public class Boss : Human public float PreferredRange = 3f; public float MoveInterval = 0.6f; - // Bottom edge (y) of the arena band. The boss guards this band: it stays - // put until the player steps into it, and never wanders below it. + // 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; } @@ -128,8 +130,10 @@ protected override void Brain() dy = (diff.y >= 0 ? 1 : -1) * sign; } - // never leave the arena band (don't chase the player back down the city) + // never leave the arena band: don't chase 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; @@ -149,6 +153,7 @@ protected override void Brain() } if (dy < 0 && me.y + dy < ArenaMinY) dy = 0; + if (dy > 0 && me.y + dy > ArenaMaxY) dy = 0; if (dx != 0 || dy != 0) { diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index a5922bd..45fa7f0 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -587,6 +587,7 @@ protected void SpawnBoss(Vector3 pos) 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; } From ace64293a24e22d139bd987aa381ca0fb60e35f4 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:35:48 +0200 Subject: [PATCH 06/22] give the boss a random wander instead of fleeing to keep distance The keep-distance AI made the boss back away from the player, and with the arena clamps it just parked itself in the top exit gap. Replaced it with a random wander within the arena band: mostly random 4-directional steps, with an occasional step toward the player when it has drifted far away so it does not corner itself. It never deliberately retreats. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs | 61 +++++++++++++++++------------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs index 6a4a9a7..cbaf7bf 100644 --- a/Assets/Scripts/Blood/Boss.cs +++ b/Assets/Scripts/Blood/Boss.cs @@ -15,9 +15,14 @@ public class Boss : Human { public float HealInterval = 1.5f; public float HealAmount = 1f; - public float PreferredRange = 3f; public float MoveInterval = 0.6f; + // Wander behaviour: mostly random steps, but when it drifts far from the + // player it has a chance to step toward them so it doesn't just corner + // itself. It never deliberately flees. + public float ApproachDistance = 5f; + public float ApproachChance = 0.35f; + // 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. @@ -114,23 +119,34 @@ protected override void Brain() Vector3 diff = playerPos - me; float dist = diff.magnitude; - bool moveToward = dist > PreferredRange + 0.5f; - bool moveAway = dist < PreferredRange - 0.5f; - if (!moveToward && !moveAway) return; - - int sign = moveToward ? 1 : -1; - int dx = 0, dy = 0; - if (System.Math.Abs(diff.x) >= System.Math.Abs(diff.y)) + + // If it has wandered far, sometimes close in; otherwise wander at + // random. It never steps away to keep distance. + if (dist > ApproachDistance && Random.value < ApproachChance) { - dx = (diff.x >= 0 ? 1 : -1) * sign; + 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 { - dy = (diff.y >= 0 ? 1 : -1) * sign; + 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 chase down into the city, and + // 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; @@ -138,28 +154,7 @@ protected override void Brain() if (dx == 0 && dy == 0) return; RaycastHit2D hit; - if (!Move(dx, dy, out hit)) - { - // primary axis blocked, try the other one - if (dx != 0) - { - dx = 0; - dy = (diff.y >= 0 ? 1 : -1) * sign; - } - else - { - dy = 0; - dx = (diff.x >= 0 ? 1 : -1) * sign; - } - - if (dy < 0 && me.y + dy < ArenaMinY) dy = 0; - if (dy > 0 && me.y + dy > ArenaMaxY) dy = 0; - - if (dx != 0 || dy != 0) - { - Move(dx, dy, out hit); - } - } + Move(dx, dy, out hit); } private void SelfHeal(float now) From 304118d807ee9aafd5162bd5d31ba2b46ed5b2f9 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:40:18 +0200 Subject: [PATCH 07/22] make the boss hunt the player and hold spots longer Pure random wander felt aimless. The boss now holds each spot for a randomized dwell (0.8-2.0s) then takes a single step; most steps hunt the player on the dominant axis so it presses the attack, with an occasional random step to stay unpredictable. Combined with the aim-and-plant shooting, it reads as actively trying to corner and kill the player. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs index cbaf7bf..e1e7ab9 100644 --- a/Assets/Scripts/Blood/Boss.cs +++ b/Assets/Scripts/Blood/Boss.cs @@ -15,13 +15,13 @@ public class Boss : Human { public float HealInterval = 1.5f; public float HealAmount = 1f; - public float MoveInterval = 0.6f; - // Wander behaviour: mostly random steps, but when it drifts far from the - // player it has a chance to step toward them so it doesn't just corner - // itself. It never deliberately flees. - public float ApproachDistance = 5f; - public float ApproachChance = 0.35f; + // 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 @@ -39,6 +39,7 @@ public class Boss : Human private const float DeathThreshold = 1f; private float lastMove; + private float moveDelay = 1f; private float lastHealTime; private bool defeated; @@ -113,17 +114,18 @@ protected override void Brain() // hold still (and keep facing the player) while aiming/firing if (IsAttacking) return; - if (now - lastMove < MoveInterval) 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; - float dist = diff.magnitude; int dx = 0, dy = 0; - // If it has wandered far, sometimes close in; otherwise wander at - // random. It never steps away to keep distance. - if (dist > ApproachDistance && Random.value < ApproachChance) + // 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)) { From d743eff1cbbee2b5373454244fefccc5ca26e0d3 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:48:17 +0200 Subject: [PATCH 08/22] fix melee whiffing on moving humans/boss Two bugs made attacks miss a target that was mid-step even when it visually overlapped the player: - the player bailed out entirely (`if (sheep.isMoving) return;`) when the target was moving - target detection was a zero-length raycast at the tile centre, which whiffs while a target's collider is sliding between tiles Replaced combat target detection with a movement-aware grid lookup (GetAttackTargetAt): a target counts if the tile ahead matches either the tile it is stepping from or to, so melee connects anywhere along its step. The lookup also covers the boss, which isn't in the humans list. Added MovingObject.GetDestinationPosition to expose the step's destination tile. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Player/MovingObject.cs | 16 ++++++++ Assets/Scripts/Player/VampirePlayer.cs | 38 ++++++++++++++----- Assets/Scripts/Scene/LevelConstruction.cs | 45 +++++++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/Player/MovingObject.cs b/Assets/Scripts/Player/MovingObject.cs index 153997d..e23f0f3 100644 --- a/Assets/Scripts/Player/MovingObject.cs +++ b/Assets/Scripts/Player/MovingObject.cs @@ -55,6 +55,22 @@ public Vector2 GetOriginalPosition() } } + // Tile this object is heading to (its destination while moving, or its + // current tile when standing still). Combined with GetOriginalPosition + // this gives both tiles a moving object overlaps, so grid-based combat + // can hit it anywhere along its step instead of only dead centre. + public Vector2 GetDestinationPosition() + { + if (isMoving) + { + return moveTo; + } + else + { + return transform.position; + } + } + protected virtual bool IsSomethingThere(Vector2 start, Vector2 end, out RaycastHit2D hit) { boxCollider.enabled = false; diff --git a/Assets/Scripts/Player/VampirePlayer.cs b/Assets/Scripts/Player/VampirePlayer.cs index 40b9026..17731df 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); } diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index 45fa7f0..ae8b436 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -522,6 +522,51 @@ public List GetHumansInRadius(Vector3 center, float radius, LayerMask blo return result; } + // Finds a meleeable target (human or boss) occupying the given tile, + // accounting for movement: a target counts if the tile matches either + // the tile it is stepping from or the tile it is stepping to. This lets + // melee connect with a target anywhere along its step, instead of only + // when a point-raycast happens to land on its collider. + 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) + { + var mover = obj.GetComponent(); + if (mover != null) + { + var from = mover.GetOriginalPosition(); + var to = mover.GetDestinationPosition(); + + if (Mathf.RoundToInt(from.x) == tx && Mathf.RoundToInt(from.y) == ty) return true; + if (Mathf.RoundToInt(to.x) == tx && Mathf.RoundToInt(to.y) == ty) return true; + + return false; + } + + 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); From 76d26009111c695338843bf023559d655ced3efa Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:50:24 +0200 Subject: [PATCH 09/22] replace deprecated FindObjectOfType/FindObjectsOfType calls Unity 6 deprecated these. Use FindObjectsByType(FindObjectsSortMode.None) where order is irrelevant (destroying all AbilityUI instances) and FindFirstObjectByType for the single-instance lookups (Canvas, AbilityIconSet). Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Game/AbilityUI.cs | 4 ++-- Assets/Scripts/Scene/GameManager.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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); } From e98e2c5125d4c4ff469de2ea157023a72f45b759 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 02:54:55 +0200 Subject: [PATCH 10/22] lower boss arrow damage from 6 to 3 At 6 damage an arrow took 60% of the player's starting 10 bloodfill and two hits was a game over before feeding. 3 keeps it threatening but survivable. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/BossAttackController.cs | 2 +- docs/mechanics.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Blood/BossAttackController.cs b/Assets/Scripts/Blood/BossAttackController.cs index ddfcc53..0586bcf 100644 --- a/Assets/Scripts/Blood/BossAttackController.cs +++ b/Assets/Scripts/Blood/BossAttackController.cs @@ -12,7 +12,7 @@ public class BossAttackController : MonoBehaviour { public float AttackCooldown = 3f; public float TelegraphDelay = 0.9f; - public int Damage = 6; + public int Damage = 3; public int LineLength = 6; public float ProjectileSpeed = 8f; diff --git a/docs/mechanics.md b/docs/mechanics.md index 14be8c3..5788e38 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -100,7 +100,7 @@ Per-level stats that affect gameplay: - Every level gets a fixed 12x12 **boss arena** 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. - 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 keeps its distance (~3 tiles) as a ranged fighter. -- **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 heavy damage (6) via `ReceivePunch`. Change the projectile sprite per boss to reskin the volley. +- **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). 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). From 07aa44247aac597fddc9b89b99eb213ef160793f Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 03:01:59 +0200 Subject: [PATCH 11/22] melee targets the tile a mover is closest to, not the one it left The from-or-to occupancy check counted a mover's origin tile as occupied for the whole step, so you could still hit the boss on the tile it had almost fully left (e.g. both moving up, you stepping into where it just stood). Now a mover occupies the single tile its interpolated position rounds to: melee connects once it is at least halfway onto a tile and stops on a tile it has mostly vacated. Removed the now-unused GetDestinationPosition accessor. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Player/MovingObject.cs | 16 ---------------- Assets/Scripts/Scene/LevelConstruction.cs | 23 ++++++----------------- 2 files changed, 6 insertions(+), 33 deletions(-) diff --git a/Assets/Scripts/Player/MovingObject.cs b/Assets/Scripts/Player/MovingObject.cs index e23f0f3..153997d 100644 --- a/Assets/Scripts/Player/MovingObject.cs +++ b/Assets/Scripts/Player/MovingObject.cs @@ -55,22 +55,6 @@ public Vector2 GetOriginalPosition() } } - // Tile this object is heading to (its destination while moving, or its - // current tile when standing still). Combined with GetOriginalPosition - // this gives both tiles a moving object overlaps, so grid-based combat - // can hit it anywhere along its step instead of only dead centre. - public Vector2 GetDestinationPosition() - { - if (isMoving) - { - return moveTo; - } - else - { - return transform.position; - } - } - protected virtual bool IsSomethingThere(Vector2 start, Vector2 end, out RaycastHit2D hit) { boxCollider.enabled = false; diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index ae8b436..1d0415d 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -522,11 +522,12 @@ public List GetHumansInRadius(Vector3 center, float radius, LayerMask blo return result; } - // Finds a meleeable target (human or boss) occupying the given tile, - // accounting for movement: a target counts if the tile matches either - // the tile it is stepping from or the tile it is stepping to. This lets - // melee connect with a target anywhere along its step, instead of only - // when a point-raycast happens to land on its collider. + // 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); @@ -551,18 +552,6 @@ public Human GetAttackTargetAt(Vector3 tile) private bool OccupiesTile(GameObject obj, int tx, int ty) { - var mover = obj.GetComponent(); - if (mover != null) - { - var from = mover.GetOriginalPosition(); - var to = mover.GetDestinationPosition(); - - if (Mathf.RoundToInt(from.x) == tx && Mathf.RoundToInt(from.y) == ty) return true; - if (Mathf.RoundToInt(to.x) == tx && Mathf.RoundToInt(to.y) == ty) return true; - - return false; - } - return Mathf.RoundToInt(obj.transform.position.x) == tx && Mathf.RoundToInt(obj.transform.position.y) == ty; } From 5d0272190c452e4cea4cd1927f8fccf06f311f17 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 03:30:35 +0200 Subject: [PATCH 12/22] add boss intro cutscene: pan, walk-in, dialogue, then fight When the player enters the arena, play a scripted intro before the fight: - lock player input (GameInput.Locked) while leaving Confirm live for dialogue - pan the camera up to frame the arena (SceneManager owns the camera during the cutscene; normal follow resumes after) - the boss is no longer pre-placed; it spawns at the exit and walks in (Boss.InIntro) to just above the arena centre - show the boss's line in a code-generated dialogue box (BossDialogUI), advanced with Confirm - Boss.BeginFight() then engages the normal hunt brain + attacks, camera pans back to the player, input unlocks SpawnBoss refactored to SpawnBossAt (returns the boss) + SpawnBossForIntro. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs | 39 ++++++ Assets/Scripts/Game/BossDialogUI.cs | 153 ++++++++++++++++++++++ Assets/Scripts/Game/GameInput.cs | 9 ++ Assets/Scripts/Scene/LevelConstruction.cs | 23 +++- Assets/Scripts/Scene/SceneManager.cs | 92 ++++++++++++- docs/mechanics.md | 3 +- 6 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 Assets/Scripts/Game/BossDialogUI.cs diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs index e1e7ab9..aa67358 100644 --- a/Assets/Scripts/Blood/Boss.cs +++ b/Assets/Scripts/Blood/Boss.cs @@ -36,6 +36,13 @@ public class Boss : Human // 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; private float lastMove; @@ -84,10 +91,24 @@ public override void LoseBlood(float hitStrength, Vector3 attackerPosition) } } + // 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; @@ -159,6 +180,24 @@ protected override void Brain() 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; 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/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/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index 1d0415d..e3c980e 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -130,10 +130,9 @@ public virtual void InitScene(int level) AddHuman(); } - if (hasBossSpawn) - { - SpawnBoss(bossSpawnPosition); - } + // 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() @@ -600,7 +599,7 @@ 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 void SpawnBoss(Vector3 pos) + protected Boss SpawnBossAt(Vector3 pos) { var template = GetRandomHumanTemplate(); var obj = Instantiate(template, pos, Quaternion.identity) as GameObject; @@ -624,6 +623,20 @@ protected void SpawnBoss(Vector3 pos) 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.InIntro = true; + boss.IntroTargetY = cityHeight + (BossArenaRows / 2) + 1; + return boss; } public bool IsExitLocked() diff --git a/Assets/Scripts/Scene/SceneManager.cs b/Assets/Scripts/Scene/SceneManager.cs index 0103c80..a26ea8f 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(); @@ -250,4 +270,70 @@ 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 + if (dialogUI == null) dialogUI = gameObject.AddComponent(); + dialogUI.Show("Vampire Hunter", new string[] + { + "Vampire! You cannot hide from me. Prepare to be put back into the ground!" + }); + 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/docs/mechanics.md b/docs/mechanics.md index 5788e38..1e9e40a 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -99,7 +99,8 @@ Per-level stats that affect gameplay: ## Boss Encounters - Every level gets a fixed 12x12 **boss arena** 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. -- 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 keeps its distance (~3 tiles) as a ranged fighter. +- **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 just above the arena centre (`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 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). 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). From 2e13398c683f08c9ccf7f2ead48b24d8b2c78d9e Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 03:37:52 +0200 Subject: [PATCH 13/22] make the boss arrow attack purely ranged with a melee blind spot The area-denial volley could hit a point-blank player. Now it has a melee blind spot: tiles within MeleeRange (1) of the boss are not telegraphed, and the arrow only arms past that distance, so it flies harmlessly over a player standing in the boss's melee range. Closing to drain the boss is now safe from arrows. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/BossAttackController.cs | 11 +++++++++-- Assets/Scripts/Blood/BossProjectile.cs | 10 ++++++++-- docs/mechanics.md | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/Blood/BossAttackController.cs b/Assets/Scripts/Blood/BossAttackController.cs index 0586bcf..258e700 100644 --- a/Assets/Scripts/Blood/BossAttackController.cs +++ b/Assets/Scripts/Blood/BossAttackController.cs @@ -16,6 +16,10 @@ public class BossAttackController : MonoBehaviour 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; @@ -89,8 +93,9 @@ private void BeginTelegraph() boss.IsAttacking = true; } + // only warn (and later hit) tiles beyond melee range ClearTelegraph(); - for (int i = 1; i <= LineLength; i++) + for (int i = MeleeRange + 1; i <= LineLength; i++) { Vector3 tilePos = fireOrigin + new Vector3(fireX * i, fireY * i, 0f); telegraphTiles.Add(CreateTelegraphTile(tilePos)); @@ -107,8 +112,10 @@ private void Fire() 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); + 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; diff --git a/Assets/Scripts/Blood/BossProjectile.cs b/Assets/Scripts/Blood/BossProjectile.cs index d428d00..395d632 100644 --- a/Assets/Scripts/Blood/BossProjectile.cs +++ b/Assets/Scripts/Blood/BossProjectile.cs @@ -11,6 +11,7 @@ public class BossProjectile : MonoBehaviour private float speed; private int damage; private float maxDistance; + private float armDistance; private Vector3 startPos; private bool hasHit; @@ -18,12 +19,13 @@ public class BossProjectile : MonoBehaviour private static Sprite arrowSprite; - public void Configure(Vector3 direction, float projectileSpeed, int projDamage, float range) + 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; @@ -45,8 +47,12 @@ private void Update() 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 (level != null) + if (armed && level != null) { Vector3 playerPos = level.GetPlayerPosition(); if ((transform.position - playerPos).sqrMagnitude <= HitRadius * HitRadius) diff --git a/docs/mechanics.md b/docs/mechanics.md index 1e9e40a..a1a1119 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -101,7 +101,7 @@ Per-level stats that affect gameplay: - Every level gets a fixed 12x12 **boss arena** 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 just above the arena centre (`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 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). Change the projectile sprite per boss to reskin the volley. +- **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). From 75a385fc53e1cd75c46e62225e5484b300214b69 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 03:41:04 +0200 Subject: [PATCH 14/22] add Unity .meta files for the new boss scripts Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs.meta | 2 ++ Assets/Scripts/Blood/BossAttackController.cs.meta | 2 ++ Assets/Scripts/Blood/BossProjectile.cs.meta | 2 ++ Assets/Scripts/Game/BossDialogUI.cs.meta | 2 ++ 4 files changed, 8 insertions(+) create mode 100644 Assets/Scripts/Blood/Boss.cs.meta create mode 100644 Assets/Scripts/Blood/BossAttackController.cs.meta create mode 100644 Assets/Scripts/Blood/BossProjectile.cs.meta create mode 100644 Assets/Scripts/Game/BossDialogUI.cs.meta 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.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/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/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 From 14ae30b0d8d26b23f3faff66551e8213ef43d028 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 03:52:49 +0200 Subject: [PATCH 15/22] boss walks in to the 'B' marker tile from the template IntroTargetY now uses the recorded 'B' tile position instead of a computed mid-arena value, so the arena template controls where the boss ends up. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Scene/LevelConstruction.cs | 3 ++- docs/mechanics.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index e3c980e..170c9b3 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -635,7 +635,8 @@ public Boss SpawnBossForIntro() Vector2 exit = GetExitPosition(); var boss = SpawnBossAt(new Vector3(exit.x, exit.y, 0f)); boss.InIntro = true; - boss.IntroTargetY = cityHeight + (BossArenaRows / 2) + 1; + // walk down to wherever the 'B' marker sits in the arena template + boss.IntroTargetY = Mathf.RoundToInt(bossSpawnPosition.y); return boss; } diff --git a/docs/mechanics.md b/docs/mechanics.md index a1a1119..d93867c 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -99,7 +99,7 @@ Per-level stats that affect gameplay: ## Boss Encounters - Every level gets a fixed 12x12 **boss arena** 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 just above the arena centre (`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. +- **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 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. From bf4f800f75106229ed0322d6d7eb5e14e11cc5a5 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 03:59:44 +0200 Subject: [PATCH 16/22] make boss encounters data-driven with a random roster Introduce BossDefinition (name, dialogue, arena template, and combat/attack tuning) and BossRoster, which picks a random boss for the level from a list (currently a level-1 roster: Vampire Hunter, Zealous Priest, Old Stalker). The chosen definition drives everything: its arena template feeds map generation (ChunkTemplates.BuildArena from supplied rows), its stats configure the Boss body and BossAttackController, and its lines drive the intro dialogue. Add or tweak encounters by editing data, no logic changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/Boss.cs | 19 ++++- Assets/Scripts/Blood/BossAttackController.cs | 11 +++ Assets/Scripts/Blood/BossDefinition.cs | 31 ++++++++ Assets/Scripts/Blood/BossRoster.cs | 80 ++++++++++++++++++++ Assets/Scripts/Map/ChunkPresets.cs | 36 ++------- Assets/Scripts/Map/Map.cs | 6 +- Assets/Scripts/Scene/LevelConstruction.cs | 8 +- Assets/Scripts/Scene/SceneManager.cs | 11 +-- docs/mechanics.md | 3 +- 9 files changed, 165 insertions(+), 40 deletions(-) create mode 100644 Assets/Scripts/Blood/BossDefinition.cs create mode 100644 Assets/Scripts/Blood/BossRoster.cs diff --git a/Assets/Scripts/Blood/Boss.cs b/Assets/Scripts/Blood/Boss.cs index aa67358..45ed067 100644 --- a/Assets/Scripts/Blood/Boss.cs +++ b/Assets/Scripts/Blood/Boss.cs @@ -45,6 +45,10 @@ public class Boss : Human 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; @@ -54,8 +58,19 @@ protected override void Start() { base.Start(); - MaxBlood = 40f; - LitresOfBlood = 40f; + 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; diff --git a/Assets/Scripts/Blood/BossAttackController.cs b/Assets/Scripts/Blood/BossAttackController.cs index 258e700..16c64c3 100644 --- a/Assets/Scripts/Blood/BossAttackController.cs +++ b/Assets/Scripts/Blood/BossAttackController.cs @@ -36,6 +36,17 @@ 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() diff --git a/Assets/Scripts/Blood/BossDefinition.cs b/Assets/Scripts/Blood/BossDefinition.cs new file mode 100644 index 0000000..e32e828 --- /dev/null +++ b/Assets/Scripts/Blood/BossDefinition.cs @@ -0,0 +1,31 @@ +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; + + // 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/BossRoster.cs b/Assets/Scripts/Blood/BossRoster.cs new file mode 100644 index 0000000..d7f50af --- /dev/null +++ b/Assets/Scripts/Blood/BossRoster.cs @@ -0,0 +1,80 @@ +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", + Dialog = new string[] + { + "Vampire! You cannot hide from me. Prepare to be put back into the ground!" + }, + ArenaTemplate = StandardArena, + }, + new BossDefinition + { + Name = "Zealous Priest", + 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", + 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/Map/ChunkPresets.cs b/Assets/Scripts/Map/ChunkPresets.cs index d35a03d..5af117b 100644 --- a/Assets/Scripts/Map/ChunkPresets.cs +++ b/Assets/Scripts/Map/ChunkPresets.cs @@ -89,36 +89,14 @@ public ConstructionChunk GetRandomChunk12x12Template() return All12x12Templates[idxTemplate]; } - // A fixed 12x12 boss arena, built in code so it does not depend on - // Resources being present (keeps out-of-Unity map tooling working). - // It is appended as an extra band on top of the full-size city (it does - // not replace any city), so row 0 sits just below the level exit and - // row 11 opens onto the city the player climbs up from. 'B' marks the - // boss spawn. - // - // Enclosed on all four sides with a one-tile gap on the centre column - // (index 6): the top gap leads to the exit, the bottom gap is the - // entrance from the city. The centre column is clear the whole way so - // the map stays traversable to the exit. - public ConstructionChunk GetBossArenaTemplate() + // 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) { - var arena = new string[] - { - "====== =====", - "| |", - "| B |", - "| |", - "| = = |", - "| |", - "| |", - "| |", - "| = = |", - "| |", - "| |", - "====== =====", - }; - - return getFromTextPreset(arena); + return getFromTextPreset(rows); } private ConstructionChunk getFromTextPreset(string[] textpreset) diff --git a/Assets/Scripts/Map/Map.cs b/Assets/Scripts/Map/Map.cs index 77f63a1..ca87f58 100644 --- a/Assets/Scripts/Map/Map.cs +++ b/Assets/Scripts/Map/Map.cs @@ -47,8 +47,10 @@ public class Map private ChunkTemplates possibleTemplates; // When set, the first chunk band generated (which ends up at the top of - // the level, next to the exit) is replaced with the fixed boss arena. + // the level, next to the exit) uses the boss arena built from + // BossArenaTemplate. public bool BossArenaAtTop = false; + public string[] BossArenaTemplate; public Map() { @@ -123,7 +125,7 @@ private void InitNewTemplatesToUse(ChunkTemplates templates) private void InitBossArenaTemplate(ChunkTemplates templates) { TemplatesToUse.Clear(); - TemplatesToUse.Add(templates.GetBossArenaTemplate()); + TemplatesToUse.Add(templates.BuildArena(BossArenaTemplate)); } public void StartNewDynamicMap() diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index 170c9b3..35771f7 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -44,6 +44,7 @@ public class LevelConstruction : MonoBehaviour protected Vector3 bossSpawnPosition; protected bool hasBossSpawn; protected bool bossDefeated; + protected BossDefinition bossDefinition; protected float tileSize = 1.0f; public Map currentMap; @@ -85,8 +86,12 @@ public virtual void InitScene(int level) 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 = true; + currentMap.BossArenaAtTop = bossDefinition != null && bossDefinition.ArenaTemplate != null; + currentMap.BossArenaTemplate = (bossDefinition != null) ? bossDefinition.ArenaTemplate : null; hasBossSpawn = false; bossDefeated = false; bossInstance = null; @@ -634,6 +639,7 @@ public Boss SpawnBossForIntro() 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); diff --git a/Assets/Scripts/Scene/SceneManager.cs b/Assets/Scripts/Scene/SceneManager.cs index a26ea8f..fc4941b 100644 --- a/Assets/Scripts/Scene/SceneManager.cs +++ b/Assets/Scripts/Scene/SceneManager.cs @@ -295,12 +295,13 @@ private IEnumerator BossIntroRoutine() yield return null; } - // its line + // its line(s), from the chosen boss definition if (dialogUI == null) dialogUI = gameObject.AddComponent(); - dialogUI.Show("Vampire Hunter", new string[] - { - "Vampire! You cannot hide from me. Prepare to be put back into the ground!" - }); + 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; diff --git a/docs/mechanics.md b/docs/mechanics.md index d93867c..8b03134 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -98,7 +98,8 @@ Per-level stats that affect gameplay: ## Boss Encounters -- Every level gets a fixed 12x12 **boss arena** 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. +- **Data-driven** (`BossDefinition` + `BossRoster`): each encounter is a plain-data entry with a name, 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 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. From 8f4c41a9986dd79d0e9bcb3a6db51b7629cc7416 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 04:07:39 +0200 Subject: [PATCH 17/22] make the player invulnerable while frozen by a cutscene A church's holy aura (HolyAura on ChurchH) at the arena entrance could burn the player to death during the boss intro, because the player is frozen and can't step out of it. Burn and ReceivePunch now no-op while GameInput.Locked, so no aura, sun, or attack can kill a player who has no way to react. Normal damage resumes as soon as control returns. Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Player/VampirePlayer.cs | 6 ++++++ docs/mechanics.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/Player/VampirePlayer.cs b/Assets/Scripts/Player/VampirePlayer.cs index 17731df..2744a12 100644 --- a/Assets/Scripts/Player/VampirePlayer.cs +++ b/Assets/Scripts/Player/VampirePlayer.cs @@ -285,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) { @@ -296,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/docs/mechanics.md b/docs/mechanics.md index 8b03134..d259aa9 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -100,7 +100,7 @@ Per-level stats that affect gameplay: - **Data-driven** (`BossDefinition` + `BossRoster`): each encounter is a plain-data entry with a name, 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. +- **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. From 4b37e7a1b886cb838b2d9777339aa491bb1677e1 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 04:11:42 +0200 Subject: [PATCH 18/22] add Unity .meta files for BossDefinition and BossRoster Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/BossDefinition.cs.meta | 2 ++ Assets/Scripts/Blood/BossRoster.cs.meta | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 Assets/Scripts/Blood/BossDefinition.cs.meta create mode 100644 Assets/Scripts/Blood/BossRoster.cs.meta 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/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 From 0b1c1178e816b28c898b5f6914ffbcf216eb085f Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 04:19:39 +0200 Subject: [PATCH 19/22] give each boss a fixed body prefab via the roster The boss used a random human body every run. BossDefinition now has a BodyPrefab name, resolved against BloodPrefabs by GameObject name (falling back to random if unset/not found), so each roster entry gets a consistent look (Vampire Hunter=Human1, Zealous Priest=Human2, Old Stalker=Human3). Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/BossDefinition.cs | 4 ++++ Assets/Scripts/Blood/BossRoster.cs | 3 +++ Assets/Scripts/Scene/LevelConstruction.cs | 20 +++++++++++++++++++- docs/mechanics.md | 2 +- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Blood/BossDefinition.cs b/Assets/Scripts/Blood/BossDefinition.cs index e32e828..65a7823 100644 --- a/Assets/Scripts/Blood/BossDefinition.cs +++ b/Assets/Scripts/Blood/BossDefinition.cs @@ -9,6 +9,10 @@ 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. diff --git a/Assets/Scripts/Blood/BossRoster.cs b/Assets/Scripts/Blood/BossRoster.cs index d7f50af..1e34767 100644 --- a/Assets/Scripts/Blood/BossRoster.cs +++ b/Assets/Scripts/Blood/BossRoster.cs @@ -30,6 +30,7 @@ public static class BossRoster new BossDefinition { Name = "Vampire Hunter", + BodyPrefab = "Human1", Dialog = new string[] { "Vampire! You cannot hide from me. Prepare to be put back into the ground!" @@ -39,6 +40,7 @@ public static class BossRoster new BossDefinition { Name = "Zealous Priest", + BodyPrefab = "Human2", Dialog = new string[] { "Unholy thing! The Lord's light will burn the night from you.", @@ -53,6 +55,7 @@ public static class BossRoster new BossDefinition { Name = "Old Stalker", + BodyPrefab = "Human3", Dialog = new string[] { "I have hunted your kind for forty years.", diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index 35771f7..0be9908 100644 --- a/Assets/Scripts/Scene/LevelConstruction.cs +++ b/Assets/Scripts/Scene/LevelConstruction.cs @@ -161,6 +161,24 @@ private GameObject GetRandomHumanTemplate() return BloodPrefabs[nextPick]; } + // Resolves the boss's body prefab from its definition (by name), falling + // back to a random human body when unset or not found. + private GameObject GetBossBodyTemplate() + { + if (bossDefinition != null && !string.IsNullOrEmpty(bossDefinition.BodyPrefab) && BloodPrefabs != null) + { + foreach (var prefab in BloodPrefabs) + { + if (prefab != null && prefab.name == bossDefinition.BodyPrefab) + { + return prefab; + } + } + } + + return GetRandomHumanTemplate(); + } + protected bool IsSortOfTheSamePosition(Vector3 a, Vector3 b) { return ((Mathf.Abs(a.x - b.x) <= Mathf.Epsilon) && @@ -606,7 +624,7 @@ public RoyT.AStar.Position[] GetPathToPlayer(Vector3 from) // the Boss brain in its place. Avoids needing a dedicated boss prefab. protected Boss SpawnBossAt(Vector3 pos) { - var template = GetRandomHumanTemplate(); + var template = GetBossBodyTemplate(); var obj = Instantiate(template, pos, Quaternion.identity) as GameObject; LayerMask blocking = 0; diff --git a/docs/mechanics.md b/docs/mechanics.md index d259aa9..7cf3e63 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -98,7 +98,7 @@ Per-level stats that affect gameplay: ## Boss Encounters -- **Data-driven** (`BossDefinition` + `BossRoster`): each encounter is a plain-data entry with a name, 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. +- **Data-driven** (`BossDefinition` + `BossRoster`): each encounter is a plain-data entry with a name, body prefab (by name, resolved against `BloodPrefabs`), 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. From 40efad0879a5121dac919f0992339fc90094a6b0 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 04:21:13 +0200 Subject: [PATCH 20/22] add a separate BossPrefabs array for boss body art Boss bodies now resolve against a dedicated inspector-assigned BossPrefabs array first, then fall back to the civilian BloodPrefabs, then random. Keeps boss art out of the civilian pool while still working before BossPrefabs is populated (the current Human1/2/3 names resolve via the BloodPrefabs fallback). Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Scene/LevelConstruction.cs | 34 +++++++++++++++++------ docs/mechanics.md | 2 +- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/Assets/Scripts/Scene/LevelConstruction.cs b/Assets/Scripts/Scene/LevelConstruction.cs index 0be9908..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; @@ -161,21 +164,34 @@ private GameObject GetRandomHumanTemplate() return BloodPrefabs[nextPick]; } - // Resolves the boss's body prefab from its definition (by name), falling - // back to a random human body when unset or not found. - private GameObject GetBossBodyTemplate() + private GameObject FindPrefabByName(GameObject[] prefabs, string name) { - if (bossDefinition != null && !string.IsNullOrEmpty(bossDefinition.BodyPrefab) && BloodPrefabs != null) + if (prefabs == null) return null; + + foreach (var prefab in prefabs) { - foreach (var prefab in BloodPrefabs) + if (prefab != null && prefab.name == name) { - if (prefab != null && prefab.name == bossDefinition.BodyPrefab) - { - return prefab; - } + 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(); } diff --git a/docs/mechanics.md b/docs/mechanics.md index 7cf3e63..f6309df 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -98,7 +98,7 @@ Per-level stats that affect gameplay: ## Boss Encounters -- **Data-driven** (`BossDefinition` + `BossRoster`): each encounter is a plain-data entry with a name, body prefab (by name, resolved against `BloodPrefabs`), 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. +- **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. From daa8c9614712f7549a3c0ff07a3189034bf4b945 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 04:28:53 +0200 Subject: [PATCH 21/22] point level-1 bosses at the Human5 boss prefab Human5 was moved into the dedicated BossPrefabs array, so the roster now references it for all three level-1 bosses (they share the one boss body until more boss prefabs are added). Co-Authored-By: Claude Opus 4.8 (1M context) --- Assets/Scripts/Blood/BossRoster.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/Blood/BossRoster.cs b/Assets/Scripts/Blood/BossRoster.cs index 1e34767..27da3ab 100644 --- a/Assets/Scripts/Blood/BossRoster.cs +++ b/Assets/Scripts/Blood/BossRoster.cs @@ -30,7 +30,7 @@ public static class BossRoster new BossDefinition { Name = "Vampire Hunter", - BodyPrefab = "Human1", + BodyPrefab = "Human5", Dialog = new string[] { "Vampire! You cannot hide from me. Prepare to be put back into the ground!" @@ -40,7 +40,7 @@ public static class BossRoster new BossDefinition { Name = "Zealous Priest", - BodyPrefab = "Human2", + BodyPrefab = "Human5", Dialog = new string[] { "Unholy thing! The Lord's light will burn the night from you.", @@ -55,7 +55,7 @@ public static class BossRoster new BossDefinition { Name = "Old Stalker", - BodyPrefab = "Human3", + BodyPrefab = "Human5", Dialog = new string[] { "I have hunted your kind for forty years.", From 3d44a8bec5db5a841e8e1a56e65f151d02e34686 Mon Sep 17 00:00:00 2001 From: Partouf Date: Tue, 14 Jul 2026 04:38:12 +0200 Subject: [PATCH 22/22] . --- Assets/Scenes/MainScene.unity | 59 ++++++++++++++++------------------- Packages/manifest.json | 4 +-- Packages/packages-lock.json | 10 +++--- 3 files changed, 34 insertions(+), 39 deletions(-) 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/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" },