From 5858838cdacc1ec9f7d7527013f6ddb1b412d9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Mon, 28 Sep 2026 21:51:31 +0700 Subject: [PATCH 01/24] Fix merge conflict markers committed into two prefab .meta files PlasticBucket.prefab.meta and PF_Pop_House1.prefab.meta each still contained an unresolved rename/rename conflict block from 5b918e6. Unity cannot parse a GUID out of them ("The GUID inside '...' cannot be extracted by the YAML Parser"), which aborts an Android player build during IL post-processing with a double fault and a Mono process hang. Each file keeps its own HEAD-side GUID, chosen from what the live scenes actually reference: b4626cd6089bbfa41a12442e228be28f PlasticBucket - 3 live scenes aa4a993eaa7f10d4ab43e873f4ffd8ba PF_Pop_House1 - 2 live scenes The third GUID appearing in both blocks, 936550419afe37f44af3bf7b596ae57f, is referenced by nothing outside the conflict markers themselves and is dropped. Picking the other side would have silently broken every reference to the prefab. These were the only two .meta files in Assets/ carrying conflict markers. --- .../Team Assets/Prefabs/Buildings/PF_Pop_House1.prefab.meta | 4 ---- .../Team Assets/Prefabs/Props/PlasticBucket.prefab.meta | 4 ---- 2 files changed, 8 deletions(-) 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: From bf19207e7cdbc9fb8230ea7e69a7fbfda5964e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Mon, 28 Sep 2026 21:51:48 +0700 Subject: [PATCH 02/24] Module 2: in-headset HUD, localized strings and interaction fixes Gameplay and UI pass over the household vector control module. New: Module2HUD in-headset HUD, added to M2Manager at runtime Module2Text every player-facing string in one place Module2Localization Module 2's string table, backed by Module2Text.tsv Module2Interaction scene-wide interaction repairs applied at runtime, so they need no prefab or scene edits LaoFontTool editor tool that regenerates the Lao TMP font asset with full coverage of the Lao block Lao_SomVang Full SDF the regenerated font asset Reworked: M2Manager, Jar, WaterButton, FishCon, TrashBin, SwatterHand, MosquitoSpawn and Mosquto. SendRecieveData now null-checks both managers before reading the score, instead of assuming GameManager.instance. Module 2 has M2Manager and no GameManager, so the old code threw a NullReferenceException whenever that scene reached the GAME state. Adding the new components from M2Manager at runtime keeps these changes out of Module_2_MainScene.unity, which is a ~1.1 MB serialized file that cannot be merged when two people touch it. --- .../Module1/GameSystem/StereoSoundListener.cs | 1 - .../Module2/Scene/Module_2_MainScene.unity | 4 +- .../Module2/Scripts/GameManager/M2Manager.cs | 547 +++++++++++-- .../Scripts/GameManager/Module2Interaction.cs | 168 ++++ .../GameManager/Module2Interaction.cs.meta | 2 + .../Scripts/GameManager/MosquitoSpawn.cs | 75 +- .../Module2/Scripts/Hand/SwatterHand.cs | 38 +- .../Module2/Scripts/Trash/TrashBin.cs | 13 +- .../Module2/Scripts/UI/Module2HUD.cs | 761 ++++++++++++++++++ .../Module2/Scripts/UI/Module2HUD.cs.meta | 2 + .../Module2/Scripts/UI/Module2Localization.cs | 100 +++ .../Scripts/UI/Module2Localization.cs.meta | 2 + .../Module2/Scripts/UI/Module2Text.cs | 148 ++++ .../Module2/Scripts/UI/Module2Text.cs.meta | 2 + .../Module2/Scripts/Water_contain/FishCon.cs | 19 +- .../Module2/Scripts/Water_contain/Jar.cs | 136 +++- .../Scripts/Water_contain/WaterButton.cs | 38 +- .../Module2/Scripts/mOSQUTO/Mosquto.cs | 110 ++- Assets/Editor/LaoFontTool.cs | 100 +++ Assets/Editor/LaoFontTool.cs.meta | 2 + Assets/Resources/Localization/Module2Text.tsv | 45 ++ .../Localization/Module2Text.tsv.meta | 7 + 22 files changed, 2162 insertions(+), 158 deletions(-) create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs.meta create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs.meta create mode 100644 Assets/Editor/LaoFontTool.cs create mode 100644 Assets/Editor/LaoFontTool.cs.meta create mode 100644 Assets/Resources/Localization/Module2Text.tsv create mode 100644 Assets/Resources/Localization/Module2Text.tsv.meta 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/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..7a5d2f44 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,585 @@ 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; 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("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; [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; + + /// 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); + /// 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; - _cameraPos = Camera.main.transform; - UpdateScore(score); + 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(); + + // 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(); + 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) UpdateScore(completionBonus); + + _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); + } + + /// Called by a mosquito that reaches the player. Ends the stint at biteLimit. + public void NoteBite() + { + if (!IsPlaying) 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); + } + + private void TickEnergy() + { + if (!IsPlaying) return; + if (Time.time - _lastBiteAt < energyRegenDelay) return; + Energy = Mathf.Clamp01(Energy + energyRegenPerSecond * Time.deltaTime); + } + + public void NoteMosquitoSwatted() + { + 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() + { + 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); + if (FindAnyObjectByType() != null) + { + if (hud != null) hud.Toast(Module2Text.SwatterAlreadyOut); + return; + } + + 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; + } + + if (creamPrefabs != null) Instantiate(creamPrefabs, pointFontPlayer); + if (particleSpwn != null) Instantiate(particleSpwn, pointFontPlayer); + if (hud != null) hud.Toast(Module2Text.CreamSpawned); } public void UpdateScore(int value) { score += value; - socreText[0].text = "Score: " + score.ToString(); - socreText[1].text = "Score: " + score.ToString(); + RefreshScoreText(); } - public void ShowGameOverPanel() + private void RefreshScoreText() + { + if (socreText == null) return; + for (int i = 0; i < socreText.Length; i++) + if (socreText[i] != null) socreText[i].text = "Score: " + score; + } + #endregion + + #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 (sitesPerRound <= 0 || _openSites.Count <= sitesPerRound) return; + + 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]); + } + + for (int i = sitesPerRound; i < pool.Count; i++) + { + var site = pool[i]; + _openSites.Remove(site); + _siteCount--; + if (site != null) site.gameObject.SetActive(false); + } + + Debug.Log($"[M2] Using {_siteCount} of the scene's breeding sites this round."); + } + + public void RegisterBreedingSite(Component site) { - gameOver.SetActive(true); + if (site == null) return; + if (_openSites.Add(site)) _siteCount++; + } + + public void NeutralizeBreedingSite(Component site) + { + if (site == null || !_openSites.Remove(site)) return; + + 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 (isOver == 1) - { - timer -= Time.deltaTime; - timerText.text = "Time: " + Mathf.Round(timer).ToString(); - //print(timer); + if (!IsPlaying) return; - if (timer <= 0) - { - ShowGameOverPanel(); - isOver = 0; - } + 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..b4ada7e4 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs @@ -0,0 +1,168 @@ +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. +/// +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; + + 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(); + 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 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..c6d839fc 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs @@ -1,4 +1,3 @@ -using NUnit.Framework; using UnityEngine; public class MosquitoSpawn : MonoBehaviour @@ -9,28 +8,82 @@ 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("Height above a container that its mosquitoes appear at.")] + public float emergeHeight = 0.3f; public GameObject[] spawnPoint; + private float _nextSpawnTime; + 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; + + currentMosquitoInMap = GameObject.FindGameObjectsWithTag("Mosquito").Length; + + // 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) 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++; } } } diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs index a7dd2f70..297d5f3e 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs @@ -2,38 +2,38 @@ 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); - } - private void OnTriggerEnter(Collider other) { - if (other.CompareTag("Mosquto")) - { - M2Manager.Instance.UpdateScore(scoreValue); - Destroy(other.gameObject); - } + // 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); } + /// Wired to the racket's selectEntered: the swatter has been picked up. public void OnEnter() { isHandActive = true; } - public void CheckOnHand() - { - if (!isHandActive) - { - DestroySelf(); - } - } - + /// + /// 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..50312c82 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs @@ -0,0 +1,761 @@ +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); + 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; + + introInputDebug.text = m.showInputDebug ? 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 --- + 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, 150), 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, 108), new Vector2(620, 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, -140), 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) + { + 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, 560)); + 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); + endSubtitle = NewText("Subtitle", panel, "", 25, mutedColor, TextAlignmentOptions.Center, + new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -94), new Vector2(-60, 70)); + endStats = NewText("Stats", panel, "", 30, textColor, TextAlignmentOptions.Center, + new Vector2(0f, 1f), new Vector2(1f, 1f), new Vector2(0.5f, 1f), new Vector2(0, -176), new Vector2(-60, 170)); + endFact = NewText("Fact", panel, "", 23, accentColor, TextAlignmentOptions.Center, + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 0f), new Vector2(0, 76), new Vector2(-80, 90), FontStyles.Italic); + 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..e412e33f --- /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.tsv. 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..09982643 --- /dev/null +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs @@ -0,0 +1,148 @@ +using UnityEngine; + +/// +/// Every player-facing string in Module 2, in one place. +/// +/// Each member reads from Resources/Localization/Module2Text.tsv 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 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/Water_contain/FishCon.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs index 8c05fbf5..bbfd3fc6 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs @@ -5,19 +5,28 @@ public class FishCon : MonoBehaviour 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); } 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/Jar.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs index f8399d4c..0467b699 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs @@ -1,4 +1,6 @@ using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit; +using UnityEngine.XR.Interaction.Toolkit.Interactables; public class Jar : MonoBehaviour { @@ -9,20 +11,118 @@ 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) _returnAt = Time.time + Mathf.Max(lidReturnDelay, 0.1f); + } + + /// 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 +131,32 @@ private void OnTriggerExit(Collider other) { if (other.CompareTag("Jar_Hat")) { - topHolo.SetActive(false); + if (!_isClosed && topHolo != null) topHolo.SetActive(false); isInColli = 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..2776b673 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs @@ -1,4 +1,3 @@ -using System.Linq; using UnityEngine; public class WaterButton : MonoBehaviour @@ -12,42 +11,43 @@ 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; private AudioSource _source; - private void Start() { _source = GetComponent(); isWaterActive = true; + + if (!isSave && M2Manager.Instance != null) M2Manager.Instance.RegisterBreedingSite(this); } void Update() { waterOut(); - if (!isSave) - { - M2Manager.Instance.notSaveWaterContainer.ToArray(); - } - else - { - M2Manager.Instance.saveWaterContainer.ToArray(); - } - Debug.DrawRay(transform.position, transform.up * 1.2f, Color.red); } private void waterOut() { - if (Physics.Raycast(transform.position, transform.up, out RaycastHit hit, 1.2f, groundLayerMask) && isWaterActive) - { - isWaterActive = false; - waterPrefab.SetActive(false); - _source.PlayOneShot(waterSplash); - M2Manager.Instance.UpdateScore(getScore); + if (!isWaterActive) return; + if (!Physics.Raycast(transform.position, transform.up, out RaycastHit hit, 1.2f, groundLayerMask)) return; + + isWaterActive = false; + + if (waterPrefab != null) waterPrefab.SetActive(false); + if (_source != null && waterSplash != null) _source.PlayOneShot(waterSplash); + + // PF_VaseWithFlowers was serialised before this field existed, so it loads as null and the + // old unguarded Instantiate threw on every tip-out. + if (waterSplashEffect != null) Instantiate(waterSplashEffect, hit.point, Quaternion.LookRotation(hit.normal)); - } + + if (M2Manager.Instance == null) return; + M2Manager.Instance.UpdateScore(getScore); + if (!isSave) M2Manager.Instance.NeutralizeBreedingSite(this); } -} \ 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..1cc584ca 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs @@ -8,17 +8,41 @@ 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; + private Vector3 _randomDirection; - private string _wllLayer = "Wall"; - private float directionChangeTimer; + private int _wallMask; + private float _directionChangeTimer; + private float _nextBiteAt; + + // 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() { + _wallMask = LayerMask.GetMask("Wall"); GenerateRandomDirection(); } - // Update is called once per frame void Update() { FlyRandomly(); @@ -26,57 +50,78 @@ void Update() 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))) - { - _randomDirection = Vector3.Reflect(_randomDirection, hitInfoLeft.normal); - } + Vector3 toHead = head.position - transform.position; + float distance = toHead.magnitude; + if (distance > attractRadius || distance < 0.0001f) return; + + // 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; + } - if (Physics.Raycast(transform.position, -transform.forward, out RaycastHit hitInfoUp, spawnBoundaryRadius, LayerMask.GetMask(_wllLayer))) + // 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 +131,4 @@ private void OnTriggerEnter(Collider other) Destroy(gameObject); } } - } 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.tsv b/Assets/Resources/Localization/Module2Text.tsv new file mode 100644 index 00000000..dc914d2d --- /dev/null +++ b/Assets/Resources/Localization/Module2Text.tsv @@ -0,0 +1,45 @@ +# 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 +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.tsv.meta b/Assets/Resources/Localization/Module2Text.tsv.meta new file mode 100644 index 00000000..aa9792a3 --- /dev/null +++ b/Assets/Resources/Localization/Module2Text.tsv.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ab0bc3d078c494b95bdaa10ec112c9d7 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 8859733d8a39b287829a2a9ea22652e74a4beeec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Mon, 28 Sep 2026 21:52:06 +0700 Subject: [PATCH 03/24] Add one-command build and deploy scripts for the AEDES modules Committed replacements for the per-machine helpers that were living in .git/info/exclude, so that building and sideloading no longer depends on untracked files. Tools/build.sh <1|2|tut2> [--clean] build a module into an APK Tools/deploy.sh <1|2|tut2> install it on an attached Quest, launch Tools/build-and-deploy.sh <1|2|tut2> both There is no separate packaging step: Unity's player build writes the signed .apk directly, debug-signed, which is what sideloading needs. Modules are built one at a time for now. Scenes are resolved by filename rather than a fixed path, so the folder reorganisations this repo keeps going through do not break the scripts, and each build contains only that module's scene rather than whatever EditorBuildSettings happens to hold. build.sh checks libil2cpp.so inside the finished APK and re-runs itself with --clean if it finds a stub; deploy.sh refuses to install one. A build that crashes part-way can leave the IL2CPP cache holding a few-KB libil2cpp.so, and every later incremental build then reuses it while still reporting result=Succeeded. The APK installs, starts the engine and dies, because it contains no compiled C# at all - so the build report alone is not enough to trust a build. deploy.sh also distinguishes the headset failure modes that otherwise look alike: an empty adb device list means developer mode is off rather than a cable fault, "unauthorized" means the on-headset prompt is still pending, and Horizon OS refuses to launch while the headset is asleep or a system dialog is open. The editor version is read from ProjectSettings/ProjectVersion.txt instead of being hardcoded, and adb is found inside the editor's bundled Android SDK. README documents all of this, including headset setup and troubleshooting. --- Assets/Editor/AedesBuild.cs | 101 +++++++++++++++++++++++++++++++ Assets/Editor/AedesBuild.cs.meta | 11 ++++ README.md | 82 +++++++++++++++++++++++++ Tools/aedes-common.sh | 88 +++++++++++++++++++++++++++ Tools/build-and-deploy.sh | 10 +++ Tools/build.sh | 92 ++++++++++++++++++++++++++++ Tools/deploy.sh | 92 ++++++++++++++++++++++++++++ 7 files changed, 476 insertions(+) create mode 100644 Assets/Editor/AedesBuild.cs create mode 100644 Assets/Editor/AedesBuild.cs.meta create mode 100755 Tools/aedes-common.sh create mode 100755 Tools/build-and-deploy.sh create mode 100755 Tools/build.sh create mode 100755 Tools/deploy.sh 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/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 From af2ba1f03eaac8bfc061696b1b6fe7f17111968f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 07:45:01 +0700 Subject: [PATCH 04/24] Module 2: tip containers out by tilt, not a ground-layer raycast The tip test cast 1.2 m along the container's up axis against the Ground layer only. The Lao house floor is on the Wall layer, so a vase on the indoor desk emptied only when held inverted below knee height; at chest height nothing happened. Found by playtesting through the harness. Pouring now triggers once the container is tilted past pourAngle (110 deg); the splash is placed on the ground mask or, failing that, the nearest surface below. --- .../Scripts/Water_contain/WaterButton.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs index 2776b673..d655be31 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs @@ -13,6 +13,8 @@ public class WaterButton : MonoBehaviour 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; @@ -27,27 +29,41 @@ private void Start() void Update() { waterOut(); - - Debug.DrawRay(transform.position, transform.up * 1.2f, Color.red); } private void waterOut() { if (!isWaterActive) return; - if (!Physics.Raycast(transform.position, transform.up, out RaycastHit hit, 1.2f, groundLayerMask)) return; + if (Vector3.Angle(transform.up, Vector3.up) < pourAngle) return; isWaterActive = false; if (waterPrefab != null) waterPrefab.SetActive(false); if (_source != null && waterSplash != null) _source.PlayOneShot(waterSplash); - // PF_VaseWithFlowers was serialised before this field existed, so it loads as null and the - // old unguarded Instantiate threw on every tip-out. - if (waterSplashEffect != null) + // The splash lands on whatever is under the container: the ground mask first, then any + // non-trigger surface (house floor, furniture) that is not the container itself. + // PF_VaseWithFlowers was serialised before waterSplashEffect existed, so it can be null. + if (waterSplashEffect != null && FindSplashPoint(out RaycastHit hit)) Instantiate(waterSplashEffect, hit.point, Quaternion.LookRotation(hit.normal)); if (M2Manager.Instance == null) return; M2Manager.Instance.UpdateScore(getScore); if (!isSave) M2Manager.Instance.NeutralizeBreedingSite(this); } + + bool FindSplashPoint(out RaycastHit best) + { + best = default; + if (Physics.Raycast(transform.position, Vector3.down, out best, 5f, groundLayerMask, QueryTriggerInteraction.Ignore)) + return true; + float nearest = float.MaxValue; + foreach (var h in Physics.RaycastAll(transform.position, Vector3.down, 5f, ~0, QueryTriggerInteraction.Ignore)) + { + if (h.collider.transform.IsChildOf(transform) || h.distance >= nearest) continue; + nearest = h.distance; + best = h; + } + return nearest < float.MaxValue; + } } From b085c3589ecf8fbe8a4ff7e50dc5963bcb7cbc75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 07:46:57 +0700 Subject: [PATCH 05/24] Module 2: stop the swarm ending a round in seconds; freeze scoring at round end Playtesting (harness) found a normal clear-the-sites run ended by bites after ~28 s: 8 bites in 5 s while covering a jar. Mosquitoes emerge 0.3 m above open containers - where the player is working - and up to 30 home in, each biting every 4 s. - M2Manager.biteGracePeriod (2.5 s): bites inside it are ignored. - Mosquito.firstBiteDelay (3 s): a freshly emerged mosquito cannot bite. - Score, site clears, swats and binned trash are ignored once the round has ended; the debrief showed 'Action score 40, 3/6' while the live state kept counting to 60 and 4/6. The completion bonus goes through a private path. --- .../Module2/Scripts/GameManager/M2Manager.cs | 17 +++++++++++++++-- .../Module2/Scripts/mOSQUTO/Mosquto.cs | 3 +++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index 7a5d2f44..f881f448 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -59,6 +59,8 @@ public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } 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 = 2.5f; [Header("Day Night System")] public Light sun; @@ -385,7 +387,7 @@ private void EndRound(RoundEndReason reason) isOver = 0; EndReason = reason; - if (reason == RoundEndReason.AllSitesCleared) UpdateScore(completionBonus); + if (reason == RoundEndReason.AllSitesCleared) AddScore(completionBonus); _endShownAt = Time.time; @@ -403,6 +405,7 @@ public void ShowGameOverPanel() public void NoteBite() { if (!IsPlaying) return; + if (Time.time - _lastBiteAt < Mathf.Max(biteGracePeriod, 0f)) return; BiteCount++; _lastBiteAt = Time.time; @@ -422,6 +425,7 @@ private void TickEnergy() 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); @@ -438,6 +442,7 @@ public void NoteMosquitoSwatted() public void SetSimulationProjection(int value) { _gamaProjection = value; } public void NoteTrashBinned() { + if (_ended) return; TrashBinned++; if (hud != null) hud.Toast(Module2Text.TrashBinned); } @@ -473,7 +478,15 @@ public void CreamUi() if (hud != null) hud.Toast(Module2Text.CreamSpawned); } + /// 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; RefreshScoreText(); @@ -521,7 +534,7 @@ public void RegisterBreedingSite(Component site) public void NeutralizeBreedingSite(Component site) { - if (site == null || !_openSites.Remove(site)) return; + if (_ended || site == null || !_openSites.Remove(site)) return; if (hud != null) hud.OnSiteCleared(site, _siteCount - _openSites.Count, _siteCount); if (_openSites.Count == 0 && IsPlaying) EndRound(RoundEndReason.AllSitesCleared); diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs index 1cc584ca..076ee4fa 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs @@ -18,6 +18,8 @@ public class Mosquito : MonoBehaviour 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; private Vector3 _randomDirection; private int _wallMask; @@ -40,6 +42,7 @@ private static Transform Head() void Start() { _wallMask = LayerMask.GetMask("Wall"); + _nextBiteAt = Time.time + Mathf.Max(firstBiteDelay, 0f); GenerateRandomDirection(); } From 866bce77bc505d0473dd33c9cad267cec8c64a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 07:47:59 +0700 Subject: [PATCH 06/24] Module 2: bite grace period 2.5 s -> 5 s At 2.5 s the swarm, which out-flies a walking player, still landed a bite at the maximum rate and ended an efficient clear (4/6 sites, no swatting) at 42 s. Found by harness replay. --- Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index f881f448..1744570d 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -60,7 +60,7 @@ public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } [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 = 2.5f; + public float biteGracePeriod = 5f; [Header("Day Night System")] public Light sun; From 094872e72174e4751673834c3fdf51ab3e8b3c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 07:53:01 +0700 Subject: [PATCH 07/24] Module 2: implement repellent cream - squeeze onto a hand for 30 s without bites The wrist menu has always spawned a repellent tube and the debrief tells players 'protection alone does not reduce the swarm', but the cream did nothing except play a particle effect. Now: - Cream: holding the tube and squeezing (trigger) it onto the other hand - nozzle ray or within applyRadius (0.2 m) - calls M2Manager.ApplyRepellent. The holding hand never counts. - M2Manager.repellentDuration (30 s): bites are ignored while active; toasts on apply and when it wears off; re-applying refreshes it. - Mosquitoes within attract range veer away from a protected player. - HUD shows the time left beside the bite count. - New strings in Module2Text.tsv (Lao column left for translation). Protection does not touch the breeding sites, so it cannot move source reduction. --- .../Module2/Scripts/GameManager/M2Manager.cs | 30 ++++++++++ .../Module2/Scripts/Hand/Cream.cs | 59 ++++++++++++++++--- .../Module2/Scripts/UI/Module2HUD.cs | 1 + .../Module2/Scripts/UI/Module2Text.cs | 3 + .../Module2/Scripts/mOSQUTO/Mosquto.cs | 7 +++ Assets/Resources/Localization/Module2Text.tsv | 3 + 6 files changed, 94 insertions(+), 9 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index 1744570d..30a6cfec 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -62,6 +62,10 @@ public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } [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; + [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; @@ -101,6 +105,8 @@ public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } 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; } @@ -126,6 +132,11 @@ public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } 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; @@ -219,6 +230,7 @@ void Update() StartTimer(); DayNightSystem(); TickEnergy(); + TickRepellent(); AutoStartIfStuck(); RestartGame(); } @@ -405,6 +417,7 @@ public void ShowGameOverPanel() public void NoteBite() { if (!IsPlaying) return; + if (RepellentActive) return; if (Time.time - _lastBiteAt < Mathf.Max(biteGracePeriod, 0f)) return; BiteCount++; @@ -416,6 +429,23 @@ public void NoteBite() 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; 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/UI/Module2HUD.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs index 50312c82..6a3ceee0 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs @@ -350,6 +350,7 @@ void UpdateVitals(M2Manager m) : 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; diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs index 09982643..9bed2ebb 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs @@ -60,6 +60,9 @@ public static string Sites(int cleared, int total) => 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; diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs index 076ee4fa..b0f0c224 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs @@ -88,6 +88,13 @@ private void SeekAndBite() 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.Slerp(_randomDirection, -toHead / distance, seekStrength * Time.deltaTime * 6f).normalized; + return; + } + // 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; diff --git a/Assets/Resources/Localization/Module2Text.tsv b/Assets/Resources/Localization/Module2Text.tsv index dc914d2d..e0e87e09 100644 --- a/Assets/Resources/Localization/Module2Text.tsv +++ b/Assets/Resources/Localization/Module2Text.tsv @@ -24,6 +24,9 @@ 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 From d5f3897dc7785cbaae756beb74db0b212200ded7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 07:54:56 +0700 Subject: [PATCH 08/24] Module 2 HUD: stop toasts covering the objective label; un-overlap end card Harness screenshots showed the toast stack (from +170 down) drawn over the objective arrow and distance label (+150/+108), and the end-card subtitle starting inside the title's box (-94 vs title bottom -112). - Objective arrow/label move to the free top-centre band (+268/+226); the label narrows to 320 so it cannot reach the sites counter. - Toasts start at +200. - End subtitle at -116 with room for three lines; stats follow at -214. --- .../Module2/Scripts/UI/Module2HUD.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs index 6a3ceee0..d95afd55 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs @@ -555,13 +555,16 @@ void BuildHud() 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, 150), new Vector2(54, 54)); + 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, 108), new Vector2(620, 34)); + 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, -140), new Vector2(ToastW, 10)); + 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)); @@ -620,10 +623,12 @@ void BuildEndPanel(Transform parent) 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, -94), new Vector2(-60, 70)); + 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, -176), new Vector2(-60, 170)); + 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, 76), new Vector2(-80, 90), FontStyles.Italic); endFooter = NewText("Footer", panel, "", 28, doneColor, TextAlignmentOptions.Center, From 92708f377b7a8da41b98cc5fce612a567f95ff69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 07:55:38 +0700 Subject: [PATCH 09/24] Module 2 HUD: give the end card's fact block room (panel 560 -> 640) The consequence line plus a random fact runs to four lines; in a 90-high box it overflowed into the stats line and the restart footer (harness screenshot of a BitesReached debrief). --- Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs index d95afd55..17685362 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs @@ -614,7 +614,9 @@ void BuildIntroPanel(Transform parent) void BuildEndPanel(Transform parent) { - 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, 560)); + // 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; @@ -630,7 +632,7 @@ void BuildEndPanel(Transform parent) 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, 76), new Vector2(-80, 90), FontStyles.Italic); + new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0.5f, 0f), new Vector2(0, 72), new Vector2(-80, 136), FontStyles.Italic); 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); } From e224cc66a5b9b94252d75718f2e99607b67a2ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 07:56:13 +0700 Subject: [PATCH 10/24] Module 2 HUD: auto-size the end card's fact text to its box A five-line consequence + fact still touched the restart footer; facts vary in length and Lao runs longer, so shrink to fit (23 -> min 15). --- Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs index 17685362..a1520878 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs @@ -633,6 +633,10 @@ void BuildEndPanel(Transform parent) 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); } From 4f2f8c42e62ba043ccef418c117d9ab0ca3fd7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 08:15:42 +0700 Subject: [PATCH 11/24] Module 2: make the lawn litter grabbable so it can be binned None of the Trash-tagged objects had a Rigidbody or XRGrabInteractable, so a player could not pick any of them up and TrashBin never fired - every debrief read 'Rubbish 0'. Found by trying to pick up a cup in the harness. Module2Interaction now adds a light Rigidbody + XRGrabInteractable to litter before tuning grabbables. --- .../Scripts/GameManager/Module2Interaction.cs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs index b4ada7e4..7db30937 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs @@ -19,7 +19,9 @@ /// - 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. +/// 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 { @@ -35,6 +37,9 @@ public class Module2Interaction : MonoBehaviour [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() @@ -53,6 +58,8 @@ void Start() { FixEventSystem(); if (forceInteractorTriggers) FixDirectInteractors(); + // Before TuneGrabbables, so the litter gets the same collider/attach/first-grab treatment. + if (makeTrashGrabbable) MakeTrashGrabbable(); TuneGrabbables(); } @@ -101,6 +108,21 @@ void FixDirectInteractors() 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; + 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)) From ea898c157a12938a2fa8af8294ec1da197212c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 08:19:28 +0700 Subject: [PATCH 12/24] Module 2: stop newly-physical litter launching into the air at round start Litter is authored partly sunk into the terrain. With a Rigidbody added, the solver depenetrated a banana leaf at 8.4 m/s on frame 1 and it flew 3 m up (harness trace). Cap maxDepenetrationVelocity at 0.3 m/s so embedded pieces settle onto the surface instead. --- .../Module2/Scripts/GameManager/Module2Interaction.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs index 7db30937..d690b5a7 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/Module2Interaction.cs @@ -117,6 +117,10 @@ void MakeTrashGrabbable() 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++; } From 4554c905c722a56195ea59f1c0d698c99ea8f3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 08:25:24 +0700 Subject: [PATCH 13/24] Module 2: a released jar lid falls instead of hanging in mid-air XRI restores the lid's kinematic flag on release, so a lid dropped or thrown anywhere but on its jar froze in the air for 1.5 s and then teleported home (harness: thrown lid hung at y 0.40). Make it dynamic on release; the existing return-home path makes it kinematic again. --- .../1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs index 0467b699..b1440d3d 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs @@ -68,7 +68,12 @@ void OnLidReleased(SelectExitEventArgs args) if (LidNearMouth()) PutTop(force: true); else PutTop(); - if (!_isClosed) _returnAt = Time.time + Mathf.Max(lidReturnDelay, 0.1f); + 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). From 6bb2f7804833b855761a4357a0bfb0a031192e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 08:32:25 +0700 Subject: [PATCH 14/24] Module 2: drop UIHand's no-op DontDestroyOnLoad The watch sits under the left hand, so DontDestroyOnLoad (root objects only) never took effect and logged a warning on every scene load, and the 'destroy duplicate' branch could only ever misfire. The rig and watch are rebuilt with the scene; UIHand now simply tracks the current instance. --- Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs index 26164cae..69d67d1b 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs @@ -14,15 +14,10 @@ public class UIHand : MonoBehaviour 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() From f48f7e05fd7b0372f919afa1fadcc5f7df73dcba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 08:40:06 +0700 Subject: [PATCH 15/24] Module 2: put the tip-out splash on the nearest surface below The previous fix preferred groundLayerMask, which is the terrain - under the house floor - so an indoor vase splashed 8 cm below the floor, invisible (harness). Use the nearest non-trigger surface below, skipping the container itself and the player's CharacterController. --- .../Module2/Scripts/Water_contain/WaterButton.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs index d655be31..f3c854ab 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs @@ -41,9 +41,8 @@ private void waterOut() if (waterPrefab != null) waterPrefab.SetActive(false); if (_source != null && waterSplash != null) _source.PlayOneShot(waterSplash); - // The splash lands on whatever is under the container: the ground mask first, then any - // non-trigger surface (house floor, furniture) that is not the container itself. - // PF_VaseWithFlowers was serialised before waterSplashEffect existed, so it can be null. + // 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. if (waterSplashEffect != null && FindSplashPoint(out RaycastHit hit)) Instantiate(waterSplashEffect, hit.point, Quaternion.LookRotation(hit.normal)); @@ -52,15 +51,15 @@ private void waterOut() if (!isSave) M2Manager.Instance.NeutralizeBreedingSite(this); } + // 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) { best = default; - if (Physics.Raycast(transform.position, Vector3.down, out best, 5f, groundLayerMask, QueryTriggerInteraction.Ignore)) - return true; float nearest = float.MaxValue; foreach (var h in Physics.RaycastAll(transform.position, Vector3.down, 5f, ~0, QueryTriggerInteraction.Ignore)) { - if (h.collider.transform.IsChildOf(transform) || h.distance >= nearest) continue; + if (h.collider.transform.IsChildOf(transform) || h.collider is CharacterController || h.distance >= nearest) continue; nearest = h.distance; best = h; } From 0c129f94827fcb293b77084af7c01f7fd53eb17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Tue, 29 Sep 2026 08:43:08 +0700 Subject: [PATCH 16/24] Module 2: every vase splashes when tipped out Only PF_VaseWithFlowers has waterSplashEffect assigned; (2), (3) and (4) emptied with sound but no visible splash (harness). Containers without their own effect now use the one that is set. --- .../Module2/Scripts/Water_contain/WaterButton.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs index f3c854ab..19c41d55 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs @@ -18,10 +18,15 @@ public class WaterButton : MonoBehaviour 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); } @@ -43,8 +48,9 @@ private void waterOut() // 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. - if (waterSplashEffect != null && FindSplashPoint(out RaycastHit hit)) - Instantiate(waterSplashEffect, hit.point, Quaternion.LookRotation(hit.normal)); + 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); From 0e308685f98b1d41f2b067703711c98d76c0a1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Wed, 30 Sep 2026 17:50:38 +0700 Subject: [PATCH 17/24] Module 2: actually load the string table; keep the input readout out of release builds Module2Text.tsv imported as a plain file (.tsv is not a text-asset extension), so Resources.Load always returned null and every string fell back to the built-in English. Renamed it to .txt (same GUID, TextScriptImporter); it now loads 44 keys. Nothing visible changes today because the Lao column is still empty, but Lao wording would have been silently ignored. The briefing's live input readout ("actions:0 ...") was on in the scene. It now also requires a development build or the editor, so a release APK never shows it to children. --- Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs | 3 ++- .../1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs | 2 +- Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs | 2 +- .../Localization/{Module2Text.tsv => Module2Text.txt} | 0 .../{Module2Text.tsv.meta => Module2Text.txt.meta} | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) rename Assets/Resources/Localization/{Module2Text.tsv => Module2Text.txt} (100%) rename Assets/Resources/Localization/{Module2Text.tsv.meta => Module2Text.txt.meta} (87%) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs index a1520878..cde22d62 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2HUD.cs @@ -475,7 +475,8 @@ void UpdateIntro(M2Manager m) { if (m.RoundStarted || introGroup.alpha <= 0f) return; - introInputDebug.text = m.showInputDebug ? m.ConfirmDebug : ""; + // 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(); diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs index e412e33f..661f5e28 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Localization.cs @@ -9,7 +9,7 @@ /// 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.tsv. Every lookup falls back to the English +/// 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. /// diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs index 9bed2ebb..d29e8f2f 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/UI/Module2Text.cs @@ -3,7 +3,7 @@ /// /// Every player-facing string in Module 2, in one place. /// -/// Each member reads from Resources/Localization/Module2Text.tsv by key, falling back to the English +/// 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. /// diff --git a/Assets/Resources/Localization/Module2Text.tsv b/Assets/Resources/Localization/Module2Text.txt similarity index 100% rename from Assets/Resources/Localization/Module2Text.tsv rename to Assets/Resources/Localization/Module2Text.txt diff --git a/Assets/Resources/Localization/Module2Text.tsv.meta b/Assets/Resources/Localization/Module2Text.txt.meta similarity index 87% rename from Assets/Resources/Localization/Module2Text.tsv.meta rename to Assets/Resources/Localization/Module2Text.txt.meta index aa9792a3..d6c9dfe1 100644 --- a/Assets/Resources/Localization/Module2Text.tsv.meta +++ b/Assets/Resources/Localization/Module2Text.txt.meta @@ -1,6 +1,6 @@ fileFormatVersion: 2 guid: ab0bc3d078c494b95bdaa10ec112c9d7 -DefaultImporter: +TextScriptImporter: externalObjects: {} userData: assetBundleName: From fc256912ede953035b8ce959d8ff650244feadff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Wed, 30 Sep 2026 17:50:49 +0700 Subject: [PATCH 18/24] Module 2: a put-down swatter falls and can be replaced The racket prefab has gravity off and XRI restores that on release, so a dropped racket kept the speed it left the hand with and drifted away for good. FlySwatterUi then refused a new one because one "already" existed. The wrist button was the only way to get a swatter, so the player was left without one for the rest of the round. SwatterHand now forces gravity on detach and gets the same continuous collision the scene's other grabbables get (it is spawned after Module2Interaction has run). FlySwatterUi only refuses while a racket is actually held; otherwise it replaces any loose one. --- .../Module2/Scripts/GameManager/M2Manager.cs | 7 +++++- .../Module2/Scripts/Hand/SwatterHand.cs | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index 30a6cfec..cd6d8167 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -484,11 +484,16 @@ public void NoteTrashBinned() // dead UIHand.SpawnSwatter. public void FlySwatterUi() { - if (FindAnyObjectByType() != null) + // 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); if (flySwatterPrefabs != null) Instantiate(flySwatterPrefabs, pointFontPlayer); if (particleSpwn != null) Instantiate(particleSpwn, pointFontPlayer); diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs index 297d5f3e..0b6a513d 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Hand/SwatterHand.cs @@ -1,4 +1,5 @@ using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit.Interactables; public class SwatterHand : MonoBehaviour { @@ -6,6 +7,30 @@ public class SwatterHand : MonoBehaviour public int scoreValue = 0; public bool isHandActive = false; + XRGrabInteractable _grab; + + /// True while a hand is holding the racket. + public bool Held => _grab != null && _grab.isSelected; + + void Awake() + { + _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) + { + body.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative; + body.interpolation = RigidbodyInterpolation.Interpolate; + } + } + private void OnTriggerEnter(Collider other) { // The tag is "Mosquito"; the old "Mosquto" spelling is not a defined tag, so CompareTag From fd66d66c57848dd7f07e1bf62049a8a355e8fcdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Wed, 30 Sep 2026 17:50:49 +0700 Subject: [PATCH 19/24] Module 2: clearing breeding sites now thins the swarm and the bites Mosquitoes lived until swatted, so covering jars only stopped new ones spawning: 30 stayed alive with 5 of 6 sites open, and 25 were still buzzing on the "House is clear" card. The bite limiter was one global grace period, so 30 mosquitoes bit no faster than one. The debrief says fewer containers means fewer bites; play did not show it. - Mosquito.Retire(): stop seeking, fly away from the player for 2.5 s, then leave. - MosquitoSpawn retires the excess (nearest the player first, 2 per check) when the swarm is bigger than the open sites support, counting only mosquitoes that are not already leaving. - A full clear sends the whole swarm away. - M2Manager.CurrentBiteGrace = biteGracePeriod x sites / sites-still-open, capped at maxBiteGraceMultiplier (4). With every site open it is the unchanged 5 s. --- .../Module2/Scripts/GameManager/M2Manager.cs | 23 ++++++++++- .../Scripts/GameManager/MosquitoSpawn.cs | 29 +++++++++++++- .../Module2/Scripts/mOSQUTO/Mosquto.cs | 39 ++++++++++++++++++- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index cd6d8167..c802640c 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -61,6 +61,8 @@ public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } 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.")] @@ -399,7 +401,12 @@ private void EndRound(RoundEndReason reason) isOver = 0; EndReason = reason; - if (reason == RoundEndReason.AllSitesCleared) AddScore(completionBonus); + 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; @@ -413,12 +420,24 @@ 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 < Mathf.Max(biteGracePeriod, 0f)) return; + if (Time.time - _lastBiteAt < CurrentBiteGrace) return; BiteCount++; _lastBiteAt = Time.time; diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs index c6d839fc..b79bb2d6 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/MosquitoSpawn.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using UnityEngine; public class MosquitoSpawn : MonoBehaviour @@ -14,11 +15,14 @@ public class MosquitoSpawn : MonoBehaviour 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() { @@ -64,7 +68,11 @@ public void SpawnMosqitoInMap() M2Manager manager = M2Manager.Instance; if (manager == null || !manager.IsPlaying) return; - currentMosquitoInMap = GameObject.FindGameObjectsWithTag("Mosquito").Length; + // 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 @@ -75,7 +83,8 @@ public void SpawnMosqitoInMap() : maxMosquitoInMap; int room = target - currentMosquitoInMap; - if (room <= 0) return; + 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++) @@ -86,4 +95,20 @@ public void SpawnMosqitoInMap() 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/mOSQUTO/Mosquto.cs b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs index b0f0c224..bd1d0473 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/mOSQUTO/Mosquto.cs @@ -21,10 +21,20 @@ public class Mosquito : MonoBehaviour [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 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. @@ -43,14 +53,41 @@ void Start() { _wallMask = LayerMask.GetMask("Wall"); _nextBiteAt = Time.time + Mathf.Max(firstBiteDelay, 0f); - GenerateRandomDirection(); + if (!Retired) GenerateRandomDirection(); } 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; From bc6e4a001344dd5502dc0622958cc0e0000d3b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Wed, 30 Sep 2026 18:07:32 +0700 Subject: [PATCH 20/24] Module 2: sites a round does not use stay in the house, already dealt with ChooseSiteSubset switched off the GameObject of every site it left out, so a round's furniture changed with the dice: jars, vases and the fish bowl simply vanished with nothing to explain the gap. Each site now implements IBreedingSite.ShowResolved and is shown as if it had been dealt with, without any score or toast: - Jar: lid on, no lid left lying around. - WaterButton (vase): water off. - FishCon: fish in the bowl. The single fish ball is then hidden, since it would do nothing when dropped in. Anything that does not implement the interface still falls back to being switched off. --- .../Module2/Scripts/GameManager/M2Manager.cs | 34 +++++++++++-------- .../Module2/Scripts/Water_contain/FishCon.cs | 15 +++++++- .../Scripts/Water_contain/IBreedingSite.cs | 9 +++++ .../Water_contain/IBreedingSite.cs.meta | 2 ++ .../Module2/Scripts/Water_contain/Jar.cs | 12 ++++++- .../Scripts/Water_contain/WaterButton.cs | 9 ++++- 6 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/IBreedingSite.cs create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/IBreedingSite.cs.meta diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index c802640c..1bee33c3 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -560,24 +560,28 @@ private void RefreshScoreText() // collectively explores the whole house instead of all learning the same nine positions. private void ChooseSiteSubset() { - if (sitesPerRound <= 0 || _openSites.Count <= sitesPerRound) return; - - var pool = new List(_openSites); - for (int i = pool.Count - 1; i > 0; i--) + if (sitesPerRound > 0 && _openSites.Count > sitesPerRound) { - int j = Random.Range(0, i + 1); - (pool[i], pool[j]) = (pool[j], pool[i]); - } + 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]); + } - for (int i = sitesPerRound; i < pool.Count; i++) - { - var site = pool[i]; - _openSites.Remove(site); - _siteCount--; - if (site != null) site.gameObject.SetActive(false); - } + // 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++) + { + 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."); + Debug.Log($"[M2] Using {_siteCount} of the scene's breeding sites this round."); + } } public void RegisterBreedingSite(Component site) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs index bbfd3fc6..6087097e 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/FishCon.cs @@ -1,6 +1,6 @@ using UnityEngine; -public class FishCon : MonoBehaviour +public class FishCon : MonoBehaviour, IBreedingSite { public GameObject fishs; public int scoreValue = 10; @@ -14,6 +14,19 @@ private void Start() 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 (_hasFish || !other.gameObject.CompareTag("FishBall")) return; 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 b1440d3d..22a1d157 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/Jar.cs @@ -2,7 +2,7 @@ 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; @@ -141,6 +141,16 @@ private void OnTriggerExit(Collider other) } } + /// 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() { PutTop(false); diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs index 19c41d55..0fbff995 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/WaterButton.cs @@ -1,6 +1,6 @@ using UnityEngine; -public class WaterButton : MonoBehaviour +public class WaterButton : MonoBehaviour, IBreedingSite { [Header("Reference")] public AudioClip waterSplash; @@ -31,6 +31,13 @@ private void Start() 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(); From b2b0a4059dea0acfb3be1d261fe75fb260cc1073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Wed, 30 Sep 2026 18:07:41 +0700 Subject: [PATCH 21/24] Module 2: open breeding sites show wrigglers and a rising column of mosquitoes The swarm is sustained by the open sites, but nothing on screen said so. BreedingSiteFx, added by M2Manager to each site the round uses, puts a few pale wrigglers swimming on the water and a slow column of small mosquitoes rising from it. Clearing the site shrinks the wrigglers away and lets the last mosquitoes drift off; the component then removes itself. Built at runtime, so no scene or prefab changes: - Wrigglers are primitive meshes on two joints, one flat colour (Resources/Module2/M2_SiteLarva). - The mosquitoes are the game's own mosquito, baked once from the prefab's skinned mesh and scaled down, so they read as the same animal as the swarm. Falls back to dark specks (M2_SiteMote) if the prefab or bake is unavailable. - Water is found per site type (jar's flat disc, vase's Water child, the fish bowl's own mesh), and sizes follow the water's radius so a vase gets fewer, smaller ones. - M2Manager.showSiteFx turns it off. Mosquito_Female.fbx is now Read/Write so BakeMesh can be used in a player build; the editor does not need it, which is why this is worth checking on the headset. --- .../Module2/Scripts/GameManager/M2Manager.cs | 8 + .../Scripts/Water_contain/BreedingSiteFx.cs | 407 ++++++++++++++++++ .../Water_contain/BreedingSiteFx.cs.meta | 2 + Assets/Resources/Module2.meta | 8 + Assets/Resources/Module2/M2_SiteLarva.mat | 138 ++++++ .../Resources/Module2/M2_SiteLarva.mat.meta | 8 + Assets/Resources/Module2/M2_SiteMote.mat | 138 ++++++ Assets/Resources/Module2/M2_SiteMote.mat.meta | 8 + 8 files changed, 717 insertions(+) create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/BreedingSiteFx.cs create mode 100644 Assets/1.TeamWorkspace/Module2/Scripts/Water_contain/BreedingSiteFx.cs.meta create mode 100644 Assets/Resources/Module2.meta create mode 100644 Assets/Resources/Module2/M2_SiteLarva.mat create mode 100644 Assets/Resources/Module2/M2_SiteLarva.mat.meta create mode 100644 Assets/Resources/Module2/M2_SiteMote.mat create mode 100644 Assets/Resources/Module2/M2_SiteMote.mat.meta diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index 1bee33c3..9ffadab4 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -45,6 +45,8 @@ public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } 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.")] @@ -582,6 +584,9 @@ private void ChooseSiteSubset() 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) @@ -594,6 +599,9 @@ 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); } 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/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: From 6fc64073128f1c4657f8dec03db9f1d7f1e7d925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 2 Oct 2026 18:22:40 +0700 Subject: [PATCH 22/24] Module 2: stop the right stick flipping the player 180 degrees The rig's ContinuousTurnProvider had Enable Turn Around on, so any stick direction in the back quarter snapped the view round 180 degrees. A thumb drifting slightly down while turning flipped the player, which felt like turning could not complete a full circle. --- .../Module2/Prefabs/Player/VR_Player_Module_2.prefab | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 198a34cddf609cc01a91e5e76776aa122a80fd35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 2 Oct 2026 22:58:46 +0700 Subject: [PATCH 23/24] Module 2: the wrist menu stays open while you reach for a button The menu was shown only while a ray along the watch's up axis hit the 8 cm sphere on the camera or the 1 cm one on the other hand's fingertip. On a headset that left two ways in: aim the wrist to within ten degrees of the eyes, or touch the watch - and the second closed the menu the moment the finger left the ray to reach a button, so the swatter could not be picked. It now opens when the watch face is turned to within 35 degrees of the eyes (staying until 55) or when the other fingertip touches the watch, and stays open while that fingertip is within 20 cm of the menu, with a half-second linger so tracking jitter cannot make it flicker. --- .../Module2/Scripts/UI/UIHand.cs | 67 +++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs b/Assets/1.TeamWorkspace/Module2/Scripts/UI/UIHand.cs index 69d67d1b..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,8 +11,29 @@ 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() { // The watch is a child of the left hand, so DontDestroyOnLoad never applied (it only works on @@ -30,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() From 16f46e607eb9c073572df4e1cae27c7d695bf8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rom=C3=A9=20van=20Grinsven?= Date: Fri, 2 Oct 2026 22:58:46 +0700 Subject: [PATCH 24/24] Module 2: wrist-menu tools appear in front of the player, not the rig origin The swatter and the cream were spawned at a point fixed 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 and turned round, so the tool they had just asked for could appear behind them (0.9 m away and 178 degrees off for a player turned 150 degrees). The point is now moved to 40 cm ahead of and 30 cm below the eyes before each spawn. --- .../Module2/Scripts/GameManager/M2Manager.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs index 9ffadab4..ba349f0f 100644 --- a/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs +++ b/Assets/1.TeamWorkspace/Module2/Scripts/GameManager/M2Manager.cs @@ -16,6 +16,10 @@ public enum RoundEndReason { TimeOut, AllSitesCleared, BitesReached } 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 static readonly int Exposure = Shader.PropertyToID("_Exposure"); @@ -516,6 +520,7 @@ public void FlySwatterUi() } 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); @@ -529,11 +534,30 @@ public void CreamUi() 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)