diff --git a/Assets/1.TeamWorkspace/Module1/GameSystem/StereoSoundListener.cs b/Assets/1.TeamWorkspace/Module1/GameSystem/StereoSoundListener.cs index 02f7d31f..3f7286ea 100644 --- a/Assets/1.TeamWorkspace/Module1/GameSystem/StereoSoundListener.cs +++ b/Assets/1.TeamWorkspace/Module1/GameSystem/StereoSoundListener.cs @@ -1,4 +1,3 @@ -using NUnit.Framework.Interfaces; using UnityEngine; public class StereoSoundListener : MonoBehaviour diff --git a/Assets/1.TeamWorkspace/Module2/Prefabs/Player/VR_Player_Module_2.prefab b/Assets/1.TeamWorkspace/Module2/Prefabs/Player/VR_Player_Module_2.prefab index b953f9da..61c8005d 100644 --- a/Assets/1.TeamWorkspace/Module2/Prefabs/Player/VR_Player_Module_2.prefab +++ b/Assets/1.TeamWorkspace/Module2/Prefabs/Player/VR_Player_Module_2.prefab @@ -527,7 +527,7 @@ MonoBehaviour: m_TransformationPriority: 0 m_TurnSpeed: 60 m_EnableTurnLeftRight: 1 - m_EnableTurnAround: 1 + m_EnableTurnAround: 0 m_LeftHandTurnInput: m_InputSourceMode: 0 m_InputAction: diff --git a/Assets/1.TeamWorkspace/Module2/Scene/Module_2_MainScene.unity b/Assets/1.TeamWorkspace/Module2/Scene/Module_2_MainScene.unity index fef46e30..e4613d94 100644 --- a/Assets/1.TeamWorkspace/Module2/Scene/Module_2_MainScene.unity +++ b/Assets/1.TeamWorkspace/Module2/Scene/Module_2_MainScene.unity @@ -27965,10 +27965,8 @@ MonoBehaviour: gameOver: {fileID: 401769903} aButton: {fileID: 5561125842531183599, guid: c348712bda248c246b8c49b3db54643f, type: 3} score: 0 - timer: 120 + timer: 180 isOver: 0 - saveWaterContainer: [] - notSaveWaterContainer: [] sun: {fileID: 185372124} skybox: {fileID: 2100000, guid: ec46d2188d4b8774e847e75f1e12a8db, type: 2} lightColor: diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index ddaeb052..ba349f0f 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -1,4 +1,5 @@ -using TMPro; +using System.Collections.Generic; +using TMPro; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.SceneManagement; @@ -7,141 +8,688 @@ public class M2Manager : MonoBehaviour { public static M2Manager Instance { get; private set; } + public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } + [Header("Reference")] - //public GameObject gameOverPanel; public TextMeshProUGUI[] socreText; public TextMeshProUGUI timerText; public GameObject flySwatterPrefabs; public GameObject creamPrefabs; public Transform pointFontPlayer; + [Tooltip("How far in front of the player's eyes, in metres, the wrist menu puts a tool.")] + public float spawnAhead = 0.4f; + [Tooltip("How far below the player's eyes, in metres, the wrist menu puts a tool.")] + public float spawnBelowEyes = 0.3f; public GameObject particleSpwn; - private Transform _cameraPos; private static readonly int Exposure = Shader.PropertyToID("_Exposure"); [Header("Reference Canvas")] + [Tooltip("Legacy scene game-over panel. Only used when there is no Module2HUD.")] public GameObject gameOver; [Header("Input System")] + [Tooltip("Primary confirm action. Kept, but no longer the only way to confirm - it fires only intermittently on the headset.")] public InputActionReference aButton; + [Tooltip("Seconds of briefing before the round starts on its own, so a confirm that never arrives cannot soft-lock the game. 0 disables.")] + public float introAutoStartSeconds = 6f; + [Tooltip("Ignore confirm presses for this long after the briefing appears, so a button already held does not skip it.")] + public float introArmDelay = 0.75f; + [Tooltip("Ignore restart presses for this long after the round ends, so whatever was in your hand cannot reload the scene.")] + public float endArmDelay = 1.5f; + [Tooltip("Show a live input readout on the briefing. Useful on-headset diagnostics; turn off for a real build.")] + public bool showInputDebug = true; [Header("Game Setting")] - public int score = 0; + public int score = 0; public float timer = 180f; public int isOver; - public GameObject[] saveWaterContainer; - public GameObject[] notSaveWaterContainer; + [Tooltip("Awarded when every breeding site has been dealt with before the timer runs out.")] + public int completionBonus = 100; + [Tooltip("Points per swatted adult. Small on purpose: the master design keeps clearing the adult swarm as an M2 mechanic, but bite count is secondary and source reduction must stay the winning strategy (A8, B2 KPIs).")] + public int swatScore = 1; + [Tooltip("Breeding sites used per round, drawn at random from those in the scene. Keeps a round inside a 2-minute slot and makes repeat plays differ.")] + public int sitesPerRound = 6; + [Tooltip("Show wrigglers and a rising column of mosquitoes at each open breeding site, so the link between the water and the swarm is visible.")] + public bool showSiteFx = true; + [Tooltip("Seconds of extra guidance at the start of a round, ending early once the player grabs anything. Module 1 is flown with a stick and never teaches grabbing, so Module 2 is the first time a child is asked to pick something up.")] + public float onboardingSeconds = 12f; + [Tooltip("Hold the timer until the player dismisses the HUD intro card.")] + public bool showIntro = true; + + [Header("Relay triggers (master design B4)")] + [Tooltip("Bites that end the pilot's stint. B4: relay trigger is 10 bites or the 3-minute action window.")] + public int biteLimit = 10; + [Tooltip("Energy lost per bite. INTERPRETATION: B2 names an Energy bar but does not specify its economy.")] + public float energyPerBite = 0.12f; + [Tooltip("Energy regained per second while not being bitten. INTERPRETATION: B2 Scene 3 says 'rest to restore Energy'; there is no rest station in the scene yet.")] + public float energyRegenPerSecond = 0.02f; + [Tooltip("Seconds without a bite before energy starts coming back.")] + public float energyRegenDelay = 4f; + [Tooltip("Seconds after a bite during which further bites are ignored. Mosquitoes emerge from the very containers the player is working on, so without this a swarm landed 8 bites in 5 seconds while a jar was being covered and ended the round in under 30 seconds.")] + public float biteGracePeriod = 5f; + [Tooltip("The grace period grows as breeding sites are cleared: biteGracePeriod x (sites / sites still open), up to this factor. With every site open it is the plain biteGracePeriod, so difficulty at the start is unchanged; it is what makes source reduction lower how often you are bitten, however many mosquitoes are near. 1 disables.")] + public float maxBiteGraceMultiplier = 4f; + [Header("Repellent")] + [Tooltip("Seconds of bite protection from one squeeze of repellent cream onto a hand.")] + public float repellentDuration = 30f; [Header("Day Night System")] public Light sun; public Material skybox; public Gradient lightColor; public AnimationCurve lightIntensity; + [Tooltip("Seconds of daylight. Clamped to the round length; half the round if unset or too large.")] public float dayDuration = 120.0f; public bool isDay, isNight; + public Module2HUD hud { get; private set; } + + // Breeding sites (jars, open containers, fish bowls) register themselves in their own Start + // and report back when they are dealt with. Clearing them all ends the round early. + private readonly HashSet _openSites = new HashSet(); + private int _siteCount; + + // Set just before RestartGame reloads the scene, so a retry drops straight back into play + // instead of making the player read the briefing again. Statics survive a scene load. + private static bool s_skipIntroOnLoad; + + private bool _started; + private bool _ended; + + // Confirm input. The serialized aButton action (map "Phettae", action "A Button", bound to + // {RightHand}/{PrimaryButton}) fires only intermittently on the headset, and with + // the briefing gated on it that intermittency became a soft-lock. So accept a confirm from any + // plausible source: the Input System actions, and the legacy UnityEngine.XR device API that + // Module 1's PlayerMain drives its whole game from on this same headset. + private InputAction[] _introActions; + private InputAction[] _restartActions; + private int _introFrame = -1, _restartFrame = -1; + private bool _introPressed, _restartPressed; + private bool _legacyAnyLastFrame, _legacyPrimaryLastFrame; + private float _introShownAt = -1f; + private float _endShownAt = -1f; + private int _gamaProjection = -1; + private bool _subsetChosen; + private float _roundStartedAt = -1f; + private float _lastBiteAt = -999f; + private float _repellentUntil = -1f; + private bool _repellentWasActive; + + /// True once the player has picked anything up, which ends the guidance early. + public bool HasGrabbed { get; private set; } + public void NoteFirstGrab() { HasGrabbed = true; } + + /// Show the extra start-of-round guidance. + public bool Onboarding => + IsPlaying && !HasGrabbed && _roundStartedAt >= 0f && Time.time - _roundStartedAt < onboardingSeconds; + private static readonly List s_devices = new List(); + + /// Live input state, shown on the briefing while showInputDebug is on. + public string ConfirmDebug { get; private set; } = ""; + private float _roundLength; + private float _dayLength; + private float _skyboxExposureOnLoad; + private bool _hasSkyboxExposure; + + // ---- state the HUD reads ---- + public bool IsPlaying => _started && !_ended; + public bool RoundStarted => _started; + public bool RoundEnded => _ended; + public RoundEndReason EndReason { get; private set; } + public int MosquitoesSwatted { get; private set; } + public int BiteCount { get; private set; } + public int BiteLimit => Mathf.Max(biteLimit, 1); + /// True while repellent protects the player: bites are ignored and mosquitoes veer away. + public bool RepellentActive => IsPlaying && Time.time < _repellentUntil; + public float RepellentRemaining => RepellentActive ? _repellentUntil - Time.time : 0f; + public int RepellentApplications { get; private set; } + + /// Energy, 0..1. Drains on bites and comes back after a spell without one. + public float Energy { get; private set; } = 1f; + + /// + /// The module's exported metric. B2's GAMA hook and A8's "Community Outcome" are both this + /// number, and it is what squads are ranked by at debrief - not the action score. + /// Feeds Outbreak_Intensity = (100 - Source_Reduction_Percent) x Population_Density_Factor. + /// + public int SourceReductionPercent => + _siteCount > 0 ? Mathf.RoundToInt(100f * SitesCleared / _siteCount) : 0; + public int TrashBinned { get; private set; } + public int SiteCount => _siteCount; + public int OpenSiteCount => _openSites.Count; + public int SitesCleared => _siteCount - _openSites.Count; + public IReadOnlyCollection OpenSites => _openSites; + public float TimeRemaining => Mathf.Max(timer, 0f); + public float RoundLength => _roundLength; + private void Awake() { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } Instance = this; isOver = 1; - DontDestroyOnLoad(gameObject); + // Deliberately NOT DontDestroyOnLoad: RestartGame reloads this scene, and a manager that + // survived the reload kept running a second timer and day/night cycle against the old + // scene's objects while the fresh one took over Instance. + hud = GetComponent(); + if (hud == null) hud = gameObject.AddComponent(); + if (GetComponent() == null) gameObject.AddComponent(); } void Start() { - gameOver.SetActive(false); - socreText[0].text = score.ToString(); isDay = true; + isNight = false; + + _roundLength = Mathf.Max(timer, 0.0001f); + // dayDuration >= the round length would mean night never arrives, so fall back to halfway. + _dayLength = (dayDuration > 0f && dayDuration < _roundLength) ? dayDuration : _roundLength * 0.5f; + + if (skybox != null && skybox.HasProperty(Exposure)) + { + _skyboxExposureOnLoad = skybox.GetFloat(Exposure); + _hasSkyboxExposure = true; + } + + if (gameOver != null) gameOver.SetActive(false); + + BuildConfirmActions(); + + bool skipIntro = s_skipIntroOnLoad; + s_skipIntroOnLoad = false; + + if (hud != null) + { + // The runtime HUD supersedes the labels baked into the player prefab. + hud.HideLegacyLabels(socreText, timerText); + if (showIntro && !skipIntro) + { + hud.ShowIntro(); + _introShownAt = Time.time; + } + } + + RefreshScoreText(); - _cameraPos = Camera.main.transform; - UpdateScore(score); + // With no HUD to dismiss the intro, or on a retry, play starts at once. + if (hud == null || !showIntro || skipIntro) BeginRound(); } + private void OnDestroy() + { + // The skybox is a shared project asset: leave its exposure as we found it so the value + // does not drift between runs and show up as a stray change in version control. + if (_hasSkyboxExposure && skybox != null) skybox.SetFloat(Exposure, _skyboxExposureOnLoad); + if (Instance == this) Instance = null; + } void Update() { + // Sites register in their own Start, so the subset can only be chosen once those have all run. + if (!_subsetChosen) { _subsetChosen = true; ChooseSiteSubset(); } + StartTimer(); DayNightSystem(); + TickEnergy(); + TickRepellent(); + AutoStartIfStuck(); RestartGame(); + } - if (isDay && timer <= dayDuration) + // Last resort: if no confirm ever arrives, start anyway rather than leave the player stranded on + // the briefing with no timer and no mosquitoes. + private void AutoStartIfStuck() + { + if (_started || _introShownAt < 0f || introAutoStartSeconds <= 0f) return; + if (Time.time - _introShownAt < introAutoStartSeconds) return; + + Debug.LogWarning("[M2] No confirm press seen - starting the round automatically."); + BeginRound(); + } + + #region Confirm input + // Two deliberately different sets. + // + // The briefing can be dismissed by almost anything - there is nothing to lose before the round + // starts, and the A button only fires intermittently on this headset. + // + // Restart must NOT be that permissive. Accepting Select (grip) and Activate (trigger) meant the + // next natural grab after the round ended instantly reloaded the scene, and "XRI UI/Submit" is + // bound to */{Submit} - a wildcard across every device - which let a thumbstick reach it too. + // Restart now only listens for a primary button (A / X), which nobody presses by accident. + private void BuildConfirmActions() + { + var intro = new List(); + var restart = new List(); + + if (aButton != null && aButton.action != null) { - isDay = false; - isNight = true; + intro.Add(aButton.action); + restart.Add(aButton.action); } + + var asset = aButton != null && aButton.action != null && aButton.action.actionMap != null + ? aButton.action.actionMap.asset + : null; + + if (asset != null) + { + // Grip and trigger, both hands. Briefing only - never restart. + string[] paths = + { + "XRI Right Interaction/Select", + "XRI Left Interaction/Select", + "XRI Right Interaction/Activate", + "XRI Left Interaction/Activate", + }; + foreach (var path in paths) + { + var action = asset.FindAction(path, false); + if (action != null && !intro.Contains(action)) intro.Add(action); + } + } + + _introActions = intro.ToArray(); + _restartActions = restart.ToArray(); + Debug.Log($"[M2] Confirm sources - briefing: {_introActions.Length} action(s) + any controller button; restart: {_restartActions.Length} action(s) + primary button only."); + } + + /// Broad confirm for the briefing. Frame-cached so several callers can ask. + public bool IntroConfirmPressed() + { + if (Time.frameCount != _introFrame) + { + _introFrame = Time.frameCount; + _introPressed = ActionFired(_introActions) + | EdgeOf(LegacyHeld(AnyButton, out string detail), ref _legacyAnyLastFrame) + | KeyboardConfirm(); + if (showInputDebug) ConfirmDebug = $"actions:{(ActionFired(_introActions) ? 1 : 0)} {detail}"; + } + return _introPressed; + } + + /// Narrow confirm for restart: a primary button press, nothing incidental. + public bool RestartConfirmPressed() + { + if (Time.frameCount != _restartFrame) + { + _restartFrame = Time.frameCount; + _restartPressed = ActionFired(_restartActions) + | EdgeOf(LegacyHeld(PrimaryOnly, out _), ref _legacyPrimaryLastFrame) + | KeyboardConfirm(); + } + return _restartPressed; } - + + private static bool ActionFired(InputAction[] actions) + { + if (actions == null) return false; + foreach (var action in actions) + if (action != null && action.enabled && action.WasPerformedThisFrame()) return true; + return false; + } + + private static bool EdgeOf(bool held, ref bool lastFrame) + { + bool edge = held && !lastFrame; + lastFrame = held; + return edge; + } + + private static bool KeyboardConfirm() + { + var keyboard = Keyboard.current; + return keyboard != null && (keyboard.spaceKey.wasPressedThisFrame || keyboard.enterKey.wasPressedThisFrame); + } + + private const int AnyButton = 0; + private const int PrimaryOnly = 1; + + // The legacy UnityEngine.XR device API, which Module 1's PlayerMain drives its whole game from on + // this same headset. It reports current state, so callers edge-detect. + private static bool LegacyHeld(int mode, out string detail) + { + detail = ""; + bool any = false; + + UnityEngine.XR.InputDevices.GetDevicesWithCharacteristics( + UnityEngine.XR.InputDeviceCharacteristics.Controller, s_devices); + + for (int i = 0; i < s_devices.Count; i++) + { + var device = s_devices[i]; + if (!device.isValid) continue; + + bool primary = false, trigger = false, grip = false; + device.TryGetFeatureValue(UnityEngine.XR.CommonUsages.primaryButton, out primary); + device.TryGetFeatureValue(UnityEngine.XR.CommonUsages.triggerButton, out trigger); + device.TryGetFeatureValue(UnityEngine.XR.CommonUsages.gripButton, out grip); + + bool right = (device.characteristics & UnityEngine.XR.InputDeviceCharacteristics.Right) != 0; + detail += $"[{(right ? "R" : "L")} a:{(primary ? 1 : 0)} t:{(trigger ? 1 : 0)} g:{(grip ? 1 : 0)}]"; + + if (mode == PrimaryOnly ? primary : (primary || trigger || grip)) any = true; + } + + if (s_devices.Count == 0) detail = "no controllers seen"; + return any; + } + #endregion + + #region Round lifecycle + /// True once the briefing has been on screen long enough to accept a confirm. + public bool IntroArmed => _introShownAt < 0f || Time.time - _introShownAt >= introArmDelay; + + public void BeginRound() + { + if (_started) return; + _started = true; + _roundStartedAt = Time.time; + if (hud != null) + { + hud.HideIntro(); + hud.Toast(Module2Text.StartToast); + } + } + + private void EndRound(RoundEndReason reason) + { + if (_ended) return; + _ended = true; + _started = true; + isOver = 0; + EndReason = reason; + + if (reason == RoundEndReason.AllSitesCleared) + { + AddScore(completionBonus); + // "House is clear" should look it: send the whole swarm away. + foreach (var mosquito in FindObjectsByType(FindObjectsSortMode.None)) mosquito.Retire(); + } + + _endShownAt = Time.time; + + if (hud != null) hud.ShowEnd(reason, score, SitesCleared, _siteCount, MosquitoesSwatted, TrashBinned, SourceReductionPercent, BiteCount, ProjectionFromSimulation); + else if (gameOver != null) gameOver.SetActive(true); + } + + /// Kept for existing callers; ends the round as a time-out. + public void ShowGameOverPanel() + { + EndRound(RoundEndReason.TimeOut); + } + + /// Seconds between bites right now: longer the more breeding sites have been cleared. + public float CurrentBiteGrace + { + get + { + float grace = Mathf.Max(biteGracePeriod, 0f); + if (_siteCount <= 0 || maxBiteGraceMultiplier <= 1f) return grace; + float scale = (float)_siteCount / Mathf.Max(_openSites.Count, 1); + return grace * Mathf.Clamp(scale, 1f, maxBiteGraceMultiplier); + } + } + + /// Called by a mosquito that reaches the player. Ends the stint at biteLimit. + public void NoteBite() + { + if (!IsPlaying) return; + if (RepellentActive) return; + if (Time.time - _lastBiteAt < CurrentBiteGrace) return; + + BiteCount++; + _lastBiteAt = Time.time; + Energy = Mathf.Clamp01(Energy - Mathf.Max(energyPerBite, 0f)); + + if (hud != null) hud.OnBitten(BiteCount, BiteLimit); + + if (BiteCount >= BiteLimit) EndRound(RoundEndReason.BitesReached); + } + + /// Called by the cream tube when it is squeezed onto a hand. Re-applying refreshes it. + public void ApplyRepellent() + { + if (!IsPlaying) return; + _repellentUntil = Time.time + Mathf.Max(repellentDuration, 0f); + _repellentWasActive = true; + RepellentApplications++; + if (hud != null) hud.Toast(Module2Text.RepellentOn(Mathf.RoundToInt(repellentDuration))); + } + + private void TickRepellent() + { + if (!_repellentWasActive || RepellentActive) return; + _repellentWasActive = false; + if (IsPlaying && hud != null) hud.Toast(Module2Text.RepellentWornOff); + } + + private void TickEnergy() + { + if (!IsPlaying) return; + if (Time.time - _lastBiteAt < energyRegenDelay) return; + Energy = Mathf.Clamp01(Energy + energyRegenPerSecond * Time.deltaTime); + } + + public void NoteMosquitoSwatted() + { + if (_ended) return; + MosquitoesSwatted++; + // Scoring lives here rather than on the swatter so the value is decided in one place. + if (swatScore != 0) UpdateScore(swatScore); + } + + /// + /// True once GAMA has acknowledged this round's source-reduction figure, so the debrief can stop + /// hedging. The number itself is SourceReductionPercent either way - the module no longer invents + /// a mosquito projection, because A5 defines the export as a percentage. + /// + public bool ProjectionFromSimulation => _gamaProjection >= 0; + + /// Called when GAMA confirms it has taken the figure. Negative clears it. + public void SetSimulationProjection(int value) { _gamaProjection = value; } + public void NoteTrashBinned() + { + if (_ended) return; + TrashBinned++; + if (hud != null) hud.Toast(Module2Text.TrashBinned); + } + #endregion + #region UI interaction + // Each poke used to spawn another swatter, each on its own two-second self-destruct timer, so + // repeated presses littered the room. The guard the live path was missing already existed in the + // dead UIHand.SpawnSwatter. public void FlySwatterUi() { - Instantiate(flySwatterPrefabs, pointFontPlayer); - Instantiate(particleSpwn, pointFontPlayer); + // A racket that is being held blocks a second one. One that has been put down does not: it may be + // out of reach, or under the furniture, and the button is the player's only way to get a swatter. + var existing = FindObjectsByType(FindObjectsSortMode.None); + foreach (var racket in existing) + { + if (!racket.Held) continue; + if (hud != null) hud.Toast(Module2Text.SwatterAlreadyOut); + return; + } + foreach (var racket in existing) Destroy(racket.gameObject); + + MoveSpawnPointInFrontOfPlayer(); + if (flySwatterPrefabs != null) Instantiate(flySwatterPrefabs, pointFontPlayer); + if (particleSpwn != null) Instantiate(particleSpwn, pointFontPlayer); + if (hud != null) hud.Toast(Module2Text.SwatterSpawned); } public void CreamUi() { - Instantiate(creamPrefabs, pointFontPlayer); - Instantiate(particleSpwn, pointFontPlayer); + if (FindAnyObjectByType() != null) + { + if (hud != null) hud.Toast(Module2Text.CreamAlreadyOut); + return; + } + + MoveSpawnPointInFrontOfPlayer(); + if (creamPrefabs != null) Instantiate(creamPrefabs, pointFontPlayer); + if (particleSpwn != null) Instantiate(particleSpwn, pointFontPlayer); + if (hud != null) hud.Toast(Module2Text.CreamSpawned); + } + + // pointFontPlayer is authored 30 cm in front of the rig's origin. In the editor the head is always + // above that origin and facing along it; on a headset the player has stepped away from it and turned + // round, so the tool they asked for appeared beside or behind them. + void MoveSpawnPointInFrontOfPlayer() + { + var cam = Camera.main; + if (cam == null || pointFontPlayer == null) return; + + Vector3 forward = Vector3.ProjectOnPlane(cam.transform.forward, Vector3.up); + // Looking straight down at the wrist leaves no forward to speak of; the top of the head points the way. + if (forward.sqrMagnitude < 0.01f) forward = Vector3.ProjectOnPlane(cam.transform.up, Vector3.up); + forward.Normalize(); + + pointFontPlayer.SetPositionAndRotation( + cam.transform.position + forward * spawnAhead + Vector3.down * spawnBelowEyes, + Quaternion.LookRotation(forward, Vector3.up)); } + /// Award points for a player action. Ignored once the round is over, so what the + /// player does after the debrief appears cannot change the numbers it shows. public void UpdateScore(int value) + { + if (_ended) return; + AddScore(value); + } + + private void AddScore(int value) { score += value; - socreText[0].text = "Score: " + score.ToString(); - socreText[1].text = "Score: " + score.ToString(); + RefreshScoreText(); } - public void ShowGameOverPanel() + private void RefreshScoreText() { - gameOver.SetActive(true); + if (socreText == null) return; + for (int i = 0; i < socreText.Length; i++) + if (socreText[i] != null) socreText[i].text = "Score: " + score; } #endregion - #region Timer Control - private void StartTimer() + + #region Breeding sites + // A round uses a random handful of the sites in the scene rather than all of them. Two reasons: + // a 2-minute slot cannot support nine, and a class of children each seeing a different subset + // collectively explores the whole house instead of all learning the same nine positions. + private void ChooseSiteSubset() { - if (isOver == 1) + if (sitesPerRound > 0 && _openSites.Count > sitesPerRound) { - timer -= Time.deltaTime; - timerText.text = "Time: " + Mathf.Round(timer).ToString(); - //print(timer); + var pool = new List(_openSites); + for (int i = pool.Count - 1; i > 0; i--) + { + int j = Random.Range(0, i + 1); + (pool[i], pool[j]) = (pool[j], pool[i]); + } - if (timer <= 0) + // The sites this round leaves out stay in the house, already dealt with (lid on, vase empty, + // fish in), so the rooms look the same every round and nothing is missing without explanation. + for (int i = sitesPerRound; i < pool.Count; i++) { - ShowGameOverPanel(); - isOver = 0; + var site = pool[i]; + _openSites.Remove(site); + _siteCount--; + if (site is IBreedingSite resolvable) resolvable.ShowResolved(); + else if (site != null) site.gameObject.SetActive(false); } + + Debug.Log($"[M2] Using {_siteCount} of the scene's breeding sites this round."); + } + + if (showSiteFx) + foreach (var site in _openSites) BreedingSiteFx.Attach(site); + } + + public void RegisterBreedingSite(Component site) + { + if (site == null) return; + if (_openSites.Add(site)) _siteCount++; + } + + public void NeutralizeBreedingSite(Component site) + { + if (_ended || site == null || !_openSites.Remove(site)) return; + + var fx = site.GetComponent(); + if (fx != null) fx.Clear(); + + if (hud != null) hud.OnSiteCleared(site, _siteCount - _openSites.Count, _siteCount); + if (_openSites.Count == 0 && IsPlaying) EndRound(RoundEndReason.AllSitesCleared); + } + #endregion + + #region Timer Control + private void StartTimer() + { + if (!IsPlaying) return; + + timer -= Time.deltaTime; + if (timerText != null) timerText.text = "Time: " + Mathf.Round(Mathf.Max(timer, 0f)); + + if (timer <= 0f) + { + timer = 0f; + EndRound(RoundEndReason.TimeOut); } } private void DayNightSystem() { - float elapsed = 120f - timer; - float dayProgress = Mathf.Clamp01(elapsed / dayDuration); - sun.color = lightColor.Evaluate(dayProgress); - skybox.SetFloat(Exposure, lightIntensity.Evaluate(dayProgress * 0.05f)); - sun.intensity = lightIntensity.Evaluate(dayProgress); - sun.transform.localRotation = Quaternion.Euler(dayProgress * 360f - -120, 30, 0); + // Progress is measured against the round length captured at Start, so the cycle no longer + // depends on the timer happening to be seeded with one particular value. The timer does not + // move until the round begins, so the sun holds still behind the intro card. + float elapsed = _roundLength - timer; + float dayProgress = Mathf.Clamp01(elapsed / _roundLength); + + if (sun != null) + { + sun.color = lightColor.Evaluate(dayProgress); + sun.intensity = lightIntensity.Evaluate(dayProgress); + sun.transform.localRotation = Quaternion.Euler(dayProgress * 360f + 120f, 30f, 0f); + } + + // Was Evaluate(dayProgress * 0.05f), which only ever sampled the first 5% of the curve + // and left the skybox at a fixed exposure for the whole round. + if (skybox != null && _hasSkyboxExposure) skybox.SetFloat(Exposure, lightIntensity.Evaluate(dayProgress)); + + if (isDay && elapsed >= _dayLength) + { + isDay = false; + isNight = true; + if (hud != null) hud.Toast(Module2Text.NightFalls); + } } #endregion - #region Singleton + + #region Restart public void RestartGame() { - if (isOver == 0) + if (!_ended) return; + + if (_endShownAt >= 0f && Time.time - _endShownAt < endArmDelay) return; + + if (RestartConfirmPressed()) { - if (aButton.action.WasPerformedThisFrame()) - { - SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); - score = 0; - timer = 180f; - isOver = 1; - gameOver.SetActive(false); - Debug.Log("Game Restarted"); - } + int index = SceneManager.GetActiveScene().buildIndex; + Debug.Log($"[M2] Restart pressed - reloading scene index {index}"); + s_skipIntroOnLoad = true; + // Reloading the scene rebuilds the manager and its references; no need to reset + // fields on an instance that is about to be destroyed. + SceneManager.LoadScene(index); } - } #endregion } diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs new file mode 100644 index 00000000..d690b5a7 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs @@ -0,0 +1,194 @@ +using UnityEngine; +using System.Collections.Generic; +using UnityEngine.EventSystems; +using UnityEngine.XR.Interaction.Toolkit; +using UnityEngine.XR.Interaction.Toolkit.Interactables; +using UnityEngine.XR.Interaction.Toolkit.Interactors; +using UnityEngine.XR.Interaction.Toolkit.UI; + +/// +/// Scene-wide interaction repairs for Module 2, applied at runtime so none of it needs a prefab or +/// scene edit. M2Manager adds this component to itself, the same way it adds Module2HUD. +/// +/// Each fix here is for a defect found during on-headset testing: +/// - the EventSystem carried a plain Input System module, so XR pokes highlighted the wrist-menu +/// buttons but never clicked them; +/// - the right hand's interactor sphere was not a trigger, making it a solid moving collider that +/// physically shoved objects away as you reached for them; +/// - no grabbable had an attachTransform, so objects snapped to your palm by their origin; +/// - grabbables used Discrete collision detection and no interpolation, so thin objects sank into +/// the floor and jittered once released; +/// - colliders were much smaller than the objects they belong to (the vase's flowers had none at +/// all), so only a small part of each object responded to touch; +/// - the litter on the lawn (tag Trash) had no Rigidbody or XRGrabInteractable, so it could not be +/// picked up and TrashBin could never fire (found by playtesting through the harness). +/// +public class Module2Interaction : MonoBehaviour +{ + [Tooltip("Grow each grabbable's box collider to cover its renderers. Turn off to keep authored colliders exactly as they are.")] + public bool fitCollidersToRenderers = true; + + [Tooltip("Make every direct interactor's collider a trigger so hands stop physically shoving objects.")] + public bool forceInteractorTriggers = true; + + [Tooltip("Give grabbables an attach point at the centre of their collider instead of their origin.")] + public bool addAttachTransforms = true; + + [Tooltip("Radius of each hand's grab sphere. The authored 0.1 m demands precision that children with no VR experience do not have; 0 leaves it alone. A proper far/ray interactor is the real fix and needs a prefab change.")] + public float interactorRadius = 0.14f; + + [Tooltip("Make litter (tag Trash) grabbable so it can be carried to the bin.")] + public bool makeTrashGrabbable = true; + + readonly List _hooked = new List(); + + void OnDestroy() + { + foreach (var grab in _hooked) + if (grab != null) grab.selectEntered.RemoveListener(OnAnyGrab); + _hooked.Clear(); + } + + void OnAnyGrab(SelectEnterEventArgs args) + { + if (M2Manager.Instance != null) M2Manager.Instance.NoteFirstGrab(); + } + + void Start() + { + FixEventSystem(); + if (forceInteractorTriggers) FixDirectInteractors(); + // Before TuneGrabbables, so the litter gets the same collider/attach/first-grab treatment. + if (makeTrashGrabbable) MakeTrashGrabbable(); + TuneGrabbables(); + } + + // XRI turns a poke or ray into a UI click through XRUIInputModule. With any other input module + // the TrackedDeviceGraphicRaycaster still drives the button's highlight, which is why the wrist + // menu lit up green but never fired its onClick. + void FixEventSystem() + { + var eventSystem = EventSystem.current != null ? EventSystem.current : FindFirstObjectByType(); + if (eventSystem == null) + { + Debug.LogWarning("[Module2Interaction] No EventSystem in the scene - the wrist menu cannot be clicked."); + return; + } + + if (eventSystem.GetComponent() != null) return; + + foreach (var module in eventSystem.GetComponents()) + module.enabled = false; + + eventSystem.gameObject.AddComponent(); + Debug.Log("[Module2Interaction] Added XRUIInputModule to the EventSystem so XR pokes can click UI."); + } + + void FixDirectInteractors() + { + int fixedCount = 0; + foreach (var interactor in FindObjectsByType(FindObjectsSortMode.None)) + { + foreach (var collider in interactor.GetComponents()) + { + if (!collider.isTrigger) + { + // Detection itself uses OverlapSphere, so this does not change what the hand can + // reach - it stops the hand being a solid object that bats things out of the way. + collider.isTrigger = true; + fixedCount++; + } + + // XRDirectInteractor sizes its overlap query from this sphere's radius, so widening it + // widens the grab window. + if (interactorRadius > 0f && collider is SphereCollider sphere) + sphere.radius = interactorRadius; + } + } + if (fixedCount > 0) Debug.Log($"[Module2Interaction] Made {fixedCount} interactor collider(s) triggers."); + } + + void MakeTrashGrabbable() + { + int count = 0; + foreach (var go in GameObject.FindGameObjectsWithTag("Trash")) + { + if (go.GetComponent() != null) continue; + var body = go.GetComponent(); + if (body == null) body = go.AddComponent(); + body.mass = 0.2f; + // Litter was placed sunk into the ground (it never had a body, so it did not matter). + // Uncapped, the solver fired a banana leaf out at 8 m/s and it flew 3 m up at round + // start; capped, embedded pieces ease up onto the surface. + body.maxDepenetrationVelocity = 0.3f; + go.AddComponent(); + count++; + } + if (count > 0) Debug.Log($"[Module2Interaction] Made {count} piece(s) of litter grabbable."); + } + + void TuneGrabbables() + { + foreach (var grab in FindObjectsByType(FindObjectsSortMode.None)) + { + var body = grab.GetComponent(); + if (body != null) + { + // ContinuousSpeculative is valid for both kinematic and dynamic bodies, unlike + // ContinuousDynamic, and stops thin props tunnelling into the floor. + body.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative; + body.interpolation = RigidbodyInterpolation.Interpolate; + } + + if (fitCollidersToRenderers) FitColliderToRenderers(grab); + if (addAttachTransforms && grab.attachTransform == null) AddAttachTransform(grab); + + // The HUD drops its beginner guidance as soon as the child picks anything up. + grab.selectEntered.AddListener(OnAnyGrab); + _hooked.Add(grab); + } + } + + // The vase is the clearest case: one 10 x 29 x 10 cm box around the stem, and no collider at all + // on the flowers, so most of what you can see could not be touched. + void FitColliderToRenderers(XRGrabInteractable grab) + { + var box = grab.GetComponent(); + if (box == null) return; + + var renderers = grab.GetComponentsInChildren(); + if (renderers.Length == 0) return; + + Bounds worldBounds = renderers[0].bounds; + for (int i = 1; i < renderers.Length; i++) + worldBounds.Encapsulate(renderers[i].bounds); + + Transform t = box.transform; + Vector3 scale = t.lossyScale; + if (Mathf.Approximately(scale.x, 0f) || Mathf.Approximately(scale.y, 0f) || Mathf.Approximately(scale.z, 0f)) return; + + // Renderer bounds are world axis-aligned, so this is an approximation for a rotated prop. + // Good enough for the upright props in this scene, and it only ever grows the collider. + Vector3 localSize = new Vector3( + worldBounds.size.x / Mathf.Abs(scale.x), + worldBounds.size.y / Mathf.Abs(scale.y), + worldBounds.size.z / Mathf.Abs(scale.z)); + + if (localSize.sqrMagnitude <= box.size.sqrMagnitude) return; + + box.center = t.InverseTransformPoint(worldBounds.center); + box.size = localSize; + } + + void AddAttachTransform(XRGrabInteractable grab) + { + var collider = grab.GetComponentInChildren(); + if (collider == null) return; + + var attach = new GameObject("[runtime] Attach"); + attach.transform.SetParent(grab.transform, false); + attach.transform.position = collider.bounds.center; + attach.transform.rotation = grab.transform.rotation; + grab.attachTransform = attach.transform; + } +} diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs.meta b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs.meta new file mode 100644 index 00000000..ea5b395a --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8d401a6e7273246f4b8193ec8a578d57 \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs index 215b6648..b79bb2d6 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs @@ -1,4 +1,4 @@ -using NUnit.Framework; +using System.Collections.Generic; using UnityEngine; public class MosquitoSpawn : MonoBehaviour @@ -9,28 +9,106 @@ public class MosquitoSpawn : MonoBehaviour [Header("Game Setting")] public int maxMosquitoInMap; public int currentMosquitoInMap; + [Tooltip("Mosquitoes sustained per breeding site that is still open.")] + public int mosquitoesPerSite = 5; + [Tooltip("Seconds between spawn checks. The old code ran a tag search every frame.")] + public float spawnInterval = 0.5f; + [Tooltip("Mosquitoes added per check, so the house fills in a few seconds rather than a few frames.")] + public int spawnsPerCheck = 3; + [Tooltip("Mosquitoes sent away per check when the swarm is bigger than the open sites support, so clearing a container thins it over a couple of seconds instead of all at once.")] + public int retiresPerCheck = 2; + [Tooltip("Height above a container that its mosquitoes appear at.")] + public float emergeHeight = 0.3f; public GameObject[] spawnPoint; + private float _nextSpawnTime; + private readonly List _live = new List(); + private void Update() { + if (Time.time < _nextSpawnTime) return; + _nextSpawnTime = Time.time + Mathf.Max(spawnInterval, 0.05f); SpawnMosqitoInMap(); } + // Mosquitoes now emerge from the containers that still hold water, so covering a jar visibly + // thins the swarm around it. They used to appear at nine fixed points unrelated to the water, + // which meant the player's actions had no legible effect on where mosquitoes came from. + private bool TryGetSpawnPosition(M2Manager manager, out Vector3 position) + { + position = Vector3.zero; + if (manager.OpenSiteCount > 0) + { + int index = Random.Range(0, manager.OpenSiteCount); + int i = 0; + foreach (var site in manager.OpenSites) + { + if (i++ != index) continue; + if (site == null) break; + position = site.transform.position + Vector3.up * emergeHeight; + return true; + } + } + + // Nothing left holding water: fall back to the authored points so the scene is never empty. + if (spawnPoint != null && spawnPoint.Length > 0) + { + var point = spawnPoint[Random.Range(0, spawnPoint.Length)]; + if (point != null) { position = point.transform.position; return true; } + } + + return false; + } public void SpawnMosqitoInMap() { - //print("isWorking"); - if (M2Manager.Instance.isOver == 1) + if (mosquitoPrefab == null) return; + + M2Manager manager = M2Manager.Instance; + if (manager == null || !manager.IsPlaying) return; + + // Ones already flying off do not count towards the swarm. + _live.Clear(); + foreach (var mosquito in FindObjectsByType(FindObjectsSortMode.None)) + if (!mosquito.Retired) _live.Add(mosquito); + currentMosquitoInMap = _live.Count; + + // The swarm is sustained by the breeding sites that are still open, so dealing with a + // container visibly thins it out. This is what the old unused + // "Random.Range(5, notSaveWaterContainer.Length)" line was reaching for; that array was + // never populated, so it computed Random.Range(5, 0) and threw the result away. + int target = manager.SiteCount > 0 + ? Mathf.Min(maxMosquitoInMap, manager.OpenSiteCount * Mathf.Max(mosquitoesPerSite, 1)) + : maxMosquitoInMap; + + int room = target - currentMosquitoInMap; + if (room < 0) { RetireExcess(-room); return; } + if (room == 0) return; + + int toSpawn = Mathf.Min(room, Mathf.Max(spawnsPerCheck, 1)); + for (int i = 0; i < toSpawn; i++) { - if (GameObject.FindGameObjectsWithTag("Mosquito").Length < maxMosquitoInMap) - { - int randomIndex = Random.Range(0, spawnPoint.Length); - int randomMosquitoCount = Random.Range(5, M2Manager.Instance.notSaveWaterContainer.Length); - Instantiate(mosquitoPrefab, spawnPoint[randomIndex].transform.position, Quaternion.identity); - currentMosquitoInMap = GameObject.FindGameObjectsWithTag("Mosquito").Length; - //print("isWork"); - } + if (!TryGetSpawnPosition(manager, out Vector3 position)) continue; + + Instantiate(mosquitoPrefab, position, Quaternion.identity); + currentMosquitoInMap++; } } + + // The ones nearest the player go first: they are the ones biting, so dealing with a container eases + // the buzzing round your head, and they fly off rather than popping out of existence. + private void RetireExcess(int excess) + { + var camera = Camera.main; + if (camera != null) + { + Vector3 head = camera.transform.position; + _live.Sort((a, b) => + (a.transform.position - head).sqrMagnitude.CompareTo((b.transform.position - head).sqrMagnitude)); + } + + int count = Mathf.Min(excess, Mathf.Max(retiresPerCheck, 1)); + for (int i = 0; i < count && i < _live.Count; i++) _live[i].Retire(); + } } diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Hand/Cream.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Hand/Cream.cs index 31ef6565..7b5134e8 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Hand/Cream.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Hand/Cream.cs @@ -1,24 +1,65 @@ using UnityEngine; -using UnityEngine.InputSystem; +using UnityEngine.XR.Interaction.Toolkit.Interactables; +using UnityEngine.XR.Interaction.Toolkit.Interactors; +/// +/// Repellent cream tube. Hold it in one hand and squeeze (trigger) it onto the other hand to be +/// protected from bites for M2Manager.repellentDuration. Protection only: it does not touch the +/// breeding sites, so it cannot move source reduction - that is the lesson the debrief draws. +/// public class Cream : MonoBehaviour { public GameObject creamEffect; - + [Tooltip("How close (metres) the other hand must be to the tube for a squeeze to count. The original check was only a 0.5 m ray straight out of the nozzle, which is hard to aim with a tracked hand.")] + public float applyRadius = 0.2f; + + private XRGrabInteractable _grab; + + private void Awake() + { + _grab = GetComponent(); + } + + /// Wired to the tube's Activated event (trigger pressed while held). public void CheckHand() { - if (Physics.Raycast(transform.position, -transform.up, out RaycastHit hit, 0.5f)) + if (!TryFindOtherHand(out Vector3 point)) return; + + if (creamEffect != null) Instantiate(creamEffect, point, Quaternion.identity); + if (M2Manager.Instance != null) M2Manager.Instance.ApplyRepellent(); + } + + private bool TryFindOtherHand(out Vector3 point) + { + point = transform.position; + + // Straight out of the nozzle onto a hand, as originally authored... + if (Physics.Raycast(transform.position, -transform.up, out RaycastHit hit, 0.5f, ~0, QueryTriggerInteraction.Collide) + && IsOtherHand(hit.collider)) { - if (hit.collider.CompareTag("Hand")) - { - Debug.Log("Hand Detected"); - Instantiate(creamEffect, hit.point, Quaternion.identity); - } + point = hit.point; + return true; } - Debug.DrawRay(transform.position, -transform.up * 0.5f, Color.red, 1f); + // ...or simply held against the other hand. + foreach (var col in Physics.OverlapSphere(transform.position, applyRadius, ~0, QueryTriggerInteraction.Collide)) + { + if (!IsOtherHand(col)) continue; + point = col.ClosestPoint(transform.position); + return true; + } + return false; + } + + private bool IsOtherHand(Collider col) + { + var interactor = col.GetComponentInParent(); + if (!col.CompareTag("Hand") && interactor == null) return false; + // The hand holding the tube does not count. + return interactor == null || _grab == null || !_grab.interactorsSelecting.Contains(interactor); } + /// Wired to the tube's SelectExited event: one tube per spawn. public void OnExit() { Destroy(gameObject); diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs index a7dd2f70..0b6a513d 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs @@ -1,39 +1,64 @@ using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit.Interactables; public class SwatterHand : MonoBehaviour { - public int scoreValue = 2; + [Tooltip("Unused: swat scoring moved to M2Manager.swatScore so the value is decided in one place.")] + public int scoreValue = 0; public bool isHandActive = false; - private void Start() - { - InvokeRepeating("CheckOnHand", 2f, 1f); - } + XRGrabInteractable _grab; - private void OnTriggerEnter(Collider other) + /// True while a hand is holding the racket. + public bool Held => _grab != null && _grab.isSelected; + + void Awake() { - if (other.CompareTag("Mosquto")) + _grab = GetComponent(); + + // The racket prefab has gravity off, and XRI puts that back on release, so a dropped racket kept + // whatever speed it left the hand with and drifted out of the house for good - and M2Manager + // refused to spawn another because one "already" existed. + if (_grab != null) _grab.forceGravityOnDetach = true; + + // Spawned after Module2Interaction ran, so it never got the tuning the scene's grabbables did. + // Without it a dropped racket can tunnel through the floor. + var body = GetComponent(); + if (body != null) { - M2Manager.Instance.UpdateScore(scoreValue); - Destroy(other.gameObject); + body.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative; + body.interpolation = RigidbodyInterpolation.Interpolate; } } - public void OnEnter() + private void OnTriggerEnter(Collider other) { - isHandActive = true; + // The tag is "Mosquito"; the old "Mosquto" spelling is not a defined tag, so CompareTag + // threw and the score was never awarded even though the mosquito died. + if (!other.CompareTag("Mosquito")) return; + + // NoteMosquitoSwatted awards M2Manager.swatScore (zero by default): swatting adults is + // exactly the measure that does not control the population, so paying for it taught the + // opposite of the lesson and could out-score a perfect clear. + if (M2Manager.Instance != null) M2Manager.Instance.NoteMosquitoSwatted(); + Destroy(other.gameObject); } - public void CheckOnHand() + /// Wired to the racket's selectEntered: the swatter has been picked up. + public void OnEnter() { - if (!isHandActive) - { - DestroySelf(); - } + isHandActive = true; } + /// + /// Wired to the racket's selectExited in the prefab, where it deleted the swatter the moment you + /// let go of it. Combined with a two-second InvokeRepeating self-destruct for a swatter that had + /// never been grabbed, the tool was effectively impossible to keep. The entry point stays because + /// the prefab calls it by name, but it now only honours a swatter that was never picked up. + /// public void DestroySelf() { + if (isHandActive) return; Destroy(gameObject); } } diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Trash/TrashBin.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Trash/TrashBin.cs index 02b9439d..e5dd31bb 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Trash/TrashBin.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Trash/TrashBin.cs @@ -7,11 +7,16 @@ public class TrashBin : MonoBehaviour private void OnTriggerEnter(Collider other) { - if (other.CompareTag("Trash")) + if (!other.CompareTag("Trash")) return; + + Destroy(other.gameObject); + // Was "M2Manager.Instance.score += score", which raised the total but never refreshed the + // HUD, so binning trash looked like it did nothing. + if (M2Manager.Instance != null) { - Destroy(other.gameObject); - M2Manager.Instance.score += score; - Debug.Log("Trash collected!"); + M2Manager.Instance.UpdateScore(score); + M2Manager.Instance.NoteTrashBinned(); } + Debug.Log("Trash collected!"); } } diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs new file mode 100644 index 00000000..cde22d62 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs @@ -0,0 +1,774 @@ +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.Rendering.Universal; +using UnityEngine.UI; + +/// +/// In-headset HUD for Module 2 (household vector control). +/// +/// Same approach as Module1HUD: everything is built from code at runtime - no prefab, no scene +/// canvas - so a UI change never has to touch the 28k-line scene and nothing can go missing on +/// merge. Every object goes on the UI layer. +/// +/// Module 1's rig already carried an overlay camera in its URP stack; the Module 2 rig has a single +/// base camera that renders every layer, so this component builds the overlay camera itself at +/// runtime (UI layer only, depth cleared) and takes UI off the base camera's culling mask. That is +/// what keeps the HUD on top of the house instead of being buried in a wall when the player steps +/// up to a jar. Set useOverlayCamera = false to fall back to a plain world-space canvas. +/// +/// The HUD only reads M2Manager state each frame. Game code pushes one-off events through Toast(), +/// ShowIntro(), ShowEnd() and OnSiteCleared(). All player-facing copy lives in Module2Text at the +/// bottom of this file. +/// +/// M2Manager adds this component to itself if the scene does not already carry one, so the +/// serialized fields below are only there for tuning in the inspector. +/// +public class Module2HUD : MonoBehaviour +{ + public static Module2HUD Instance { get; private set; } + + [Header("Placement (world units - the Module 2 rig is unscaled, so 1.0 is 1 m)")] + public float distance = 1.2f; + public float followSpeed = 6f; + + [Header("Text")] + [Tooltip("Font used for all HUD text. Leave empty for the TMP default - but note that default is LiberationSans, which has no Lao glyphs, so a Lao build must set this.")] + public TMP_FontAsset fontOverride; + + [Header("Rendering")] + [Tooltip("Build a URP overlay camera so the HUD always draws on top of the house.")] + public bool useOverlayCamera = true; + + [Header("Timing")] + public float toastDuration = 3.5f; + public float promptRefreshInterval = 0.2f; + + [Header("Distances")] + [Tooltip("Height above a container that its beacon floats at.")] + public float beaconHeight = 0.45f; + [Tooltip("How close an open site has to be before its prompt appears.")] + public float promptDistance = 2.5f; + + [Header("Colours")] + public Color accentColor = new Color(0.45f, 0.82f, 1f); + public Color doneColor = new Color(0.4f, 0.85f, 0.45f); + public Color warnColor = new Color(1f, 0.55f, 0.1f); + public Color dangerColor = new Color(0.9f, 0.18f, 0.18f); + public Color textColor = Color.white; + public Color mutedColor = new Color(0.75f, 0.78f, 0.82f); + public Color panelColor = new Color(0.04f, 0.05f, 0.07f, 0.72f); + [Tooltip("Deliberately different from the intro panel: both screens ask for A, and looking alike made a restart read as nothing happening.")] + public Color endPanelColor = new Color(0.10f, 0.05f, 0.04f, 0.92f); + public Color barBackColor = new Color(0f, 0f, 0f, 0.55f); + + // Canvas size in millimetres (canvas scale is 0.001, so 1 unit = 1 mm at `distance`). + const float CanvasW = 1100f, CanvasH = 620f; + const float ToastW = 460f, PromptW = 640f; + const int MaxToasts = 3; + + // ---- runtime references ---- + Transform cam; + Camera baseCamera, overlayCamera; + Vector3 smoothForward; + int uiLayer; + TMP_FontAsset font; + + RectTransform hudRoot; + CanvasGroup gameplayGroup, promptGroup, introGroup, endGroup; + + TextMeshProUGUI timerText, dayTagText, scoreText, sitesText, objectiveText, promptText; + TextMeshProUGUI energyLabel, biteText; + Image energyFill, biteFlash; + float biteFlashUntil = -1f; + RectTransform promptRect; + string lastPrompt; + Image objectiveArrow, sitesFill; + TextMeshProUGUI introKicker, introTitle, introBody, introFooter, introInputDebug; + TextMeshProUGUI endKicker, endTitle, endSubtitle, endStats, endFact, endFooter; + + RectTransform beaconRoot; + Image beacon; + RectTransform toastContainer; + readonly List toasts = new List(); + + Sprite roundedSprite, arrowSprite, dropSprite; + + // ---- state ---- + Component nearestSite; + float nearestDistance; + float nextPromptRefresh; + bool warnedOneMinute, warnedThirtySeconds; + bool overlayReady; + + class ToastEntry + { + public RectTransform rect; + public CanvasGroup group; + public float born; + } + + // ------------------------------------------------------------------ lifecycle + + void Awake() + { + Instance = this; + uiLayer = LayerMask.NameToLayer("UI"); + if (uiLayer < 0) uiLayer = 5; + // Prefer the regenerated Lao font (full Lao block + Latin, see Editor/LaoFontTool). TMP's own + // default is LiberationSans, which renders every Lao glyph as a blank. + font = fontOverride; + if (font == null) font = Resources.Load("Fonts/Lao_SomVang Full SDF"); + if (font == null) font = TMP_Settings.defaultFontAsset; + + BuildSprites(); + BuildHud(); + + gameplayGroup.alpha = 0f; + promptGroup.alpha = 0f; + introGroup.alpha = 0f; + endGroup.alpha = 0f; + } + + void OnDestroy() + { + if (Instance == this) Instance = null; + if (hudRoot != null) Destroy(hudRoot.gameObject); + if (beaconRoot != null) Destroy(beaconRoot.gameObject); + if (overlayCamera != null) + { + // Take the overlay back out of the stack before it is destroyed, or URP keeps a null + // entry and logs every frame. + if (baseCamera != null) + { + var data = baseCamera.GetComponent(); + if (data != null) data.cameraStack.Remove(overlayCamera); + } + Destroy(overlayCamera.gameObject); + } + } + + void LateUpdate() + { + if (!ResolveCamera()) return; + if (useOverlayCamera && !overlayReady) SetupOverlayCamera(); + Follow(); + + var m = M2Manager.Instance; + if (m == null) return; + + Fade(gameplayGroup, m.IsPlaying); + + UpdateStatus(m); + UpdateVitals(m); + UpdateSites(m); + RefreshNearest(m); + UpdateObjective(m); + UpdatePrompt(m); + UpdateToasts(); + UpdateBeacon(m); + UpdateIntro(m); + + if (introGroup.alpha > 0f) + introFooter.alpha = 0.55f + 0.45f * Mathf.Sin(Time.time * 4f); + if (endGroup.alpha > 0f) + endFooter.alpha = 0.55f + 0.45f * Mathf.Sin(Time.time * 4f); + } + + // ------------------------------------------------------------------ public API + + public void ShowIntro() + { + introKicker.text = Module2Text.IntroKicker; + introTitle.text = Module2Text.IntroTitle; + introBody.text = Module2Text.IntroBody; + introFooter.text = Module2Text.IntroFooter; + introGroup.alpha = 1f; + } + + public void HideIntro() + { + introGroup.alpha = 0f; + } + + /// Short message stacked below the timer. Newest on top, at most three at once. + public void Toast(string message, Color? accent = null) + { + if (string.IsNullOrEmpty(message)) return; + + var rect = NewRect("Toast", toastContainer, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), Vector2.zero, new Vector2(ToastW, 44)); + var bg = rect.gameObject.AddComponent(); + bg.sprite = roundedSprite; bg.type = Image.Type.Sliced; bg.color = panelColor; bg.raycastTarget = false; + var text = NewText("Text", rect, message, 24, accent ?? textColor, TextAlignmentOptions.Center, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(-28, 0)); + rect.sizeDelta = new Vector2(ToastW, Mathf.Max(44f, text.GetPreferredValues(message, ToastW - 28f, 0f).y + 18f)); + + var group = rect.gameObject.AddComponent(); + group.alpha = 0f; + toasts.Insert(0, new ToastEntry { rect = rect, group = group, born = Time.time }); + + while (toasts.Count > MaxToasts) + { + Destroy(toasts[toasts.Count - 1].rect.gameObject); + toasts.RemoveAt(toasts.Count - 1); + } + } + + /// A mosquito reached the player. B4's relay trigger is 10 bites. + public void OnBitten(int count, int limit) + { + biteFlashUntil = Time.time + 0.35f; + if (count >= limit - 2 && count < limit) Toast(Module2Text.BitesNearLimit, dangerColor); + } + + public void OnSiteCleared(Component site, int cleared, int total) + { + if (site == nearestSite) nearestSite = null; + nextPromptRefresh = 0f; + Toast(Module2Text.SiteCleared(SiteLabel(site), cleared, total), doneColor); + } + + public void ShowEnd(M2Manager.RoundEndReason reason, int score, int sitesCleared, int sitesTotal, int swatted, int trash, int sourceReductionPercent, int bites, bool fromSimulation) + { + endKicker.text = Module2Text.EndKicker; + endTitle.text = Module2Text.EndTitle(reason); + endSubtitle.text = Module2Text.EndSubtitle(reason, sitesCleared, sitesTotal); + endStats.text = Module2Text.EndStats(score, sitesCleared, sitesTotal, swatted, trash, sourceReductionPercent, bites); + // The consequence is the teaching moment, so it sits above the trivia rather than in it. + endFact.text = Module2Text.Consequence(sitesTotal - sitesCleared, sourceReductionPercent, fromSimulation) + + "\n" + Module2Text.RandomFact(); + endFooter.text = Module2Text.EndFooter; + endTitle.color = reason == M2Manager.RoundEndReason.AllSitesCleared ? doneColor : warnColor; + + endGroup.alpha = 1f; + promptGroup.alpha = 0f; + ClearToasts(); + } + + /// + /// Switches off the score and timer labels baked into VR_Player_Module_2 so they do not sit + /// behind this HUD saying the same thing twice. They stay wired, so deleting this component + /// brings the old labels straight back. + /// + public void HideLegacyLabels(TextMeshProUGUI[] scoreLabels, TextMeshProUGUI timerLabel) + { + if (scoreLabels != null) + foreach (var label in scoreLabels) + if (label != null) label.gameObject.SetActive(false); + if (timerLabel != null) timerLabel.gameObject.SetActive(false); + } + + // ------------------------------------------------------------------ per-frame updates + + bool ResolveCamera() + { + if (cam != null) return true; + if (Camera.main != null) + { + baseCamera = Camera.main; + cam = baseCamera.transform; + smoothForward = cam.forward; + } + return cam != null; + } + + // The Module 2 rig has one base camera that renders every layer, so a world-space HUD would be + // occluded by the house. An overlay camera that clears depth and renders only the UI layer puts + // the HUD on top, which is how Module 1's rig is set up in its scene. + void SetupOverlayCamera() + { + overlayReady = true; + + var baseData = baseCamera.GetComponent(); + if (baseData == null) + { + Debug.LogWarning("[Module2HUD] No URP camera data on the main camera - the HUD will be depth-sorted with the world."); + return; + } + + baseCamera.cullingMask &= ~(1 << uiLayer); + + var go = new GameObject("Module2HUD Overlay Camera (runtime)"); + go.transform.SetParent(baseCamera.transform, false); + overlayCamera = go.AddComponent(); + overlayCamera.clearFlags = CameraClearFlags.Nothing; + overlayCamera.cullingMask = 1 << uiLayer; + overlayCamera.nearClipPlane = baseCamera.nearClipPlane; + overlayCamera.farClipPlane = baseCamera.farClipPlane; + overlayCamera.fieldOfView = baseCamera.fieldOfView; + overlayCamera.depth = baseCamera.depth + 1f; + + var data = go.AddComponent(); + data.renderType = CameraRenderType.Overlay; + data.renderShadows = false; + data.renderPostProcessing = false; + // clearDepth is read-only on UniversalAdditionalCameraData; it is serialized true by + // default, which is exactly what an overlay needs to draw over the world. + + if (!baseData.cameraStack.Contains(overlayCamera)) baseData.cameraStack.Add(overlayCamera); + } + + // Translation follows the head instantly; the view direction is smoothed so the HUD trails a + // little when the player turns instead of being glued to the eyes. + void Follow() + { + smoothForward = Vector3.Slerp(smoothForward, cam.forward, 1f - Mathf.Exp(-followSpeed * Time.deltaTime)); + hudRoot.position = cam.position + smoothForward * distance; + hudRoot.rotation = Quaternion.LookRotation(smoothForward, cam.up); + } + + void UpdateStatus(M2Manager m) + { + int t = Mathf.CeilToInt(m.TimeRemaining); + timerText.text = (t / 60) + ":" + (t % 60).ToString("00"); + timerText.color = t <= 10 ? dangerColor : t <= 30 ? warnColor : textColor; + timerText.alpha = (t <= 10 && m.IsPlaying) ? 0.6f + 0.4f * Mathf.Abs(Mathf.Sin(Time.time * 6f)) : 1f; + + scoreText.text = Module2Text.Score(m.score); + dayTagText.text = m.isNight ? Module2Text.NightTag : Module2Text.DayTag; + dayTagText.color = m.isNight ? accentColor : mutedColor; + + if (!m.IsPlaying) return; + + if (!warnedOneMinute && m.TimeRemaining <= 60f && m.RoundLength > 60f) + { + warnedOneMinute = true; + Toast(Module2Text.OneMinuteLeft, warnColor); + } + if (!warnedThirtySeconds && m.TimeRemaining <= 30f) + { + warnedThirtySeconds = true; + Toast(Module2Text.ThirtySecondsLeft, warnColor); + } + } + + void UpdateVitals(M2Manager m) + { + energyLabel.text = Module2Text.EnergyLabel; + energyFill.fillAmount = Mathf.Clamp01(m.Energy); + energyFill.color = m.Energy < 0.35f + ? Color.Lerp(warnColor, dangerColor, 0.5f + 0.5f * Mathf.Sin(Time.time * 6f)) + : doneColor; + + biteText.text = Module2Text.Bites(m.BiteCount, m.BiteLimit); + if (m.RepellentActive) biteText.text += " " + Module2Text.RepellentStatus(Mathf.CeilToInt(m.RepellentRemaining)) + ""; + biteText.color = m.BiteCount >= m.BiteLimit - 2 ? dangerColor : textColor; + + float alpha = biteFlashUntil > Time.time ? 0.35f * Mathf.InverseLerp(0f, 0.35f, biteFlashUntil - Time.time) : 0f; + biteFlash.color = new Color(dangerColor.r, dangerColor.g, dangerColor.b, alpha); + } + + void UpdateSites(M2Manager m) + { + int total = m.SiteCount; + int cleared = m.SitesCleared; + sitesText.text = Module2Text.Sites(cleared, total); + sitesFill.fillAmount = total > 0 ? (float)cleared / total : 0f; + sitesFill.color = (total > 0 && cleared >= total) ? doneColor : accentColor; + } + + void RefreshNearest(M2Manager m) + { + if (Time.time < nextPromptRefresh) return; + nextPromptRefresh = Time.time + Mathf.Max(promptRefreshInterval, 0.05f); + + nearestSite = null; + nearestDistance = float.MaxValue; + + foreach (var site in m.OpenSites) + { + if (site == null) continue; + float d = Vector3.Distance(cam.position, site.transform.position); + if (d < nearestDistance) + { + nearestDistance = d; + nearestSite = site; + } + } + } + + void UpdateObjective(M2Manager m) + { + bool show = m.IsPlaying && nearestSite != null; + objectiveArrow.enabled = show; + + if (!show) + { + objectiveText.text = m.IsPlaying && m.SiteCount > 0 ? Module2Text.AllClear : ""; + objectiveText.color = doneColor; + return; + } + + Vector3 local = cam.InverseTransformDirection(nearestSite.transform.position - cam.position); + float bearing = Mathf.Atan2(local.x, local.z) * Mathf.Rad2Deg; + objectiveArrow.rectTransform.localRotation = Quaternion.Euler(0f, 0f, -bearing); + + objectiveText.text = Module2Text.Target(SiteLabel(nearestSite), nearestDistance); + objectiveText.color = textColor; + } + + void UpdatePrompt(M2Manager m) + { + string prompt = ""; + + // Normally the prompt appears when you are close. During the opening guidance it stays up at + // any distance, because the child does not yet know that picking things up is the verb. + float reach = m.Onboarding ? float.MaxValue : promptDistance; + if (m.IsPlaying && nearestSite != null && nearestDistance <= reach) + prompt = SitePrompt(nearestSite); + + Fade(promptGroup, !string.IsNullOrEmpty(prompt)); + if (prompt == lastPrompt) return; + + lastPrompt = prompt; + if (string.IsNullOrEmpty(prompt)) return; + + promptText.text = prompt; + promptRect.sizeDelta = new Vector2(PromptW, Mathf.Max(56f, promptText.GetPreferredValues(prompt, PromptW - 40f, 0f).y + 26f)); + } + + void UpdateBeacon(M2Manager m) + { + bool show = m.IsPlaying && nearestSite != null; + beacon.enabled = show; + if (!show) return; + + beaconRoot.position = nearestSite.transform.position + Vector3.up * beaconHeight; + beaconRoot.rotation = Quaternion.LookRotation(beaconRoot.position - cam.position, Vector3.up); + + // Pulse harder during the opening guidance, then settle to a gentle marker. + float speed = m.Onboarding ? 6f : 2.5f; + float pulse = 0.85f + 0.15f * Mathf.Sin(Time.time * speed); + beaconRoot.localScale = Vector3.one * 0.001f * pulse; + + var colour = m.Onboarding ? doneColor : accentColor; + beacon.color = new Color(colour.r, colour.g, colour.b, m.Onboarding ? 1f : 0.7f); + } + + void UpdateToasts() + { + for (int i = toasts.Count - 1; i >= 0; i--) + { + var toast = toasts[i]; + float age = Time.time - toast.born; + if (age > toastDuration) + { + Destroy(toast.rect.gameObject); + toasts.RemoveAt(i); + continue; + } + // Quick fade in, slow fade out over the last third of the life. + float fadeOut = Mathf.InverseLerp(toastDuration, toastDuration * 0.66f, age); + toast.group.alpha = Mathf.Min(Mathf.InverseLerp(0f, 0.18f, age), fadeOut); + } + + // Newest on top; each one sits below the previous. + float y = 0f; + for (int i = 0; i < toasts.Count; i++) + { + var rect = toasts[i].rect; + rect.anchoredPosition = new Vector2(0f, y); + y -= rect.sizeDelta.y + 8f; + } + } + + void UpdateIntro(M2Manager m) + { + if (m.RoundStarted || introGroup.alpha <= 0f) return; + + // Development builds and the editor only, so a release build never shows children a debug line. + introInputDebug.text = m.showInputDebug && Debug.isDebugBuild ? m.ConfirmDebug : ""; + + // IntroArmed keeps a button that was already held from skipping the briefing on frame one. + if (m.IntroArmed && m.IntroConfirmPressed()) m.BeginRound(); + } + + void ClearToasts() + { + foreach (var toast in toasts) + if (toast.rect != null) Destroy(toast.rect.gameObject); + toasts.Clear(); + } + + void Fade(CanvasGroup group, bool visible) + { + group.alpha = Mathf.MoveTowards(group.alpha, visible ? 1f : 0f, Time.deltaTime * 6f); + } + + static string SiteLabel(Component site) + { + if (site is Jar) return Module2Text.SiteJar; + if (site is FishCon) return Module2Text.SiteFishBowl; + if (site is WaterButton) return Module2Text.SiteContainer; + return Module2Text.SiteGeneric; + } + + static string SitePrompt(Component site) + { + if (site is Jar jar) return jar.LidHeld ? Module2Text.PromptJarCarrying : Module2Text.PromptJar; + if (site is FishCon) return Module2Text.PromptFish; + if (site is WaterButton) return Module2Text.PromptTip; + return Module2Text.PromptGeneric; + } + + // ------------------------------------------------------------------ construction + + void BuildHud() + { + hudRoot = NewCanvas("Module2HUD (runtime)", CanvasW, CanvasH, 10); + + var gameplay = NewRect("Gameplay", hudRoot, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero); + gameplayGroup = gameplay.gameObject.AddComponent(); + + // --- top left: timer + day tag --- + timerText = NewText("Timer", gameplay, "0:00", 54, textColor, TextAlignmentOptions.TopLeft, + new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(28, -22), new Vector2(240, 66), FontStyles.Bold); + dayTagText = NewText("DayTag", gameplay, "", 22, mutedColor, TextAlignmentOptions.TopLeft, + new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(30, -88), new Vector2(460, 30)); + + // --- top left, under the day tag: Energy bar and Bite counter. B4 specifies exactly these two + // for M2; everything else on this HUD is additional. + energyLabel = NewText("EnergyLabel", gameplay, "", 20, mutedColor, TextAlignmentOptions.TopLeft, + new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(30, -120), new Vector2(300, 26), FontStyles.Bold); + var energyBack = NewImage("EnergyBack", gameplay, roundedSprite, barBackColor, Image.Type.Sliced, + new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(30, -150), new Vector2(300, 16)); + energyFill = NewImage("EnergyFill", energyBack.rectTransform, roundedSprite, doneColor, Image.Type.Filled, + Vector2.zero, Vector2.one, new Vector2(0f, 0.5f), Vector2.zero, Vector2.zero); + energyFill.fillMethod = Image.FillMethod.Horizontal; + energyFill.fillAmount = 1f; + + biteText = NewText("Bites", gameplay, "", 30, textColor, TextAlignmentOptions.TopLeft, + new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(0f, 1f), new Vector2(30, -182), new Vector2(300, 38), FontStyles.Bold); + + // --- top right: score --- + scoreText = NewText("Score", gameplay, "", 40, textColor, TextAlignmentOptions.TopRight, + new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-28, -26), new Vector2(320, 52), FontStyles.Bold); + + // --- top right under the score: breeding-site progress --- + sitesText = NewText("Sites", gameplay, "", 24, accentColor, TextAlignmentOptions.TopRight, + new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-28, -84), new Vector2(360, 30), FontStyles.Bold); + NewImage("SitesIcon", gameplay, dropSprite, accentColor, Image.Type.Simple, + new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-300, -82), new Vector2(26, 26)); + var barBack = NewImage("SitesBarBack", gameplay, roundedSprite, barBackColor, Image.Type.Sliced, + new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-28, -118), new Vector2(300, 14)); + sitesFill = NewImage("SitesBarFill", barBack.rectTransform, roundedSprite, accentColor, Image.Type.Filled, + Vector2.zero, Vector2.one, new Vector2(0f, 0.5f), Vector2.zero, Vector2.zero); + sitesFill.fillMethod = Image.FillMethod.Horizontal; + sitesFill.fillAmount = 0f; + + // --- centre: objective arrow + label --- + // Arrow and distance sit in the free band at the top centre, above the toast stack. They used + // to be at +150/+108, exactly where toasts stack down from, so a toast covered the label. + // The label is narrow enough to stay clear of the sites counter on the right. + objectiveArrow = NewImage("ObjectiveArrow", gameplay, arrowSprite, accentColor, Image.Type.Simple, + new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0, 268), new Vector2(54, 54)); + objectiveText = NewText("ObjectiveText", gameplay, "", 26, textColor, TextAlignmentOptions.Center, + new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0, 226), new Vector2(320, 34)); + + // --- toasts, stacked under the timer --- + toastContainer = NewRect("Toasts", gameplay, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -112), new Vector2(ToastW, 10)); + + // --- bottom: contextual prompt --- + promptRect = NewRect("Prompt", gameplay, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0, 54), new Vector2(PromptW, 56)); + promptGroup = promptRect.gameObject.AddComponent(); + var promptBg = promptRect.gameObject.AddComponent(); + promptBg.sprite = roundedSprite; promptBg.type = Image.Type.Sliced; promptBg.color = panelColor; promptBg.raycastTarget = false; + promptText = NewText("Text", promptRect, "", 28, textColor, TextAlignmentOptions.Center, + Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(-40, 0)); + + // A beacon parked on the nearest open container. On the UI layer, so the overlay camera draws + // it through the house - a child cannot hunt for something occluded by a wall in 90 seconds. + beaconRoot = NewCanvas("Module2HUD Beacon (runtime)", 200, 200, 9); + beacon = NewImage("Beacon", beaconRoot, dropSprite, accentColor, Image.Type.Simple, + new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(120, 120)); + beacon.enabled = false; + + // Full-field red flash on a bite: wordless feedback, which matters for players who cannot read + // the counter quickly. + biteFlash = NewImage("BiteFlash", hudRoot, roundedSprite, new Color(dangerColor.r, dangerColor.g, dangerColor.b, 0f), + Image.Type.Sliced, Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero); + + BuildIntroPanel(hudRoot); + BuildEndPanel(hudRoot); + } + + void BuildIntroPanel(Transform parent) + { + var panel = NewRect("Intro", parent, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(960, 600)); + introGroup = panel.gameObject.AddComponent(); + var bg = panel.gameObject.AddComponent(); + bg.sprite = roundedSprite; bg.type = Image.Type.Sliced; bg.color = new Color(panelColor.r, panelColor.g, panelColor.b, 0.9f); bg.raycastTarget = false; + + introKicker = NewText("Kicker", panel, "", 20, mutedColor, TextAlignmentOptions.Center, + new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -16), new Vector2(-40, 26), FontStyles.Bold); + introTitle = NewText("Title", panel, "", 46, accentColor, TextAlignmentOptions.Center, + new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -46), new Vector2(-40, 60), FontStyles.Bold); + introBody = NewText("Body", panel, "", 26, textColor, TextAlignmentOptions.TopLeft, + new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(0.5f, 0.5f), new Vector2(0, -16), new Vector2(-80, -170)); + introFooter = NewText("Footer", panel, "", 30, doneColor, TextAlignmentOptions.Center, + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 0f), new Vector2(0, 46), new Vector2(-40, 44), FontStyles.Bold); + // On-headset input diagnostics: which confirm source is actually alive. M2Manager.showInputDebug + // controls it; it exists because the A button fires only intermittently and guessing from the + // filesystem got us nowhere. + introInputDebug = NewText("InputDebug", panel, "", 17, mutedColor, TextAlignmentOptions.Center, + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 0f), new Vector2(0, 16), new Vector2(-40, 26)); + } + + void BuildEndPanel(Transform parent) + { + // 640 tall: the consequence line plus a random fact runs to four lines, which overflowed a + // 90-high box into the stats above and the footer below on a 560 panel. + var panel = NewRect("End", parent, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(820, 640)); + endGroup = panel.gameObject.AddComponent(); + var bg = panel.gameObject.AddComponent(); + bg.sprite = roundedSprite; bg.type = Image.Type.Sliced; bg.color = endPanelColor; bg.raycastTarget = false; + + endKicker = NewText("Kicker", panel, "", 20, mutedColor, TextAlignmentOptions.Center, + new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -18), new Vector2(-40, 26), FontStyles.Bold); + endTitle = NewText("Title", panel, "", 52, textColor, TextAlignmentOptions.Center, + new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -48), new Vector2(-40, 64), FontStyles.Bold); + // Below the title (which ends at -112) with room for three lines; it used to start at -94 + // and the title was drawn over it. + endSubtitle = NewText("Subtitle", panel, "", 25, mutedColor, TextAlignmentOptions.Center, + new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -116), new Vector2(-60, 96)); + endStats = NewText("Stats", panel, "", 30, textColor, TextAlignmentOptions.Center, + new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -214), new Vector2(-60, 160)); + endFact = NewText("Fact", panel, "", 23, accentColor, TextAlignmentOptions.Center, + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 0f), new Vector2(0, 72), new Vector2(-80, 136), FontStyles.Italic); + // Facts differ in length (and Lao runs longer); shrink to fit instead of spilling onto the footer. + endFact.enableAutoSizing = true; + endFact.fontSizeMin = 15f; + endFact.fontSizeMax = 23f; + endFooter = NewText("Footer", panel, "", 28, doneColor, TextAlignmentOptions.Center, + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 0f), new Vector2(0, 22), new Vector2(-40, 44), FontStyles.Bold); + } + + RectTransform NewCanvas(string name, float w, float h, int sortingOrder) + { + var go = new GameObject(name); + go.layer = uiLayer; + var rt = go.AddComponent(); + var canvas = go.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + canvas.sortingOrder = sortingOrder; + canvas.additionalShaderChannels = AdditionalCanvasShaderChannels.TexCoord1 | AdditionalCanvasShaderChannels.Normal | AdditionalCanvasShaderChannels.Tangent; + rt.sizeDelta = new Vector2(w, h); + rt.localScale = Vector3.one * 0.001f; + return rt; + } + + RectTransform NewRect(string name, Transform parent, Vector2 anchorMin, Vector2 anchorMax, Vector2 pivot, Vector2 pos, Vector2 size) + { + var go = new GameObject(name); + go.layer = uiLayer; + var rt = go.AddComponent(); + rt.SetParent(parent, false); + rt.anchorMin = anchorMin; + rt.anchorMax = anchorMax; + rt.pivot = pivot; + rt.anchoredPosition = pos; + rt.sizeDelta = size; + return rt; + } + + Image NewImage(string name, Transform parent, Sprite sprite, Color color, Image.Type type, Vector2 anchorMin, Vector2 anchorMax, Vector2 pivot, Vector2 pos, Vector2 size) + { + var rt = NewRect(name, parent, anchorMin, anchorMax, pivot, pos, size); + var img = rt.gameObject.AddComponent(); + img.sprite = sprite; + img.color = color; + img.type = type; + img.raycastTarget = false; + return img; + } + + TextMeshProUGUI NewText(string name, Transform parent, string text, float size, Color color, TextAlignmentOptions align, Vector2 anchorMin, Vector2 anchorMax, Vector2 pivot, Vector2 pos, Vector2 sizeDelta, FontStyles style = FontStyles.Normal) + { + var rt = NewRect(name, parent, anchorMin, anchorMax, pivot, pos, sizeDelta); + var t = rt.gameObject.AddComponent(); + if (font != null) t.font = font; + t.text = text; + t.fontSize = size; + t.color = color; + t.alignment = align; + t.fontStyle = style; + t.textWrappingMode = TextWrappingModes.Normal; + t.overflowMode = TextOverflowModes.Overflow; + t.richText = true; + t.raycastTarget = false; + return t; + } + + // ------------------------------------------------------------------ procedural sprites + + void BuildSprites() + { + // Rounded rectangle, 9-sliced (border 10 px = 10 mm corners at multiplier 1). + roundedSprite = MakeSprite(Raster(32, (u, v) => + { + float r = 10f / 32f; + Vector2 p = new Vector2(u - 0.5f, v - 0.5f); + Vector2 q = new Vector2(Mathf.Abs(p.x), Mathf.Abs(p.y)) - new Vector2(0.5f - r, 0.5f - r); + float d = new Vector2(Mathf.Max(q.x, 0f), Mathf.Max(q.y, 0f)).magnitude + Mathf.Min(Mathf.Max(q.x, q.y), 0f) - r; + return d < 0f; + }), new Vector4(10, 10, 10, 10)); + + // Chevron arrow pointing up. + arrowSprite = MakeSprite(Raster(64, (u, v) => + InTri(u, v, 0.5f, 0.95f, 0.08f, 0.1f, 0.92f, 0.1f) && !InTri(u, v, 0.5f, 0.5f, 0.24f, 0.05f, 0.76f, 0.05f))); + + // Water drop: circle plus a triangle up to the apex. + dropSprite = MakeSprite(Raster(64, (u, v) => + InCircle(u, v, 0.5f, 0.36f, 0.28f) || InTri(u, v, 0.5f, 0.97f, 0.251f, 0.4885f, 0.749f, 0.4885f))); + } + + static Sprite MakeSprite(Texture2D tex, Vector4 border = default) + { + return Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f, 0, SpriteMeshType.FullRect, border); + } + + // Supersampled hard-edged shape -> antialiased alpha. + static Texture2D Raster(int size, System.Func inside, int ss = 4) + { + var px = new Color32[size * size]; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + int hits = 0; + for (int sy = 0; sy < ss; sy++) + for (int sx = 0; sx < ss; sx++) + { + float u = (x + (sx + 0.5f) / ss) / size; + float v = (y + (sy + 0.5f) / ss) / size; + if (inside(u, v)) hits++; + } + byte a = (byte)(255 * hits / (ss * ss)); + px[y * size + x] = new Color32(255, 255, 255, a); + } + + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false) { wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Bilinear }; + tex.SetPixels32(px); + tex.Apply(); + return tex; + } + + static bool InCircle(float u, float v, float cx, float cy, float r) + { + float dx = u - cx, dy = v - cy; + return dx * dx + dy * dy <= r * r; + } + + static bool InTri(float u, float v, float ax, float ay, float bx, float by, float cx, float cy) + { + float d1 = Sign(u, v, ax, ay, bx, by); + float d2 = Sign(u, v, bx, by, cx, cy); + float d3 = Sign(u, v, cx, cy, ax, ay); + bool neg = d1 < 0 || d2 < 0 || d3 < 0; + bool pos = d1 > 0 || d2 > 0 || d3 > 0; + return !(neg && pos); + } + + static float Sign(float px, float py, float ax, float ay, float bx, float by) + { + return (px - bx) * (ay - by) - (ax - bx) * (py - by); + } +} diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs.meta b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs.meta new file mode 100644 index 00000000..baa4e7c4 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5fc6c8f3bab0b449791eed04f99cc12b \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs new file mode 100644 index 00000000..661f5e28 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using UnityEngine; + +/// +/// Module 2's string table. +/// +/// Separate from the framework's LocalizationManager on purpose: that one parses its CSV with a bare +/// Split(','), so no value may contain a comma, and it currently holds three keys for English, +/// Vietnamese and French - no Lao. This reads a tab-separated file instead, so copy can contain +/// commas, and its columns are the languages this module actually ships in. +/// +/// The table lives at Resources/Localization/Module2Text.txt. Every lookup falls back to the English +/// string compiled into Module2Text, so a missing key or a missing file degrades to readable English +/// rather than to blanks. +/// +/// Note for a Lao build: TMP's default font (LiberationSans) has no Lao glyphs. Set +/// Module2HUD.fontOverride to a Lao-capable font asset, and keep strings short - Lao does not put +/// spaces between words, so TMP's word wrapping has nothing to break on in a long line. +/// +public static class Module2Localization +{ + const string ResourcePath = "Localization/Module2Text"; + const string DefaultLanguage = "English"; + public const string LanguagePrefKey = "AEDES.Language"; + + static Dictionary _rows; // key -> value per column + static string[] _languages; // column headers, index 0 is the key column + static int _column = -1; + static bool _loaded; + + /// Language column in use. Falls back to English when the name is not a column. + public static string Language { get; private set; } = DefaultLanguage; + + public static void SetLanguage(string language) + { + Language = string.IsNullOrWhiteSpace(language) ? DefaultLanguage : language.Trim(); + _column = ResolveColumn(Language); + PlayerPrefs.SetString(LanguagePrefKey, Language); + } + + /// Localised value for a key, or the supplied English fallback. + public static string Get(string key, string fallback) + { + EnsureLoaded(); + + if (_rows == null || _column < 1 || string.IsNullOrEmpty(key)) return fallback; + if (!_rows.TryGetValue(key, out var values)) return fallback; + if (_column >= values.Length) return fallback; + + string value = values[_column]; + return string.IsNullOrWhiteSpace(value) ? fallback : value.Replace("\\n", "\n"); + } + + static void EnsureLoaded() + { + if (_loaded) return; + _loaded = true; + + var asset = Resources.Load(ResourcePath); + if (asset == null) + { + Debug.Log($"[Module2Localization] No table at Resources/{ResourcePath} - using built-in English."); + return; + } + + _rows = new Dictionary(); + var lines = asset.text.Split(new[] { '\r', '\n' }, System.StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].StartsWith("#")) continue; + var cells = lines[i].Split('\t'); + for (int c = 0; c < cells.Length; c++) cells[c] = cells[c].Trim(); + + if (_languages == null) { _languages = cells; continue; } + if (cells.Length < 2 || string.IsNullOrEmpty(cells[0])) continue; + _rows[cells[0]] = cells; + } + + string stored = PlayerPrefs.GetString(LanguagePrefKey, DefaultLanguage); + Language = string.IsNullOrWhiteSpace(stored) ? DefaultLanguage : stored; + _column = ResolveColumn(Language); + + Debug.Log($"[Module2Localization] {_rows.Count} key(s), columns: {string.Join(", ", _languages)} - using '{Language}'."); + } + + static int ResolveColumn(string language) + { + EnsureLoaded(); + if (_languages == null) return -1; + + for (int i = 1; i < _languages.Length; i++) + if (string.Equals(_languages[i], language, System.StringComparison.OrdinalIgnoreCase)) return i; + + for (int i = 1; i < _languages.Length; i++) + if (string.Equals(_languages[i], DefaultLanguage, System.StringComparison.OrdinalIgnoreCase)) return i; + + return -1; + } +} diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs.meta b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs.meta new file mode 100644 index 00000000..d1284a15 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f00c0c245ba754c9d84ab6ef41787219 \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs new file mode 100644 index 00000000..d29e8f2f --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs @@ -0,0 +1,151 @@ +using UnityEngine; + +/// +/// Every player-facing string in Module 2, in one place. +/// +/// Each member reads from Resources/Localization/Module2Text.txt by key, falling back to the English +/// literal here. So the English build needs no table at all, a Lao build needs only the table filled +/// in, and a missing key degrades to readable English instead of a blank. +/// +/// Keep these short. Two minutes per child, no VR experience and possibly low English means prose is +/// not read; and Lao does not separate words with spaces, so long lines wrap badly. +/// +public static class Module2Text +{ + static string L(string key, string english) => Module2Localization.Get(key, english); + + // Vitals (B4 specifies these two for M2) + public static string EnergyLabel => L("hud.energy", "ENERGY"); + public static string Bites(int count, int limit) => L("hud.bites", "Bites") + " " + count + " / " + limit; + public static string BitesNearLimit => L("toast.bitesNearLimit", "Too many bites - get away from the swarm"); + + // Status + public static string DayTag => L("hud.dayTag", "Daytime - Aedes bite by day"); + public static string NightTag => L("hud.nightTag", "Evening - the swarm is settling"); + public static string Score(int score) => "Score " + score; + public static string Sites(int cleared, int total) => + total > 0 ? "BREEDING SITES " + cleared + " / " + total : "BREEDING SITES"; + + // Objective + public static string SiteJar => L("site.jar", "Water jar"); + public static string SiteContainer => L("site.container", "Open container"); + public static string SiteFishBowl => L("site.bowl", "Water bowl"); + public static string SiteGeneric => L("site.generic", "Standing water"); + public static string AllClear => L("hud.allClear", "Every breeding site dealt with"); + public static string Target(string what, float dist) => what + " " + Mathf.RoundToInt(dist) + " m"; + + // The 5 ປ / 5P. CMPE-validated wording (Marcombe, Jul 2026): Lao is the operative campaign + // wording, English only a comprehension gloss. Lao lives in the table's Lao column - it will not + // render under TMP's default LiberationSans, which has no Lao glyphs. + public static string MeasureCover => L("measure.cover", "Cover"); // ປິດ + public static string MeasureChange => L("measure.change", "Change"); // ປ່ຽນ + public static string MeasureRelease => L("measure.release", "Release");// ປ່ອຍ + public static string MeasureImprove => L("measure.improve", "Improve");// ປັບປຸງ + public static string MeasurePractice => L("measure.practice", "Practice"); // ປະຕິບັດ + + // Prompts, each naming the measure it teaches. + public static string PromptJar => L("prompt.jar", MeasureCover + " - grab the lid beside the jar"); + public static string PromptJarCarrying => L("prompt.jarCarrying", MeasureCover + " - hold the lid over the jar and let go"); + public static string PromptTip => L("prompt.tip", MeasureChange + " - tip the water out"); + public static string PromptFish => L("prompt.fish", MeasureRelease + " - drop a guppy in, the fish eat the larvae"); + public static string PromptGeneric => L("prompt.generic", MeasureImprove + " - get rid of the standing water"); + + // Toasts + public static string StartToast => L("toast.start", "Find the standing water - that is where they breed"); + public static string TrashBinned => L("toast.trash", "Rubbish binned - no more water traps"); + public static string NightFalls => L("toast.night", "Evening is coming - the light is going"); + public static string OneMinuteLeft => L("toast.oneMinute", "One minute left!"); + public static string ThirtySecondsLeft => L("toast.thirtySeconds", "30 seconds left!"); + public static string SwatterSpawned => L("toast.swatterOut", "Swatter is in front of you - grab it"); + public static string SwatterAlreadyOut => L("toast.swatterAlready", "You already have a swatter"); + public static string CreamSpawned => L("toast.creamOut", "Repellent is in front of you - grab it"); + public static string CreamAlreadyOut => L("toast.creamAlready", "You already have repellent"); + public static string RepellentOn(int seconds) => L("toast.repellentOn", "Repellent on - no bites for {0} s").Replace("{0}", seconds.ToString()); + public static string RepellentWornOff => L("toast.repellentOff", "Repellent has worn off"); + public static string RepellentStatus(int seconds) => L("hud.repellent", "Repellent") + " " + seconds + "s"; + public static string SiteCleared(string what, int cleared, int total) => + what + " dealt with - " + cleared + " / " + total; + + // Briefing + public static string IntroKicker => L("intro.kicker", "BRIEFING"); + public static string IntroTitle => L("intro.title", "Clear the mosquito breeding sites"); + public static string IntroBody => L("intro.body", + "Mosquitoes breed in still water.\n" + + "Find it. Cover it. Tip it out.\n" + + "\n" + + "Follow the arrow."); + public static string IntroFooter => L("intro.footer", "Press A, trigger or grip to begin"); + + // Debrief + public static string EndKicker => L("end.kicker", "ROUND OVER"); + public static string EndFooter => L("end.footer", "Press A, trigger or grip to play again"); + + public static string EndTitle(M2Manager.RoundEndReason reason) + { + switch (reason) + { + case M2Manager.RoundEndReason.AllSitesCleared: return L("end.titleClear", "House is clear!"); + case M2Manager.RoundEndReason.BitesReached: return L("end.titleBitten", "Driven off by the swarm"); + default: return L("end.titleTimeout", "Time's up"); + } + } + + public static string EndSubtitle(M2Manager.RoundEndReason reason, int cleared, int total) + { + if (reason == M2Manager.RoundEndReason.BitesReached) + return L("end.subBitten", "The swarm got you before the water was dealt with. Fewer breeding containers means fewer mosquitoes to bite you - protection alone does not reduce the swarm."); + if (reason == M2Manager.RoundEndReason.AllSitesCleared) + return L("end.subClear", "Every source of standing water is dealt with. Do this once a week and the mosquitoes never finish their life cycle."); + if (total > 0 && cleared == 0) + return L("end.subNone", "Nothing was dealt with, so every jar and container is still a nursery. Look for standing water, not for mosquitoes."); + return L("end.subPartial", "Some standing water is still out there. One forgotten container is enough to keep a household supplied with mosquitoes."); + } + + /// + /// A8: Action Score and Community Outcome are two scores that can diverge, and squads are ranked + /// by the Community Outcome - here the Source Reduction %, which is also the M2 -> M3 GAMA hook. + /// Swatting and protection move the action score only; they cannot move source reduction at all. + /// + public static string EndStats(int score, int sitesCleared, int sitesTotal, int swatted, int trash, int sourceReductionPercent, int bites) + { + return "" + L("end.statSourceReduction", "SOURCE REDUCTION") + " " + sourceReductionPercent + "% " + + "(" + sitesCleared + " / " + sitesTotal + ")\n" + + L("end.statAction", "Action score") + " " + score + "\n" + + "" + L("end.statBites", "Bites") + " " + bites + " " + + L("end.statTrash", "Rubbish") + " " + trash + " " + + L("end.statSwatted", "Swatted") + " " + swatted + ""; + } + + /// The point of the whole round: what the containers you left behind will do. + public static string Consequence(int missed, int sourceReductionPercent, bool fromSimulation) + { + if (missed <= 0) return L("end.consequenceNone", "Nothing left breeding. Do this every week and the cycle never restarts."); + + // A5: Outbreak_Intensity = (100 - Source_Reduction_Percent) x Population_Density_Factor, and + // below 50% the Critical triage cases in Module 3 double. So the warning is stated in the + // terms the model actually uses rather than as an invented mosquito count. + string containers = missed == 1 ? L("end.container", "container") : L("end.containers", "containers"); + string line = missed + " " + containers + " " + L("end.stillHolding", "still holding water") + "."; + + if (sourceReductionPercent < 50) + line += "\n" + L("end.belowHalf", "Under half the sites cleared - the outbreak you hand on will be twice as severe."); + + return line; + } + + static readonly string[] Facts = + { + "Aedes aegypti breeds in clean standing water - a jar, a bucket or a saucer under a plant pot.", + "Aedes eggs can survive months in a dry container and hatch as soon as the rain returns.", + "Guppies and other small fish eat mosquito larvae, which is why they are put in water jars.", + "Emptying and scrubbing water containers once a week breaks the mosquito life cycle.", + "Swatting adults barely dents the population - removing their breeding water is what works.", + "A covered water jar is the single most effective change most households can make.", + }; + + public static string RandomFact() + { + int i = Random.Range(0, Facts.Length); + return L("fact." + i, Facts[i]); + } +} diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs.meta b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs.meta new file mode 100644 index 00000000..f50ba493 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5b0e636dc5de94f569cd924adc210748 \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs index 26164cae..ae4de08a 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs @@ -1,4 +1,5 @@ using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit.Interactors; public class UIHand : MonoBehaviour { @@ -10,19 +11,35 @@ public class UIHand : MonoBehaviour public GameObject handSwatterPoint; [Header("Setting")] + [Tooltip("Unused. The menu used to open only while a ray from the watch hit a collider on this layer; see ShowUIHanD.")] public LayerMask layerMaskHand; + [Tooltip("The menu opens when the watch face is turned to within this many degrees of the player's eyes.")] + public float openAngle = 35f; + + [Tooltip("Once open, the menu stays until the watch face is turned further from the eyes than this.")] + public float closeAngle = 55f; + + [Tooltip("Touching the watch with the other hand's fingertip opens the menu too: how close the fingertip has to come, in metres.")] + public float touchDistance = 0.07f; + + [Tooltip("The menu stays open while the other hand's fingertip is within this many metres of it, so reaching for a button cannot close it.")] + public float reachDistance = 0.2f; + + [Tooltip("Seconds the menu stays up after the last reason to show it has gone, so tracking jitter does not make it flicker.")] + public float closeDelay = 0.5f; + + Transform _head; + Transform _finger; + float _nextFingerSearch; + float _lastWanted = float.NegativeInfinity; + private void Awake() { - if (Instance == null) - { - Instance = this; - DontDestroyOnLoad(gameObject); - } - else - { - Destroy(gameObject); - } + // The watch is a child of the left hand, so DontDestroyOnLoad never applied (it only works on + // root objects) and only logged a warning on every load; the rig and its watch are rebuilt + // with the scene. Just track the current one. + Instance = this; } public void Start() @@ -35,18 +52,51 @@ public void Update() ShowUIHanD(); } + // The menu used to show only while a ray along the watch's up axis hit a collider on layerMaskHand: + // the 8 cm sphere on the camera, or the 1 cm one on the other hand's fingertip. On a headset that + // meant aiming the wrist to within about ten degrees of the eyes, which nobody holds. What players + // found instead was that touching the watch opened the menu - and it shut again the moment the + // finger left that ray to reach for a button, because the buttons sit either side of it. public void ShowUIHanD() { - if (Physics.Raycast(transform.position, transform.up, out RaycastHit hit, 10f, layerMaskHand)) + if (WantsMenu(uiHand.activeSelf)) _lastWanted = Time.unscaledTime; + + bool show = Time.unscaledTime - _lastWanted <= closeDelay; + if (uiHand.activeSelf != show) uiHand.SetActive(show); + } + + bool WantsMenu(bool open) + { + if (_head == null && Camera.main != null) _head = Camera.main.transform; + if (_head != null) { - uiHand.SetActive(true); + float angle = Vector3.Angle(transform.up, _head.position - transform.position); + if (angle <= (open ? closeAngle : openAngle)) return true; } - else + + Transform finger = OtherFingertip(); + if (finger == null) return false; + + // Closed, the finger has to come to the watch itself. Open, anywhere around the menu will do. + return open + ? (finger.position - uiHand.transform.position).sqrMagnitude <= reachDistance * reachDistance + : (finger.position - transform.position).sqrMagnitude <= touchDistance * touchDistance; + } + + // The poking fingertip of the hand that is not wearing the watch. + Transform OtherFingertip() + { + if (_finger != null) return _finger; + if (Time.unscaledTime < _nextFingerSearch) return null; + _nextFingerSearch = Time.unscaledTime + 1f; + + foreach (var poke in FindObjectsByType(FindObjectsSortMode.None)) { - uiHand.SetActive(false); + if (transform.parent != null && poke.transform.IsChildOf(transform.parent)) continue; + _finger = poke.GetAttachTransform(null); + break; } - - Debug.DrawRay(transform.position, transform.up); + return _finger; } public void SpawnSwatter() diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/BreedingSiteFx.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/BreedingSiteFx.cs new file mode 100644 index 00000000..1ccb4047 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/BreedingSiteFx.cs @@ -0,0 +1,407 @@ +using System.Collections.Generic; +using UnityEngine; + +/// +/// Makes an open breeding site look like one: a few pale wrigglers swimming at the water's surface and +/// a slow column of dark mosquitoes rising from it. It is what connects "clear the water" to "clear the +/// mosquitoes" - the swarm is sustained by the open sites, and without this nothing on screen said so. +/// +/// Built entirely at runtime from primitive meshes and two small materials in Resources/Module2, so no +/// scene or prefab has to change. M2Manager attaches one to each site the round uses and calls Clear() +/// when the site is dealt with: the wrigglers shrink away and the last mosquitoes drift off. +/// +public class BreedingSiteFx : MonoBehaviour +{ + const string LarvaMaterial = "Module2/M2_SiteLarva"; + const string MoteMaterial = "Module2/M2_SiteMote"; + + // ---- shared, loaded once ---- + static Material s_larvaMat, s_moteMat; + static Mesh s_sphere, s_capsule; + static bool s_loaded, s_failed; + + // The game's own mosquito, baked once to a static mesh, so what rises from the water is recognisably + // the thing in the swarm. Falls back to a dark speck if the prefab cannot be found. + static Mesh s_mosquitoMesh; + static Material[] s_mosquitoMaterials; + static float s_mosquitoLength; + static bool s_mosquitoTried; + + class Larva + { + public Transform root, joint1, joint2; + public Vector2 center; + public float orbit, angle, speed, phase, wiggleRate; + } + + class Mote + { + public Transform t; + public float age, life, phase, yaw; + public Vector2 start; + } + + readonly List _larvae = new List(); + readonly List _motes = new List(); + + Transform _larvaRoot, _moteRoot; + Vector3 _surfaceLocal; + Quaternion _attachRotation; + float _radius, _scale, _moteSize, _moteScale; + + // Longest side of a rising mosquito in metres at a jar's scale; smaller containers scale it down. + const float MoteLength = 0.16f; + bool _clearing; + float _clearedAt; + + /// Attach to a site (once). Does nothing if the water cannot be found or the assets are missing. + public static BreedingSiteFx Attach(Component site) + { + if (site == null) return null; + var fx = site.GetComponent(); + if (fx == null) fx = site.gameObject.AddComponent(); + return fx; + } + + /// The site has been dealt with: wrigglers vanish, no new mosquitoes rise. + public void Clear() + { + if (_clearing) return; + _clearing = true; + _clearedAt = Time.time; + } + + void Awake() + { + if (!LoadShared() || !FindWater(out Bounds water)) + { + enabled = false; + return; + } + + _radius = Mathf.Max(Mathf.Min(water.extents.x, water.extents.z) * 0.75f, 0.03f); + // A vase's water is an 8 cm disc: keep the wrigglers big enough to read on it. + _scale = Mathf.Clamp(_radius / 0.3f, 0.55f, 1.3f); + _surfaceLocal = transform.InverseTransformPoint(new Vector3(water.center.x, water.max.y, water.center.z)); + _attachRotation = transform.rotation; + + _larvaRoot = new GameObject("[runtime] Larvae").transform; + _moteRoot = new GameObject("[runtime] Rising mosquitoes").transform; + Follow(); + + BuildLarvae(); + BuildMotes(); + } + + void OnEnable() + { + if (_larvaRoot != null) _larvaRoot.gameObject.SetActive(true); + if (_moteRoot != null) _moteRoot.gameObject.SetActive(true); + } + + void OnDisable() + { + if (_larvaRoot != null) _larvaRoot.gameObject.SetActive(false); + if (_moteRoot != null) _moteRoot.gameObject.SetActive(false); + } + + void OnDestroy() + { + if (_larvaRoot != null) Destroy(_larvaRoot.gameObject); + if (_moteRoot != null) Destroy(_moteRoot.gameObject); + } + + // ------------------------------------------------------------------ setup + + static bool LoadShared() + { + if (s_loaded) return true; + if (s_failed) return false; + + s_larvaMat = Resources.Load(LarvaMaterial); + s_moteMat = Resources.Load(MoteMaterial); + if (s_larvaMat == null || s_moteMat == null) + { + Debug.LogWarning("[BreedingSiteFx] Materials missing under Resources/Module2 - no site effects this run."); + s_failed = true; + return false; + } + + // Meshes come from a throwaway primitive so they exist in a player build exactly as they do here. + s_sphere = PrimitiveMesh(PrimitiveType.Sphere); + s_capsule = PrimitiveMesh(PrimitiveType.Capsule); + s_loaded = s_sphere != null && s_capsule != null; + s_failed = !s_loaded; + return s_loaded; + } + + static Mesh PrimitiveMesh(PrimitiveType type) + { + var temp = GameObject.CreatePrimitive(type); + var mesh = temp.GetComponent().sharedMesh; + Destroy(temp); + return mesh; + } + + static bool LoadMosquito() + { + if (s_mosquitoMesh != null) return true; + if (s_mosquitoTried) return false; + s_mosquitoTried = true; + + var spawner = FindFirstObjectByType(FindObjectsInactive.Include); + if (spawner == null || spawner.mosquitoPrefab == null) return false; + + // Bake from a throwaway instance: BakeMesh gives the pose as rendered, scale included, which the + // skinned mesh's own bounds do not (its armature carries the scale). Needs Read/Write on the + // mosquito FBX (Mosquito_Female.fbx), which is switched on for this. + var temp = Instantiate(spawner.mosquitoPrefab); + try + { + var skinned = temp.GetComponentInChildren(true); + if (skinned == null) return false; + + var baked = new Mesh { name = "Mosquito (baked)" }; + skinned.BakeMesh(baked, true); + if (baked.vertexCount == 0) return false; + var size = baked.bounds.size; + s_mosquitoLength = Mathf.Max(size.x, size.y, size.z); + if (s_mosquitoLength < 0.0001f) return false; + + s_mosquitoMesh = baked; + s_mosquitoMaterials = skinned.sharedMaterials; + return true; + } + finally + { + DestroyImmediate(temp); + } + } + + // Where the water is, in world space. Each site type keeps it somewhere different. + bool FindWater(out Bounds water) + { + water = default; + + var button = GetComponent(); + if (button != null && button.waterPrefab != null && Combine(button.waterPrefab.GetComponentsInChildren(), out water)) + return true; + + // The fish bowl's own mesh is the flat disc of water. + if (GetComponent() != null) + { + var own = GetComponent(); + if (own != null) { water = own.bounds; return true; } + } + + var jar = GetComponent(); + if (jar != null) + { + // The water is a flat disc among the jar's children; the lid pieces are flat too, so skip them. + Renderer flattest = null; + float best = 0.3f; + foreach (var r in GetComponentsInChildren(false)) + { + if (r.gameObject == gameObject || IsLidPiece(jar, r.transform)) continue; + var b = r.bounds; + float wide = Mathf.Max(b.size.x, b.size.z); + if (wide < 0.05f) continue; + float ratio = b.size.y / wide; + if (ratio < best) { best = ratio; flattest = r; } + } + if (flattest != null) { water = flattest.bounds; return true; } + } + + // Unknown site: the top of whatever it is made of. + if (Combine(GetComponentsInChildren(), out var all)) + { + water = new Bounds(new Vector3(all.center.x, all.max.y, all.center.z), new Vector3(all.size.x * 0.6f, 0f, all.size.z * 0.6f)); + return true; + } + return false; + } + + static bool IsLidPiece(Jar jar, Transform t) + { + return (jar.top != null && t.IsChildOf(jar.top.transform)) + || (jar.topHolo != null && t.IsChildOf(jar.topHolo.transform)) + || (jar.top_grab != null && t.IsChildOf(jar.top_grab.transform)); + } + + static bool Combine(Renderer[] renderers, out Bounds bounds) + { + bounds = default; + bool any = false; + foreach (var r in renderers) + { + if (r == null) continue; + if (!any) { bounds = r.bounds; any = true; } + else bounds.Encapsulate(r.bounds); + } + return any; + } + + Transform Piece(Transform parent, string name, Mesh mesh, Material material, Vector3 localPosition, Vector3 localScale) + { + var go = new GameObject(name); + var t = go.transform; + t.SetParent(parent, false); + t.localPosition = localPosition; + t.localScale = localScale; + go.AddComponent().sharedMesh = mesh; + var renderer = go.AddComponent(); + renderer.sharedMaterial = material; + renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + renderer.receiveShadows = false; + return t; + } + + void BuildLarvae() + { + int count = Mathf.Clamp(Mathf.RoundToInt(2f + _radius * 18f), 2, 7); + float head = 0.04f * _scale; + float seg = 0.05f * _scale; + + for (int i = 0; i < count; i++) + { + var larva = new Larva(); + var root = new GameObject("Larva").transform; + root.SetParent(_larvaRoot, false); + larva.root = root; + + Piece(root, "Head", s_sphere, s_larvaMat, Vector3.zero, Vector3.one * head); + + larva.joint1 = new GameObject("Joint1").transform; + larva.joint1.SetParent(root, false); + larva.joint1.localPosition = new Vector3(0f, 0f, -head * 0.4f); + Piece(larva.joint1, "Tail1", s_capsule, s_larvaMat, new Vector3(0f, 0f, -seg * 0.5f), new Vector3(head * 0.55f, seg * 0.5f, head * 0.55f)) + .localRotation = Quaternion.Euler(90f, 0f, 0f); + + larva.joint2 = new GameObject("Joint2").transform; + larva.joint2.SetParent(larva.joint1, false); + larva.joint2.localPosition = new Vector3(0f, 0f, -seg); + Piece(larva.joint2, "Tail2", s_capsule, s_larvaMat, new Vector3(0f, 0f, -seg * 0.5f), new Vector3(head * 0.33f, seg * 0.5f, head * 0.33f)) + .localRotation = Quaternion.Euler(90f, 0f, 0f); + + larva.center = Random.insideUnitCircle * (_radius * 0.3f); + larva.orbit = Random.Range(0.25f, 0.6f) * _radius; + larva.angle = Random.value * Mathf.PI * 2f; + float dir = Random.value < 0.5f ? -1f : 1f; + larva.speed = dir * Random.Range(0.35f, 0.7f) / Mathf.Max(_radius * 4f, 0.4f); + larva.phase = Random.value * 10f; + larva.wiggleRate = Random.Range(6f, 9f); + _larvae.Add(larva); + } + } + + void BuildMotes() + { + int count = Mathf.Clamp(Mathf.RoundToInt(3f + _radius * 12f), 3, 6); + bool real = LoadMosquito(); + _moteSize = real ? MoteLength * _scale : 0.05f * _scale; + _moteScale = real ? _moteSize / s_mosquitoLength : 1f; + + for (int i = 0; i < count; i++) + { + var mote = new Mote(); + if (real) + { + var go = new GameObject("Mosquito"); + mote.t = go.transform; + mote.t.SetParent(_moteRoot, false); + go.AddComponent().sharedMesh = s_mosquitoMesh; + var renderer = go.AddComponent(); + renderer.sharedMaterials = s_mosquitoMaterials; + renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + renderer.receiveShadows = false; + } + else + { + mote.t = Piece(_moteRoot, "Mosquito", s_sphere, s_moteMat, Vector3.zero, new Vector3(_moteSize, _moteSize * 0.6f, _moteSize * 0.6f)); + } + mote.yaw = Random.value * 360f; + mote.life = Random.Range(3.2f, 4.6f); + mote.age = Random.value * mote.life; // staggered, so the column is already full when the round starts + mote.phase = Random.value * 10f; + mote.start = Random.insideUnitCircle * (_radius * 0.6f); + _motes.Add(mote); + } + } + + // ------------------------------------------------------------------ per frame + + void Follow() + { + Vector3 surface = transform.TransformPoint(_surfaceLocal); + // Larvae keep to the water's plane and tilt with the container; the mosquitoes rise in world up. + _larvaRoot.SetPositionAndRotation(surface, transform.rotation * Quaternion.Inverse(_attachRotation)); + _moteRoot.position = surface; + } + + void Update() + { + Follow(); + float now = Time.time; + float dt = Time.deltaTime; + + float clearT = _clearing ? Mathf.Clamp01((now - _clearedAt) / 0.5f) : 0f; + float larvaScale = 1f - clearT * clearT * (3f - 2f * clearT); + _larvaRoot.gameObject.SetActive(larvaScale > 0.001f); + + if (larvaScale > 0.001f) + for (int i = 0; i < _larvae.Count; i++) UpdateLarva(_larvae[i], now, dt, larvaScale); + + bool anyMote = false; + for (int i = 0; i < _motes.Count; i++) anyMote |= UpdateMote(_motes[i], now, dt); + + // Once cleared and everything has faded, the whole effect goes. + if (_clearing && larvaScale <= 0.001f && !anyMote) Destroy(this); + } + + void UpdateLarva(Larva l, float now, float dt, float scale) + { + // A little speed pulse makes the swimming stop-and-go rather than a smooth orbit. + l.angle += l.speed * (1f + 0.6f * Mathf.Sin(now * 2.3f + l.phase)) * dt; + float c = Mathf.Cos(l.angle), s = Mathf.Sin(l.angle); + float lift = 0.004f + 0.006f * (0.5f + 0.5f * Mathf.Sin(now * 1.7f + l.phase * 1.3f)); + + l.root.localPosition = new Vector3(l.center.x + l.orbit * c, lift * _scale, l.center.y + l.orbit * s); + float sign = Mathf.Sign(l.speed); + l.root.localRotation = Quaternion.LookRotation(new Vector3(-s * sign, 0f, c * sign)); + l.root.localScale = Vector3.one * scale; + + float w = now * l.wiggleRate + l.phase; + l.joint1.localRotation = Quaternion.Euler(0f, Mathf.Sin(w) * 28f, 0f); + l.joint2.localRotation = Quaternion.Euler(0f, Mathf.Sin(w - 1.1f) * 40f, 0f); + } + + // Returns true while the mosquito is still visible. + bool UpdateMote(Mote m, float now, float dt) + { + m.age += dt; + if (m.age >= m.life) + { + if (_clearing) { m.t.gameObject.SetActive(false); return false; } + m.age -= m.life; + m.start = Random.insideUnitCircle * (_radius * 0.6f); + m.life = Random.Range(3.2f, 4.6f); + } + if (!m.t.gameObject.activeSelf) m.t.gameObject.SetActive(true); + + float u = m.age / m.life; + float rise = 1.1f * Mathf.Max(_scale, 0.7f) * u; + float sway = 0.07f * _scale; + m.t.localPosition = new Vector3( + m.start.x + Mathf.Sin(now * 2.1f + m.phase) * sway, + 0.06f + rise, + m.start.y + Mathf.Cos(now * 1.6f + m.phase) * sway); + + // Fade in over the water, out at the top. + float fade = Mathf.SmoothStep(0f, 1f, u / 0.12f) * (1f - Mathf.SmoothStep(0f, 1f, (u - 0.75f) / 0.25f)); + float flutter = Mathf.Sin(now * 14f + m.phase) * 10f; + m.t.localRotation = Quaternion.Euler(0f, m.yaw, flutter); + if (s_mosquitoMesh != null) m.t.localScale = Vector3.one * (_moteScale * fade); + else m.t.localScale = new Vector3(_moteSize, _moteSize * 0.6f, _moteSize * 0.6f) * fade; + return true; + } +} diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/BreedingSiteFx.cs.meta b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/BreedingSiteFx.cs.meta new file mode 100644 index 00000000..d8b094ae --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/BreedingSiteFx.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2ffc8ad0a191a477994e8e013034ceae \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs index 8c05fbf5..6087097e 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs @@ -1,23 +1,45 @@ using UnityEngine; -public class FishCon : MonoBehaviour +public class FishCon : MonoBehaviour, IBreedingSite { public GameObject fishs; public int scoreValue = 10; + private bool _hasFish = false; + private void Start() { - fishs.SetActive(false); + if (fishs != null) fishs.SetActive(false); + + if (M2Manager.Instance != null) M2Manager.Instance.RegisterBreedingSite(this); + } + + /// The fish are already in and nobody is credited: how a round shows a bowl it is not using. + public void ShowResolved() + { + _hasFish = true; + if (fishs != null) fishs.SetActive(true); + + // The ball is only there to be put in the bowl. With no bowl left that needs one it is a prop + // that does nothing when dropped in, so take it away rather than let it mislead. + foreach (var other in FindObjectsByType(FindObjectsSortMode.None)) + if (other != this && !other._hasFish) return; + foreach (var ball in GameObject.FindGameObjectsWithTag("FishBall")) ball.SetActive(false); } private void OnTriggerEnter(Collider other) { - if (other.gameObject.CompareTag("FishBall")) + if (_hasFish || !other.gameObject.CompareTag("FishBall")) return; + + _hasFish = true; + if (fishs != null) fishs.SetActive(true); + + if (M2Manager.Instance != null) { - fishs.SetActive(true); M2Manager.Instance.UpdateScore(scoreValue); - - Destroy(other.gameObject); + M2Manager.Instance.NeutralizeBreedingSite(this); } + + Destroy(other.gameObject); } } diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/IBreedingSite.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/IBreedingSite.cs new file mode 100644 index 00000000..151f2a5f --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/IBreedingSite.cs @@ -0,0 +1,9 @@ +/// +/// A breeding site that can be shown already dealt with, without the player having done it and without +/// awarding anything. M2Manager uses this for the sites a round leaves out, so the house keeps its +/// furniture: a jar with its lid on, a vase with no water, a fish bowl with fish in it. +/// +public interface IBreedingSite +{ + void ShowResolved(); +} diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/IBreedingSite.cs.meta b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/IBreedingSite.cs.meta new file mode 100644 index 00000000..a1159954 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/IBreedingSite.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e4a07107fb6648d5a7db7743c506285 \ No newline at end of file diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs index f8399d4c..22a1d157 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs @@ -1,6 +1,8 @@ using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit; +using UnityEngine.XR.Interaction.Toolkit.Interactables; -public class Jar : MonoBehaviour +public class Jar : MonoBehaviour, IBreedingSite { [Header("Reference")] public GameObject topHolo; @@ -9,20 +11,123 @@ public class Jar : MonoBehaviour [Header("Game Setting")] public int getScore = 10; + [Tooltip("Seconds before a dropped lid returns to its place beside the jar.")] + public float lidReturnDelay = 1.5f; + [Tooltip("How close (metres) the lid has to be to the jar mouth for a release to seat it.")] + public float placeDistance = 0.25f; private bool isInColli = false; + private bool _isClosed = false; + + XRGrabInteractable _lidGrab; + Rigidbody _lidBody; + Vector3 _lidHomePosition; + Quaternion _lidHomeRotation; + float _returnAt = -1f; + + /// True while the player is holding this jar's lid, so the HUD can change its prompt. + public bool LidHeld => _lidGrab != null && _lidGrab.isSelected; public void Start() { - top.SetActive(false); - topHolo.SetActive(false); + if (top != null) top.SetActive(false); + if (topHolo != null) topHolo.SetActive(false); + + if (top_grab != null) + { + _lidHomePosition = top_grab.transform.localPosition; + _lidHomeRotation = top_grab.transform.localRotation; + + // The lid was authored beside the jar at jar height, but its Rigidbody had gravity on, + // so every lid dropped to the floor the instant play started and the player had to bend + // down to the real floor to pick one up. Holding it kinematic keeps it where it was put. + _lidBody = top_grab.GetComponent(); + if (_lidBody != null) _lidBody.isKinematic = true; + + _lidGrab = top_grab.GetComponent(); + if (_lidGrab != null) _lidGrab.selectExited.AddListener(OnLidReleased); + } + + if (M2Manager.Instance != null) M2Manager.Instance.RegisterBreedingSite(this); + } + + void OnDestroy() + { + if (_lidGrab != null) _lidGrab.selectExited.RemoveListener(OnLidReleased); + } + + // The prefab wires PutTop to the lid's LastHoverExited, so the jar was closed when the hand + // stopped pointing at the lid rather than when the lid was let go over the jar. Hooking the + // release gives the interaction its intended meaning; _isClosed keeps the two paths idempotent. + void OnLidReleased(SelectExitEventArgs args) + { + // Trigger overlap turned out to be unreliable here: the lid objects carry a negative scale + // (-2.0857), which mirrors their transform basis and makes Unity's collider bounds wrong, so + // isInColli often never became true and a release over the jar did nothing. Measuring the + // distance to the jar mouth instead sidesteps the broken collider entirely. + if (LidNearMouth()) PutTop(force: true); + else PutTop(); + + if (_isClosed) return; + + _returnAt = Time.time + Mathf.Max(lidReturnDelay, 0.1f); + // XRI restores the kinematic flag on release, so a dropped or thrown lid used to freeze in + // mid-air until it teleported home. Let it fall; ReturnLidHome makes it kinematic again. + if (_lidBody != null) _lidBody.isKinematic = false; + } + + /// World-space position the lid is meant to end up at (where Jar_top sits). + Vector3 MouthPosition => top != null ? top.transform.position : transform.position; + + bool LidNearMouth() + { + if (top_grab == null) return false; + return Vector3.Distance(top_grab.transform.position, MouthPosition) <= Mathf.Max(placeDistance, 0.01f); + } + + void Update() + { + if (_isClosed) return; + + if (LidHeld) + { + // Show the ghost lid whenever the carried lid is close enough to seat, so "am I in the + // right place?" is answered before the player lets go. + if (topHolo != null) topHolo.SetActive(LidNearMouth()); + _returnAt = -1f; + return; + } + + if (_returnAt < 0f) return; + + if (Time.time < _returnAt) return; + _returnAt = -1f; + ReturnLidHome(); + } + + // A lid dropped anywhere other than on its jar goes back to where it started, so it can never be + // lost under the furniture. + void ReturnLidHome() + { + if (top_grab == null) return; + + if (top_grab.transform.parent != transform) top_grab.transform.SetParent(transform, false); + top_grab.transform.localPosition = _lidHomePosition; + top_grab.transform.localRotation = _lidHomeRotation; + + if (_lidBody != null) + { + _lidBody.isKinematic = true; + _lidBody.linearVelocity = Vector3.zero; + _lidBody.angularVelocity = Vector3.zero; + } } private void OnTriggerEnter(Collider other) { - if (other.CompareTag("Jar_Hat")) + if (other.CompareTag("Jar_Hat") && !_isClosed) { - topHolo.SetActive(true); + if (topHolo != null) topHolo.SetActive(true); isInColli = true; } } @@ -31,20 +136,42 @@ private void OnTriggerExit(Collider other) { if (other.CompareTag("Jar_Hat")) { - topHolo.SetActive(false); + if (!_isClosed && topHolo != null) topHolo.SetActive(false); isInColli = false; } } + /// The lid is on and nobody is credited: how a round shows a jar it is not using. + public void ShowResolved() + { + _isClosed = true; + _returnAt = -1f; + if (top != null) top.SetActive(true); + if (topHolo != null) topHolo.SetActive(false); + if (top_grab != null) top_grab.SetActive(false); + } + public void PutTop() { - if (isInColli) - { - top.SetActive(true); - topHolo.SetActive(false); - top_grab.SetActive(false); + PutTop(false); + } - M2Manager.Instance.UpdateScore(getScore); - } + /// Seat the lid on a distance match, without needing the trigger to have fired. + public void PutTop(bool force) + { + // _isClosed stops a second call re-awarding the score: isInColli stays true while the lid + // is still inside the trigger. + if (_isClosed) return; + if (!force && !isInColli) return; + + _isClosed = true; + _returnAt = -1f; + if (top != null) top.SetActive(true); + if (topHolo != null) topHolo.SetActive(false); + if (top_grab != null) top_grab.SetActive(false); + + if (M2Manager.Instance == null) return; + M2Manager.Instance.UpdateScore(getScore); + M2Manager.Instance.NeutralizeBreedingSite(this); } } diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs index 67ff4fce..0fbff995 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs @@ -1,7 +1,6 @@ -using System.Linq; using UnityEngine; -public class WaterButton : MonoBehaviour +public class WaterButton : MonoBehaviour, IBreedingSite { [Header("Reference")] public AudioClip waterSplash; @@ -12,42 +11,71 @@ public class WaterButton : MonoBehaviour [Header("Game Setting")] public int getScore = 10; public bool isWaterActive; + [Tooltip("A 'save' container holds no standing water and does not count as a breeding site.")] public bool isSave; + [Tooltip("Degrees from upright past which the water spills out. Tipping is judged by tilt alone: the old test cast along the container's up axis for 1.2 m against groundLayerMask only, and the house floor is on the Wall layer, so an indoor vase only emptied when held inverted below knee height.")] + public float pourAngle = 110f; private AudioSource _source; - + + // Only one of the scene's vases has waterSplashEffect assigned, so three of four tipped out with + // sound but no splash. Containers without their own effect borrow the one that is set. + private static GameObject s_sharedSplashEffect; private void Start() { _source = GetComponent(); isWaterActive = true; + if (waterSplashEffect != null) s_sharedSplashEffect = waterSplashEffect; + + if (!isSave && M2Manager.Instance != null) M2Manager.Instance.RegisterBreedingSite(this); + } + + /// Already emptied and nobody is credited: how a round shows a container it is not using. + public void ShowResolved() + { + isWaterActive = false; + if (waterPrefab != null) waterPrefab.SetActive(false); } void Update() { waterOut(); + } - if (!isSave) - { - M2Manager.Instance.notSaveWaterContainer.ToArray(); - } - else - { - M2Manager.Instance.saveWaterContainer.ToArray(); - } + private void waterOut() + { + if (!isWaterActive) return; + if (Vector3.Angle(transform.up, Vector3.up) < pourAngle) return; - Debug.DrawRay(transform.position, transform.up * 1.2f, Color.red); + isWaterActive = false; + + if (waterPrefab != null) waterPrefab.SetActive(false); + if (_source != null && waterSplash != null) _source.PlayOneShot(waterSplash); + + // The splash lands on the nearest surface under the container (ground, house floor or + // furniture). PF_VaseWithFlowers was serialised before waterSplashEffect existed, so it can be null. + var splash = waterSplashEffect != null ? waterSplashEffect : s_sharedSplashEffect; + if (splash != null && FindSplashPoint(out RaycastHit hit)) + Instantiate(splash, hit.point, Quaternion.LookRotation(hit.normal)); + + if (M2Manager.Instance == null) return; + M2Manager.Instance.UpdateScore(getScore); + if (!isSave) M2Manager.Instance.NeutralizeBreedingSite(this); } - private void waterOut() + // Nearest non-trigger surface below, ignoring the container and the player's own capsule. Preferring groundLayerMask + // put indoor splashes on the terrain under the house floor, where nobody could see them. + bool FindSplashPoint(out RaycastHit best) { - if (Physics.Raycast(transform.position, transform.up, out RaycastHit hit, 1.2f, groundLayerMask) && isWaterActive) + best = default; + float nearest = float.MaxValue; + foreach (var h in Physics.RaycastAll(transform.position, Vector3.down, 5f, ~0, QueryTriggerInteraction.Ignore)) { - isWaterActive = false; - waterPrefab.SetActive(false); - _source.PlayOneShot(waterSplash); - M2Manager.Instance.UpdateScore(getScore); - Instantiate(waterSplashEffect, hit.point, Quaternion.LookRotation(hit.normal)); + if (h.collider.transform.IsChildOf(transform) || h.collider is CharacterController || h.distance >= nearest) continue; + nearest = h.distance; + best = h; } + return nearest < float.MaxValue; } -} \ No newline at end of file +} diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs index 59ef449a..bd1d0473 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs @@ -8,75 +8,167 @@ public class Mosquito : MonoBehaviour public float minHeight = 0f; public Vector3 maxDirection = new Vector3(1f, 1f, 1f); public float spawnBoundaryRadius = 1f; + + [Header("Biting (master design B2/B4: 10 bites ends the pilot's stint)")] + [Tooltip("Distance at which this mosquito starts steering toward the player.")] + public float attractRadius = 4f; + [Tooltip("How strongly it homes in once inside attractRadius.")] + [Range(0f, 1f)] public float seekStrength = 0.55f; + [Tooltip("Distance at which it lands a bite.")] + public float biteDistance = 0.35f; + [Tooltip("Seconds before the same mosquito can bite again.")] + public float biteCooldown = 4f; + [Tooltip("Seconds after emerging before a mosquito can bite. They emerge just above open containers, i.e. in the face of a player who is covering one.")] + public float firstBiteDelay = 3f; + + [Header("Retiring")] + [Tooltip("Seconds a retired mosquito spends flying off before it is removed.")] + public float retireSeconds = 2.5f; + [Tooltip("Speed multiplier while flying off.")] + public float retireSpeedFactor = 1.5f; + private Vector3 _randomDirection; - private string _wllLayer = "Wall"; - private float directionChangeTimer; + private int _wallMask; + private float _directionChangeTimer; + private float _nextBiteAt; + private float _retireAt = -1f; + + /// True once the mosquito has been told to leave. It no longer seeks or bites. + public bool Retired => _retireAt >= 0f; + + // Camera.main walks the scene graph, and there can be fifty of these alive at once, so the head + // transform is resolved once and shared. + private static Transform s_head; + private static int s_headFrame = -1; + + private static Transform Head() + { + if (s_headFrame == Time.frameCount) return s_head; + s_headFrame = Time.frameCount; + if (s_head == null && Camera.main != null) s_head = Camera.main.transform; + return s_head; + } - // Start is called once before the first execution of Update after the MonoBehaviour is created void Start() { - GenerateRandomDirection(); + _wallMask = LayerMask.GetMask("Wall"); + _nextBiteAt = Time.time + Mathf.Max(firstBiteDelay, 0f); + if (!Retired) GenerateRandomDirection(); } - // Update is called once per frame void Update() { + if (Retired) { FlyOff(); return; } FlyRandomly(); } + /// + /// Send this mosquito away from the player and remove it shortly after. The swarm follows the open + /// breeding sites, so clearing one has to thin it: mosquitoes used to live until they were swatted, + /// which left the swarm the same size after every container was dealt with. + /// + public void Retire() + { + if (Retired) return; + _retireAt = Time.time + Mathf.Max(retireSeconds, 0.1f); + + var head = Head(); + Vector3 away = head != null ? transform.position - head.position : Random.onUnitSphere; + away.y = Mathf.Abs(away.y) * 0.5f + 0.4f; + _randomDirection = away.normalized; + } + + private void FlyOff() + { + if (Time.time >= _retireAt) { Destroy(gameObject); return; } + + BounceOffWalls(); + transform.position += _randomDirection * (flySpeed * retireSpeedFactor * Time.deltaTime); + if (_randomDirection.sqrMagnitude > 0.0001f) + transform.rotation = Quaternion.LookRotation(_randomDirection); + } + public void FlyRandomly() { - directionChangeTimer -= Time.deltaTime; + _directionChangeTimer -= Time.deltaTime; - if (directionChangeTimer <= 0f) + if (_directionChangeTimer <= 0f) { GenerateRandomDirection(); - directionChangeTimer = randomChangeInterval; + _directionChangeTimer = randomChangeInterval; } - transform.position += _randomDirection * flySpeed * Time.deltaTime; + SeekAndBite(); + BounceOffWalls(); + + transform.position += _randomDirection * (flySpeed * Time.deltaTime); Vector3 clampedPosition = transform.position; clampedPosition.y = Mathf.Clamp(clampedPosition.y, minHeight, maxHeight); - transform.position = clampedPosition; - Vector3 pointLook = transform.position + _randomDirection; - transform.LookAt(pointLook); + if (_randomDirection.sqrMagnitude > 0.0001f) + transform.rotation = Quaternion.LookRotation(_randomDirection); } - private void GenerateRandomDirection() + // The swarm has to actually be a threat: B2's Scene 4 ends the stint at 10 bites, and nothing in + // the module bit the player at all before this. + private void SeekAndBite() { - if (Physics.Raycast(transform.position, transform.forward, out RaycastHit hitInfo, spawnBoundaryRadius, LayerMask.GetMask(_wllLayer))) - { - _randomDirection = Vector3.Reflect(_randomDirection, hitInfo.normal); - } + var manager = M2Manager.Instance; + if (manager == null || !manager.IsPlaying) return; - if (Physics.Raycast(transform.position, transform.right, out RaycastHit hitInfoRight, spawnBoundaryRadius, LayerMask.GetMask(_wllLayer))) - { - _randomDirection = Vector3.Reflect(_randomDirection, hitInfoRight.normal); - } + var head = Head(); + if (head == null) return; - if (Physics.Raycast(transform.position, -transform.right, out RaycastHit hitInfoLeft, spawnBoundaryRadius, LayerMask.GetMask(_wllLayer))) + Vector3 toHead = head.position - transform.position; + float distance = toHead.magnitude; + if (distance > attractRadius || distance < 0.0001f) return; + + // Repellent: veer away from the player instead of homing in. + if (manager.RepellentActive) { - _randomDirection = Vector3.Reflect(_randomDirection, hitInfoLeft.normal); + _randomDirection = Vector3.Slerp(_randomDirection, -toHead / distance, seekStrength * Time.deltaTime * 6f).normalized; + return; } - if (Physics.Raycast(transform.position, -transform.forward, out RaycastHit hitInfoUp, spawnBoundaryRadius, LayerMask.GetMask(_wllLayer))) + // Home in, but keep some wander so the swarm does not become a laser-guided cloud. + _randomDirection = Vector3.Slerp(_randomDirection, toHead / distance, seekStrength * Time.deltaTime * 3f).normalized; + + if (distance > biteDistance || Time.time < _nextBiteAt) return; + + _nextBiteAt = Time.time + Mathf.Max(biteCooldown, 0.5f); + manager.NoteBite(); + + // Peel away after feeding so one mosquito cannot pin the player. + _randomDirection = (-toHead / distance + Random.insideUnitSphere * 0.4f).normalized; + _directionChangeTimer = randomChangeInterval; + } + + // Reflect off the wall we are about to fly into. The previous version raycast along four fixed + // axes, computed a reflection, and then unconditionally overwrote it with a fresh random + // direction on the next line - so the bounce never took effect and mosquitoes drifted straight + // through the house. There is no Rigidbody here, so nothing else stops them. + private void BounceOffWalls() + { + if (Physics.Raycast(transform.position, _randomDirection, out RaycastHit hit, spawnBoundaryRadius, _wallMask)) { - _randomDirection = Vector3.Reflect(_randomDirection, hitInfoUp.normal); + _randomDirection = Vector3.Reflect(_randomDirection, hit.normal).normalized; + _directionChangeTimer = randomChangeInterval; } - Debug.DrawRay(transform.position, transform.forward * spawnBoundaryRadius, Color.green); - Debug.DrawRay(transform.position, transform.right * spawnBoundaryRadius, Color.green); - Debug.DrawRay(transform.position, -transform.forward * spawnBoundaryRadius, Color.green); - Debug.DrawRay(transform.position, -transform.right * spawnBoundaryRadius, Color.green); + Debug.DrawRay(transform.position, _randomDirection * spawnBoundaryRadius, Color.green); + } - _randomDirection = new Vector3( + private void GenerateRandomDirection() + { + Vector3 direction = new Vector3( Random.Range(-maxDirection.x, maxDirection.x), Random.Range(-maxDirection.y, maxDirection.y), Random.Range(-maxDirection.z, maxDirection.z) - ).normalized; + ); + + _randomDirection = direction.sqrMagnitude > 0.0001f ? direction.normalized : transform.forward; } private void OnTriggerEnter(Collider other) @@ -86,5 +178,4 @@ private void OnTriggerEnter(Collider other) Destroy(gameObject); } } - } diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_Pop_House1.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_Pop_House1.prefab.meta index c7bc16c8..b4e4a50b 100644 --- a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_Pop_House1.prefab.meta +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Buildings/PF_Pop_House1.prefab.meta @@ -1,9 +1,5 @@ fileFormatVersion: 2 -<<<<<<<< HEAD:Assets/1.TeamWorkspace/Team Assets/Prefabs/PlasticBucket.prefab.meta guid: aa4a993eaa7f10d4ab43e873f4ffd8ba -======== -guid: 936550419afe37f44af3bf7b596ae57f ->>>>>>>> 66c7ba2ed74b81250fcc2291afeb2a221057a134:Assets/1.TeamWorkspace/Team Assets/Prefabs/PF_Pop_House1.prefab.meta PrefabImporter: externalObjects: {} userData: diff --git a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PlasticBucket.prefab.meta b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PlasticBucket.prefab.meta index a5d992db..f2941df2 100644 --- a/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PlasticBucket.prefab.meta +++ b/Assets/1.TeamWorkspace/Team Assets/Prefabs/Props/PlasticBucket.prefab.meta @@ -1,9 +1,5 @@ fileFormatVersion: 2 -<<<<<<<< HEAD:Assets/1.TeamWorkspace/Team Assets/Prefabs/PlasticBucket.prefab.meta guid: b4626cd6089bbfa41a12442e228be28f -======== -guid: 936550419afe37f44af3bf7b596ae57f ->>>>>>>> 66c7ba2ed74b81250fcc2291afeb2a221057a134:Assets/1.TeamWorkspace/Team Assets/Prefabs/PF_Pop_House1.prefab.meta PrefabImporter: externalObjects: {} userData: diff --git a/Assets/Editor/AedesBuild.cs b/Assets/Editor/AedesBuild.cs new file mode 100644 index 00000000..6e6e242b --- /dev/null +++ b/Assets/Editor/AedesBuild.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEditor.Build.Reporting; +using UnityEngine; + +/// +/// Headless Android player build, driven from the command line by Tools/build.sh. +/// +/// Invoked as: +/// Unity -batchmode -nographics -quit -projectPath . -buildTarget Android \ +/// -executeMethod AedesBuild.BuildAndroid \ +/// -scenes "Assets/A.unity;Assets/B.unity" -outputPath build/Android/X.apk [-clean] +/// +/// The player build *is* the APK: Unity writes a signed .apk straight to -outputPath, so +/// there is no separate packaging step. Debug signing is used because no custom keystore is +/// configured, which is what sideloading to a developer-mode headset needs. +/// +public static class AedesBuild +{ + /// Value of a "-name value" pair, or null when absent. + private static string Arg(string name) + { + var args = Environment.GetCommandLineArgs(); + // Stops one short: a trailing "-name" has no value to read. + for (int i = 0; i < args.Length - 1; i++) + if (args[i] == name) return args[i + 1]; + return null; + } + + /// + /// Presence of a valueless flag. cannot express this: it consumes a + /// following value and ignores the final argv slot. + /// + private static bool HasFlag(string name) => + Environment.GetCommandLineArgs().Any(a => a == name); + + private static void Fail(string message) + { + Debug.LogError("[AedesBuild] " + message); + EditorApplication.Exit(1); + } + + public static void BuildAndroid() + { + string scenesArg = Arg("-scenes"); + string[] scenes = string.IsNullOrEmpty(scenesArg) + ? EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray() + : scenesArg.Split(';').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray(); + + if (scenes.Length == 0) + { + Fail("No scenes to build. Pass -scenes, or enable scenes in Build Settings."); + return; + } + + foreach (var scene in scenes) + { + if (!File.Exists(scene)) + { + Fail("Scene not found: " + scene); + return; + } + Debug.Log("[AedesBuild] scene: " + scene); + } + + string output = Arg("-outputPath"); + if (string.IsNullOrEmpty(output)) + { + Fail("Missing -outputPath."); + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(output))); + + // An .apk is what we sideload; an .aab cannot be installed with `adb install`. + EditorUserBuildSettings.buildAppBundle = false; + + // A crashed build can leave the IL2CPP cache holding a stub libil2cpp.so, and every + // later incremental build then reuses it and still reports success. -clean is the + // way out. Tools/build.sh detects the stub and re-runs with this flag. + bool clean = HasFlag("-clean"); + Debug.Log($"[AedesBuild] output: {output} (clean={clean})"); + + var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + { + scenes = scenes, + locationPathName = output, + target = BuildTarget.Android, + targetGroup = BuildTargetGroup.Android, + options = clean ? BuildOptions.CleanBuildCache : BuildOptions.None, + }); + + var summary = report.summary; + Debug.Log($"[AedesBuild] result={summary.result} errors={summary.totalErrors} " + + $"warnings={summary.totalWarnings} size={summary.totalSize} time={summary.totalTime}"); + + EditorApplication.Exit(summary.result == BuildResult.Succeeded ? 0 : 1); + } +} diff --git a/Assets/Editor/AedesBuild.cs.meta b/Assets/Editor/AedesBuild.cs.meta new file mode 100644 index 00000000..e6ee3549 --- /dev/null +++ b/Assets/Editor/AedesBuild.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd91528b64c643908147bcc8ec9f2cd3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/LaoFontTool.cs b/Assets/Editor/LaoFontTool.cs new file mode 100644 index 00000000..d00c1fa4 --- /dev/null +++ b/Assets/Editor/LaoFontTool.cs @@ -0,0 +1,100 @@ +using System.Text; +using TMPro; +using UnityEditor; +using UnityEngine; +using UnityEngine.TextCore.LowLevel; + +/// +/// Regenerates the Lao TMP font asset with full coverage of the Lao block. +/// +/// Why this exists: the committed "Lao_SomVang SDF_Custom" asset was baked from a handful of specific +/// strings and holds only 47 of the 87 Lao glyphs in the source typeface. One of the missing glyphs is +/// U+0EBD, which ປ່ຽນ (Change) needs - a CMPE-validated 5 ປ measure. Rather than approximate a +/// letterform, this rebuilds the asset from Lao_SomVang.ttf so every glyph is the designed one. +/// +/// Output goes to Resources so Module2HUD can load it at runtime (the HUD is created by code, so it +/// has no inspector field to wire). +/// +/// Unity -batchmode -quit -projectPath . -executeMethod LaoFontTool.Generate +/// or the menu: AEDES / Regenerate Lao font asset +/// +public static class LaoFontTool +{ + const string SourcePath = "Assets/1.TeamWorkspace/Team Assets/TextMesh Pro/Fonts/Lao/Lao_SomVang.ttf"; + const string OutputPath = "Assets/Resources/Fonts/Lao_SomVang Full SDF.asset"; + + [MenuItem("AEDES/Regenerate Lao font asset")] + public static void Generate() + { + var font = AssetDatabase.LoadAssetAtPath(SourcePath); + if (font == null) + { + Debug.LogError($"[LaoFontTool] Source font not found at {SourcePath}"); + EditorApplication.Exit(1); + return; + } + + // Built dynamic so TryAddCharacters can rasterise into the atlas, then frozen to Static so the + // player build carries the atlas and needs no runtime access to the .ttf. + var fontAsset = TMP_FontAsset.CreateFontAsset( + font, 90, 9, GlyphRenderMode.SDFAA, 1024, 1024, AtlasPopulationMode.Dynamic, true); + + if (fontAsset == null) + { + Debug.LogError("[LaoFontTool] CreateFontAsset returned null."); + EditorApplication.Exit(1); + return; + } + + string characters = BuildCharacterSet(); + fontAsset.TryAddCharacters(characters, out string missing, true); + + fontAsset.atlasPopulationMode = AtlasPopulationMode.Static; + + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(OutputPath)); + AssetDatabase.DeleteAsset(OutputPath); + AssetDatabase.CreateAsset(fontAsset, OutputPath); + + // Atlas texture and material have to live inside the asset or they are lost on reload. + if (fontAsset.atlasTextures != null) + { + foreach (var texture in fontAsset.atlasTextures) + { + if (texture == null) continue; + texture.name = fontAsset.name + " Atlas"; + AssetDatabase.AddObjectToAsset(texture, fontAsset); + } + } + if (fontAsset.material != null) + { + fontAsset.material.name = fontAsset.name + " Material"; + AssetDatabase.AddObjectToAsset(fontAsset.material, fontAsset); + } + + EditorUtility.SetDirty(fontAsset); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + int lao = 0, latin = 0; + foreach (var kvp in fontAsset.characterLookupTable) + { + if (kvp.Key >= 0x0E80 && kvp.Key <= 0x0EFF) lao++; + else if (kvp.Key >= 0x20 && kvp.Key <= 0x7E) latin++; + } + + Debug.Log($"[LaoFontTool] {OutputPath}: {fontAsset.characterLookupTable.Count} glyphs " + + $"(Lao {lao}, Latin {latin}). U+0EBD present: {fontAsset.characterLookupTable.ContainsKey(0x0EBD)}. " + + $"Missing: '{missing}'"); + + EditorApplication.Exit(0); + } + + // Everything the Lao block defines, plus Basic Latin for the English gloss and digits. + static string BuildCharacterSet() + { + var sb = new StringBuilder(); + for (int c = 0x20; c <= 0x7E; c++) sb.Append((char)c); + for (int c = 0x0E80; c <= 0x0EFF; c++) sb.Append((char)c); + return sb.ToString(); + } +} diff --git a/Assets/Editor/LaoFontTool.cs.meta b/Assets/Editor/LaoFontTool.cs.meta new file mode 100644 index 00000000..dd8083ee --- /dev/null +++ b/Assets/Editor/LaoFontTool.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ee720651d686641dca37b9965c9201c3 \ No newline at end of file diff --git a/Assets/Resources/Localization/Module2Text.txt b/Assets/Resources/Localization/Module2Text.txt new file mode 100644 index 00000000..e0e87e09 --- /dev/null +++ b/Assets/Resources/Localization/Module2Text.txt @@ -0,0 +1,48 @@ +# Module 2 strings. TAB separated so values may contain commas. Use \n for a line break. +# Lao is primary; English is the fallback and is also compiled into Module2Text.cs. +# Lao column intentionally left empty until the curriculum wording arrives. +key Lao English +intro.kicker BRIEFING +intro.title Clear the mosquito breeding sites +intro.body Mosquitoes breed in still water.\nFind it. Cover it. Tip it out.\n\nFollow the arrow. +intro.footer Press A, trigger or grip to begin +end.kicker ROUND OVER +end.footer Press A, trigger or grip to play again +site.jar Water jar +site.container Open container +site.bowl Water bowl +site.generic Standing water +hud.dayTag Daytime - Aedes bite by day +hud.nightTag Evening - the swarm is settling +hud.allClear Every breeding site dealt with +toast.start Find the standing water - that is where they breed +toast.trash Rubbish binned - no more water traps +toast.night Evening is coming - the light is going +toast.oneMinute One minute left! +toast.thirtySeconds 30 seconds left! +toast.swatterOut Swatter is in front of you - grab it +toast.swatterAlready You already have a swatter +toast.creamOut Repellent is in front of you - grab it +toast.creamAlready You already have repellent +toast.repellentOn Repellent on - no bites for {0} s +toast.repellentOff Repellent has worn off +hud.repellent Repellent +measure.cover ປິດ Cover +measure.change ປ່ຽນ Change +measure.release ປ່ອຍ Release +measure.improve ປັບປຸງ Improve +measure.practice ປະຕິບັດ Practice +hud.energy ENERGY +hud.bites Bites +toast.bitesNearLimit Too many bites - get away from the swarm +end.titleBitten Driven off by the swarm +end.statSourceReduction SOURCE REDUCTION +end.statAction Action score +end.statBites Bites +end.statSwatted Swatted +end.belowHalf Under half the sites cleared - the outbreak you hand on will be twice as severe. +prompt.jar Cover - grab the lid beside the jar +prompt.jarCarrying Cover - hold the lid over the jar and let go +prompt.tip Change - tip the water out +prompt.fish Release - drop a guppy in, the fish eat the larvae +prompt.generic Improve - get rid of the standing water diff --git a/Assets/Resources/Localization/Module2Text.txt.meta b/Assets/Resources/Localization/Module2Text.txt.meta new file mode 100644 index 00000000..d6c9dfe1 --- /dev/null +++ b/Assets/Resources/Localization/Module2Text.txt.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ab0bc3d078c494b95bdaa10ec112c9d7 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Module2.meta b/Assets/Resources/Module2.meta new file mode 100644 index 00000000..e6b8289a --- /dev/null +++ b/Assets/Resources/Module2.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cb33a385ea3b046ec9f349b68b94e270 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Module2/M2_SiteLarva.mat b/Assets/Resources/Module2/M2_SiteLarva.mat new file mode 100644 index 00000000..7fd0bb3a --- /dev/null +++ b/Assets/Resources/Module2/M2_SiteLarva.mat @@ -0,0 +1,138 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: M2_SiteLarva + m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: + - _SPECULARHIGHLIGHTS_OFF + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 1 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: + RenderType: Opaque + disabledShaderPasses: + - MOTIONVECTORS + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _SpecGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_Lightmaps: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_LightmapsInd: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_ShadowMasks: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DetailAlbedoMapScale: 1 + - _DetailNormalMapScale: 1 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _EnvironmentReflections: 1 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _Smoothness: 0.15 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 0 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.96, g: 0.9, b: 0.72, a: 1} + - _Color: {r: 0.96, g: 0.9, b: 0.72, a: 1} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &1657748989873513190 +MonoBehaviour: + m_ObjectHideFlags: 11 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 diff --git a/Assets/Resources/Module2/M2_SiteLarva.mat.meta b/Assets/Resources/Module2/M2_SiteLarva.mat.meta new file mode 100644 index 00000000..55240a4c --- /dev/null +++ b/Assets/Resources/Module2/M2_SiteLarva.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c043e930b1df4aff9d819df006ec255a +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Module2/M2_SiteMote.mat b/Assets/Resources/Module2/M2_SiteMote.mat new file mode 100644 index 00000000..71026a55 --- /dev/null +++ b/Assets/Resources/Module2/M2_SiteMote.mat @@ -0,0 +1,138 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: M2_SiteMote + m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: + - _SPECULARHIGHLIGHTS_OFF + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 1 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: + RenderType: Opaque + disabledShaderPasses: + - MOTIONVECTORS + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _BumpMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailAlbedoMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailMask: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _DetailNormalMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _EmissionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MetallicGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _OcclusionMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _ParallaxMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _SpecGlossMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_Lightmaps: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_LightmapsInd: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - unity_ShadowMasks: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _AddPrecomputedVelocity: 0 + - _AlphaClip: 0 + - _AlphaToMask: 0 + - _Blend: 0 + - _BlendModePreserveSpecular: 1 + - _BumpScale: 1 + - _ClearCoatMask: 0 + - _ClearCoatSmoothness: 0 + - _Cull: 2 + - _Cutoff: 0.5 + - _DetailAlbedoMapScale: 1 + - _DetailNormalMapScale: 1 + - _DstBlend: 0 + - _DstBlendAlpha: 0 + - _EnvironmentReflections: 1 + - _GlossMapScale: 0 + - _Glossiness: 0 + - _GlossyReflections: 0 + - _Metallic: 0 + - _OcclusionStrength: 1 + - _Parallax: 0.005 + - _QueueOffset: 0 + - _ReceiveShadows: 1 + - _Smoothness: 0.1 + - _SmoothnessTextureChannel: 0 + - _SpecularHighlights: 0 + - _SrcBlend: 1 + - _SrcBlendAlpha: 1 + - _Surface: 0 + - _WorkflowMode: 1 + - _XRMotionVectorsPass: 1 + - _ZWrite: 1 + m_Colors: + - _BaseColor: {r: 0.07, g: 0.06, b: 0.05, a: 1} + - _Color: {r: 0.06999998, g: 0.059999965, b: 0.049999982, a: 1} + - _EmissionColor: {r: 0, g: 0, b: 0, a: 1} + - _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!114 &1657748989873513190 +MonoBehaviour: + m_ObjectHideFlags: 11 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 diff --git a/Assets/Resources/Module2/M2_SiteMote.mat.meta b/Assets/Resources/Module2/M2_SiteMote.mat.meta new file mode 100644 index 00000000..0ae09fab --- /dev/null +++ b/Assets/Resources/Module2/M2_SiteMote.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 26c6b3bd7fb24b838bae79033f344848 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index 64475901..98d4f1e6 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,88 @@ Composed of two types of elements: 7. To run the application in conjunction with GAMA, make sure you have installed [GAMA 2025.06](https://gama-platform.org/download) and the [Unity Plugin for GAMA](https://github.com/project-SIMPLE/simple.toolchain/tree/Unity-6/GAMA%20Plugin). Information on installing the plugin is available [here](https://github.com/project-SIMPLE/simple.toolchain/tree/Unity-6/GAMA%20Plugin#installation). The plugin provides a set of model (added in Plugin models/LinkToUnity) that works with the Unity project. +## Building and deploying AEDES to a Quest headset + +> This section covers the **AEDES game** in this repository, not the upstream SIMPLE +> template. Everything below runs from a Mac with no Windows machine required. + +Two committed scripts cover the whole loop. There is no separate "make an APK" step: +Unity's player build *is* the APK, written signed and ready to sideload. + +```bash +Tools/build.sh 2 # build module 2 -> build/Android/AEDES-Module2.apk +Tools/deploy.sh 2 # install it on the attached headset and launch it + +Tools/build-and-deploy.sh 2 # both in one command +``` + +### Modules + +Each module is built and deployed separately for now. + +| Key | Module | Scene | +|---|---|---| +| `1` | Play as the mosquito | `Module_1_MainScene` | +| `2` | Household vector control | `Module_2_MainScene` | +| `tut2` | Module 2 tutorial | `Tutorial_M2` | + +Scenes are located by filename, so moving or renaming their folders will not break the +scripts. Build Settings are ignored: each build contains only that module's scene. + +> **Only one module can be installed at a time.** They all share the application id +> `com.unity.template.vr`, so deploying module 2 replaces module 1 on the headset. + +### Requirements + +- **Unity 6000.3.8f1** with the **Android Build Support** module (IL2CPP + SDK/NDK). + The version is read from `ProjectSettings/ProjectVersion.txt`; the scripts refuse to run + with a different one, because opening the project in another version rewrites asset files. +- **Rosetta 2** on Apple silicon: `softwareupdate --install-rosetta --agree-to-license`. + A macOS major upgrade removes it, after which Unity exits silently with no log. +- `adb` is found automatically inside the editor's bundled Android SDK; it does not need + to be on your `PATH`. + +### Preparing the headset (once) + +1. Create a **developer team** at [developers.meta.com](https://developers.meta.com) and + verify the account (SMS two-factor, or a payment method). +2. Turn on developer mode in the **Meta Horizon phone app**: headset icon → your headset → + *Headset settings* → *Developer mode*. There is **no in-headset toggle** for this. +3. Connect a **data-capable** USB-C cable, put the headset on, and accept + *Allow USB debugging*, ticking **Always allow from this computer**. + +The headset must be **awake** to launch an app. Horizon OS also refuses to start one while +a system dialog is open, so `Tools/deploy.sh` retries until it succeeds or times out +(`--wait `, default 180). + +The installed app appears in the headset library under **Unknown Sources**, named +**SIMPLE Template** — the template branding was never changed, so do not look for "AEDES". + +### Clean builds + +`Tools/build.sh --clean` forces IL2CPP to recompile from scratch. This takes +several minutes rather than the usual two or three, and is occasionally necessary: a build +that crashes part-way can leave the IL2CPP cache holding a stub `libil2cpp.so`, after which +every incremental build happily reuses it and still reports success. The resulting APK +installs and starts the engine, then dies, because it contains no compiled C# at all. + +You should not normally need the flag. `Tools/build.sh` checks `libil2cpp.so` inside the +finished APK and re-runs itself with `--clean` automatically if it finds a stub, and +`Tools/deploy.sh` refuses to install one. + +### Troubleshooting + +| Symptom | Cause | +|---|---| +| `no headset detected over adb` | Developer mode off, or a charge-only cable. An empty device list means the ADB interface is absent entirely, not that authorization is pending. | +| `headset is connected but unauthorized` | The *Allow USB debugging* prompt has not been accepted. Put the headset on. | +| Build fails, log mentions `cannot be extracted by the YAML Parser` | A `.meta` file has git conflict markers committed into it. Find them with `grep -rlE '^<{4,}' Assets --include='*.meta'` and keep the GUID the live scenes reference — never just delete the markers at random, as the wrong GUID silently breaks every reference to that asset. | +| App installs but never starts | Headset asleep, or a system dialog is open in it. | +| Changes missing from the build | Unity compiles scripts in roughly the first 90 seconds. Anything saved after that misses the build with no warning. Do not edit while a build runs. | + +Build logs are written to `Logs/build/module-.log`. + + ## Documentation This section focuses only on the C# scripts which are useful for a Unity developer. The scripts not mentioned here are at least commented. diff --git a/Tools/aedes-common.sh b/Tools/aedes-common.sh new file mode 100755 index 00000000..2086ece4 --- /dev/null +++ b/Tools/aedes-common.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Shared helpers for Tools/build.sh and Tools/deploy.sh. Sourced, never run directly. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Pin the editor to what the project is serialized with. Opening with another version +# rewrites asset files and produces enormous, unreviewable diffs. +UNITY_VERSION="$(sed -n 's/^m_EditorVersion: //p' "$REPO_ROOT/ProjectSettings/ProjectVersion.txt" | tr -d '\r')" +UNITY_APP="/Applications/Unity/Hub/Editor/$UNITY_VERSION/Unity.app/Contents/MacOS/Unity" +ANDROID_SDK="/Applications/Unity/Hub/Editor/$UNITY_VERSION/PlaybackEngines/AndroidPlayer/SDK" + +# adb ships inside the editor's Android SDK and is not on PATH by default. +if [ -x "$ANDROID_SDK/platform-tools/adb" ]; then + ADB="$ANDROID_SDK/platform-tools/adb" +else + ADB="$(command -v adb || true)" +fi + +die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +info() { printf '\033[36m==>\033[0m %s\n' "$*"; } +ok() { printf '\033[32mok:\033[0m %s\n' "$*"; } +warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; } + +usage_modules() { + cat >&2 <<'USAGE' +modules: + 1 Module 1 - play as the mosquito (Module_1_MainScene) + 2 Module 2 - household vector control (Module_2_MainScene) + tut2 Module 2 tutorial (Tutorial_M2) +USAGE +} + +# Map a module key to its scene file. Located by filename rather than a fixed path so +# that folder reorganisations (GameTesting -> Module1, Phettae -> Module2) do not break it. +module_scene() { + local key="$1" name + case "$key" in + 1) name="Module_1_MainScene" ;; + 2) name="Module_2_MainScene" ;; + tut2) name="Tutorial_M2" ;; + *) usage_modules; die "unknown module '$key'" ;; + esac + + local matches count + matches="$(cd "$REPO_ROOT" && find Assets/1.TeamWorkspace -name "$name.unity" -not -path '*/_Recovery/*')" + count="$(printf '%s' "$matches" | grep -c . || true)" + + [ "$count" -eq 0 ] && die "scene $name.unity not found under Assets/1.TeamWorkspace" + if [ "$count" -gt 1 ]; then + warn "several copies of $name.unity found; using the first:" + printf '%s\n' "$matches" >&2 + fi + printf '%s\n' "$matches" | head -1 +} + +module_apk() { + case "$1" in + 1) printf 'build/Android/AEDES-Module1.apk\n' ;; + 2) printf 'build/Android/AEDES-Module2.apk\n' ;; + tut2) printf 'build/Android/AEDES-TutorialM2.apk\n' ;; + *) usage_modules; die "unknown module '$1'" ;; + esac +} + +# libil2cpp.so holds every compiled C# script. A corrupt IL2CPP cache yields a few-KB stub +# while the build still reports success, producing an APK that installs, starts the engine +# and then dies with no game code. Real builds are tens of MB. +IL2CPP_MIN_BYTES=$((20 * 1024 * 1024)) + +# Echoes libil2cpp.so's size; returns non-zero if it is missing or stub-sized. +apk_il2cpp_size() { + local apk="$1" size + size="$(unzip -l "$apk" 2>/dev/null | awk '$4 == "lib/arm64-v8a/libil2cpp.so" { print $1; exit }')" + [ -z "$size" ] && { echo 0; return 1; } + printf '%s\n' "$size" + [ "$size" -ge "$IL2CPP_MIN_BYTES" ] +} + +require_unity() { + [ -x "$UNITY_APP" ] || die "Unity $UNITY_VERSION not found at $UNITY_APP +Install that exact version via Unity Hub - the project is serialized with it." +} + +require_adb() { + [ -n "$ADB" ] && [ -x "$ADB" ] || die "adb not found. Install the Android Build Support module for Unity $UNITY_VERSION." +} diff --git a/Tools/build-and-deploy.sh b/Tools/build-and-deploy.sh new file mode 100755 index 00000000..9b31fc2a --- /dev/null +++ b/Tools/build-and-deploy.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Build a module and put it on the attached headset in one command. +# +# Tools/build-and-deploy.sh <1|2|tut2> [--clean] + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE="${1:-}" +[ -z "$MODULE" ] && { echo "usage: Tools/build-and-deploy.sh <1|2|tut2> [--clean]" >&2; exit 1; } +shift +"$HERE/build.sh" "$MODULE" "$@" && "$HERE/deploy.sh" "$MODULE" diff --git a/Tools/build.sh b/Tools/build.sh new file mode 100755 index 00000000..ea6d6bd9 --- /dev/null +++ b/Tools/build.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Build an AEDES module into a sideloadable APK. +# +# Tools/build.sh <1|2|tut2> [--clean] +# +# The player build IS the APK - Unity writes a signed .apk directly, so there is no +# separate packaging step. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/aedes-common.sh" + +MODULE="${1:-}" +CLEAN="" +[ -z "$MODULE" ] && { echo "usage: Tools/build.sh <1|2|tut2> [--clean]" >&2; usage_modules; exit 1; } +shift +for arg in "$@"; do + case "$arg" in + --clean) CLEAN="-clean" ;; + *) die "unknown option '$arg'" ;; + esac +done + +require_unity +cd "$REPO_ROOT" + +SCENE="$(module_scene "$MODULE")" +APK="$(module_apk "$MODULE")" +LOG="Logs/build/module-$MODULE.log" +mkdir -p "$(dirname "$LOG")" "$(dirname "$APK")" + +# A stale editor holding the project lock makes the batchmode build fail in confusing ways. +if [ -f "Temp/UnityLockfile" ] && ! pgrep -f "MacOS/Unity .*-projectPath .*$(basename "$REPO_ROOT")" >/dev/null 2>&1; then + rm -f Temp/UnityLockfile +fi + +run_unity() { + local extra="$1" + rm -f "$LOG" + # shellcheck disable=SC2086 + "$UNITY_APP" -batchmode -nographics -quit \ + -projectPath "$REPO_ROOT" \ + -buildTarget Android \ + -executeMethod AedesBuild.BuildAndroid \ + -scenes "$SCENE" \ + -outputPath "$APK" \ + $extra \ + -logFile "$LOG" || true + # Unity's batchmode exit code is unreliable (it has returned 0 for cancelled dialogs and + # aborted package resolution), so success is read from the log and the artifact instead. +} + +# Building rewrites these two tracked files as a side effect of the embedded XRI package. +revert_build_noise() { + git checkout -q -- Packages/packages-lock.json 2>/dev/null || true + git checkout -q -- ./*.slnx 2>/dev/null || true +} + +check_result() { + grep -q '\[AedesBuild\] result=Succeeded' "$LOG" && [ -f "$APK" ] +} + +info "Building module $MODULE" +info " scene: $SCENE" +info " output: $APK" +[ -n "$CLEAN" ] && info " clean build (IL2CPP recompiles from scratch - several minutes)" + +run_unity "$CLEAN" +revert_build_noise + +if ! check_result; then + echo >&2 + die "build failed - see $LOG +$(grep -E 'error CS|Error building|cannot be extracted by the YAML Parser' "$LOG" 2>/dev/null | sort -u | head -5)" +fi + +# Verify real compiled code made it in, not a stub from a poisoned IL2CPP cache. +IL2CPP_SIZE="$(apk_il2cpp_size "$APK")" || { + if [ -z "$CLEAN" ]; then + warn "libil2cpp.so is only ${IL2CPP_SIZE} bytes - the IL2CPP cache is corrupt." + warn "Rebuilding with --clean automatically." + run_unity "-clean" + revert_build_noise + check_result || die "clean build failed - see $LOG" + IL2CPP_SIZE="$(apk_il2cpp_size "$APK")" \ + || die "libil2cpp.so is still only ${IL2CPP_SIZE} bytes after a clean build - see $LOG" + else + die "libil2cpp.so is only ${IL2CPP_SIZE} bytes after a clean build - see $LOG" + fi +} + +ok "$APK ($(du -h "$APK" | cut -f1)), libil2cpp.so $((IL2CPP_SIZE / 1024 / 1024)) MB" +grep -E '\[AedesBuild\] result=' "$LOG" | tail -1 +echo "Deploy it with: Tools/deploy.sh $MODULE" diff --git a/Tools/deploy.sh b/Tools/deploy.sh new file mode 100755 index 00000000..60cb375a --- /dev/null +++ b/Tools/deploy.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Install an already-built AEDES module onto an attached Quest and launch it. +# +# Tools/deploy.sh <1|2|tut2> [--no-launch] [--wait ] +# +# Every module installs under the same application id, so only one can be on the headset +# at a time - deploying module 2 replaces module 1. + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/aedes-common.sh" + +APP_ID="com.unity.template.vr" +ACTIVITY="$APP_ID/com.unity3d.player.UnityPlayerGameActivity" + +MODULE="${1:-}" +LAUNCH=1 +WAIT_SECS=180 +[ -z "$MODULE" ] && { echo "usage: Tools/deploy.sh <1|2|tut2> [--no-launch] [--wait ]" >&2; usage_modules; exit 1; } +shift +while [ $# -gt 0 ]; do + case "$1" in + --no-launch) LAUNCH=0 ;; + --wait) shift; WAIT_SECS="${1:-180}" ;; + *) die "unknown option '$1'" ;; + esac + shift +done + +require_adb +cd "$REPO_ROOT" +APK="$(module_apk "$MODULE")" +[ -f "$APK" ] || die "no APK at $APK - run: Tools/build.sh $MODULE" + +# Refuse to ship a stub. Installing one wastes a test cycle: it installs and starts the +# engine, then dies silently because none of the C# is present. +IL2CPP_SIZE="$(apk_il2cpp_size "$APK")" \ + || die "$APK contains a ${IL2CPP_SIZE}-byte libil2cpp.so (no compiled game code). +Rebuild with: Tools/build.sh $MODULE --clean" + +# An empty device list means no ADB interface at all, which is developer mode being off +# rather than a cable fault. "unauthorized" means the on-headset prompt is still pending. +STATE="$("$ADB" devices | awk 'NR>1 && NF>=2 { print $2; exit }')" +case "${STATE:-none}" in + device) ;; + unauthorized) + die "headset is connected but unauthorized. +Put it on and accept 'Allow USB debugging', ticking 'Always allow from this computer'." ;; + none) + die "no headset detected over adb. + - Developer mode must be ON (Meta Horizon phone app -> Devices -> Headset settings). + - Use a data-capable USB-C cable. + - Check the headset is awake, then re-run." ;; + *) die "headset in unexpected adb state '$STATE'" ;; +esac + +info "Installing $APK ($(du -h "$APK" | cut -f1))" +"$ADB" install -r "$APK" >/dev/null || die "adb install failed" +ok "installed: $("$ADB" shell dumpsys package "$APP_ID" 2>/dev/null | sed -n 's/.*lastUpdateTime=//p' | head -1)" + +[ "$LAUNCH" -eq 0 ] && { echo "Skipping launch (--no-launch)."; exit 0; } + +# Horizon OS refuses to start an app while the headset is asleep, and caches the launch +# when a system dialog is up, so retry rather than failing on the first attempt. +info "Launching (put the headset on if it is asleep; waiting up to ${WAIT_SECS}s)" +deadline=$(( $(date +%s) + WAIT_SECS )) +pid="" +while [ "$(date +%s)" -lt "$deadline" ]; do + if [ "$("$ADB" shell dumpsys power 2>/dev/null | grep -o 'mWakefulness=[A-Za-z]*' | head -1)" = "mWakefulness=Awake" ]; then + "$ADB" shell am start -n "$ACTIVITY" >/dev/null 2>&1 || true + sleep 6 + pid="$("$ADB" shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" + [ -n "$pid" ] && break + fi + sleep 5 +done + +if [ -z "$pid" ]; then + warn "not running after ${WAIT_SECS}s. It is installed - launch it from the headset's" + warn "app library under 'Unknown Sources' (it is named 'SIMPLE Template')." + "$ADB" logcat -d -t 400 2>/dev/null | grep -iE 'Launch is blocked|Rejecting start' | tail -2 >&2 || true + exit 1 +fi + +ok "running (pid $pid)" +CRASHES="$("$ADB" logcat -d -t 600 2>/dev/null \ + | grep -iE 'FATAL|AndroidRuntime:|NullReferenceException|MissingReference' \ + | grep -viE 'RuntimeInit|boot image|lock profiling|main entry|VM exiting' | tail -5 || true)" +if [ -n "$CRASHES" ]; then + warn "errors in logcat:" + printf '%s\n' "$CRASHES" >&2 +else + ok "no crashes or exceptions in logcat" +fi