Commit b18116ac authored by Mahmoud Aglan's avatar Mahmoud Aglan

Pre-reset backup: all uncommitted IStalk Unity work + .claude config

parent d61ad480
......@@ -38,12 +38,12 @@ RenderSettings:
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_IndirectSpecularColor: {r: 0.18028378, g: 0.22571412, b: 0.30692285, a: 1}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 12
serializedVersion: 13
m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
......@@ -206,7 +206,7 @@ Transform:
m_GameObject: {fileID: 330585543}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 1, z: -10}
m_LocalPosition: {x: 0, y: 1, z: -9.27}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
......@@ -248,14 +248,14 @@ MonoBehaviour:
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_Version: 2
m_TaaSettings:
quality: 3
frameInfluence: 0.1
jitterScale: 1
mipBias: 0
varianceClampScale: 0.9
contrastAdaptiveSharpening: 0
m_Quality: 3
m_FrameInfluence: 0.1
m_JitterScale: 1
m_MipBias: 0
m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0
m_Version: 2
--- !u!1 &410087039
GameObject:
m_ObjectHideFlags: 0
......@@ -282,14 +282,14 @@ Light:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 410087039}
m_Enabled: 1
serializedVersion: 11
serializedVersion: 12
m_Type: 1
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Intensity: 2
m_Range: 10
m_SpotAngle: 30
m_InnerSpotAngle: 21.80208
m_CookieSize: 10
m_CookieSize2D: {x: 10, y: 10}
m_Shadows:
m_Type: 2
m_Resolution: -1
......@@ -336,6 +336,9 @@ Light:
m_ForceVisible: 0
m_ShadowRadius: 0
m_ShadowAngle: 0
m_LightUnit: 1
m_LuxAtDistance: 1
m_EnableSpotReflector: 1
--- !u!4 &410087041
Transform:
m_ObjectHideFlags: 0
......@@ -363,17 +366,23 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 474bcb49853aa07438625e644c072ee6, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Version: 3
m_UsePipelineSettings: 1
m_AdditionalLightsShadowResolutionTier: 2
m_LightLayerMask: 1
m_RenderingLayers: 1
m_CustomShadowLayers: 0
m_ShadowLayerMask: 1
m_ShadowRenderingLayers: 1
m_LightCookieSize: {x: 1, y: 1}
m_LightCookieOffset: {x: 0, y: 0}
m_SoftShadowQuality: 1
m_RenderingLayersMask:
serializedVersion: 0
m_Bits: 1
m_ShadowRenderingLayersMask:
serializedVersion: 0
m_Bits: 1
m_Version: 4
m_LightLayerMask: 1
m_ShadowLayerMask: 1
m_RenderingLayers: 1
m_ShadowRenderingLayers: 1
--- !u!1 &832575517
GameObject:
m_ObjectHideFlags: 0
......
using System.Collections.Generic;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using UnityEngine;
using IStalk.Data;
using IStalk.Runner;
namespace IStalk.Core
{
public class AntiAfkRitual : NetworkBehaviour
{
private readonly SyncVar<float> _ritualTimer = new();
private readonly SyncVar<int> _targetObjectId = new();
private readonly SyncVar<int> _voterCount = new();
private readonly SyncVar<bool> _isActive = new();
private readonly HashSet<NetworkObject> _voters = new();
private NetworkObject _target;
private ExtractionManager _extractionManager;
private GlobalRules_SO Rules => GlobalRules_SO.Instance;
public bool IsActive => _isActive.Value;
public float Timer => _ritualTimer.Value;
public int TargetId => _targetObjectId.Value;
public int VoterCount => _voterCount.Value;
public event System.Action<NetworkObject> OnRitualStarted;
public event System.Action OnRitualCancelled;
public event System.Action<NetworkObject> OnRitualComplete;
public override void OnStartServer()
{
base.OnStartServer();
_extractionManager = FindAnyObjectByType<ExtractionManager>();
}
[ServerRpc(RequireOwnership = false)]
public void ServerVoteKick(NetworkObject voter, NetworkObject target)
{
if (voter == null || target == null) return;
if (voter == target) return;
var voterHealth = voter.GetComponent<RunnerHealth>();
if (voterHealth != null && voterHealth.IsDown) return;
var targetHealth = target.GetComponent<RunnerHealth>();
if (targetHealth != null && targetHealth.IsDown) return;
if (_isActive.Value && _target != target)
return;
if (!_isActive.Value)
{
_target = target;
_targetObjectId.Value = target.ObjectId;
_ritualTimer.Value = Rules.antiAfkRitualDuration;
_isActive.Value = true;
_voters.Clear();
}
_voters.Add(voter);
_voterCount.Value = _voters.Count;
if (_voters.Count < Rules.antiAfkMinVoters)
{
_isActive.Value = false;
_voters.Clear();
_voterCount.Value = 0;
return;
}
RitualStartedObservers(target);
}
[ServerRpc(RequireOwnership = false)]
public void ServerCancelVote(NetworkObject voter)
{
if (!_isActive.Value) return;
_voters.Remove(voter);
_voterCount.Value = _voters.Count;
if (_voters.Count < Rules.antiAfkMinVoters)
{
_isActive.Value = false;
_target = null;
_targetObjectId.Value = 0;
_voters.Clear();
_voterCount.Value = 0;
RitualCancelledObservers();
}
}
private void Update()
{
if (!IsServerInitialized || !_isActive.Value) return;
if (_voters.Count < Rules.antiAfkMinVoters)
{
CancelRitual();
return;
}
_ritualTimer.Value -= Time.deltaTime;
if (_ritualTimer.Value <= 0)
CompleteRitual();
}
[Server]
private void CompleteRitual()
{
if (_target == null) return;
var health = _target.GetComponent<RunnerHealth>();
if (health != null)
health.TakeHit();
if (health != null && !health.IsDown)
{
health.TakeHit();
if (!health.IsDown) health.TakeHit();
}
if (_extractionManager != null)
_extractionManager.MarkRunnerDead(_target);
RitualCompleteObservers(_target);
ResetState();
}
[Server]
private void CancelRitual()
{
ResetState();
RitualCancelledObservers();
}
private void ResetState()
{
_isActive.Value = false;
_target = null;
_targetObjectId.Value = 0;
_voters.Clear();
_voterCount.Value = 0;
_ritualTimer.Value = 0;
}
[ObserversRpc]
private void RitualStartedObservers(NetworkObject target)
{
OnRitualStarted?.Invoke(target);
}
[ObserversRpc]
private void RitualCancelledObservers()
{
OnRitualCancelled?.Invoke();
}
[ObserversRpc]
private void RitualCompleteObservers(NetworkObject target)
{
OnRitualComplete?.Invoke(target);
}
}
}
fileFormatVersion: 2
guid: b7e2a3650cfba4653a85291a65dc4e2b
\ No newline at end of file
......@@ -11,24 +11,46 @@ namespace IStalk.Core
public class ExtractionManager : NetworkBehaviour
{
private readonly SyncVar<int> _teamLootDeposited = new();
private readonly SyncList<int> _extractedPlayerIds = new();
private readonly SyncVar<bool> _extractionRoomUnlocked = new();
private readonly SyncVar<bool> _allExtracted = new();
private GlobalRules_SO Rules => GlobalRules_SO.Instance;
private List<Vector3> _altarPositions = new();
private ExtractionRoom _extractionRoom;
private readonly HashSet<NetworkObject> _aliveRunners = new();
public int TeamLootDeposited => _teamLootDeposited.Value;
public int ExtractionThreshold => Rules.extractionThreshold;
public bool IsExtractionMet => _teamLootDeposited.Value >= Rules.extractionThreshold;
public bool IsRoomUnlocked => _extractionRoomUnlocked.Value;
public int AliveRunnerCount => _aliveRunners.Count;
public event System.Action<int> OnLootDeposited;
public event System.Action<int> OnPlayerExtracted;
public event System.Action OnExtractionThresholdMet;
public event System.Action OnAllExtracted;
public void SetAltarPositions(List<Vector3> positions)
{
_altarPositions = positions;
}
public void SetExtractionRoom(ExtractionRoom room)
{
_extractionRoom = room;
}
[Server]
public void RegisterRunner(NetworkObject runner)
{
_aliveRunners.Add(runner);
}
[Server]
public void MarkRunnerDead(NetworkObject runner)
{
_aliveRunners.Remove(runner);
}
[Server]
public void DepositLoot(PlayerInventory inventory, int amount)
{
......@@ -39,8 +61,13 @@ namespace IStalk.Core
_teamLootDeposited.Value += amount;
NotifyDepositObservers(_teamLootDeposited.Value);
if (IsExtractionMet)
if (IsExtractionMet && !_extractionRoomUnlocked.Value)
{
_extractionRoomUnlocked.Value = true;
if (_extractionRoom != null)
_extractionRoom.Unlock();
OnExtractionThresholdMet?.Invoke();
}
}
[ObserversRpc]
......@@ -50,14 +77,10 @@ namespace IStalk.Core
}
[Server]
public bool TryExtract(NetworkObject player)
public void ExtractAll()
{
if (!IsExtractionMet) return false;
if (_extractedPlayerIds.Contains(player.ObjectId)) return false;
_extractedPlayerIds.Add(player.ObjectId);
OnPlayerExtracted?.Invoke(player.ObjectId);
return true;
_allExtracted.Value = true;
OnAllExtracted?.Invoke();
}
public bool IsNearAltar(Vector3 position)
......
using System.Collections.Generic;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using UnityEngine;
using IStalk.Data;
namespace IStalk.Core
{
public class ExtractionRoom : NetworkBehaviour
{
[SerializeField] private Collider _roomTrigger;
private readonly SyncVar<bool> _isUnlocked = new();
private readonly SyncVar<float> _extractionTimer = new(new SyncTypeSettings(0.5f));
private readonly SyncVar<int> _runnersInsideCount = new();
private readonly HashSet<NetworkObject> _runnersInside = new();
private ExtractionManager _extractionManager;
public bool IsUnlocked => _isUnlocked.Value;
public float Timer => _extractionTimer.Value;
public int RunnersInside => _runnersInsideCount.Value;
public event System.Action OnExtractionStarted;
public event System.Action OnExtractionComplete;
public event System.Action OnExtractionInterrupted;
public override void OnStartServer()
{
base.OnStartServer();
_extractionManager = FindAnyObjectByType<ExtractionManager>();
_extractionTimer.Value = GlobalRules_SO.Instance.extractionChannelDuration;
}
public override void OnStartClient()
{
base.OnStartClient();
_extractionTimer.OnChange += OnTimerChanged;
}
[Server]
public void Unlock()
{
_isUnlocked.Value = true;
UnlockDoorObservers();
}
[ObserversRpc]
private void UnlockDoorObservers()
{
}
private void OnTriggerEnter(Collider other)
{
if (!IsServerInitialized || !_isUnlocked.Value) return;
var nob = other.GetComponentInParent<NetworkObject>();
if (nob == null) return;
var health = nob.GetComponent<Runner.RunnerHealth>();
if (health == null || health.IsDown) return;
_runnersInside.Add(nob);
_runnersInsideCount.Value = _runnersInside.Count;
}
private void OnTriggerExit(Collider other)
{
if (!IsServerInitialized) return;
var nob = other.GetComponentInParent<NetworkObject>();
if (nob == null) return;
if (_runnersInside.Remove(nob))
{
_runnersInsideCount.Value = _runnersInside.Count;
if (_extractionTimer.Value < GlobalRules_SO.Instance.extractionChannelDuration)
{
_extractionTimer.Value = GlobalRules_SO.Instance.extractionChannelDuration;
ExtractionInterruptedObservers();
}
}
}
private void Update()
{
if (!IsServerInitialized || !_isUnlocked.Value) return;
CleanupDownedRunners();
int aliveCount = _extractionManager != null ? _extractionManager.AliveRunnerCount : 0;
if (aliveCount <= 0) return;
if (_runnersInside.Count >= aliveCount && _runnersInside.Count > 0)
{
_extractionTimer.Value -= Time.deltaTime;
if (_extractionTimer.Value <= 0)
{
_extractionTimer.Value = 0;
CompleteExtraction();
}
}
else
{
if (_extractionTimer.Value < GlobalRules_SO.Instance.extractionChannelDuration)
{
_extractionTimer.Value = GlobalRules_SO.Instance.extractionChannelDuration;
ExtractionInterruptedObservers();
}
}
}
private void CleanupDownedRunners()
{
var toRemove = new List<NetworkObject>();
foreach (var nob in _runnersInside)
{
if (nob == null)
{
toRemove.Add(nob);
continue;
}
var health = nob.GetComponent<Runner.RunnerHealth>();
if (health != null && health.IsDown)
toRemove.Add(nob);
}
foreach (var nob in toRemove)
_runnersInside.Remove(nob);
if (toRemove.Count > 0)
_runnersInsideCount.Value = _runnersInside.Count;
}
[Server]
private void CompleteExtraction()
{
if (_extractionManager != null)
_extractionManager.ExtractAll();
ExtractionCompleteObservers();
}
[ObserversRpc]
private void ExtractionCompleteObservers()
{
OnExtractionComplete?.Invoke();
}
[ObserversRpc]
private void ExtractionInterruptedObservers()
{
OnExtractionInterrupted?.Invoke();
}
private void OnTimerChanged(float prev, float next, bool asServer)
{
if (!asServer && next < prev && prev >= GlobalRules_SO.Instance.extractionChannelDuration)
OnExtractionStarted?.Invoke();
}
}
}
fileFormatVersion: 2
guid: 3c7eb0798b2b847b2987bf36207239c3
\ No newline at end of file
......@@ -23,7 +23,6 @@ namespace IStalk.Core
private readonly SyncVar<int> _sealedRoomCount = new();
private GlobalRules_SO Rules => GlobalRules_SO.Instance;
private int _extractedPlayers;
private int _totalRunners;
private float _nextSealTime;
......@@ -35,8 +34,9 @@ namespace IStalk.Core
public event System.Action<MatchState> OnStateChanged;
public event System.Action<int> OnRoomSealed;
private void Awake()
public override void OnStartNetwork()
{
base.OnStartNetwork();
Instance = this;
}
......@@ -45,7 +45,7 @@ namespace IStalk.Core
base.OnStartServer();
_state.Value = MatchState.WaitingForPlayers;
_state.OnChange += HandleStateChange;
_extractionManager.OnPlayerExtracted += HandlePlayerExtracted;
_extractionManager.OnAllExtracted += HandleAllExtracted;
StartMatch();
}
......@@ -138,11 +138,9 @@ namespace IStalk.Core
OnRoomSealed?.Invoke(roomIndex);
}
private void HandlePlayerExtracted(int objectId)
private void HandleAllExtracted()
{
_extractedPlayers++;
if (_extractedPlayers >= _totalRunners)
_state.Value = MatchState.RunnersWin;
_state.Value = MatchState.RunnersWin;
}
[Server]
......
using UnityEngine;
namespace IStalk.Data
{
public enum EyeAbility
{
Laser,
Scan,
Traps,
Incarnation
}
[CreateAssetMenu(fileName = "EyeType_", menuName = "IStalk/Eye Type")]
public class EyeType_SO : ScriptableObject
{
[Header("Identity")]
public string typeId;
public string displayName;
[TextArea(2, 4)]
public string description;
public Sprite icon;
[Header("Abilities")]
public EyeAbility[] enabledAbilities;
public bool hasEyelids;
[Header("Stat Overrides")]
[Tooltip("Multiplier for laser cooldown (lower = faster)")]
public float laserCooldownMultiplier = 1f;
[Tooltip("Multiplier for laser radius (higher = wider)")]
public float laserRadiusMultiplier = 1f;
[Tooltip("Multiplier for scan cooldown (lower = faster)")]
public float scanCooldownMultiplier = 1f;
[Tooltip("Multiplier for scan radius (higher = wider)")]
public float scanRadiusMultiplier = 1f;
[Tooltip("Multiplier for trap Dread cost (lower = cheaper)")]
public float trapCostMultiplier = 1f;
[Tooltip("Bonus to max active traps")]
public int trapMaxActiveBonus = 0;
[Header("Dread Overrides")]
[Tooltip("Override starting Dread (-1 = use default)")]
public float dreadStartOverride = -1f;
[Tooltip("Override Dread regen rate (-1 = use default)")]
public float dreadRegenOverride = -1f;
public bool HasAbility(EyeAbility ability)
{
if (enabledAbilities == null) return false;
for (int i = 0; i < enabledAbilities.Length; i++)
{
if (enabledAbilities[i] == ability) return true;
}
return false;
}
}
}
fileFormatVersion: 2
guid: 470b264832d4340b887b07cab10b6085
\ No newline at end of file
......@@ -93,6 +93,36 @@ namespace IStalk.Data
public float altarInteractRadius = 2.5f;
public float playerSpawnTimeout = 10f;
[Header("Eye Types")]
public float flashDuration = 5f;
public float flashTeamCooldown = 15f;
public float watcherScanCooldownMultiplier = 0.6f;
public float watcherScanRadiusMultiplier = 1.3f;
public float predatorLaserCooldownMultiplier = 0.5f;
public float predatorLaserRadiusMultiplier = 1.4f;
public float trapperDreadCostMultiplier = 0.6f;
public int trapperMaxActiveBonus = 3;
[Header("Wearables")]
public int maxWearableSlots = 2;
public float dashCooldown = 3f;
public float airDashCooldown = 5f;
public float grappleCooldown = 8f;
public float throwRangeBase = 8f;
public float throwRangeWeightScale = 0.5f;
[Header("Co-op")]
public float boostHeight = 2f;
public float boostRange = 1.5f;
public float linkedSprintBonus = 0.2f;
public float linkedSprintRadius = 5f;
public float linkedSprintSlowDuration = 1f;
[Header("Extraction Room")]
public float extractionChannelDuration = 30f;
public float antiAfkRitualDuration = 20f;
public int antiAfkMinVoters = 2;
public void Initialize()
{
Instance = this;
......
......@@ -12,6 +12,8 @@ namespace IStalk.Data
private Dictionary<string, ShrineItemData_SO> _shrineItems = new();
private Dictionary<string, TrapData_SO> _traps = new();
private Dictionary<string, RoomNode_SO> _rooms = new();
private Dictionary<string, WearableData_SO> _wearables = new();
private Dictionary<string, EyeType_SO> _eyeTypes = new();
private void Awake()
{
......@@ -39,7 +41,13 @@ namespace IStalk.Data
foreach (var room in Resources.LoadAll<RoomNode_SO>("Items/Rooms"))
_rooms[room.roomId] = room;
Debug.Log($"[ItemDatabase] Loaded {_lootItems.Count} loot, {_shrineItems.Count} shrine, {_traps.Count} traps, {_rooms.Count} rooms");
foreach (var wearable in Resources.LoadAll<WearableData_SO>("Items/Wearables"))
_wearables[wearable.wearableId] = wearable;
foreach (var eyeType in Resources.LoadAll<EyeType_SO>("EyeTypes"))
_eyeTypes[eyeType.typeId] = eyeType;
Debug.Log($"[ItemDatabase] Loaded {_lootItems.Count} loot, {_shrineItems.Count} shrine, {_traps.Count} traps, {_rooms.Count} rooms, {_wearables.Count} wearables, {_eyeTypes.Count} eye types");
}
public ItemData_SO GetLootItem(string id) => _lootItems.GetValueOrDefault(id);
......@@ -49,6 +57,11 @@ namespace IStalk.Data
public IReadOnlyCollection<ItemData_SO> AllLootItems => _lootItems.Values;
public IReadOnlyCollection<RoomNode_SO> AllRooms => _rooms.Values;
public IReadOnlyCollection<WearableData_SO> AllWearables => _wearables.Values;
public IReadOnlyCollection<EyeType_SO> AllEyeTypes => _eyeTypes.Values;
public WearableData_SO GetWearable(string id) => _wearables.GetValueOrDefault(id);
public EyeType_SO GetEyeType(string id) => _eyeTypes.GetValueOrDefault(id);
public List<RoomNode_SO> GetRoomsWithLootSlots() =>
_rooms.Values.Where(r => r.lootSpawnSlots > 0).ToList();
......
......@@ -9,7 +9,11 @@ namespace IStalk.Data
SmokeBomb,
Decoy,
WeightReducer,
GrapplingHook
GrapplingHook,
FlashOrb,
LinkedSprintTotem,
SilenceShroud,
VitalityLink
}
[CreateAssetMenu(fileName = "ShrineItem_", menuName = "IStalk/Item Database/Shrine Item")]
......
using IStalk.Runner;
using UnityEngine;
namespace IStalk.Data
{
[CreateAssetMenu(fileName = "Wearable_", menuName = "IStalk/Item Database/Wearable")]
public class WearableData_SO : ScriptableObject
{
[Header("Identity")]
public string wearableId;
public string displayName;
[TextArea(2, 4)]
public string description;
public Sprite icon;
[Header("Ability")]
public WearableAbilityType abilityType;
public bool isPassive;
[Tooltip("Cooldown in seconds (0 for passive abilities)")]
public float cooldown;
[Header("World Pickup")]
public GameObject worldPrefab;
[Header("Shrine Purchase")]
[Tooltip("0 = not purchasable at shrine")]
public int lootCost;
[Header("Throw")]
[Tooltip("Max throw distance when passing to teammate")]
public float throwRange = 8f;
}
}
fileFormatVersion: 2
guid: f9a147f1024714a088cf7c466e30409e
\ No newline at end of file
......@@ -72,6 +72,54 @@ namespace IStalk.Editor
ProjectWindowUtil.CreateAsset(item, "Assets/Resources/Items/Rooms/Room_New.asset");
}
[MenuItem("IStalk/Create/Eye Type SO", false, 210)]
public static void CreateEyeType()
{
var item = ScriptableObject.CreateInstance<EyeType_SO>();
ProjectWindowUtil.CreateAsset(item, "Assets/Resources/EyeTypes/EyeType_New.asset");
}
[MenuItem("IStalk/Create/Wearable SO", false, 211)]
public static void CreateWearable()
{
var item = ScriptableObject.CreateInstance<WearableData_SO>();
ProjectWindowUtil.CreateAsset(item, "Assets/Resources/Items/Wearables/Wearable_New.asset");
}
[MenuItem("IStalk/Create/Extraction Room Prefab Setup", false, 110)]
public static void CreateExtractionRoomPrefab()
{
var root = new GameObject("ExtractionRoom");
var boxCol = root.AddComponent<BoxCollider>();
boxCol.isTrigger = true;
boxCol.size = new Vector3(15, 4, 15);
boxCol.center = new Vector3(0, 2, 0);
for (int i = 0; i < 6; i++)
{
var prop = new GameObject($"IncarnationProp_{i + 1}");
prop.transform.parent = root.transform;
float angle = i * 60f * Mathf.Deg2Rad;
prop.transform.localPosition = new Vector3(Mathf.Cos(angle) * 4f, 0.5f, Mathf.Sin(angle) * 4f);
var slot = prop.AddComponent<SpawnSlot>();
slot.slotType = SpawnSlotType.PropIncarnation;
}
Selection.activeGameObject = root;
Undo.RegisterCreatedObjectUndo(root, "Create Extraction Room");
EditorUtility.DisplayDialog("Extraction Room Created",
"Extraction Room prefab structure created.\n\n" +
"1. Add room geometry (floor, walls, ceiling)\n" +
"2. Replace SpawnSlot children with actual IncarnationProp prefabs\n" +
"3. Adjust BoxCollider to fit your room size\n" +
"4. Add NetworkObject + ExtractionRoom.cs + AntiAfkRitual.cs\n" +
"5. Save as prefab in Assets/Prefabs/Props/\n" +
"6. Register in FishNet DefaultPrefabObjects",
"OK");
}
[MenuItem("IStalk/Create/Global Rules SO", false, 300)]
public static void CreateGlobalRules()
{
......@@ -95,6 +143,8 @@ namespace IStalk.Editor
"Assets/Resources/Items/Shrine",
"Assets/Resources/Items/Traps",
"Assets/Resources/Items/Rooms",
"Assets/Resources/Items/Wearables",
"Assets/Resources/EyeTypes",
"Assets/Prefabs/Player",
"Assets/Prefabs/Props",
"Assets/Prefabs/UI",
......
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using IStalk.Core;
using IStalk.Data;
namespace IStalk.Editor.Inspectors
{
[CustomEditor(typeof(ExtractionRoom))]
public class ExtractionRoomEditor : UnityEditor.Editor
{
private ExtractionRoom _room;
private void OnEnable()
{
_room = (ExtractionRoom)target;
}
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(15);
EditorGUILayout.LabelField("Extraction Room Setup", EditorStyles.boldLabel);
var rules = FindRules();
if (rules != null)
{
EditorGUILayout.LabelField($"Channel Duration: {rules.extractionChannelDuration:F0}s");
EditorGUILayout.LabelField($"Anti-AFK Ritual: {rules.antiAfkRitualDuration:F0}s (min {rules.antiAfkMinVoters} voters)");
}
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Design Notes", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Extraction Room Rules:\n" +
"• All alive runners must be inside simultaneously\n" +
"• Timer pauses/resets if anyone leaves\n" +
"• Downed runners are ignored (not required)\n" +
"• Room should be dense with IncarnationProp objects\n" +
"• Place a Trigger collider covering the entire room area",
MessageType.Info);
EditorGUILayout.Space(5);
DrawSetupValidator();
if (Application.isPlaying && _room.IsSpawned)
{
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Runtime State", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Unlocked: {_room.IsUnlocked}");
EditorGUILayout.LabelField($" Timer: {_room.Timer:F1}s");
EditorGUILayout.LabelField($" Runners Inside: {_room.RunnersInside}");
Repaint();
}
}
private void DrawSetupValidator()
{
EditorGUILayout.LabelField("Setup Checklist", EditorStyles.boldLabel);
var trigger = _room.GetComponent<Collider>();
if (trigger == null)
{
trigger = _room.GetComponentInChildren<Collider>();
}
if (trigger == null)
EditorGUILayout.HelpBox("No Collider found! Add a trigger collider covering the room.", MessageType.Error);
else if (!trigger.isTrigger)
EditorGUILayout.HelpBox("Collider is not set as Trigger. Enable 'Is Trigger'.", MessageType.Error);
else
EditorGUILayout.LabelField(" ✓ Trigger collider present");
int incarnationCount = _room.GetComponentsInChildren<MonoBehaviour>().Length;
var incarnationProps = _room.transform.GetComponentsInChildren<Transform>();
int propCount = 0;
foreach (var t in incarnationProps)
{
if (t.GetComponent<FishNet.Object.NetworkObject>() != null && t != _room.transform)
propCount++;
}
if (propCount == 0)
EditorGUILayout.HelpBox("No IncarnationProp children found. Add props for the Eye to incarnate during extraction.", MessageType.Warning);
else
EditorGUILayout.LabelField($" ✓ {propCount} networked prop(s) in room");
}
private GlobalRules_SO FindRules()
{
if (GlobalRules_SO.Instance != null) return GlobalRules_SO.Instance;
string[] guids = AssetDatabase.FindAssets("t:GlobalRules_SO");
if (guids.Length == 0) return null;
return AssetDatabase.LoadAssetAtPath<GlobalRules_SO>(AssetDatabase.GUIDToAssetPath(guids[0]));
}
}
}
#endif
fileFormatVersion: 2
guid: 96e801393c5d6461bbd3a12f3cfcbebd
\ No newline at end of file
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using IStalk.Data;
namespace IStalk.Editor.Inspectors
{
[CustomEditor(typeof(EyeType_SO))]
public class EyeTypeEditor : UnityEditor.Editor
{
private EyeType_SO _eyeType;
private bool _showAbilityPreview = true;
private bool _showStatPreview = true;
private void OnEnable()
{
_eyeType = (EyeType_SO)target;
}
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(15);
_showAbilityPreview = EditorGUILayout.Foldout(_showAbilityPreview, "Ability Summary", true);
if (_showAbilityPreview)
{
EditorGUI.indentLevel++;
DrawAbilityMatrix();
EditorGUI.indentLevel--;
}
EditorGUILayout.Space(10);
_showStatPreview = EditorGUILayout.Foldout(_showStatPreview, "Effective Stats Preview", true);
if (_showStatPreview)
{
EditorGUI.indentLevel++;
DrawStatPreview();
EditorGUI.indentLevel--;
}
EditorGUILayout.Space(10);
DrawQuickActions();
DrawValidation();
}
private void DrawAbilityMatrix()
{
EditorGUILayout.LabelField("Enabled Abilities:", EditorStyles.boldLabel);
bool hasLaser = _eyeType.HasAbility(EyeAbility.Laser);
bool hasScan = _eyeType.HasAbility(EyeAbility.Scan);
bool hasTraps = _eyeType.HasAbility(EyeAbility.Traps);
bool hasIncarnation = _eyeType.HasAbility(EyeAbility.Incarnation);
DrawAbilityRow("Laser", hasLaser, "Fire damaging beam at runners");
DrawAbilityRow("Scan", hasScan, "Reveal all runners briefly");
DrawAbilityRow("Traps", hasTraps, "Place traps on the map");
DrawAbilityRow("Incarnation", hasIncarnation, "Possess and move props");
DrawAbilityRow("Eyelids", _eyeType.hasEyelids, "Close eyes to dodge flash (skill timing)");
int abilityCount = (hasLaser ? 1 : 0) + (hasScan ? 1 : 0) + (hasTraps ? 1 : 0) + (hasIncarnation ? 1 : 0);
EditorGUILayout.Space(5);
EditorGUILayout.LabelField($"Total: {abilityCount}/4 abilities active");
}
private void DrawAbilityRow(string name, bool enabled, string tooltip)
{
string icon = enabled ? "✓" : "✗";
Color color = enabled ? new Color(0.2f, 0.8f, 0.2f) : new Color(0.5f, 0.5f, 0.5f);
var style = new GUIStyle(EditorStyles.label) { richText = true };
string colorHex = ColorUtility.ToHtmlStringRGB(color);
EditorGUILayout.LabelField(new GUIContent($" <color=#{colorHex}>{icon}</color> {name}", tooltip), style);
}
private void DrawStatPreview()
{
var rules = FindRules();
if (rules == null)
{
EditorGUILayout.HelpBox("GlobalRules_SO not found. Create one to see effective stats.", MessageType.Info);
return;
}
EditorGUILayout.LabelField("Laser", EditorStyles.boldLabel);
if (_eyeType.HasAbility(EyeAbility.Laser))
{
float cd = rules.laserCooldown * _eyeType.laserCooldownMultiplier;
float rad = rules.laserRadius * _eyeType.laserRadiusMultiplier;
EditorGUILayout.LabelField($" Cooldown: {cd:F1}s (base {rules.laserCooldown:F1} x {_eyeType.laserCooldownMultiplier:F2})");
EditorGUILayout.LabelField($" Radius: {rad:F2}m (base {rules.laserRadius:F2} x {_eyeType.laserRadiusMultiplier:F2})");
EditorGUILayout.LabelField($" DPS potential: {1f / cd:F2} shots/sec");
}
else
{
EditorGUILayout.LabelField(" (Disabled for this type)");
}
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Scan", EditorStyles.boldLabel);
if (_eyeType.HasAbility(EyeAbility.Scan))
{
float cd = (rules.laserCooldown * 3f) * _eyeType.scanCooldownMultiplier;
float rad = 30f * _eyeType.scanRadiusMultiplier;
EditorGUILayout.LabelField($" Cooldown: {cd:F1}s (x{_eyeType.scanCooldownMultiplier:F2})");
EditorGUILayout.LabelField($" Radius: {rad:F1}m (x{_eyeType.scanRadiusMultiplier:F2})");
}
else
{
EditorGUILayout.LabelField(" (Disabled for this type)");
}
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Traps", EditorStyles.boldLabel);
if (_eyeType.HasAbility(EyeAbility.Traps))
{
EditorGUILayout.LabelField($" Cost multiplier: {_eyeType.trapCostMultiplier:F2}x (lower = cheaper)");
EditorGUILayout.LabelField($" Max active bonus: +{_eyeType.trapMaxActiveBonus}");
}
else
{
EditorGUILayout.LabelField(" (Disabled for this type)");
}
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Dread", EditorStyles.boldLabel);
if (_eyeType.dreadStartOverride >= 0)
EditorGUILayout.LabelField($" Starting Dread: {_eyeType.dreadStartOverride:F0} (override)");
else
EditorGUILayout.LabelField($" Starting Dread: {rules.dreadStartingAmount:F0} (default)");
if (_eyeType.dreadRegenOverride >= 0)
EditorGUILayout.LabelField($" Regen rate: {_eyeType.dreadRegenOverride:F1}/s (override)");
else
EditorGUILayout.LabelField($" Regen rate: {rules.dreadBaseRegenRate:F1}/s (default)");
}
private void DrawQuickActions()
{
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Quick Actions", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Generate ID from Name"))
{
Undo.RecordObject(_eyeType, "Generate Eye Type ID");
_eyeType.typeId = _eyeType.name.Replace(" ", "_").ToLower();
EditorUtility.SetDirty(_eyeType);
}
if (GUILayout.Button("Preset: Watcher"))
{
ApplyPreset(new[] { EyeAbility.Scan, EyeAbility.Incarnation }, false,
1f, 1f, 0.6f, 1.3f, 1f, 0);
}
if (GUILayout.Button("Preset: Predator"))
{
ApplyPreset(new[] { EyeAbility.Laser, EyeAbility.Incarnation }, true,
0.5f, 1.4f, 1f, 1f, 1f, 0);
}
if (GUILayout.Button("Preset: Trapper"))
{
ApplyPreset(new[] { EyeAbility.Traps, EyeAbility.Scan, EyeAbility.Incarnation }, false,
1f, 1f, 1f, 1f, 0.6f, 3);
}
EditorGUILayout.EndHorizontal();
}
private void ApplyPreset(EyeAbility[] abilities, bool eyelids,
float laserCd, float laserRad, float scanCd, float scanRad, float trapCost, int trapBonus)
{
Undo.RecordObject(_eyeType, "Apply Eye Type Preset");
_eyeType.enabledAbilities = abilities;
_eyeType.hasEyelids = eyelids;
_eyeType.laserCooldownMultiplier = laserCd;
_eyeType.laserRadiusMultiplier = laserRad;
_eyeType.scanCooldownMultiplier = scanCd;
_eyeType.scanRadiusMultiplier = scanRad;
_eyeType.trapCostMultiplier = trapCost;
_eyeType.trapMaxActiveBonus = trapBonus;
EditorUtility.SetDirty(_eyeType);
}
private void DrawValidation()
{
EditorGUILayout.Space(10);
if (string.IsNullOrEmpty(_eyeType.typeId))
EditorGUILayout.HelpBox("Type ID is empty. Click 'Generate ID from Name'.", MessageType.Error);
if (string.IsNullOrEmpty(_eyeType.displayName))
EditorGUILayout.HelpBox("Display Name is empty.", MessageType.Warning);
if (_eyeType.enabledAbilities == null || _eyeType.enabledAbilities.Length == 0)
EditorGUILayout.HelpBox("No abilities enabled! This eye type will have no actions.", MessageType.Error);
if (_eyeType.icon == null)
EditorGUILayout.HelpBox("No icon assigned (optional for greybox).", MessageType.Info);
}
private GlobalRules_SO FindRules()
{
if (GlobalRules_SO.Instance != null) return GlobalRules_SO.Instance;
string[] guids = AssetDatabase.FindAssets("t:GlobalRules_SO");
if (guids.Length == 0) return null;
return AssetDatabase.LoadAssetAtPath<GlobalRules_SO>(AssetDatabase.GUIDToAssetPath(guids[0]));
}
}
}
#endif
fileFormatVersion: 2
guid: 90d3beee4dcfe4087b5974e307da06ac
\ No newline at end of file
......@@ -10,6 +10,10 @@ namespace IStalk.Editor.Inspectors
{
private GlobalRules_SO _rules;
private bool _showBalancePreview = true;
private bool _showEyeTypes = true;
private bool _showWearables = true;
private bool _showCoop = true;
private bool _showExtraction = true;
private void OnEnable()
{
......@@ -23,41 +27,96 @@ namespace IStalk.Editor.Inspectors
EditorGUILayout.Space(15);
_showBalancePreview = EditorGUILayout.Foldout(_showBalancePreview, "Balance Preview", true);
if (!_showBalancePreview) return;
EditorGUILayout.LabelField("Weight Tiers", EditorStyles.boldLabel);
DrawTierRow("Naked", 0, _rules.weightThresholdLight, _rules.speedMultNaked);
DrawTierRow("Light", _rules.weightThresholdLight, _rules.weightThresholdModerate, _rules.speedMultLight);
DrawTierRow("Moderate", _rules.weightThresholdModerate, _rules.weightThresholdHeavy, _rules.speedMultModerate);
DrawTierRow("Heavy", _rules.weightThresholdHeavy, _rules.weightThresholdOverloaded, _rules.speedMultHeavy);
DrawTierRow("Overloaded", _rules.weightThresholdOverloaded, float.MaxValue, _rules.speedMultOverloaded);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Movement at Base Speed", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Walk: {_rules.baseRunSpeed:F1} m/s");
EditorGUILayout.LabelField($" Sprint: {_rules.baseRunSpeed * _rules.sprintMultiplier:F1} m/s");
EditorGUILayout.LabelField($" Slide: {_rules.slideSpeed:F1} m/s");
EditorGUILayout.LabelField($" Overloaded Walk: {_rules.baseRunSpeed * _rules.speedMultOverloaded:F1} m/s");
EditorGUILayout.Space();
EditorGUILayout.LabelField("Extraction Economy", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Threshold: {_rules.extractionThreshold} loot value");
EditorGUILayout.LabelField($" Min loot in map: ~{_rules.extractionThreshold * _rules.lootValueToThresholdRatio:F0} value");
EditorGUILayout.LabelField($" Channel time (empty): {_rules.altarChannelBaseTime:F1}s");
EditorGUILayout.LabelField($" Channel time (20kg): {_rules.GetChannelTime(20):F1}s");
EditorGUILayout.Space();
EditorGUILayout.LabelField("Map Generation", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Rooms: {_rules.minRoomsPerMap}{_rules.maxRoomsPerMap}");
EditorGUILayout.LabelField($" Floors: {_rules.minFloors}{_rules.maxFloors}");
EditorGUILayout.LabelField($" Altars: {_rules.minAltars}{_rules.maxAltars}");
EditorGUILayout.LabelField($" Map area (max): ~{_rules.maxRoomsPerMap * _rules.cellWidth * _rules.cellWidth:F0}m²");
EditorGUILayout.Space();
EditorGUILayout.LabelField("Match Timing", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Grace: {_rules.gracePeriodDuration}s");
EditorGUILayout.LabelField($" Hunt: {_rules.matchTimeLimit / 60f:F1} min");
EditorGUILayout.LabelField($" Total max: {(_rules.gracePeriodDuration + _rules.matchTimeLimit) / 60f:F1} min");
if (_showBalancePreview)
{
EditorGUILayout.LabelField("Weight Tiers", EditorStyles.boldLabel);
DrawTierRow("Naked", 0, _rules.weightThresholdLight, _rules.speedMultNaked);
DrawTierRow("Light", _rules.weightThresholdLight, _rules.weightThresholdModerate, _rules.speedMultLight);
DrawTierRow("Moderate", _rules.weightThresholdModerate, _rules.weightThresholdHeavy, _rules.speedMultModerate);
DrawTierRow("Heavy", _rules.weightThresholdHeavy, _rules.weightThresholdOverloaded, _rules.speedMultHeavy);
DrawTierRow("Overloaded", _rules.weightThresholdOverloaded, float.MaxValue, _rules.speedMultOverloaded);
EditorGUILayout.Space();
EditorGUILayout.LabelField("Movement at Base Speed", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Walk: {_rules.baseRunSpeed:F1} m/s");
EditorGUILayout.LabelField($" Sprint: {_rules.baseRunSpeed * _rules.sprintMultiplier:F1} m/s");
EditorGUILayout.LabelField($" Slide: {_rules.slideSpeed:F1} m/s");
EditorGUILayout.LabelField($" Overloaded Walk: {_rules.baseRunSpeed * _rules.speedMultOverloaded:F1} m/s");
EditorGUILayout.Space();
EditorGUILayout.LabelField("Extraction Economy", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Threshold: {_rules.extractionThreshold} loot value");
EditorGUILayout.LabelField($" Min loot in map: ~{_rules.extractionThreshold * _rules.lootValueToThresholdRatio:F0} value");
EditorGUILayout.LabelField($" Channel time (empty): {_rules.altarChannelBaseTime:F1}s");
EditorGUILayout.LabelField($" Channel time (20kg): {_rules.GetChannelTime(20):F1}s");
EditorGUILayout.Space();
EditorGUILayout.LabelField("Map Generation", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Rooms: {_rules.minRoomsPerMap}{_rules.maxRoomsPerMap}");
EditorGUILayout.LabelField($" Floors: {_rules.minFloors}{_rules.maxFloors}");
EditorGUILayout.LabelField($" Altars: {_rules.minAltars}{_rules.maxAltars}");
EditorGUILayout.LabelField($" Map area (max): ~{_rules.maxRoomsPerMap * _rules.cellWidth * _rules.cellWidth:F0}m²");
EditorGUILayout.Space();
EditorGUILayout.LabelField("Match Timing", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Grace: {_rules.gracePeriodDuration}s");
EditorGUILayout.LabelField($" Hunt: {_rules.matchTimeLimit / 60f:F1} min");
EditorGUILayout.LabelField($" Total max: {(_rules.gracePeriodDuration + _rules.matchTimeLimit) / 60f:F1} min");
}
EditorGUILayout.Space(10);
_showEyeTypes = EditorGUILayout.Foldout(_showEyeTypes, "Eye Type Balance", true);
if (_showEyeTypes)
{
EditorGUILayout.LabelField("Flash/Eyelid System", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Flash blind duration: {_rules.flashDuration:F1}s");
EditorGUILayout.LabelField($" Team flash cooldown: {_rules.flashTeamCooldown:F1}s");
EditorGUILayout.Space(3);
EditorGUILayout.LabelField("Watcher", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Scan cooldown: x{_rules.watcherScanCooldownMultiplier:F2} (faster)");
EditorGUILayout.LabelField($" Scan radius: x{_rules.watcherScanRadiusMultiplier:F2} (wider)");
EditorGUILayout.Space(3);
EditorGUILayout.LabelField("Predator", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Laser cooldown: x{_rules.predatorLaserCooldownMultiplier:F2} (faster)");
EditorGUILayout.LabelField($" Laser radius: x{_rules.predatorLaserRadiusMultiplier:F2} (wider)");
EditorGUILayout.Space(3);
EditorGUILayout.LabelField("Trapper", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Trap cost: x{_rules.trapperDreadCostMultiplier:F2} (cheaper)");
EditorGUILayout.LabelField($" Max active bonus: +{_rules.trapperMaxActiveBonus}");
}
EditorGUILayout.Space(10);
_showWearables = EditorGUILayout.Foldout(_showWearables, "Wearable Balance", true);
if (_showWearables)
{
EditorGUILayout.LabelField($" Max slots: {_rules.maxWearableSlots}");
EditorGUILayout.LabelField($" Dash cooldown: {_rules.dashCooldown:F1}s");
EditorGUILayout.LabelField($" Air Dash cooldown: {_rules.airDashCooldown:F1}s");
EditorGUILayout.LabelField($" Grapple cooldown: {_rules.grappleCooldown:F1}s");
EditorGUILayout.LabelField($" Throw range: {_rules.throwRangeBase:F1}m (weight scale: {_rules.throwRangeWeightScale:F2})");
}
EditorGUILayout.Space(10);
_showCoop = EditorGUILayout.Foldout(_showCoop, "Co-op Balance", true);
if (_showCoop)
{
EditorGUILayout.LabelField($" Boost height: {_rules.boostHeight:F1}m (range: {_rules.boostRange:F1}m)");
EditorGUILayout.LabelField($" Linked Sprint bonus: +{_rules.linkedSprintBonus:P0} speed");
EditorGUILayout.LabelField($" Linked Sprint radius: {_rules.linkedSprintRadius:F1}m");
EditorGUILayout.LabelField($" Linked Sprint slow on break: {_rules.linkedSprintSlowDuration:F1}s");
}
EditorGUILayout.Space(10);
_showExtraction = EditorGUILayout.Foldout(_showExtraction, "Extraction Room", true);
if (_showExtraction)
{
EditorGUILayout.LabelField($" Channel duration: {_rules.extractionChannelDuration:F0}s (all runners present)");
EditorGUILayout.LabelField($" Anti-AFK ritual: {_rules.antiAfkRitualDuration:F0}s");
EditorGUILayout.LabelField($" Min voters to start ritual: {_rules.antiAfkMinVoters}");
}
}
private void DrawTierRow(string name, float min, float max, float speedMult)
......
......@@ -9,6 +9,7 @@ namespace IStalk.Editor.Inspectors
public class ItemDataEditor : UnityEditor.Editor
{
private ItemData_SO _item;
private bool _showDesignerPanel = true;
private void OnEnable()
{
......@@ -19,50 +20,139 @@ namespace IStalk.Editor.Inspectors
{
DrawDefaultInspector();
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel);
EditorGUILayout.Space(15);
_showDesignerPanel = EditorGUILayout.Foldout(_showDesignerPanel, "Designer Panel", true);
if (!_showDesignerPanel) return;
DrawIconPreview();
DrawBalanceInfo();
DrawComparison();
DrawQuickActions();
DrawValidation();
}
private void DrawIconPreview()
{
if (_item.icon != null)
{
Rect rect = GUILayoutUtility.GetRect(64, 64);
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
Rect rect = GUILayoutUtility.GetRect(80, 80);
EditorGUI.DrawPreviewTexture(rect, _item.icon.texture);
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(5);
}
}
EditorGUILayout.Space();
EditorGUILayout.LabelField("Computed Values", EditorStyles.boldLabel);
private void DrawBalanceInfo()
{
var rules = FindRules();
if (rules != null)
if (rules == null)
{
EditorGUILayout.HelpBox("Create a GlobalRules_SO to see balance info.", MessageType.Info);
return;
}
EditorGUILayout.LabelField("Impact Analysis", EditorStyles.boldLabel);
WeightTier tier = rules.GetTier(_item.weight);
float speedMult = rules.GetSpeedMultiplier(tier);
float ratio = _item.weight > 0 ? (float)_item.lootValue / _item.weight : 0;
EditorGUILayout.LabelField($" Weight Tier (alone): {tier}");
EditorGUILayout.LabelField($" Speed when carrying: {rules.baseRunSpeed * speedMult:F1} m/s ({speedMult:P0})");
EditorGUILayout.LabelField($" Value/Weight Ratio: {ratio:F2}");
EditorGUILayout.Space(3);
EditorGUILayout.LabelField("Economy Context", EditorStyles.boldLabel);
float pctOfThreshold = (float)_item.lootValue / rules.extractionThreshold * 100f;
EditorGUILayout.LabelField($" Extraction contribution: {pctOfThreshold:F0}% of threshold ({_item.lootValue}/{rules.extractionThreshold})");
float channelTime = rules.GetChannelTime(_item.weight);
EditorGUILayout.LabelField($" Channel time carrying this: {channelTime:F1}s");
Color barColor = ratio > 2f ? Color.green : ratio > 1f ? Color.yellow : Color.red;
string riskLabel = ratio > 2f ? "High reward" : ratio > 1f ? "Balanced" : "Risky (heavy for value)";
var style = new GUIStyle(EditorStyles.label) { richText = true };
string hex = ColorUtility.ToHtmlStringRGB(barColor);
EditorGUILayout.LabelField($" Risk/Reward: <color=#{hex}>{riskLabel}</color>", style);
}
private void DrawComparison()
{
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Loot Pool Context", EditorStyles.boldLabel);
var allItems = Resources.LoadAll<ItemData_SO>("Items/Loot");
if (allItems.Length <= 1)
{
WeightTier tier = rules.GetTier(_item.weight);
float speedMult = rules.GetSpeedMultiplier(tier);
EditorGUILayout.LabelField($"Weight Tier (alone): {tier}");
EditorGUILayout.LabelField($"Speed Multiplier (alone): {speedMult:P0}");
EditorGUILayout.LabelField($"Value/Weight Ratio: {(_item.weight > 0 ? _item.lootValue / _item.weight : 0):F2}");
EditorGUILayout.LabelField(" (Create more items to see comparisons)");
return;
}
EditorGUILayout.Space();
if (GUILayout.Button("Generate ID from Name"))
float avgWeight = 0, avgValue = 0;
foreach (var item in allItems) { avgWeight += item.weight; avgValue += item.lootValue; }
avgWeight /= allItems.Length;
avgValue /= allItems.Length;
EditorGUILayout.LabelField($" This item: {_item.weight:F1}kg / {_item.lootValue} value");
EditorGUILayout.LabelField($" Pool average: {avgWeight:F1}kg / {avgValue:F1} value ({allItems.Length} items total)");
if (_item.weight > avgWeight * 1.5f)
EditorGUILayout.LabelField(" → Heavier than average (consider higher value)");
if (_item.lootValue > avgValue * 2f)
EditorGUILayout.LabelField(" → Very high value (rare spawn weight recommended)");
}
private void DrawQuickActions()
{
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Quick Actions", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Generate ID"))
{
Undo.RecordObject(_item, "Generate Item ID");
_item.itemId = _item.name.Replace(" ", "_").ToLower();
EditorUtility.SetDirty(_item);
}
DrawValidation();
if (GUILayout.Button("Preset: Common"))
{
Undo.RecordObject(_item, "Preset Common");
_item.weight = 1; _item.lootValue = 1; _item.rarity = Rarity.Common;
EditorUtility.SetDirty(_item);
}
if (GUILayout.Button("Preset: Rare"))
{
Undo.RecordObject(_item, "Preset Rare");
_item.weight = 4; _item.lootValue = 7; _item.rarity = Rarity.Rare;
EditorUtility.SetDirty(_item);
}
if (GUILayout.Button("Preset: Legendary"))
{
Undo.RecordObject(_item, "Preset Legendary");
_item.weight = 7; _item.lootValue = 12; _item.rarity = Rarity.Legendary;
EditorUtility.SetDirty(_item);
}
EditorGUILayout.EndHorizontal();
}
private void DrawValidation()
{
EditorGUILayout.Space();
EditorGUILayout.Space(10);
bool hasIssues = false;
if (string.IsNullOrEmpty(_item.itemId))
EditorGUILayout.HelpBox("Item ID is empty.", MessageType.Error);
{ EditorGUILayout.HelpBox("Item ID is empty. Click 'Generate ID'.", MessageType.Error); hasIssues = true; }
if (_item.worldPrefab == null)
EditorGUILayout.HelpBox("No world prefab assigned.", MessageType.Warning);
{ EditorGUILayout.HelpBox("No world prefab assigned (needed for spawning in-world).", MessageType.Warning); hasIssues = true; }
if (_item.lootValue <= 0)
EditorGUILayout.HelpBox("Loot value is 0 or negative.", MessageType.Warning);
{ EditorGUILayout.HelpBox("Loot value is 0 — item won't contribute to extraction.", MessageType.Warning); hasIssues = true; }
if (_item.weight <= 0)
EditorGUILayout.HelpBox("Weight is 0 or negative.", MessageType.Warning);
{ EditorGUILayout.HelpBox("Weight is 0 — item won't slow runner at all.", MessageType.Warning); hasIssues = true; }
if (!hasIssues)
EditorGUILayout.HelpBox("All checks pass. Item is ready.", MessageType.Info);
}
private GlobalRules_SO FindRules()
......
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using IStalk.Data;
using IStalk.Runner;
namespace IStalk.Editor.Inspectors
{
[CustomEditor(typeof(WearableData_SO))]
public class WearableDataEditor : UnityEditor.Editor
{
private WearableData_SO _wearable;
private bool _showBalancePreview = true;
private void OnEnable()
{
_wearable = (WearableData_SO)target;
}
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(15);
if (_wearable.icon != null)
{
EditorGUILayout.LabelField("Icon Preview", EditorStyles.boldLabel);
Rect rect = GUILayoutUtility.GetRect(64, 64);
EditorGUI.DrawPreviewTexture(rect, _wearable.icon.texture);
EditorGUILayout.Space(5);
}
_showBalancePreview = EditorGUILayout.Foldout(_showBalancePreview, "Balance Preview", true);
if (_showBalancePreview)
{
EditorGUI.indentLevel++;
DrawBalancePreview();
EditorGUI.indentLevel--;
}
EditorGUILayout.Space(10);
DrawQuickActions();
DrawValidation();
}
private void DrawBalancePreview()
{
var rules = FindRules();
EditorGUILayout.LabelField("Ability Info", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Type: {_wearable.abilityType}");
EditorGUILayout.LabelField($" Mode: {(_wearable.isPassive ? "Passive (always active while worn)" : "Active (cooldown-based)")}");
if (!_wearable.isPassive)
{
EditorGUILayout.LabelField($" Cooldown: {_wearable.cooldown:F1}s");
if (_wearable.cooldown > 0)
{
float uptime = 1f / (1f + _wearable.cooldown);
EditorGUILayout.LabelField($" Max activation rate: {60f / _wearable.cooldown:F1} uses/min");
}
}
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Movement Granted", EditorStyles.boldLabel);
string movementDesc = _wearable.abilityType switch
{
WearableAbilityType.Dash => "Ground dash burst (directional)",
WearableAbilityType.WallRun => "Run along vertical walls while sprinting",
WearableAbilityType.AirDash => "Mid-air directional dash",
WearableAbilityType.Slide => "Sprint + Crouch to slide under gaps",
WearableAbilityType.Vault => "Auto-vault waist-height obstacles",
WearableAbilityType.Grapple => "Hook to GrappleAnchor points, swing",
_ => "Unknown"
};
EditorGUILayout.LabelField($" {movementDesc}");
if (rules != null)
{
float cd = _wearable.abilityType switch
{
WearableAbilityType.Dash => rules.dashCooldown,
WearableAbilityType.AirDash => rules.airDashCooldown,
WearableAbilityType.Grapple => rules.grappleCooldown,
_ => 0
};
if (cd > 0)
EditorGUILayout.LabelField($" GlobalRules cooldown: {cd:F1}s");
}
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Economy", EditorStyles.boldLabel);
if (_wearable.lootCost > 0)
EditorGUILayout.LabelField($" Shrine cost: {_wearable.lootCost} loot");
else
EditorGUILayout.LabelField($" Not purchasable (world drop only)");
EditorGUILayout.LabelField($" Throw range: {_wearable.throwRange:F1}m");
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Slot Impact", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Occupies 1 of {(rules != null ? rules.maxWearableSlots : 2)} wearable slots");
EditorGUILayout.LabelField($" Weight: 0 (wearables are weightless)");
}
private void DrawQuickActions()
{
EditorGUILayout.LabelField("Quick Actions", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Generate ID from Name"))
{
Undo.RecordObject(_wearable, "Generate Wearable ID");
_wearable.wearableId = _wearable.name.Replace(" ", "_").ToLower();
EditorUtility.SetDirty(_wearable);
}
if (GUILayout.Button("Set as Active Ability"))
{
Undo.RecordObject(_wearable, "Set Active Ability");
_wearable.isPassive = false;
if (_wearable.cooldown <= 0) _wearable.cooldown = 3f;
EditorUtility.SetDirty(_wearable);
}
if (GUILayout.Button("Set as Passive Ability"))
{
Undo.RecordObject(_wearable, "Set Passive Ability");
_wearable.isPassive = true;
_wearable.cooldown = 0;
EditorUtility.SetDirty(_wearable);
}
EditorGUILayout.EndHorizontal();
}
private void DrawValidation()
{
EditorGUILayout.Space(10);
if (string.IsNullOrEmpty(_wearable.wearableId))
EditorGUILayout.HelpBox("Wearable ID is empty. Click 'Generate ID from Name'.", MessageType.Error);
if (string.IsNullOrEmpty(_wearable.displayName))
EditorGUILayout.HelpBox("Display Name is empty.", MessageType.Warning);
if (_wearable.worldPrefab == null)
EditorGUILayout.HelpBox("No world prefab assigned (needed for drops).", MessageType.Warning);
if (!_wearable.isPassive && _wearable.cooldown <= 0)
EditorGUILayout.HelpBox("Active ability with 0 cooldown — intended?", MessageType.Warning);
if (_wearable.lootCost <= 0 && _wearable.worldPrefab == null)
EditorGUILayout.HelpBox("No purchase cost AND no world prefab. How will players get this?", MessageType.Error);
}
private GlobalRules_SO FindRules()
{
if (GlobalRules_SO.Instance != null) return GlobalRules_SO.Instance;
string[] guids = AssetDatabase.FindAssets("t:GlobalRules_SO");
if (guids.Length == 0) return null;
return AssetDatabase.LoadAssetAtPath<GlobalRules_SO>(AssetDatabase.GUIDToAssetPath(guids[0]));
}
}
}
#endif
fileFormatVersion: 2
guid: 6cf33eacfa7ea4d3db82832cc7702a23
\ No newline at end of file
......@@ -2,6 +2,7 @@
using UnityEditor;
using UnityEngine;
using System.Diagnostics;
using System.Linq;
using IStalk.Core;
using IStalk.Data;
using IStalk.Network;
......@@ -14,43 +15,71 @@ namespace IStalk.Editor.MapGen
private int _testCount = 100;
private string _lastResult;
private Vector2 _scrollPos;
private bool _lastTestPassed;
[MenuItem("IStalk/Map Generation Tester")]
public static void ShowWindow()
{
GetWindow<MapGenTestWindow>("Map Gen Tester");
var window = GetWindow<MapGenTestWindow>("Map Gen Tester");
window.minSize = new Vector2(380, 500);
}
private void OnGUI()
{
EditorGUILayout.LabelField("Map Generation Tester", EditorStyles.boldLabel);
EditorGUILayout.HelpBox("Test procedural map generation without entering Play mode. Use to validate room pool and GlobalRules settings.", MessageType.None);
EditorGUILayout.Space();
DrawPreflightCheck();
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Single Generation", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
_seed = EditorGUILayout.IntField("Seed", _seed);
if (GUILayout.Button("Randomize Seed"))
if (GUILayout.Button("Random", GUILayout.Width(60)))
_seed = Random.Range(int.MinValue, int.MaxValue);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
if (GUILayout.Button("Generate Single Map (Preview)"))
if (GUILayout.Button("Generate Single Map (Preview)", GUILayout.Height(28)))
GenerateSingle();
EditorGUILayout.Space();
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Stress Test", EditorStyles.boldLabel);
_testCount = EditorGUILayout.IntSlider("Iterations", _testCount, 10, 1000);
if (GUILayout.Button($"Run {_testCount} Generations"))
if (GUILayout.Button($"Run {_testCount} Generations (target: 0 failures, <10ms avg)", GUILayout.Height(28)))
StressTest();
if (!string.IsNullOrEmpty(_lastResult))
{
EditorGUILayout.Space();
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(200));
EditorGUILayout.Space(10);
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.MinHeight(180));
EditorGUILayout.TextArea(_lastResult, GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
}
}
private void DrawPreflightCheck()
{
string[] rulesGuids = AssetDatabase.FindAssets("t:GlobalRules_SO");
var rooms = Resources.LoadAll<RoomNode_SO>("Items/Rooms");
if (rulesGuids.Length == 0)
{
EditorGUILayout.HelpBox("No GlobalRules_SO found! Create one first: IStalk > Create > Global Rules SO", MessageType.Error);
return;
}
if (rooms.Length == 0)
{
EditorGUILayout.HelpBox("No RoomNode_SO assets found in Resources/Items/Rooms/. Create rooms first.", MessageType.Error);
return;
}
int withPrefab = rooms.Count(r => r.roomPrefab != null);
EditorGUILayout.LabelField($" Room pool: {rooms.Length} rooms ({withPrefab} with prefabs assigned)");
}
private void GenerateSingle()
{
var rules = FindGlobalRules();
......@@ -66,12 +95,17 @@ namespace IStalk.Editor.MapGen
gen.Generate();
sw.Stop();
_lastResult = $"Seed: {_seed}\n" +
$"Rooms placed: {gen.PlacedRooms.Count}\n" +
$"Altars: {gen.AltarPositions.Count}\n" +
bool roomCountOk = gen.PlacedRooms.Count >= rules.minRoomsPerMap && gen.PlacedRooms.Count <= rules.maxRoomsPerMap;
bool altarCountOk = gen.AltarPositions.Count >= rules.minAltars;
_lastTestPassed = roomCountOk && altarCountOk;
_lastResult = $"Single Map — Seed: {_seed}\n" +
$"─────────────────────────────\n" +
$"Rooms placed: {gen.PlacedRooms.Count} (target: {rules.minRoomsPerMap}{rules.maxRoomsPerMap}) {(roomCountOk ? "✓" : "✗")}\n" +
$"Altars: {gen.AltarPositions.Count} (target: {rules.minAltars}{rules.maxAltars}) {(altarCountOk ? "✓" : "✗")}\n" +
$"Loot slots: {gen.LootSlotPositions.Count}\n" +
$"Time: {sw.ElapsedMilliseconds}ms\n" +
$"Bounds: {gen.GetMapBounds()}\n\n" +
$"Generation time: {sw.ElapsedMilliseconds}ms\n" +
$"Map bounds: {gen.GetMapBounds()}\n\n" +
"Room breakdown:\n";
var categoryCounts = new System.Collections.Generic.Dictionary<RoomCategory, int>();
......@@ -84,6 +118,9 @@ namespace IStalk.Editor.MapGen
foreach (var kvp in categoryCounts)
_lastResult += $" {kvp.Key}: {kvp.Value}\n";
_lastResult += $"\nVerdict: {(_lastTestPassed ? "PASS" : "ISSUES DETECTED — check room pool")}";
DestroyImmediate(go);
Repaint();
}
......@@ -127,13 +164,16 @@ namespace IStalk.Editor.MapGen
DestroyImmediate(go);
_lastResult = $"Stress Test Results ({_testCount} iterations)\n" +
_lastTestPassed = failures == 0;
string verdict = _lastTestPassed ? "PASS" : "FAIL";
_lastResult = $"Stress Test Results ({_testCount} iterations) — {verdict}\n" +
$"─────────────────────────────\n" +
$"Failures: {failures}/{_testCount}\n" +
$"Avg time: {totalMs / _testCount}ms\n" +
$"Max time: {totalMs}ms total\n" +
$"Total time: {totalMs}ms\n" +
$"Room range: {minRooms}{maxRooms}\n" +
$"Success rate: {((_testCount - failures) * 100f / _testCount):F1}%";
$"Success rate: {((_testCount - failures) * 100f / _testCount):F1}%\n\n" +
(failures > 0 ? "Fix: Check room connector directions and GlobalRules min/max room counts." : "All maps generated successfully.");
Repaint();
}
......
......@@ -12,6 +12,7 @@ namespace IStalk.Editor.MapGen
{
private RoomNode_SO _room;
private bool _showValidation = true;
private bool _showDesignerPanel = true;
private void OnEnable()
{
......@@ -22,17 +23,83 @@ namespace IStalk.Editor.MapGen
{
DrawDefaultInspector();
EditorGUILayout.Space(15);
_showDesignerPanel = EditorGUILayout.Foldout(_showDesignerPanel, "Designer Panel", true);
if (!_showDesignerPanel) return;
DrawRoomSummary();
DrawMapGenContext();
DrawRoomTools();
DrawValidationResults();
}
private void DrawRoomSummary()
{
EditorGUILayout.LabelField("Room Summary", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Category: {_room.category}");
EditorGUILayout.LabelField($" Grid Size: {_room.gridSize} ({_room.gridSize.x * _room.gridSize.z} cells)");
EditorGUILayout.LabelField($" Connections: {_room.openConnections.Count} ({string.Join(", ", _room.openConnections)})");
EditorGUILayout.Space(3);
EditorGUILayout.LabelField("Content Slots", EditorStyles.boldLabel);
EditorGUILayout.LabelField($" Loot: {_room.lootSpawnSlots}");
EditorGUILayout.LabelField($" Incarnation Props: {_room.incarnationPropSlots}");
EditorGUILayout.LabelField($" Runner Spawn: {(_room.isValidRunnerSpawn ? "Yes" : "No")}");
EditorGUILayout.LabelField($" Altar Location: {(_room.isValidAltarLocation ? "Yes" : "No")}");
EditorGUILayout.LabelField($" Shrine: {(_room.canContainShrine ? "Yes" : "No")}");
}
private void DrawMapGenContext()
{
EditorGUILayout.Space(5);
EditorGUILayout.LabelField("Map Generation Context", EditorStyles.boldLabel);
var allRooms = Resources.LoadAll<RoomNode_SO>("Items/Rooms");
if (allRooms.Length == 0) return;
int sameCategory = allRooms.Count(r => r.category == _room.category);
float totalWeight = allRooms.Where(r => r.category == _room.category).Sum(r => r.spawnWeight);
float myChance = totalWeight > 0 ? (_room.spawnWeight / totalWeight) * 100f : 0;
EditorGUILayout.LabelField($" Rooms in '{_room.category}' pool: {sameCategory}");
EditorGUILayout.LabelField($" Spawn weight: {_room.spawnWeight} ({myChance:F1}% chance when picking from pool)");
EditorGUILayout.LabelField($" Max per map: {(_room.maxPerMap <= 0 ? "Unlimited" : _room.maxPerMap.ToString())}");
if (_room.openConnections.Count <= 1)
EditorGUILayout.HelpBox("Only 1 connection → dead-end room. Good for special rooms, limits map flow.", MessageType.Info);
else if (_room.openConnections.Count >= 4)
EditorGUILayout.HelpBox("4+ connections → hub room. Creates branching paths, great for large rooms.", MessageType.Info);
}
private void DrawRoomTools()
{
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Room Tools", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Auto-Populate from Prefab"))
AutoPopulate();
if (GUILayout.Button("Generate ID"))
{
Undo.RecordObject(_room, "Generate Room ID");
_room.roomId = _room.name.Replace(" ", "_").ToLower();
EditorUtility.SetDirty(_room);
}
EditorGUILayout.EndHorizontal();
if (GUILayout.Button("Validate Room"))
ValidateRoom();
if (_showValidation)
DrawValidationResults();
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Select Prefab"))
{
if (_room.roomPrefab != null)
Selection.activeObject = _room.roomPrefab;
}
if (GUILayout.Button("Open Prefab"))
{
if (_room.roomPrefab != null)
AssetDatabase.OpenAsset(_room.roomPrefab);
}
EditorGUILayout.EndHorizontal();
}
private void AutoPopulate()
......@@ -66,38 +133,45 @@ namespace IStalk.Editor.MapGen
_room.isValidAltarLocation = slots.Any(s => s.slotType == SpawnSlotType.AltarLocation);
_room.canContainShrine = slots.Any(s => s.slotType == SpawnSlotType.Shrine);
if (string.IsNullOrEmpty(_room.roomId))
_room.roomId = _room.name.Replace(" ", "_").ToLower();
EditorUtility.SetDirty(_room);
Debug.Log($"[RoomNodeEditor] Auto-populated: {connectors.Length} connectors, {slots.Length} slots, grid {_room.gridSize}");
}
private void ValidateRoom()
{
_showValidation = true;
Repaint();
}
private void DrawValidationResults()
{
EditorGUILayout.Space(5);
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Validation", EditorStyles.boldLabel);
bool valid = true;
if (string.IsNullOrEmpty(_room.roomId))
{
EditorGUILayout.HelpBox("Room ID is empty.", MessageType.Error);
EditorGUILayout.HelpBox("Room ID is empty. Click 'Generate ID'.", MessageType.Error);
valid = false;
}
if (_room.roomPrefab == null)
{
EditorGUILayout.HelpBox("No prefab assigned.", MessageType.Error);
EditorGUILayout.HelpBox("No prefab assigned. Drag a room prefab here.", MessageType.Error);
valid = false;
}
else
{
var connectors = _room.roomPrefab.GetComponentsInChildren<ConnectorMarker>(true);
if (connectors.Length == 0)
EditorGUILayout.HelpBox("Prefab has no ConnectorMarker components — room can't connect to anything.", MessageType.Error);
var bounds = _room.roomPrefab.GetComponentInChildren<RoomBounds>(true);
if (bounds == null)
EditorGUILayout.HelpBox("Prefab has no RoomBounds — MapGenerator can't size it.", MessageType.Warning);
}
if (_room.openConnections.Count == 0)
{
EditorGUILayout.HelpBox("No connections defined — room will be isolated.", MessageType.Warning);
EditorGUILayout.HelpBox("No connections defined — click 'Auto-Populate' to read from prefab.", MessageType.Warning);
}
if (_room.gridSize.x <= 0 || _room.gridSize.y <= 0 || _room.gridSize.z <= 0)
......@@ -108,13 +182,16 @@ namespace IStalk.Editor.MapGen
if (_room.spawnWeight <= 0)
{
EditorGUILayout.HelpBox("Spawn weight is 0 — room will never appear.", MessageType.Warning);
EditorGUILayout.HelpBox("Spawn weight is 0 — room will NEVER appear in generated maps.", MessageType.Warning);
}
if (valid)
if (_room.lootSpawnSlots == 0 && _room.category != RoomCategory.Corridor)
{
EditorGUILayout.HelpBox("Room passes all checks.", MessageType.Info);
EditorGUILayout.HelpBox("No loot slots — runners can't find items here.", MessageType.Info);
}
if (valid && _room.roomPrefab != null)
EditorGUILayout.HelpBox("Room passes all checks. Ready for map generation.", MessageType.Info);
}
}
}
......
......@@ -12,29 +12,61 @@ namespace IStalk.Editor.MapGen
{
private Vector2 _scrollPos;
private List<ValidationResult> _results = new();
private int _errorCount;
private int _warningCount;
private int _passCount;
[MenuItem("IStalk/Room Validator")]
public static void ShowWindow()
{
GetWindow<RoomValidator>("Room Validator");
var window = GetWindow<RoomValidator>("Room Validator");
window.minSize = new Vector2(400, 350);
}
private void OnGUI()
{
EditorGUILayout.LabelField("Room Validator", EditorStyles.boldLabel);
EditorGUILayout.HelpBox("Validates all RoomNode_SO assets against their assigned prefabs. Run after editing any room.", MessageType.None);
EditorGUILayout.Space();
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Validate All Rooms"))
if (GUILayout.Button("Validate All Rooms", GUILayout.Height(28)))
ValidateAll();
if (GUILayout.Button("Validate Selected Prefab"))
if (GUILayout.Button("Validate Selected", GUILayout.Height(28)))
ValidateSelected();
if (GUILayout.Button("Auto-Fix All IDs", GUILayout.Height(28)))
AutoFixIds();
EditorGUILayout.EndHorizontal();
if (_results.Count > 0)
{
EditorGUILayout.Space(5);
EditorGUILayout.LabelField($"Results: {_errorCount} errors, {_warningCount} warnings, {_passCount} passed",
_errorCount > 0 ? EditorStyles.boldLabel : EditorStyles.label);
}
EditorGUILayout.Space();
DrawResults();
}
private void AutoFixIds()
{
var rooms = Resources.LoadAll<RoomNode_SO>("Items/Rooms");
int fixed_ = 0;
foreach (var room in rooms)
{
if (string.IsNullOrEmpty(room.roomId))
{
Undo.RecordObject(room, "Auto-Fix Room ID");
room.roomId = room.name.Replace(" ", "_").ToLower();
EditorUtility.SetDirty(room);
fixed_++;
}
}
Debug.Log($"[RoomValidator] Auto-fixed {fixed_} room IDs.");
ValidateAll();
}
private void ValidateAll()
{
_results.Clear();
......@@ -43,9 +75,10 @@ namespace IStalk.Editor.MapGen
foreach (var room in rooms)
_results.AddRange(ValidateRoom(room));
int errors = _results.Count(r => r.Severity == MessageType.Error);
int warnings = _results.Count(r => r.Severity == MessageType.Warning);
Debug.Log($"[RoomValidator] Validated {rooms.Length} rooms: {errors} errors, {warnings} warnings");
_errorCount = _results.Count(r => r.Severity == MessageType.Error);
_warningCount = _results.Count(r => r.Severity == MessageType.Warning);
_passCount = _results.Count(r => r.Severity == MessageType.Info);
Debug.Log($"[RoomValidator] Validated {rooms.Length} rooms: {_errorCount} errors, {_warningCount} warnings, {_passCount} passed");
}
private void ValidateSelected()
......@@ -54,10 +87,13 @@ namespace IStalk.Editor.MapGen
var selected = Selection.activeObject as RoomNode_SO;
if (selected == null)
{
EditorUtility.DisplayDialog("Room Validator", "Select a RoomNode_SO asset first.", "OK");
EditorUtility.DisplayDialog("Room Validator", "Select a RoomNode_SO asset in the Project window first.", "OK");
return;
}
_results.AddRange(ValidateRoom(selected));
_errorCount = _results.Count(r => r.Severity == MessageType.Error);
_warningCount = _results.Count(r => r.Severity == MessageType.Warning);
_passCount = _results.Count(r => r.Severity == MessageType.Info);
}
private List<ValidationResult> ValidateRoom(RoomNode_SO room)
......
......@@ -38,7 +38,8 @@ namespace IStalk.Eye
{
if (!IsServerStarted) return;
float regen = Rules.dreadBaseRegenRate + _noiseBonus;
float baseRegen = _regenOverride >= 0 ? _regenOverride : Rules.dreadBaseRegenRate;
float regen = baseRegen + _noiseBonus;
_currentDread.Value = UnityEngine.Mathf.Min(_currentDread.Value + regen * UnityEngine.Time.deltaTime, _maxDread);
_noiseBonus *= 0.95f;
}
......@@ -56,5 +57,19 @@ namespace IStalk.Eye
{
_noiseBonus += intensity * Rules.dreadNoiseBonusMultiplier;
}
[Server]
public void ApplyStartOverride(float amount)
{
_currentDread.Value = amount;
}
[Server]
public void ApplyRegenOverride(float rate)
{
_regenOverride = rate;
}
private float _regenOverride = -1f;
}
}
using FishNet.Object;
using FishNet.Object.Synchronizing;
using UnityEngine;
namespace IStalk.Eye
{
public class EyeEyelidController : NetworkBehaviour
{
private readonly SyncVar<bool> _eyelidsClosed = new();
private GameObject _blackOverlay;
public bool IsClosed => _eyelidsClosed.Value;
public override void OnStartClient()
{
base.OnStartClient();
_eyelidsClosed.OnChange += OnEyelidStateChanged;
if (!IsOwner)
{
enabled = false;
return;
}
CreateBlackOverlay();
}
private void Update()
{
if (!IsOwner) return;
if (Input.GetMouseButtonDown(1))
ServerSetEyelids(!_eyelidsClosed.Value);
}
[ServerRpc]
private void ServerSetEyelids(bool closed)
{
_eyelidsClosed.Value = closed;
}
private void OnEyelidStateChanged(bool prev, bool next, bool asServer)
{
if (IsOwner && _blackOverlay != null)
_blackOverlay.SetActive(next);
}
private void CreateBlackOverlay()
{
var canvas = new GameObject("EyelidOverlay");
var canvasComp = canvas.AddComponent<Canvas>();
canvasComp.renderMode = RenderMode.ScreenSpaceOverlay;
canvasComp.sortingOrder = 999;
var panel = new GameObject("BlackPanel");
panel.transform.SetParent(canvas.transform, false);
var img = panel.AddComponent<UnityEngine.UI.Image>();
img.color = Color.black;
var rect = panel.GetComponent<RectTransform>();
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
_blackOverlay = canvas;
_blackOverlay.SetActive(false);
}
public override void OnStopClient()
{
base.OnStopClient();
if (_blackOverlay != null)
Destroy(_blackOverlay);
}
}
}
fileFormatVersion: 2
guid: 2078889c66eab4f6f9c789aadc13ea75
\ No newline at end of file
using System;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using IStalk.Data;
using UnityEngine;
namespace IStalk.Eye
{
public class EyeFlashReceiver : NetworkBehaviour
{
private readonly SyncVar<float> _blindTimer = new(new SyncTypeSettings(0.5f));
private readonly SyncVar<float> _teamFlashCooldownTimer = new(new SyncTypeSettings(1f));
private EyeEyelidController _eyelid;
private EyeLaserController _laser;
private EyeScanAbility _scan;
private EyeTrapPlacer _traps;
private EyeIncarnationController _incarnation;
private GameObject _flashOverlay;
public bool IsBlinded => _blindTimer.Value > 0f;
public float BlindTimeRemaining => _blindTimer.Value;
public bool IsFlashOnCooldown => _teamFlashCooldownTimer.Value > 0f;
public event Action OnFlashDodged;
public event Action<float> OnFlashHit;
public override void OnStartNetwork()
{
base.OnStartNetwork();
_eyelid = GetComponent<EyeEyelidController>();
_laser = GetComponent<EyeLaserController>();
_scan = GetComponent<EyeScanAbility>();
_traps = GetComponent<EyeTrapPlacer>();
_incarnation = GetComponent<EyeIncarnationController>();
}
public override void OnStartClient()
{
base.OnStartClient();
if (IsOwner)
CreateFlashOverlay();
}
private void Update()
{
if (IsServerInitialized)
{
if (_blindTimer.Value > 0f)
_blindTimer.Value -= Time.deltaTime;
if (_teamFlashCooldownTimer.Value > 0f)
_teamFlashCooldownTimer.Value -= Time.deltaTime;
}
if (IsOwner && _flashOverlay != null)
_flashOverlay.SetActive(_blindTimer.Value > 0f);
}
[Server]
public bool TryApplyFlash()
{
if (_teamFlashCooldownTimer.Value > 0f)
return false;
var rules = GlobalRules_SO.Instance;
_teamFlashCooldownTimer.Value = rules.flashTeamCooldown;
if (_eyelid != null && _eyelid.IsClosed)
{
FlashDodgedObservers();
return false;
}
_blindTimer.Value = rules.flashDuration;
FlashHitObservers(rules.flashDuration);
return true;
}
[ObserversRpc]
private void FlashDodgedObservers()
{
OnFlashDodged?.Invoke();
}
[ObserversRpc]
private void FlashHitObservers(float duration)
{
OnFlashHit?.Invoke(duration);
}
private void CreateFlashOverlay()
{
var canvas = new GameObject("FlashOverlay");
var canvasComp = canvas.AddComponent<Canvas>();
canvasComp.renderMode = RenderMode.ScreenSpaceOverlay;
canvasComp.sortingOrder = 998;
var panel = new GameObject("WhitePanel");
panel.transform.SetParent(canvas.transform, false);
var img = panel.AddComponent<UnityEngine.UI.Image>();
img.color = Color.white;
var rect = panel.GetComponent<RectTransform>();
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
_flashOverlay = canvas;
_flashOverlay.SetActive(false);
}
public override void OnStopClient()
{
base.OnStopClient();
if (_flashOverlay != null)
Destroy(_flashOverlay);
}
}
}
fileFormatVersion: 2
guid: 91e73192cfc004ff08cc2e471dfe7891
\ No newline at end of file
......@@ -21,9 +21,17 @@ namespace IStalk.Eye
private float _chargeTimer;
private float _cooldownTimer;
private DreadEconomy _dread;
private float _cooldownMultiplier = 1f;
private float _radiusMultiplier = 1f;
private GlobalRules_SO Rules => GlobalRules_SO.Instance;
public void ApplyTypeModifiers(float cooldownMult, float radiusMult)
{
_cooldownMultiplier = cooldownMult;
_radiusMultiplier = radiusMult;
}
private enum LaserState { Idle, Telegraphing, Charging, Firing, Cooldown }
public override void OnStartClient()
......@@ -98,14 +106,15 @@ namespace IStalk.Eye
Vector3 dir = (_aimEnd.Value - _aimStart.Value).normalized;
if (dir.sqrMagnitude < 0.01f) dir = Vector3.forward;
if (Physics.SphereCast(_aimStart.Value, Rules.laserRadius, dir, out RaycastHit hit, Rules.laserRange, _hitLayers))
float radius = Rules.laserRadius * _radiusMultiplier;
if (Physics.SphereCast(_aimStart.Value, radius, dir, out RaycastHit hit, Rules.laserRange, _hitLayers))
{
var runner = hit.collider.GetComponentInParent<Runner.RunnerController>();
if (runner != null)
HandleRunnerHit(runner);
}
SetCooldownObservers(Rules.laserCooldown);
SetCooldownObservers(Rules.laserCooldown * _cooldownMultiplier);
Invoke(nameof(ResetToIdle), 0.3f);
}
......
......@@ -17,10 +17,18 @@ namespace IStalk.Eye
private DreadEconomy _dread;
private float _scanTimer;
private float _cooldownMultiplier = 1f;
private float _radiusMultiplier = 1f;
public void ApplyTypeModifiers(float cooldownMult, float radiusMult)
{
_cooldownMultiplier = cooldownMult;
_radiusMultiplier = radiusMult;
}
public bool IsScanning => _isScanning.Value;
public float CooldownRemaining => _cooldownRemaining.Value;
public float ScanRadius => _scanRadius;
public float ScanRadius => _scanRadius * _radiusMultiplier;
public event System.Action OnScanStarted;
public event System.Action OnScanEnded;
......@@ -55,7 +63,7 @@ namespace IStalk.Eye
if (_scanTimer <= 0)
{
_isScanning.Value = false;
_cooldownRemaining.Value = _scanCooldown;
_cooldownRemaining.Value = _scanCooldown * _cooldownMultiplier;
}
}
}
......
......@@ -13,6 +13,14 @@ namespace IStalk.Eye
private DreadEconomy _dread;
private TrapData_SO _selectedTrap;
private readonly SyncVar<int> _activeTrapCount = new();
private float _costMultiplier = 1f;
private int _maxActiveBonus = 0;
public void ApplyTypeModifiers(float costMult, int maxActiveBonus)
{
_costMultiplier = costMult;
_maxActiveBonus = maxActiveBonus;
}
public override void OnStartClient()
{
......@@ -54,10 +62,12 @@ namespace IStalk.Eye
var trapData = ItemDatabase.Instance.GetTrap(trapId);
if (trapData == null) return;
if (trapData.maxActivePerMatch > 0 && _activeTrapCount.Value >= trapData.maxActivePerMatch)
int maxActive = trapData.maxActivePerMatch + _maxActiveBonus;
if (maxActive > 0 && _activeTrapCount.Value >= maxActive)
return;
if (!_dread.TrySpend(trapData.dreadCost))
int adjustedCost = Mathf.RoundToInt(trapData.dreadCost * _costMultiplier);
if (!_dread.TrySpend(adjustedCost))
return;
if (trapData.placedPrefab == null) return;
......
using FishNet.Object;
using FishNet.Object.Synchronizing;
using IStalk.Data;
using UnityEngine;
namespace IStalk.Eye
{
public class EyeTypeController : NetworkBehaviour
{
private readonly SyncVar<string> _eyeTypeId = new();
private EyeType_SO _eyeType;
private EyeLaserController _laser;
private EyeScanAbility _scan;
private EyeTrapPlacer _traps;
private EyeIncarnationController _incarnation;
private EyeEyelidController _eyelid;
private DreadEconomy _dread;
public EyeType_SO CurrentType => _eyeType;
public override void OnStartNetwork()
{
base.OnStartNetwork();
_laser = GetComponent<EyeLaserController>();
_scan = GetComponent<EyeScanAbility>();
_traps = GetComponent<EyeTrapPlacer>();
_incarnation = GetComponent<EyeIncarnationController>();
_eyelid = GetComponent<EyeEyelidController>();
_dread = GetComponent<DreadEconomy>();
_eyeTypeId.OnChange += OnTypeIdChanged;
}
public override void OnStartServer()
{
base.OnStartServer();
if (!string.IsNullOrEmpty(_eyeTypeId.Value))
ApplyType();
}
[Server]
public void SetEyeType(string typeId)
{
_eyeTypeId.Value = typeId;
ApplyType();
}
private void OnTypeIdChanged(string prev, string next, bool asServer)
{
if (!asServer)
ApplyType();
}
private void ApplyType()
{
_eyeType = ItemDatabase.Instance.GetEyeType(_eyeTypeId.Value);
if (_eyeType == null)
{
Debug.LogError($"[EyeTypeController] Unknown eye type: {_eyeTypeId.Value}");
return;
}
if (_laser != null)
{
bool hasLaser = _eyeType.HasAbility(EyeAbility.Laser);
_laser.enabled = hasLaser;
if (hasLaser)
_laser.ApplyTypeModifiers(_eyeType.laserCooldownMultiplier, _eyeType.laserRadiusMultiplier);
}
if (_scan != null)
{
bool hasScan = _eyeType.HasAbility(EyeAbility.Scan);
_scan.enabled = hasScan;
if (hasScan)
_scan.ApplyTypeModifiers(_eyeType.scanCooldownMultiplier, _eyeType.scanRadiusMultiplier);
}
if (_traps != null)
{
bool hasTraps = _eyeType.HasAbility(EyeAbility.Traps);
_traps.enabled = hasTraps;
if (hasTraps)
_traps.ApplyTypeModifiers(_eyeType.trapCostMultiplier, _eyeType.trapMaxActiveBonus);
}
if (_incarnation != null)
_incarnation.enabled = _eyeType.HasAbility(EyeAbility.Incarnation);
if (_eyelid != null)
_eyelid.enabled = _eyeType.hasEyelids;
if (_dread != null && IsServerInitialized)
{
if (_eyeType.dreadStartOverride >= 0)
_dread.ApplyStartOverride(_eyeType.dreadStartOverride);
if (_eyeType.dreadRegenOverride >= 0)
_dread.ApplyRegenOverride(_eyeType.dreadRegenOverride);
}
}
}
}
fileFormatVersion: 2
guid: 869c5d559bae24bd9b7905e666c4e8ad
\ No newline at end of file
......@@ -30,8 +30,9 @@ namespace IStalk.Eye
public IReadOnlyList<DecoyBlip> ActiveDecoys => _decoyBlips;
public event System.Action<List<int>> OnVisibilityChanged;
private void Awake()
public override void OnStartNetwork()
{
base.OnStartNetwork();
Instance = this;
}
......
using System.Collections.Generic;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using IStalk.Data;
using UnityEngine;
namespace IStalk.Runner
{
public struct ActiveBuff
{
public string BuffId;
public float RemainingTime;
}
public class RunnerBuffSystem : NetworkBehaviour
{
private readonly SyncList<string> _activeBuffIds = new();
private readonly List<float> _buffTimers = new();
private RunnerController _controller;
private GlobalRules_SO Rules => GlobalRules_SO.Instance;
public event System.Action OnBuffsChanged;
public override void OnStartNetwork()
{
base.OnStartNetwork();
_controller = GetComponent<RunnerController>();
_activeBuffIds.OnChange += (op, idx, old, next, server) => OnBuffsChanged?.Invoke();
}
private void Update()
{
if (!IsServerInitialized) return;
for (int i = _buffTimers.Count - 1; i >= 0; i--)
{
_buffTimers[i] -= Time.deltaTime;
if (_buffTimers[i] <= 0)
{
_activeBuffIds.RemoveAt(i);
_buffTimers.RemoveAt(i);
}
}
ApplyProximityBuffs();
}
[Server]
public void AddBuff(string buffId, float duration)
{
_activeBuffIds.Add(buffId);
_buffTimers.Add(duration);
}
[Server]
public void RemoveBuff(string buffId)
{
int idx = _activeBuffIds.IndexOf(buffId);
if (idx >= 0)
{
_activeBuffIds.RemoveAt(idx);
_buffTimers.RemoveAt(idx);
}
}
private void ApplyProximityBuffs()
{
float linkedBonus = 0f;
var colliders = Physics.OverlapSphere(transform.position, Rules.linkedSprintRadius);
foreach (var col in colliders)
{
if (col.gameObject == gameObject) continue;
var otherBuffs = col.GetComponentInParent<RunnerBuffSystem>();
if (otherBuffs == null) continue;
if (otherBuffs.HasBuff("linked_sprint"))
linkedBonus = Mathf.Max(linkedBonus, Rules.linkedSprintBonus);
}
if (HasBuff("linked_sprint"))
{
bool anyTeammateNearby = false;
foreach (var col in colliders)
{
if (col.gameObject == gameObject) continue;
if (col.GetComponentInParent<RunnerController>() != null)
{
anyTeammateNearby = true;
break;
}
}
if (anyTeammateNearby)
linkedBonus = Mathf.Max(linkedBonus, Rules.linkedSprintBonus);
}
if (_controller != null)
_controller.SetSpeedBonus(linkedBonus);
}
public bool HasBuff(string buffId) => _activeBuffIds.Contains(buffId);
public int ActiveBuffCount => _activeBuffIds.Count;
public string GetBuffAt(int index)
{
if (index < 0 || index >= _activeBuffIds.Count) return null;
return _activeBuffIds[index];
}
}
}
fileFormatVersion: 2
guid: 7357a637acc77476aabef8a9525b1e40
\ No newline at end of file
......@@ -43,8 +43,42 @@ namespace IStalk.Runner
private Vector3 _parkourStart;
private Vector3 _parkourEnd;
private bool _dashEnabled;
private bool _wallRunEnabled;
private bool _airDashEnabled;
private bool _slideEnabled;
private bool _vaultEnabled;
private float _speedBonus;
private GlobalRules_SO Rules => GlobalRules_SO.Instance;
public void EnableAbility(WearableAbilityType ability)
{
switch (ability)
{
case WearableAbilityType.Dash: _dashEnabled = true; break;
case WearableAbilityType.WallRun: _wallRunEnabled = true; break;
case WearableAbilityType.AirDash: _airDashEnabled = true; break;
case WearableAbilityType.Slide: _slideEnabled = true; break;
case WearableAbilityType.Vault: _vaultEnabled = true; break;
}
}
public void DisableAbility(WearableAbilityType ability)
{
switch (ability)
{
case WearableAbilityType.Dash: _dashEnabled = false; break;
case WearableAbilityType.WallRun: _wallRunEnabled = false; break;
case WearableAbilityType.AirDash: _airDashEnabled = false; break;
case WearableAbilityType.Slide: _slideEnabled = false; break;
case WearableAbilityType.Vault: _vaultEnabled = false; break;
}
}
public void SetSpeedBonus(float bonus) => _speedBonus = bonus;
public enum MoveState { Normal, WallRunning, Vaulting, Mantling, Sliding, Dashing }
private struct MoveData : IReplicateData
......@@ -201,32 +235,32 @@ namespace IStalk.Runner
_isSprinting = md.Sprint && _isGrounded && canSprint;
float speedMult = _weight != null ? Rules.GetSpeedMultiplier(_weight.CurrentTier) : 1f;
float healthMult = _health != null ? _health.SlowMultiplier : 1f;
float speed = Rules.baseRunSpeed * speedMult * healthMult;
float speed = Rules.baseRunSpeed * speedMult * healthMult * (1f + _speedBonus);
if (_isSprinting)
{
speed *= Rules.sprintMultiplier;
if (IsServerStarted && _stamina != null) _stamina.DrainSprint(dt);
}
if (md.Slide && _isSprinting && _isGrounded)
if (_slideEnabled && md.Slide && _isSprinting && _isGrounded)
{
EnterSlide(md.Yaw);
return;
}
if (md.Dash && _dashCooldown <= 0 && !_isGrounded && (_stamina == null || _stamina.CanDash))
if ((_dashEnabled || _airDashEnabled) && md.Dash && _dashCooldown <= 0 && !_isGrounded && (_stamina == null || _stamina.CanDash))
{
if (IsServerStarted && _stamina != null && !_stamina.TryDash()) { }
else { EnterDash(md); return; }
}
if (!_isGrounded && _canWallRun && md.Move.y > 0.1f)
if (_wallRunEnabled && !_isGrounded && _canWallRun && md.Move.y > 0.1f)
{
if (TryEnterWallRun(md))
return;
}
if (!_isGrounded && md.Move.y > 0.1f)
if (_vaultEnabled && !_isGrounded && md.Move.y > 0.1f)
{
if (TryVault(md.Yaw))
return;
......
using FishNet.Object;
using UnityEngine;
using IStalk.Data;
namespace IStalk.Runner
{
public class RunnerCoopActions : NetworkBehaviour
{
private PlayerInventory _inventory;
private RunnerController _controller;
private RunnerHealth _health;
private float _boostCooldown;
private GlobalRules_SO Rules => GlobalRules_SO.Instance;
public override void OnStartClient()
{
base.OnStartClient();
if (!IsOwner)
{
enabled = false;
return;
}
_inventory = GetComponent<PlayerInventory>();
_controller = GetComponent<RunnerController>();
_health = GetComponent<RunnerHealth>();
}
private void Update()
{
if (!IsOwner) return;
if (_health != null && _health.IsDown) return;
_boostCooldown -= Time.deltaTime;
if (Input.GetKeyDown(KeyCode.F))
TryBoost();
if (Input.GetKey(KeyCode.G))
TryThrowItem();
}
private void TryBoost()
{
if (_boostCooldown > 0) return;
var colliders = Physics.OverlapSphere(transform.position, Rules.boostRange);
foreach (var col in colliders)
{
if (col.gameObject == gameObject) continue;
var otherController = col.GetComponentInParent<RunnerController>();
if (otherController == null) continue;
var otherHealth = col.GetComponentInParent<RunnerHealth>();
if (otherHealth != null && otherHealth.IsDown) continue;
if (IsCrouchingNearWall(otherController))
{
ServerRequestBoost(otherController.NetworkObject);
_boostCooldown = 1f;
return;
}
}
}
private bool IsCrouchingNearWall(RunnerController other)
{
Vector3 pos = other.transform.position;
return Physics.Raycast(pos, other.transform.forward, 1.2f) ||
Physics.Raycast(pos, -other.transform.forward, 1.2f) ||
Physics.Raycast(pos, other.transform.right, 1.2f) ||
Physics.Raycast(pos, -other.transform.right, 1.2f);
}
[ServerRpc]
private void ServerRequestBoost(NetworkObject booster)
{
if (booster == null) return;
var boosterHealth = booster.GetComponent<RunnerHealth>();
if (boosterHealth != null && boosterHealth.IsDown) return;
float dist = Vector3.Distance(transform.position, booster.transform.position);
if (dist > Rules.boostRange * 1.5f) return;
var cc = GetComponent<CharacterController>();
if (cc != null)
cc.Move(Vector3.up * Rules.boostHeight);
BoostVfxObservers(transform.position);
}
[ObserversRpc]
private void BoostVfxObservers(Vector3 position)
{
// VFX/SFX hook for boost effect
}
private void TryThrowItem()
{
if (_inventory == null) return;
var items = _inventory.GetItems();
if (items.Count == 0) return;
var target = FindThrowTarget();
if (target == null) return;
string lastItem = items[items.Count - 1];
ServerThrowItem(lastItem, target.NetworkObject);
}
private RunnerController FindThrowTarget()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit, 30f))
{
var runner = hit.collider.GetComponentInParent<RunnerController>();
if (runner != null && runner.gameObject != gameObject)
{
float dist = Vector3.Distance(transform.position, runner.transform.position);
if (dist <= Rules.throwRangeBase)
return runner;
}
}
var colliders = Physics.OverlapSphere(transform.position, Rules.throwRangeBase);
RunnerController closest = null;
float closestDist = float.MaxValue;
foreach (var col in colliders)
{
if (col.gameObject == gameObject) continue;
var runner = col.GetComponentInParent<RunnerController>();
if (runner == null) continue;
var health = col.GetComponentInParent<RunnerHealth>();
if (health != null && health.IsDown) continue;
float d = Vector3.Distance(transform.position, runner.transform.position);
if (d < closestDist)
{
closestDist = d;
closest = runner;
}
}
return closest;
}
[ServerRpc]
private void ServerThrowItem(string itemId, NetworkObject target)
{
if (target == null) return;
if (!_inventory.Contains(itemId)) return;
float dist = Vector3.Distance(transform.position, target.transform.position);
var itemData = ItemDatabase.Instance.GetLootItem(itemId);
float maxRange = Rules.throwRangeBase;
if (itemData != null)
maxRange -= itemData.weight * Rules.throwRangeWeightScale;
if (dist > maxRange * 1.5f) return;
_inventory.TryRemoveItem(itemId);
var targetInventory = target.GetComponent<PlayerInventory>();
if (targetInventory != null && targetInventory.HasSpace)
{
targetInventory.TryAddItem(itemId);
}
else
{
WorldItem.SpawnWorldItem(itemId, target.transform.position + Vector3.up * 0.5f);
}
ThrowVfxObservers(transform.position, target.transform.position);
}
[ObserversRpc]
private void ThrowVfxObservers(Vector3 from, Vector3 to)
{
// VFX/SFX hook for throw arc
}
}
}
fileFormatVersion: 2
guid: 7c442aac166254768ae9229889609d3e
\ No newline at end of file
......@@ -53,6 +53,9 @@ namespace IStalk.Runner
var inventory = GetComponent<PlayerInventory>();
if (inventory != null)
inventory.DropAll(transform.position);
var wearables = GetComponent<RunnerWearableManager>();
if (wearables != null)
wearables.DropAllWearables(transform.position);
}
HitFeedbackObservers();
......
......@@ -146,10 +146,7 @@ namespace IStalk.Runner
if (_channelProgress >= 1f)
{
_channelProgress = 0;
if (_extraction.IsExtractionMet)
ServerExtract();
else
ServerDeposit();
ServerDeposit();
}
}
else
......@@ -180,12 +177,6 @@ namespace IStalk.Runner
_extraction.DepositLoot(_inventory, value);
}
[ServerRpc]
private void ServerExtract()
{
_extraction.TryExtract(NetworkObject);
}
[ServerRpc]
private void ServerUseItem()
{
......
using System;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using IStalk.Data;
using UnityEngine;
namespace IStalk.Runner
{
public class RunnerWearableManager : NetworkBehaviour
{
private readonly SyncList<string> _equippedIds = new();
private RunnerController _controller;
private GlobalRules_SO Rules => GlobalRules_SO.Instance;
public event Action OnWearablesChanged;
public int SlotCount => Rules.maxWearableSlots;
public int UsedSlots => _equippedIds.Count;
public bool HasSpace => _equippedIds.Count < Rules.maxWearableSlots;
public override void OnStartNetwork()
{
base.OnStartNetwork();
_controller = GetComponent<RunnerController>();
_equippedIds.OnChange += HandleWearableListChanged;
}
public override void OnStartClient()
{
base.OnStartClient();
for (int i = 0; i < _equippedIds.Count; i++)
ApplyWearableLocally(_equippedIds[i], true);
}
private void HandleWearableListChanged(SyncListOperation op, int index, string oldItem, string newItem, bool asServer)
{
switch (op)
{
case SyncListOperation.Add:
ApplyWearableLocally(newItem, true);
break;
case SyncListOperation.RemoveAt:
ApplyWearableLocally(oldItem, false);
break;
case SyncListOperation.Clear:
break;
}
OnWearablesChanged?.Invoke();
}
[ServerRpc]
public void ServerEquipWearable(string wearableId)
{
if (_equippedIds.Count >= Rules.maxWearableSlots) return;
if (_equippedIds.Contains(wearableId)) return;
var data = ItemDatabase.Instance.GetWearable(wearableId);
if (data == null) return;
_equippedIds.Add(wearableId);
}
[ServerRpc]
public void ServerUnequipWearable(string wearableId)
{
if (!_equippedIds.Contains(wearableId)) return;
_equippedIds.Remove(wearableId);
}
[Server]
public void DropAllWearables(Vector3 position)
{
for (int i = _equippedIds.Count - 1; i >= 0; i--)
{
string id = _equippedIds[i];
_equippedIds.RemoveAt(i);
SpawnWearableWorldItem(id, position + UnityEngine.Random.insideUnitSphere * 1.5f);
}
}
[Server]
private void SpawnWearableWorldItem(string wearableId, Vector3 position)
{
var data = ItemDatabase.Instance.GetWearable(wearableId);
if (data == null || data.worldPrefab == null) return;
var go = Instantiate(data.worldPrefab, position, Quaternion.identity);
var nob = go.GetComponent<FishNet.Object.NetworkObject>();
if (nob != null)
ServerManager.Spawn(nob);
}
private void ApplyWearableLocally(string wearableId, bool equip)
{
var data = ItemDatabase.Instance.GetWearable(wearableId);
if (data == null || _controller == null) return;
if (equip)
_controller.EnableAbility(data.abilityType);
else
_controller.DisableAbility(data.abilityType);
}
public string GetEquippedAt(int slot)
{
if (slot < 0 || slot >= _equippedIds.Count) return null;
return _equippedIds[slot];
}
public bool HasWearable(string wearableId) => _equippedIds.Contains(wearableId);
}
}
fileFormatVersion: 2
guid: 81f0ef915bf774648969dd4a7b3c4461
\ No newline at end of file
......@@ -79,6 +79,18 @@ namespace IStalk.Runner
case ShrineItemType.GrapplingHook:
ActivateGrapplingHook();
break;
case ShrineItemType.FlashOrb:
ActivateFlashOrb();
break;
case ShrineItemType.LinkedSprintTotem:
ActivateLinkedSprint();
break;
case ShrineItemType.SilenceShroud:
ActivateSilenceShroud();
break;
case ShrineItemType.VitalityLink:
ActivateVitalityLink();
break;
}
SpawnVfxObservers();
......@@ -142,6 +154,38 @@ namespace IStalk.Runner
controller.GrantExtraDash();
}
[Server]
private void ActivateFlashOrb()
{
var eyeFlash = FindAnyObjectByType<EyeFlashReceiver>();
if (eyeFlash != null)
eyeFlash.TryApplyFlash();
}
[Server]
private void ActivateLinkedSprint()
{
var buffs = GetComponent<RunnerBuffSystem>();
if (buffs != null)
buffs.AddBuff("linked_sprint", _activeData.effectDuration);
}
[Server]
private void ActivateSilenceShroud()
{
var noise = GetComponent<NoiseEmitter>();
if (noise != null)
noise.Suppress(_activeData.effectDuration);
}
[Server]
private void ActivateVitalityLink()
{
var buffs = GetComponent<RunnerBuffSystem>();
if (buffs != null)
buffs.AddBuff("vitality_link", _activeData.effectDuration);
}
[Server]
private void EndEffect()
{
......
namespace IStalk.Runner
{
public enum WearableAbilityType
{
Dash,
WallRun,
AirDash,
Slide,
Vault,
Grapple
}
}
fileFormatVersion: 2
guid: 2f85d6ac9d9e1424a8e3a82788ace1c0
\ No newline at end of file
......@@ -11,6 +11,8 @@ namespace IStalk.Shared
[Server]
public void EmitNoise(Vector3 position, float radius, float intensity)
{
if (_suppressTimer > 0) return;
if (_cachedDread == null)
_cachedDread = FindAnyObjectByType<DreadEconomy>();
......@@ -31,6 +33,23 @@ namespace IStalk.Shared
Destroy(pulse, 0.5f);
}
private float _suppressTimer;
[Server]
public void Suppress(float duration)
{
_suppressTimer = duration;
}
public bool IsSuppressed => _suppressTimer > 0f;
private void Update()
{
if (!IsServerInitialized) return;
if (_suppressTimer > 0)
_suppressTimer -= Time.deltaTime;
}
public static void ClearCache()
{
_cachedDread = null;
......
......@@ -12,8 +12,8 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: bf2edee5c58d82540a51f03df9d42094, type: 3}
m_Name: Mobile_RPAsset
m_EditorClassIdentifier:
k_AssetVersion: 12
k_AssetPreviousVersion: 12
k_AssetVersion: 13
k_AssetPreviousVersion: 13
m_RendererType: 1
m_RendererData: {fileID: 0}
m_RendererDataList:
......@@ -53,6 +53,7 @@ MonoBehaviour:
m_AdditionalLightsShadowResolutionTierHigh: 1024
m_ReflectionProbeBlending: 1
m_ReflectionProbeBoxProjection: 1
m_ReflectionProbeAtlas: 1
m_ShadowDistance: 50
m_ShadowCascadeCount: 1
m_Cascade2Split: 0.25
......@@ -78,11 +79,11 @@ MonoBehaviour:
m_UseAdaptivePerformance: 1
m_ColorGradingMode: 0
m_ColorGradingLutSize: 32
m_AllowPostProcessAlphaOutput: 0
m_UseFastSRGBLinearConversion: 1
m_SupportDataDrivenLensFlare: 1
m_SupportScreenSpaceLensFlare: 1
m_GPUResidentDrawerMode: 0
m_UseLegacyLightmaps: 0
m_SmallMeshScreenPercentage: 0
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
m_ShadowType: 1
......@@ -109,6 +110,7 @@ MonoBehaviour:
m_PrefilterDebugKeywords: 1
m_PrefilterWriteRenderingLayers: 1
m_PrefilterHDROutput: 1
m_PrefilterAlphaOutput: 0
m_PrefilterSSAODepthNormals: 1
m_PrefilterSSAOSourceDepthLow: 1
m_PrefilterSSAOSourceDepthMedium: 0
......@@ -126,8 +128,14 @@ MonoBehaviour:
m_PrefilterSoftShadowsQualityHigh: 1
m_PrefilterSoftShadows: 0
m_PrefilterScreenCoord: 1
m_PrefilterScreenSpaceIrradiance: 0
m_PrefilterNativeRenderPass: 1
m_PrefilterUseLegacyLightmaps: 0
m_PrefilterBicubicLightmapSampling: 0
m_PrefilterReflectionProbeRotation: 0
m_PrefilterReflectionProbeBlending: 0
m_PrefilterReflectionProbeBoxProjection: 0
m_PrefilterReflectionProbeAtlas: 0
m_ShaderVariantLogLevel: 0
m_ShadowCascades: 0
m_Textures:
......
......@@ -3,10 +3,11 @@
--- !u!55 &1
PhysicsManager:
m_ObjectHideFlags: 0
serializedVersion: 13
serializedVersion: 23
m_Gravity: {x: 0, y: -9.81, z: 0}
m_DefaultMaterial: {fileID: 0}
m_BounceThreshold: 2
m_DefaultMaxDepenetrationVelocity: 10
m_SleepThreshold: 0.005
m_DefaultContactOffset: 0.01
m_DefaultSolverIterations: 6
......@@ -16,11 +17,11 @@ PhysicsManager:
m_EnableAdaptiveForce: 0
m_ClothInterCollisionDistance: 0.1
m_ClothInterCollisionStiffness: 0.2
m_ContactsGeneration: 1
m_LayerCollisionMatrix: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
m_AutoSimulation: 1
m_LayerCollisionMatrix: fffdfffffffdfffffffdfffffffffffffffdfffffffdffffffedffffffedfffffff8ffff08fcfffffffeffffffffffff3fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
m_SimulationMode: 0
m_AutoSyncTransforms: 0
m_ReuseCollisionCallbacks: 1
m_InvokeCollisionCallbacks: 1
m_ClothInterCollisionSettingsToggle: 0
m_ClothGravity: {x: 0, y: -9.81, z: 0}
m_ContactPairsMode: 0
......@@ -31,6 +32,14 @@ PhysicsManager:
m_WorldSubdivisions: 8
m_FrictionType: 0
m_EnableEnhancedDeterminism: 0
m_EnableUnifiedHeightmaps: 1
m_ImprovedPatchFriction: 0
m_GenerateOnTriggerStayEvents: 1
m_SolverType: 0
m_DefaultMaxAngularSpeed: 50
m_ScratchBufferChunkCount: 4
m_CurrentBackendId: 4072204805
m_FastMotionThreshold: 3.4028235e+38
m_SceneBuffersReleaseInterval: 0
m_ReleaseSceneBuffers: 0
m_LogVerbosity: 3
m_IncrementalStaticBroadphase: 1
......@@ -941,7 +941,7 @@ PlayerSettings:
qnxGraphicConfPath:
apiCompatibilityLevel: 6
captureStartupLogs: {}
activeInputHandler: 1
activeInputHandler: 2
windowsGamepadBackendHint: 0
cloudProjectId:
framebufferDepthMemorylessMode: 0
......
{
"templatePinStates": [],
"dependencyTypeInfos": [
{
"userAdded": false,
"type": "UnityEngine.AnimationClip",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEditor.Animations.AnimatorController",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.AnimatorOverrideController",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEditor.Audio.AudioMixerController",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.ComputeShader",
"defaultInstantiationMode": 1
},
{
"userAdded": false,
"type": "UnityEngine.Cubemap",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.GameObject",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEditor.LightingDataAsset",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.LightingSettings",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.Material",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEditor.MonoScript",
"defaultInstantiationMode": 1
},
{
"userAdded": false,
"type": "UnityEngine.PhysicsMaterial",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.PhysicsMaterial2D",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.Rendering.PostProcessing.PostProcessProfile",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.Rendering.PostProcessing.PostProcessResources",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.Rendering.VolumeProfile",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEditor.SceneAsset",
"defaultInstantiationMode": 1
},
{
"userAdded": false,
"type": "UnityEngine.Shader",
"defaultInstantiationMode": 1
},
{
"userAdded": false,
"type": "UnityEngine.ShaderVariantCollection",
"defaultInstantiationMode": 1
},
{
"userAdded": false,
"type": "UnityEngine.Texture",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.Texture2D",
"defaultInstantiationMode": 0
},
{
"userAdded": false,
"type": "UnityEngine.Timeline.TimelineAsset",
"defaultInstantiationMode": 0
}
],
"defaultDependencyTypeInfo": {
"userAdded": false,
"type": "<default_scene_template_dependencies>",
"defaultInstantiationMode": 1
},
"newSceneOverride": 0
}
\ No newline at end of file
......@@ -2,8 +2,15 @@
%TAG !u! tag:unity3d.com,2011:
--- !u!78 &1
TagManager:
serializedVersion: 2
tags: []
serializedVersion: 3
tags:
- Runner
- Eye
- WorldItem
- Altar
- Shrine
- Connector
- SpawnSlot
layers:
- Default
- TransparentFX
......@@ -11,13 +18,13 @@ TagManager:
-
- Water
- UI
-
-
-
-
-
-
-
- Wall
- Prop
- Runner
- Eye
- SmokeVolume
- Interactable
- Trap
-
-
-
......@@ -50,27 +57,3 @@ TagManager:
- Light Layer 5
- Light Layer 6
- Light Layer 7
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
This diff is collapsed.
......@@ -11,6 +11,56 @@ showStatusBar = false
scopes = {
}
providers = {
asset = {
active = true
priority = 25
defaultAction = null
}
scene = {
active = true
priority = 50
defaultAction = null
}
adb = {
active = false
priority = 2500
defaultAction = null
}
presets_provider = {
active = false
priority = -10
defaultAction = null
}
find = {
active = true
priority = 25
defaultAction = null
}
packages = {
active = false
priority = 90
defaultAction = null
}
profilermarkers = {
active = false
priority = 100
defaultAction = null
}
performance = {
active = false
priority = 100
defaultAction = null
}
store = {
active = false
priority = 100
defaultAction = null
}
log = {
active = false
priority = 210
defaultAction = null
}
}
objectSelectors = {
}
......
......@@ -41,10 +41,10 @@ Every match tightens. Dead Runners become minions. The Eye grows stronger. Extra
**Design test:** "Can this mechanic create a stalemate?" → If yes, add a timer, escalation trigger, or pressure valve that breaks the deadlock.
### Pillar 5: Paranoia is a Feature
Other Runners are unreliable. Objects might be watching. Safe rooms might be trapped. Trust is a resource that depletes. The social and environmental design should make players second-guess everything.
### Pillar 5: Together or Dead
Runners who coordinate survive. The game rewards proximity, timing, and sacrifice. Solo play is viable but the optimal path always involves teammates. The Eye exploits separation — clustered runners are harder to isolate but easier to area-deny.
**Design test:** "Is this interaction too predictable?" → If a Runner can always trust X, add a reason they might not be able to.
**Design test:** "Can a solo runner accomplish this equally well?" → If yes, add a co-op bonus that makes the team version faster/safer.
---
......@@ -52,7 +52,7 @@ Other Runners are unreliable. Objects might be watching. Safe rooms might be tra
1. **NOT a precision shooter** — The Eye's skill is strategic, not mechanical aim.
2. **NOT a survival horror resource game** — No health packs, ammo, or crafting. Parkour flow stays clean.
3. **NOT a team game with roles** — No Tank/Healer/DPS archetypes. All Runners have the same toolkit. Trust is optional.
3. **NOT a class-based team game** — No fixed roles or forced compositions. Any runner can equip any wearable. Teamwork is rewarded, not required for basic play.
4. **NOT a battle royale with shrinking zones** — Escalation is organic (minions + economy), not artificial.
5. **NOT a content-heavy live-service grind** — Replayability from procedural maps and player dynamics, not seasonal content.
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment