diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ef206..7361c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added +- **Troubleshooting Tab**: Added a persistent **Enable verbose mode** option to the GAMA Panel, with common-issue guidance and quick access to the Unity Console. + +### Changed +- **Unified Logging**: Detailed diagnostics are now controlled by verbose mode instead of requiring a separate development branch; essential warnings, errors, and actionable UI messages remain enabled. +- **English Messages**: Standardized package-owned Console, debug, and UI messages in English. + ## [1.0.0-preview.1] - 2026-04-24 ### Changed @@ -18,4 +27,4 @@ All notable changes to this project will be documented in this file. ### Added - **Default Assets**: Added `LocalizationData.csv` and default materials. -- **Third Party Notices**: Documented the use of `NativeWebSocket`. \ No newline at end of file +- **Third Party Notices**: Documented the use of `NativeWebSocket`. diff --git a/Documentation~/09-troubleshooting.md b/Documentation~/09-troubleshooting.md new file mode 100644 index 0000000..c2b6359 --- /dev/null +++ b/Documentation~/09-troubleshooting.md @@ -0,0 +1,460 @@ +# Troubleshooting + +This page lists recurring problems encountered when connecting GAMA, +`simple.webplatform`, and Unity. + +Use it as a first diagnostic checklist before changing code. + +## Quick Checks + +Before investigating a specific symptom, check the following points: + +- GAMA is open and the target experiment is selected. +- The experiment used by Unity is the generated `vr_xp` experiment withe the Unity Plugin, not the + original GAMA experiment. +- `simple.webplatform` is running with `npm start`. +- The Unity package is up to date. + +## Port Reference + +`simple.webplatform` exposes several ports. They do not have the same role: + +```text +8000: web interface opened in the browser +8001: monitor WebSocket used by Unity to drive preview capture +8080: Unity player/runtime WebSocket used in Play Mode +1000: GAMA Server behind simple.webplatform +``` + +Seeing the web interface on `http://localhost:8000/` does not guarantee that +the monitor socket on `8001` or the Unity player socket on `8080` is running. + +## GAMA Model Was Not Converted To `vr_xp` + +The original GAMA experiment cannot be used directly by Unity. It must first be +converted into a Unity-compatible `vr_xp` experiment with the SIMPLE Unity +plugin for GAMA. + +If this step is skipped, Unity may connect to `simple.webplatform`, but it will +not receive the expected Unity geometry data. + +## Species Were Not Exported During `vr_xp` Conversion + +During the `vr_xp` conversion, each species that should appear in Unity must be +explicitly exported in the **Export species** step withe the Unity Plugin in GAMA. + +Do not simply click **Next** through the conversion wizard. Select each species +on the left and click **+** under **Aspect in Unity**. + +If Unity logs a JSON output with `names=0`, `pointsLoc=0`, or `pointsGeom=0`, +GAMA is probably sending an empty geometry payload. + +## Generate Preview Button Is Used Before GAMA Is Ready + +Before using **Generate Preview from GAMA**, the target experiment must be +selected or already open in GAMA. For the tutorial workflow, start the +`vr_xp` experiment in GAMA first, then generate the preview from Unity. + +If the experiment is not selected or not ready, Unity may connect to the +middleware but fail to capture useful geometry data. + +Steps **in this exact order**: + +1. Start `simple.webplatform` with `npm start`. +2. Open the `vr_xp` experiment in GAMA. +3. Then, and only then, click **Generate Preview from GAMA** in Unity. + +## Generate Preview Says That `localhost:8001` Is Not Responding + +Unity shows this kind of message: + +```text +The existing monitor ws://localhost:8001/ is not responding. +Launch simple.webplatform manually, then try again. +``` + +This means Unity cannot reach the `simple.webplatform` monitor WebSocket. The +browser page on `8000` may still be visible, but the backend service needed by +Unity is not responding on `8001`. + +Check that `simple.webplatform` was started with: + +```bash +npm start +``` + +Then check that the monitor and player sockets are listening. + +On macOS or Linux: + +```bash +lsof -nP -iTCP:8001 -sTCP:LISTEN +lsof -nP -iTCP:8080 -sTCP:LISTEN +``` + +On Windows: + +```powershell +netstat -ano | findstr :8001 +netstat -ano | findstr :8080 +``` + +If only port `8000` is open, the web interface is running but Unity cannot +generate the preview. Restart `simple.webplatform` and check the terminal for +backend errors. + +## Unity Connects But No Agents Appear In Play Mode + +If GAMA starts, Unity connects, but no simulation objects appear, check the +following points: + +- the experiment is the generated `vr_xp` experiment; +- species were exported during the `vr_xp` conversion; +- the generated model contains `add_geometries_to_send(...)`; +- `simple.webplatform` is listening on `8080`; +- the Unity Console does not show repeated socket close messages; +- the GAMA experiment is actually sending geometry updates. + +Useful Unity Console filters: + +```text +[GAMA][RUNTIME][CONNECTION] +[GAMA][CONNECTION] +[GAMA][RUNTIME][FLOW] +``` + +If the socket is open but the JSON output is empty, the problem is usually on +the GAMA model/export side, not on Unity rendering. + +## `unity_linker` Or Player Creation Fails + +Sometimes GAMA or `simple.webplatform` may report that the Unity linker/player +could not be created. This can happen when the middleware keeps stale state or +when the `vr_xp` experiment is incomplete. + +Fix: + +1. Stop the GAMA experiment. +2. Stop `simple.webplatform`. +3. Restart `simple.webplatform`. +4. Re-open the experiment in GAMA. +5. Enter Play Mode again in Unity. + +If the issue persists, inspect the generated GAML model: + +- `unity_linker_species` should point to the linker species. +- `create_player(string id)` should ask the `unity_linker` to create the player. +- `player_unity_properties` should not point to missing or invalid properties. +- the species to display should be sent with `add_geometries_to_send(...)`. + +## GAMA Reports A Nil Value Error + +A GAMA error such as `nil value detected` can break the Unity flow even if the +middleware and Unity are running. + +Check: + +- the generated `vr_xp` model compiles without errors; +- exported Unity properties are initialized before they are used; +- `player_unity_properties` does not contain an invalid property; +- the experiment was regenerated after changing exported species; +- GAMA and `simple.webplatform` were both restarted after a failed connection. + +If the error happens during `create_player`, regenerate the `vr_xp` experiment +and make sure the Unity linker and exported species are present. + +## No Preview Is Generated + +Check: + +- `simple.webplatform` is launched first and running; +- the experiment is then opened in GAMA (make sur to make it linkable by converting it with the Unity Plugin); +- the GAMA experiment was converted to `vr_xp`; +- the Unity player socket uses port `8080`; +- the monitor socket on `8001` is reachable; +- the GAMA model sends geometries through the Unity linker. + +If the preview still fails, enable **GAMA > GAMA Panel > Troubleshooting > +Enable verbose mode**, open the Unity Console, and search for: + +```text +[GAMA][CAPTURE] +[GAMA][PREVIEW] +[GAMA][CONNECTION] +``` + +## Preview Capture Is Cancelled Or Incomplete + +If the preview ends with a cancelled or incomplete capture, the most common +causes are: + +- the GAMA experiment was not ready when the capture started; +- the monitor socket on `8001` disconnected; +- the player socket on `8080` did not receive `json_output`; +- GAMA was still starting or compiling the experiment; +- an old middleware/player state was still active. + +Fix: + +1. Stop Play Mode in Unity. +2. Stop or reset the GAMA experiment. +3. Restart `simple.webplatform`. +4. Start the `vr_xp` experiment in GAMA. +5. Generate the preview again. + +If the second attempt works, the first capture most likely started before all +services were synchronized. + +## Preview Settings And Game Manager Settings Seem To Do The Same Thing + +The preview panel and the `Game Manager` Inspector edit the same species visual +override data. + +Changing values in the preview panel updates the preview scene and stores the +settings used later by Play Mode. Editing the same species in the `Game Manager` +Inspector changes the same kind of data. + +The **Validate Preview and Close Panel** button is mainly a workflow action: + +- it applies the current preview settings; +- it keeps the generated preview available in Edit Mode; +- it closes the preview panel; +- it makes the chosen settings ready for reuse in Play Mode. + +If the scene already updates while editing values, this is expected. The +validate button confirms and exits the preview workflow. + +## Colors Do Not Follow Attributes + +Unity can only use attributes that GAMA explicitly sends. + +Check: + +- the GAMA model sends the attribute in `add_geometries_to_send(...)`; +- Unity receives non-empty attributes; +- the species dynamic color override is enabled; +- the selected attribute name matches the GAMA attribute key exactly; +- discrete rules match the received values. + +For example, to expose a `food` attribute on `vegetation_cell`: + +```gaml +list grass_food <- vegetation_cell collect each.food; +map> grass_atts <- ["food":: grass_food]; + +do add_geometries_to_send(vegetation_cell, up_vegetation_cell, grass_atts); +``` + +If `food` is not sent by GAMA, it will not be available in Unity dynamic color +settings. + +## Discrete Dynamic Colors Cannot Be Tested On The Tutorial Model + +Discrete dynamic colors require an attribute with distinct values, for example a +state, status, type, or category. + +If the current model only exposes continuous numeric values such as `food`, use +the continuous dynamic color mode instead. + +To test discrete colors, first add or expose a categorical GAMA attribute, then +send it through `add_geometries_to_send(...)` and configure the discrete mapping +in Unity. + +## Prefab Does Not Change In Play Mode + +For Play Mode, the prefab must be loadable from a Unity `Resources` path. + +If a prefab is outside a `Resources` folder, Edit Mode preview may still use it, +but runtime loading can fall back to the default GAMA geometry. + +Move runtime prefabs under a `Resources` folder or use a prefab path that can be +resolved at runtime. + +## FPS Player Prefab Does Not Work + +The default FPS player prefab may not be configured for every project. This does +not prevent GAMA agents from being imported, but it can make navigation or +interaction unusable. + +Fix: + +- use the default scene setup only as a connection baseline; +- replace the FPS player with a project-specific player prefab if needed; +- check camera, input, colliders, and movement scripts before testing + interaction. + +## Scene Is Not Saved After Default Setup + +Unity does not always save the scene automatically after pressing **Default +Setup** in the GAMA Panel. + +Fix: + +1. Press **Default Setup**. +2. Check that the scene contains the required managers. +3. Save the scene manually with **File > Save**. + +If the scene is not saved, the required objects may disappear after reopening +the project. + +## Scale Looks Different Between Preview And Play Mode + +The scale multiplier is a visual override. It should change the size of each +agent visual without changing the logical spread of the species. + +If the species looks stretched or contracted as a whole: + +- check that the latest Unity package version is installed; +- regenerate the preview; +- reset the affected species override; +- verify that the scale is applied to agent visuals, not to the common species + parent object. + +Cell-like background species and moving agent species can have very different +geometry shapes, so compare the result in both Edit Mode preview and Play Mode. + +## Geometry Mesh Needs Height Instead Of Scale + +When no Unity prefab is assigned, Unity uses the geometry mesh sent by GAMA. In +that case, changing a global visual scale may not be the best control for flat +or background geometries. + +Workaround: + +- keep the scale multiplier close to `1` for background meshes; +- adjust the geometry height/depth in the GAMA Unity aspect when possible; +- use prefabs when per-agent visual control is required. + +This is a known usability limitation: a dedicated height control for GAMA +geometry meshes would be clearer than a generic scale control. + +## Play Mode Start Or Stop Is Slow + +Starting or stopping Play Mode may take several seconds on large scenes or when +the connection stack is restarting. + +Possible causes: + +- WebSocket initialization or shutdown; +- large numbers of runtime agents; +- repeated console logging; +- Unity recompilation or scene reload; +- `simple.webplatform` reconnecting or purging old players. + +Fix: + +- keep **GAMA > GAMA Panel > Troubleshooting > Enable verbose mode** disabled + during normal use; +- enable verbose mode only while collecting detailed diagnostics; +- keep the Unity Console collapsed when testing large simulations; +- restart `simple.webplatform` if old sockets remain open. + +## Runtime Appearance Changes Cause Performance Spikes + +Changing colors, scale, visibility, or prefab overrides during Play Mode can +trigger updates on many agents at once. + +This is expected to be more expensive on large simulations. + +Workaround: + +- configure species appearance in Edit Mode preview when possible; +- avoid repeatedly changing overrides while thousands of agents are active; +- keep global verbose mode disabled during performance testing; +- test large visual changes on a smaller model first. + +## Editor Becomes Slow When Editing Game Manager Values + +In Edit Mode, changing `Game Manager` Inspector values can immediately update +the preview. On large previews, this can cause editor stutter. + +Workaround: + +- prefer the GAMA Panel preview workflow for common appearance changes; +- avoid dragging sliders continuously on very large scenes; +- type numeric values directly instead of scrubbing controls; +- hide or reduce the number of visible species while configuring one species. + +A future improvement would be a manual **Apply** button for expensive preview +updates instead of applying every editor value change immediately. + +## Simulation Stops When Unity Loses Focus + +If Unity is not the focused application during runtime, the connection can slow +down, pause, or be closed depending on the platform and middleware state. + +Workaround: + +- keep the Unity window focused while testing Play Mode; +- avoid switching to another application during connection-sensitive tests; +- if the socket closes, stop Play Mode, restart `simple.webplatform`, and run + the experiment again. + +## Agents Freeze Or Accumulate + +For dynamic species, Unity should receive complete enough updates to know which +agents still exist. + +If agents accumulate or freeze: + +- check that GAMA is still running; +- check that Unity is still connected to `simple.webplatform`; +- verify that the runtime socket on `8080` remains open; +- make sure the model sends regular geometry updates for dynamic species. + +Static or background species should not be removed just because they are absent +from a dynamic tick. + +## Background Geometry Is Not Explicitly Defined During GAMA Conversion + +The GAMA Unity conversion wizard currently exports species and aspects, but it +does not always make the distinction between dynamic agents and background +geometry obvious. + +Workaround: + +- export background species separately; +- configure their appearance in Unity preview; +- keep background species visible and stable; +- hide or reduce dynamic species while configuring the background. + +For road/building/static geometry workflows, it is useful to keep those species +as separate exported entries so their Unity appearance can be controlled +independently. + +## Background Geometry Changes Seem To Be Applied Repeatedly + +If background geometry or static species appear to be modified continuously, +check whether GAMA is resending the same static geometry every cycle. + +Workaround: + +- keep background species separate from dynamic species; +- avoid sending unnecessary changing attributes for background geometry; +- configure background appearance once in Unity preview; +- hide dynamic species temporarily while checking the background; +- if possible, send static/background geometry less frequently than dynamic + agents. + +A future improvement on the GAMA conversion side would be an explicit +background-geometry option and a way to avoid repeatedly applying unchanged +background visual data. + +## Too Many Debug Logs In Unity + +Very frequent `Debug.Log` calls can slow down Unity, especially when thousands +of agents are updated. + +Open **GAMA > GAMA Panel > Troubleshooting**. Keep **Enable verbose mode** +disabled for normal use. Enable it temporarily when detailed preview, +middleware, connection, or runtime diagnostics are needed, then disable it +after collecting the relevant Console output. + +Verbose mode is a global package setting and is disabled by default. Disabling +it does not hide essential warnings, errors, or actionable messages shown in +the GAMA Panel and Unity dialogs. + +The Simulation Manager Inspector also contains a per-scene **Advanced Debug** +section. Those fields select specific diagnostics, such as streaming or agent +update statistics. They are separate from the global setting and their verbose +Console output is emitted only while **Enable verbose mode** is also enabled. diff --git a/Documentation~/README.md b/Documentation~/README.md index c7a50e1..1fe6ea8 100644 --- a/Documentation~/README.md +++ b/Documentation~/README.md @@ -15,12 +15,14 @@ documentation. - [4. Generate and configure the Unity preview](tutorial/04-generate-preview.md) - [5. Dynamic colors from GAMA attributes](tutorial/05-dynamic-colors.md) +## Troubleshooting +- [Troubleshooting](09-troubleshooting.md) + ## Work in Progress - [Work in progress overview](work-in-progress/README.md) - [6. Configure species appearance](work-in-progress/06-configure-species.md) - [7. Apply preview settings in Play Mode](work-in-progress/07-apply-preview-settings.md) - [8. Optimize large simulations](work-in-progress/08-large-models-performance.md) -- [9. Troubleshooting](work-in-progress/09-troubleshooting.md) - [User guide](user-guide/README.md) - [Technical documentation](technical/README.md) - [Runtime architecture notes](gama-unity.md) diff --git a/Documentation~/images/tutorial/02-vr-generation-export-species-add.png b/Documentation~/images/tutorial/02-vr-generation-export-species-add.png new file mode 100644 index 0000000..f71ff6b Binary files /dev/null and b/Documentation~/images/tutorial/02-vr-generation-export-species-add.png differ diff --git a/Documentation~/images/tutorial/02-vr-generation-export-species-final.png b/Documentation~/images/tutorial/02-vr-generation-export-species-final.png new file mode 100644 index 0000000..3d985dc Binary files /dev/null and b/Documentation~/images/tutorial/02-vr-generation-export-species-final.png differ diff --git a/Documentation~/images/tutorial/02-vr-generation-general-parameters.png b/Documentation~/images/tutorial/02-vr-generation-general-parameters.png new file mode 100644 index 0000000..fe27591 Binary files /dev/null and b/Documentation~/images/tutorial/02-vr-generation-general-parameters.png differ diff --git a/Documentation~/images/tutorial/02-vr-generation-property-name.png b/Documentation~/images/tutorial/02-vr-generation-property-name.png new file mode 100644 index 0000000..fe40738 Binary files /dev/null and b/Documentation~/images/tutorial/02-vr-generation-property-name.png differ diff --git a/Documentation~/tutorial/00-gama-model-preparation.md b/Documentation~/tutorial/00-gama-model-preparation.md index f9a3c8b..2280c1d 100644 --- a/Documentation~/tutorial/00-gama-model-preparation.md +++ b/Documentation~/tutorial/00-gama-model-preparation.md @@ -5,14 +5,16 @@ render the experiment. ## 0.1 GAMA Requirements -Open your target VR-model in GAMA in the configuration of the screenshot below (ready to run). For explanations on how to install the Simple Unity Plugin, follow [this link](https://doc.project-simple.eu/gama/installation). +This tutorial requires a GAMA experiment converted into a Unity-compatible **vr_xp** experiment. This conversion is done with the **SIMPLE plugin**. + +Before continuing, make sure the **SIMPLE plugin** is installed in GAMA. Installation instructions are available [here](https://doc.project-simple.eu/gama/installation). + > [!WARNING] -> Don't forget to convert the experiment in "vr_xp", otherwise it will not work!!! -> -> This step is explained [in this tutorial](https://doc.project-simple.eu/tutorials/Tutorial-Step-1) +> Without the SIMPLE Unity plugin, you will not be able to convert the experiment to **vr_xp**, and Unity will not receive the simulation data correctly. + ![Open a GAMA experiment](../images/tutorial/02-open-gama-experiment.png) -_Exemple of an opened experiment in "Library models\Tutorials\Predator Prey\models"_ +_Example of the experiment that will be used throughout this tutorial_ ## 0.2 Middleware Requirements diff --git a/Documentation~/tutorial/02-first-experiment-launching.md b/Documentation~/tutorial/02-first-experiment-launching.md index 6473974..ce65f5c 100644 --- a/Documentation~/tutorial/02-first-experiment-launching.md +++ b/Documentation~/tutorial/02-first-experiment-launching.md @@ -1,9 +1,9 @@ # 2. Run the GAMA Experiment in Play Mode -It is time to run the first GAMA experiment in Unity with this package. In this -tutorial, use the 6th **Prey Predator** model located in the following hierarchy from GAMA. +It is time to run the first GAMA experiment in Unity with this package. For this +tutorial, use the **6th prey Predator** model located in the following hierarchy from GAMA. -![Prey Predator 7 model location](../images/tutorial/02-prey-predator-7-location.png) +![Prey Predator 6 model location](../images/tutorial/02-prey-predator-7-location.png) This experiment is used throughout the rest of the tutorial because it covers the main features provided by the package: static background species, dynamic agents, @@ -13,11 +13,53 @@ This chapter validates the baseline live workflow: Unity enters Play Mode, connects to `simple.webplatform`, receives the running GAMA simulation, and creates Unity objects from the GAMA agents. -## 2.1 Steps +> [!WARNING] +> The original GAMA experiment cannot be used directly in Unity. It must first be converted into a `vr_xp` experiment with the SIMPLE Unity plugin. + +## 2.1 Convert the GAMA Experiment to `vr_xp` + +Before running the model in Unity, convert the GAMA experiment with the SIMPLE Unity plugin. During this conversion, each species that should appear in Unity must be explicitly exported. + +On the first **Definition of the VR experiment** screen, keep the default values and click **Next**. + +![VR experiment general parameters](../images/tutorial/02-vr-generation-general-parameters.png) + +On the **Export species** screen, do not immediately click **Next**. Select a species on the left, then click the **+** button under **Aspect in Unity**. + +![Export species add one by one](../images/tutorial/02-vr-generation-export-species-add.png) + +Keep the default property name and click **OK**. + +![Keep default Unity property name](../images/tutorial/02-vr-generation-property-name.png) + +Repeat this for each species that must be visible in Unity, for example: + +- `prey` +- `predator` +- `vegetation_cell` + +At the end, these species should be marked as exported. + +![Final exported species selection](../images/tutorial/02-vr-generation-export-species-final.png) + +The `generic_species` entry is abstract and is not required for the visual result. Exporting it is harmless, but it can also be ignored. + +> [!IMPORTANT] +> If the species are not added on the **Export species** screen, the experiment may still start in GAMA and the Unity player may be created, but no simulation agents will appear in Unity. + +After this, click **Next**, keep the default values, set the number of players between `0` and `1`, then click **Finish**. + +For more details, this step is explained [in this tutorial](https://doc.project-simple.eu/tutorials/Tutorial-Step-1) + +## 2.2 Steps + +> [!WARNING] +> These steps must be followed **exactly in the order shown below**. +> If you change the order, the Unity connection may fail!!! 1. Make sure the scene was prepared with **GAMA > GAMA Panel > Setup Scene**. 2. Start `simple.webplatform` with `npm start` -3. Open and run the **Prey Predator 7** experiment in GAMA. +3. Open and run the **vr_xp** version of **Prey Predator 6** in GAMA. ![Windows Overview](../images/tutorial/02-windows-overview-gama-unity.png) 4. Press **Play** in Unity. @@ -35,10 +77,10 @@ while the experiment is running. ![Runtime live overview](../images/tutorial/02-runtime-live-overview.png) -## 2.2 Expected Result +## 2.3 Expected Result During Play Mode, Unity should connect to `simple.webplatform` and create live -Unity objects from the agents received from the **Prey Predator 7** model. +Unity objects from the agents received from the **Prey Predator 6** model. The imported agents are grouped by species in the Unity hierarchy. @@ -47,7 +89,7 @@ The imported agents are grouped by species in the Unity hierarchy. At this stage, the important result is that the connection works and that GAMA agents are imported into Unity while the experiment is running. -## 2.3 Into the Next Step +## 2.4 Into the Next Step This is already useful: we now have a functional connection between GAMA, `simple.webplatform`, and Unity. The preys, predators and vegetation cells agents are imported and diff --git a/Documentation~/tutorial/05-dynamic-colors.md b/Documentation~/tutorial/05-dynamic-colors.md index 9a417f9..f821225 100644 --- a/Documentation~/tutorial/05-dynamic-colors.md +++ b/Documentation~/tutorial/05-dynamic-colors.md @@ -9,7 +9,7 @@ the `food` attribute of each `vegetation_cell`: instead of showing every grass cell with the same green, Unity can use a more or less intense green depending on the `food` value, like in the GAMA display. -## Attribute Requirements +## 5.1 Attribute Requirements The GAMA model must send the attribute in `add_geometries_to_send(...)`. @@ -33,20 +33,40 @@ map> prey_atts <- ["energy":: prey_energy]; do add_geometries_to_send(prey, up_prey, prey_atts); ``` -## Continuous Example: Vegetation Food +## 5.2 Continuous Example: Vegetation Food -Use this mode when an attribute is numeric and should produce a gradual visual -change. +### 5.2.1 Attributes Sent By GAMA +Unity can only use dynamic color attributes that are sent by GAMA through the websocket. +In this experiment, the `send_geometries` reflex sends the agents and geometries to Unity. By default, `vegetation_cell` is sent without its `food` attribute: +```gaml +do add_geometries_to_send(vegetation_cell, up_vegetation_cell); +``` +To make `food` available in Unity, collect the `food` values and pass them as the third argument of `add_geometries_to_send`: +```gaml +list grass_food <- vegetation_cell collect each.food; +map> grass_atts <- ["food":: grass_food]; +do add_geometries_to_send(vegetation_cell, up_vegetation_cell, grass_atts); +``` +The complete reflex should look like this: +```gaml +reflex send_geometries { + list grass_food <- vegetation_cell collect each.food; + map> grass_atts <- ["food":: grass_food]; + do add_geometries_to_send(prey, up_prey); + do add_geometries_to_send(predator, up_predator); + do add_geometries_to_send(vegetation_cell, up_vegetation_cell, grass_atts); + do add_geometries_to_send(generic_species, up_generic_species); +} +``` +> [!NOTE] +> The key `food` is the attribute name that will be used later in Unity. This attribute must already exist on the GAMA `vegetation_cell` agents. -In this example, the `vegetation_cell` species receives a numeric `food` -attribute. The goal is: -```text -low food -> lighter/darker green -high food -> stronger green -``` +### 5.2.2 Using The Attribute In Unity + +After generating the preview, Unity can use the `food` attribute sent by GAMA through the websocket. -Steps: +In this tutorial, to display the numeric attribute `food`, you should follow the next steps in the Edit mode of a preview : 1. Select the `Game Manager`. 2. In the Inspector, find `vegetation_cell`. @@ -62,6 +82,16 @@ The numbered close-up below shows the important controls: ![Dynamic food color settings legend](../images/tutorial/05-dynamic-color-food-settings-legend.png) +In this example, the `vegetation_cell` species receives a numeric `food` attribute. The goal is: + +```text +low food -> lighter/darker green +high food -> stronger green +``` + +> [!NOTE] +> Unity can only use attributes that were explicitly sent by GAMA. If `food` is not included in `add_geometries_to_send`, it will not be available in the Unity dynamic color settings. + 1. Open the **Dynamic Color** foldout. 2. Enable the override. 3. Choose **Continuous** mode. @@ -85,7 +115,7 @@ stronger than high values. ![Food dynamic color with invert enabled](../images/tutorial/05-dynamic-color-preview-food-result.png) -## Discrete Colors For States +## 5.3 Discrete Colors For States For attributes that represent a small set of states, use **Discrete** mode instead. This is useful for experiments with states such as: @@ -102,7 +132,7 @@ state = recovered -> green state = dead -> black ``` -## Runtime Behavior +## 5.4 Runtime Behavior Dynamic colors are applied per agent when Unity receives GAMA attributes. @@ -113,7 +143,7 @@ They do not replace the static preview workflow: - if the attribute is missing or cannot be parsed, Unity keeps the static/GAMA color instead of crashing. -## Result +## 5.4 Result At the end of this chapter, Unity should be able to show both static species settings and per-agent attribute variations, such as vegetation cells becoming diff --git a/Documentation~/work-in-progress/09-troubleshooting.md b/Documentation~/work-in-progress/09-troubleshooting.md deleted file mode 100644 index 2d18709..0000000 --- a/Documentation~/work-in-progress/09-troubleshooting.md +++ /dev/null @@ -1,84 +0,0 @@ -# 9. Troubleshooting - -This chapter lists common problems and the first checks to perform. - -## No Preview Is Generated - -Check: - -- `simple.webplatform` is running; -- GAMA is running; -- the experiment is open or selected; -- the Unity middleware port is `8080`; -- the model sends geometries through the Unity linker. - -> Screenshot to add: GAMA Panel state when preview generation fails. - -> Screenshot to add: middleware terminal or console showing whether -> `simple.webplatform` is running. - -## Runtime Agents Do Not Appear - -Check the Unity console for: - -```text -[GAMA][RUNTIME][CONNECTION] -[GAMA][CONNECTION] -[GAMA][RUNTIME][FLOW] -``` - -If Unity logs that the socket is not open, verify the middleware and connection -settings. - -> Screenshot to add: Console filtered on `[GAMA][RUNTIME][CONNECTION]` and -> `[GAMA][CONNECTION]`. - -> Screenshot to add: `Connection Manager` inspector with host and ports. - -## Colors Do Not Follow Attributes - -Check: - -- the GAMA model sends the attribute in `add_geometries_to_send(...)`; -- Unity receives non-empty attributes; -- the species dynamic color mode is enabled; -- the selected attribute name matches the GAMA attribute key exactly; -- discrete rules match the received values. - -> Screenshot to add: Dynamic Color setup next to a console log proving whether -> attributes were received. - -## Prefab Does Not Change In Play Mode - -For Play Mode, the prefab should be loadable from a Unity `Resources` path. - -If a prefab is outside `Resources`, Edit Mode preview can use it, but runtime -loading may fallback and log a warning. - -> Screenshot to add: prefab outside `Resources` warning in the console. - -## Scale Is Too Large In Play Mode - -Check: - -- the scale multiplier is not applied twice; -- the species override asset contains the expected context-specific entry; -- cell-like species keep their logical parent at scale `(1, 1, 1)`; -- the visual child receives the visual scale. - -> Screenshot to add: inspector showing scale fields in the `Game Manager`. - -> Screenshot to add: hierarchy showing logical parent scale and visual child -> scale. - -## Agents Freeze Or Accumulate - -For dynamic species, check that the live updates are complete or cumulative. -Unity should remove dynamic agents only after a complete update confirms they are -missing. - -Static/background species should not be pruned just because they are absent from -a dynamic tick. - -> Optional GIF to add: predator/prey or dynamic agents disappearing and -> appearing correctly during Play Mode. diff --git a/Documentation~/work-in-progress/README.md b/Documentation~/work-in-progress/README.md index e37b2f7..0e95d62 100644 --- a/Documentation~/work-in-progress/README.md +++ b/Documentation~/work-in-progress/README.md @@ -5,4 +5,3 @@ These tutorial chapters are not part of the stable tutorial flow yet. - [6. Configure species appearance](06-configure-species.md) - [7. Apply preview settings in Play Mode](07-apply-preview-settings.md) - [8. Optimize large simulations](08-large-models-performance.md) -- [9. Troubleshooting](09-troubleshooting.md) diff --git a/Editor/GAMAMenu.cs b/Editor/GAMAMenu.cs index 582dcee..eb79fc5 100644 --- a/Editor/GAMAMenu.cs +++ b/Editor/GAMAMenu.cs @@ -57,7 +57,7 @@ public static void ConfigureVrProjectSettings() EnsureRequiredTags(); EnsureVrReadyProjectSettings(); AssetDatabase.SaveAssets(); - Debug.Log("[GAMA] VR project settings configured."); + GamaLog.Dev("[GAMA] VR project settings configured."); } private static void SetupSceneCore(bool configureEditorSimulator, bool configureVrProjectSettings) @@ -88,7 +88,7 @@ private static void SetupSceneCore(bool configureEditorSimulator, bool configure string mode = configureVrProjectSettings ? (configureEditorSimulator ? "VR simulator" : "headset-ready") : "desktop/no-XR"; - Debug.Log("[GAMA] Scene setup complete (" + mode + "). Removed " + removedRootObjects + " previous root object(s)."); + GamaLog.Info("[GAMA] Scene setup complete (" + mode + ")."); } private static void EnsureVrReadyProjectSettings() @@ -104,7 +104,7 @@ private static void EnsureVrReadyProjectSettings() if (!configuredAnyTarget) { - Debug.LogWarning("[GAMA] OpenXR project setup was skipped because the OpenXR/XR Management editor APIs are not available yet. Unity should install package dependencies first, then run GAMA > Setup Scene again."); + GamaLog.Warning("[GAMA] OpenXR project setup was skipped because the OpenXR/XR Management editor APIs are not available yet. Unity should install package dependencies first, then run GAMA > Setup Scene again."); } } @@ -167,11 +167,11 @@ private static void EnsureInputSystemEnabled() } activeInputHandling.SetValue(null, targetValue, null); - Debug.Log("[GAMA] Enabled Unity Input System support for VR input."); + GamaLog.Dev("[GAMA] Enabled Unity Input System support for VR input."); } catch (Exception exception) { - Debug.LogWarning("[GAMA] Could not update the active input handling setting: " + exception.GetBaseException().Message); + GamaLog.Warning("[GAMA] Could not update the active input handling setting: " + exception.GetBaseException().Message); } } @@ -241,18 +241,18 @@ private static bool TryConfigureOpenXR(BuildTargetGroup buildTargetGroup) if (loaderAssigned) { - Debug.Log("[GAMA] OpenXR enabled for " + buildTargetGroup + " and set to initialize on startup."); + GamaLog.Dev("[GAMA] OpenXR enabled for " + buildTargetGroup + " and set to initialize on startup."); } else { - Debug.LogWarning("[GAMA] OpenXR project settings were created for " + buildTargetGroup + ", but assigning the OpenXR loader failed. Check Project Settings > XR Plug-in Management > OpenXR."); + GamaLog.Warning("[GAMA] OpenXR project settings were created for " + buildTargetGroup + ", but assigning the OpenXR loader failed. Check Project Settings > XR Plug-in Management > OpenXR."); } return loaderAssigned || openXrSettingsConfigured; } catch (Exception exception) { - Debug.LogWarning("[GAMA] OpenXR setup failed for " + buildTargetGroup + ": " + exception.GetBaseException().Message); + GamaLog.Warning("[GAMA] OpenXR setup failed for " + buildTargetGroup + ": " + exception.GetBaseException().Message); return false; } } @@ -332,7 +332,7 @@ private static bool ConfigureOpenXRFeatures(BuildTargetGroup buildTargetGroup) if (changed) { - Debug.Log("[GAMA] Enabled default OpenXR interaction profiles for " + buildTargetGroup + "."); + GamaLog.Dev("[GAMA] Enabled default OpenXR interaction profiles for " + buildTargetGroup + "."); } return true; @@ -444,14 +444,14 @@ private static void EnsureEditorVrSimulator() if (string.IsNullOrEmpty(prefabPath)) { - Debug.LogWarning("[GAMA] XR Device Simulator sample was not found in the project. Import XR Interaction Toolkit > Samples > XR Device Simulator, then run GAMA > Setup Scene (VR Simulator) again."); + GamaLog.Warning("[GAMA] XR Device Simulator sample was not found in the project. Import XR Interaction Toolkit > Samples > XR Device Simulator, then run GAMA > Setup Scene (VR Simulator) again."); return; } GameObject simulatorPrefab = AssetDatabase.LoadAssetAtPath(prefabPath); if (simulatorPrefab == null) { - Debug.LogWarning("[GAMA] XR Device Simulator prefab could not be loaded from " + prefabPath + "."); + GamaLog.Warning("[GAMA] XR Device Simulator prefab could not be loaded from " + prefabPath + "."); return; } @@ -460,7 +460,7 @@ private static void EnsureEditorVrSimulator() { simulatorInstance.name = simulatorPrefab.name; EnsureEventSystemExists(); - Debug.Log("[GAMA] Added XR Device Simulator from imported XR Interaction Toolkit samples."); + GamaLog.Dev("[GAMA] Added XR Device Simulator from imported XR Interaction Toolkit samples."); } } @@ -605,7 +605,7 @@ private static void RemoveMissingScriptsFromScene() if (removed > 0) { - Debug.Log("[GAMA] Removed " + removed + " obsolete missing script component(s) from the scene."); + GamaLog.Dev("[GAMA] Removed " + removed + " obsolete missing script component(s) from the scene."); } } @@ -616,7 +616,7 @@ private static void EnsureRequiredTags() UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/TagManager.asset"); if (assets == null || assets.Length == 0) { - Debug.LogWarning("[GAMA] Could not open TagManager.asset; tags were not created."); + GamaLog.Warning("[GAMA] Could not open TagManager.asset; tags were not created."); return; } @@ -624,7 +624,7 @@ private static void EnsureRequiredTags() SerializedProperty tags = tagManager.FindProperty("tags"); if (tags == null) { - Debug.LogWarning("[GAMA] Could not find the tags list in TagManager.asset."); + GamaLog.Warning("[GAMA] Could not find the tags list in TagManager.asset."); return; } diff --git a/Editor/GAMAPrefabImporter.cs b/Editor/GAMAPrefabImporter.cs index fd75c22..929fcc5 100644 --- a/Editor/GAMAPrefabImporter.cs +++ b/Editor/GAMAPrefabImporter.cs @@ -69,7 +69,7 @@ private void ImportPrefabs() } catch (System.Exception e) { - Debug.LogError($"[GAMA] Error importing prefabs: {e.Message}"); + GamaLog.Error($"[GAMA] Error importing prefabs: {e.Message}"); EditorUtility.DisplayDialog("Error", $"Failed to import prefabs. See console for details.\n\n{e.Message}", "OK"); } } diff --git a/Editor/GamaEditorBackgroundProcess.cs b/Editor/GamaEditorBackgroundProcess.cs index d8e10cc..fdff843 100644 --- a/Editor/GamaEditorBackgroundProcess.cs +++ b/Editor/GamaEditorBackgroundProcess.cs @@ -6,9 +6,9 @@ using System.Threading.Tasks; /// -/// Wrapper non bloquant autour d'un : démarrage via cmd /c "script.bat", -/// capture asynchrone de stdout/stderr, kill propre. Utilisé pour lancer GAMA et/ou le middleware -/// en arrière-plan pendant que l'éditeur écoute la websocket. +/// Non-blocking wrapper around a : starts it through cmd /c "script.bat", +/// captures stdout/stderr asynchronously, and terminates it cleanly. Used to run GAMA and/or the +/// middleware in the background while the editor listens to the WebSocket. /// internal sealed class GamaEditorBackgroundProcess : IDisposable { @@ -70,7 +70,7 @@ public static GamaEditorBackgroundProcess StartCmdScript( error = null; if (string.IsNullOrWhiteSpace(scriptPath) || !File.Exists(scriptPath)) { - error = "Script introuvable : " + scriptPath; + error = "Script not found: " + scriptPath; return null; } @@ -108,7 +108,7 @@ public static GamaEditorBackgroundProcess StartCmdScript( { if (!p.Start()) { - error = "Process.Start a retourné false."; + error = "Process.Start returned false."; return null; } @@ -119,7 +119,7 @@ public static GamaEditorBackgroundProcess StartCmdScript( } catch (Exception ex) { - error = "Échec du démarrage : " + ex.Message; + error = "Failed to start process: " + ex.Message; try { p.Dispose(); } catch { /* ignore */ } return null; } @@ -137,7 +137,7 @@ public static GamaEditorBackgroundProcess StartCommand( error = null; if (string.IsNullOrWhiteSpace(fileName)) { - error = "Commande vide."; + error = "Command is empty."; return null; } @@ -175,7 +175,7 @@ public static GamaEditorBackgroundProcess StartCommand( { if (!p.Start()) { - error = "Process.Start a retourné false."; + error = "Process.Start returned false."; return null; } @@ -186,7 +186,7 @@ public static GamaEditorBackgroundProcess StartCommand( } catch (Exception ex) { - error = "Échec du démarrage : " + ex.Message; + error = "Failed to start process: " + ex.Message; try { p.Dispose(); } catch { /* ignore */ } return null; } diff --git a/Editor/GamaEditorFirstTickCapture.cs b/Editor/GamaEditorFirstTickCapture.cs index a2218c2..1ae1a1f 100644 --- a/Editor/GamaEditorFirstTickCapture.cs +++ b/Editor/GamaEditorFirstTickCapture.cs @@ -11,23 +11,23 @@ using UnityEngine; /// -/// Capture un état d'aperçu GAMA stabilisé via le middleware websocket -/// (simple.webplatform, ws://host:port/), comme en Play : -/// handshake connection, attente json_state (authentifié), puis send_init_data -/// et player_ready_to_receive_geometries, puis collecte multi-ticks jusqu'à apparition -/// d'agents dynamiques ou fin de la fenêtre d'aperçu. -/// L'éditeur reste asynchrone : renvoie une Task. +/// Captures a stabilized GAMA preview state through the middleware WebSocket +/// (simple.webplatform, ws://host:port/), following the same route as in Play Mode: +/// sends the connection handshake, waits for authenticated json_state, then sends +/// send_init_data and player_ready_to_receive_geometries. It collects multiple ticks +/// until dynamic agents appear or the preview window ends. +/// The editor remains asynchronous: returns a Task. /// internal static class GamaEditorFirstTickCapture { - /// Même cible que (champ privé AgentToSendInfo). + /// Uses the same target as (private AgentToSendInfo field). private const string UnityLinkerAgent = "simulation[0].unity_linker[0]"; private static readonly bool VerboseCaptureDebug = true; /// - /// Preview « Piloté par Unity » : dès json_state in_game=true, envoi immédiat send_init_data + player_ready - /// (sans attendre create_player GAMA ni port 1000). + /// Unity-managed preview: as soon as json_state reports in_game=true, immediately send send_init_data + player_ready + /// (without waiting for GAMA create_player or port 1000). /// private const bool EDITOR_PREVIEW_IMMEDIATE_INIT_BURST = true; @@ -39,12 +39,12 @@ private static int NextCaptureSessionId() return Interlocked.Increment(ref s_captureSessionCounter); } - /// Journalisation toujours visible (console Unity + trail capture). + /// Logging that is always visible (Unity Console + capture trail). private static void CapLog(Action append, int sessionId, string channel, string message) { string line = "[GAMA][CAPTURE][#" + sessionId + "][" + channel + "] " + message; append(line); - Debug.Log(line); + GamaLog.Dev(line); } private static void CapLog8080(Action append, string direction, string message) @@ -106,12 +106,12 @@ private static async Task PreCapturePurgeOccupyingPlayersAsync( CancellationToken ct) { CapLog(append, sessionId, "pre", - "Purge préventive middleware ws://" + (string.IsNullOrWhiteSpace(host) ? "localhost" : host.Trim()) + ":" + - (string.IsNullOrWhiteSpace(port) ? "8080" : port.Trim()) + "/ avant capture id=\"" + capturePlayerId + "\""); + "Preventive middleware purge ws://" + (string.IsNullOrWhiteSpace(host) ? "localhost" : host.Trim()) + ":" + + (string.IsNullOrWhiteSpace(port) ? "8080" : port.Trim()) + "/ before capture id=\"" + capturePlayerId + "\""); foreach (string ghostId in CollectGhostPlayerIdsToPurge(capturePlayerId)) { - CapLog(append, sessionId, "pre", "→ fermeture propre id=\"" + ghostId + "\"…"); + CapLog(append, sessionId, "pre", "-> clean shutdown id=\"" + ghostId + "\"..."); string outcome; try { @@ -119,7 +119,7 @@ private static async Task PreCapturePurgeOccupyingPlayersAsync( } catch (Exception ex) { - outcome = "Exception purge \"" + ghostId + "\" : " + ex.Message; + outcome = "Purge exception for \"" + ghostId + "\": " + ex.Message; } CapLog(append, sessionId, "pre", outcome); @@ -133,7 +133,7 @@ private static async Task PreCapturePurgeOccupyingPlayersAsync( } } - CapLog(append, sessionId, "pre", "Purge préventive terminée."); + CapLog(append, sessionId, "pre", "Preventive purge completed."); } private static void DbgRaw(Action append, int sessionId, string channel, string text) @@ -197,6 +197,7 @@ public sealed class CaptureResult public int BestWorldTickIndex; public string BestWorldJsonPath; public bool DynamicAgentsFound; + public string ExperimentId = string.Empty; public string PreviewWarning; public string LogTrail; } @@ -355,7 +356,7 @@ public static async Task CaptureAsync( string effectiveIdEarly = ResolveMiddlewarePlayerId(connectionId); CapLog(append, sessionId, "capture", - "DEBUT uri=ws://" + hostNorm + ":" + portNorm + "/" + + "START uri=ws://" + hostNorm + ":" + portNorm + "/" + " direct=" + directGamaServer + " playerId=" + effectiveIdEarly + " runtimeId=" + StaticInformation.getId() + @@ -403,7 +404,7 @@ public static async Task CaptureAsync( return result; } - append(directGamaServer ? "[GAMA] Connexion directe au serveur GAMA " + uri : "[GAMA] Connexion au middleware " + uri); + append(directGamaServer ? "[GAMA] Connecting directly to the GAMA server " + uri : "[GAMA] Connecting to the middleware " + uri); using (CancellationTokenSource captureCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken)) { @@ -416,8 +417,8 @@ public static async Task CaptureAsync( return result; } - append("[GAMA] Mode piloté par Unity : orchestration monitor ws://" + hostNorm + ":" + monitorPort + - "/ puis capture joueur " + uri + ". Cible forcée: model=\"" + directModelPath + + append("[GAMA] Unity-managed mode: monitor orchestration ws://" + hostNorm + ":" + monitorPort + + "/ followed by player capture " + uri + ". Forced target: model=\"" + directModelPath + "\" experiment=\"" + directExperimentName + "\"."); GamaEditorMiddlewareOrchestrator.ManagedExperimentResult orch = await GamaEditorMiddlewareOrchestrator.StartMiddlewareManagedExperimentAsync( @@ -434,11 +435,16 @@ await GamaEditorMiddlewareOrchestrator.StartMiddlewareManagedExperimentAsync( result.LogTrail = logBuilder + orch.LogTrail; return result; } + if (!string.IsNullOrWhiteSpace(orch.ExperimentId) && + !string.Equals(orch.ExperimentId.Trim(), "0", StringComparison.Ordinal)) + { + result.ExperimentId = orch.ExperimentId.Trim(); + } } else if (managedFromUnity && !directGamaServer) { CapLog8080(append, "INFO", "EXTERNAL MIDDLEWARE MODE — no kill, no restart"); - append("[GAMA] Mode piloté par Unity : middleware externe déjà lancé, aucune orchestration monitor/catalogue."); + append("[GAMA] Unity-managed mode: external middleware is already running; no monitor/catalog orchestration."); } if (!directGamaServer && !skipRemoteLoad && !managedFromUnity) @@ -489,11 +495,11 @@ await PreCapturePurgeOccupyingPlayersAsync( if (directGamaServer) { - append("[GAMA] Connecté au serveur GAMA. Pas d'envoi du message middleware 'connection'."); + append("[GAMA] Connected to the GAMA server. The middleware 'connection' message is not sent."); } else { - append("[GAMA] Connecté. Handshake connection (id=" + effectiveId + ", comme au Play)."); + append("[GAMA] Connected. Sending connection handshake (id=" + effectiveId + ", as in Play Mode)."); string handshake = JsonConvert.SerializeObject(new { @@ -539,7 +545,7 @@ await PreCapturePurgeOccupyingPlayersAsync( }; Dbg(append, sessionId, "capture", - "Connecté effectiveId=" + effectiveId + " deadlineUtc=" + state.CaptureDeadlineUtc.ToString("O")); + "Connected effectiveId=" + effectiveId + " deadlineUtc=" + state.CaptureDeadlineUtc.ToString("O")); if (directGamaServer) { state.DirectGamaMode = true; @@ -558,11 +564,11 @@ await PreCapturePurgeOccupyingPlayersAsync( state.SkipRemoteLoad = skipRemoteLoad; append(skipRemoteLoad - ? "[GAMA] Expérience déjà ouverte : WebSocket GAMA direct (port " + portNorm + - "), pas de load/play — create_player puis send_init_data." + ? "[GAMA] Experiment already open: direct GAMA WebSocket (port " + portNorm + + "), no load/play; create_player followed by send_init_data." : state.DirectLoadRequested - ? "[GAMA] En attente de ConnectionSuccessful avant load: " + directExperimentName + " (" + directModelPath + ")." - : "[GAMA] Aucun modèle/expérience fourni pour load direct; utilisation de l'expérience courante."); + ? "[GAMA] Waiting for ConnectionSuccessful before loading: " + directExperimentName + " (" + directModelPath + ")." + : "[GAMA] No model/experiment supplied for direct loading; using the current experiment."); } bool gamaPortLockHeld = false; @@ -570,10 +576,10 @@ await PreCapturePurgeOccupyingPlayersAsync( { try { - Dbg(append, sessionId, "direct", "Attente verrou port 1000 (une seule capture/load GAMA à la fois)…"); + Dbg(append, sessionId, "direct", "Waiting for port 1000 lock (only one GAMA capture/load at a time)..."); await GamaPort1000Lock.WaitAsync(captureCts.Token).ConfigureAwait(false); gamaPortLockHeld = true; - Dbg(append, sessionId, "direct", "Verrou port 1000 acquis."); + Dbg(append, sessionId, "direct", "Port 1000 lock acquired."); } catch (Exception ex) { @@ -595,24 +601,24 @@ await PreCapturePurgeOccupyingPlayersAsync( state.GamaBootstrapPhase = 2; state.ManagedFromUnity = true; state.HybridGamaCommandChannel = false; - append("[GAMA] Mode piloté par Unity : socket joueur 8080 uniquement (expérience lancée via monitor)."); - Dbg(append, sessionId, "capture", "managedFromUnity → pump Play-like sur " + uri); + append("[GAMA] Unity-managed mode: player socket 8080 only (experiment launched through the monitor)."); + Dbg(append, sessionId, "capture", "managedFromUnity -> Play-like pump on " + uri); } else if (skipRemoteLoad && !directGamaServer) { state.GamaBootstrapPhase = 2; state.HybridGamaCommandChannel = false; - append("[GAMA] Mode expérience ouverte : middleware pur 8080, aucun port 1000 (diagnostic Play-like)."); + append("[GAMA] Open-experiment mode: middleware-only 8080, no port 1000 (Play-like diagnostic)."); Dbg(append, sessionId, "capture", - "skipRemoteLoad → middleware pur " + uri + ", pas de HybridGamaCommandChannel"); + "skipRemoteLoad -> middleware-only " + uri + ", no HybridGamaCommandChannel"); } else if (shouldBootstrapMiddleware) { string bootstrapPort = IsGamaNativeWebSocketPort(port) ? port.Trim() : "1000"; state.GamaBootstrapPhase = 1; Dbg(append, sessionId, "bootstrap", - "Lancement parallèle load/play/create_player sur port " + bootstrapPort + - " (middleware sur " + port + ")"); + "Starting load/play/create_player in parallel on port " + bootstrapPort + + " (middleware on " + port + ")"); bootstrapTask = RunGamaSimulationBootstrapAsync( sessionId, string.IsNullOrWhiteSpace(host) ? "localhost" : host.Trim(), @@ -628,7 +634,7 @@ await PreCapturePurgeOccupyingPlayersAsync( else if (!directGamaServer) { state.GamaBootstrapPhase = 2; - Dbg(append, sessionId, "bootstrap", "Pas de bootstrap (modèle ou expérience manquant)."); + Dbg(append, sessionId, "bootstrap", "No bootstrap (model or experiment missing)."); } Task pumpTask = RunMiddlewarePumpAsync( @@ -646,7 +652,7 @@ await PreCapturePurgeOccupyingPlayersAsync( { if (DateTime.UtcNow >= state.CaptureDeadlineUtc) { - append("[GAMA] Fin de fenêtre de capture (timeout)."); + append("[GAMA] Capture window ended (timeout)."); Dbg(append, sessionId, "capture", "TIMEOUT " + FormatCaptureStateSnapshot(state, effectiveId, ws.State)); break; @@ -675,8 +681,8 @@ await PreCapturePurgeOccupyingPlayersAsync( if (state.DebugReceiveIdleTicks % 15 == 0) { Dbg(append, sessionId, "direct", - "Toujours en attente Load confirmé (" + state.DebugReceiveIdleTicks + - " s) — dialogue GAMA « fermer simulation » ? Cliquez Yes. " + + "Still waiting for Load confirmation (" + state.DebugReceiveIdleTicks + + " s). Is GAMA showing a close-simulation dialog? Click Yes. " + FormatCaptureStateSnapshot(state, effectiveId, ws.State)); } } @@ -688,17 +694,17 @@ await PreCapturePurgeOccupyingPlayersAsync( { string closeInfo = "WebSocket Close (status=" + rr.CloseStatus + " desc=" + (rr.CloseStatusDescription ?? "") + ")"; - append("[GAMA] Le serveur a fermé la connexion. " + closeInfo); + append("[GAMA] The server closed the connection. " + closeInfo); Dbg(append, sessionId, "capture", closeInfo); if (state.DirectGamaMode && state.DirectLoadSent && !state.DirectLoadCompleted) { result.Error = - "GAMA a fermé la connexion juste après le load (sans Load confirmé). " + - "Fermez/stoppez la simulation dans GAMA GUI, ou cochez « Expérience déjà ouverte dans GAMA (sans load) »."; + "GAMA closed the connection immediately after load (without confirming Load). " + + "Close/stop the simulation in the GAMA GUI, or select 'Experiment already open in GAMA (no load)'."; } else if (state.SkipRemoteLoad && !state.HybridGamaCommandChannel && !state.IsCaptureComplete) { - append("[GAMA] Middleware fermé sans JSON (mode 8080 pur — pas de repli GAMA:1000)."); + append("[GAMA] Middleware closed without JSON (middleware-only 8080 mode; no GAMA:1000 fallback)."); } break; @@ -724,14 +730,14 @@ await HandleIncomingAsync( if (!string.IsNullOrWhiteSpace(result.Error)) { - Dbg(append, sessionId, "capture", "Arrêt sur erreur : " + result.Error); + Dbg(append, sessionId, "capture", "Stopping because of error: " + result.Error); break; } } } catch (Exception ex) { - append("[GAMA] Erreur de réception : " + ex.Message); + append("[GAMA] Receive error: " + ex.Message); } try @@ -742,11 +748,11 @@ await HandleIncomingAsync( } catch (OperationCanceledException) { - // normal à l’arrêt + // Expected while stopping. } catch (Exception ex) { - append("[GAMA] Pompe middleware : " + ex.Message); + append("[GAMA] Middleware pump: " + ex.Message); } try @@ -755,11 +761,11 @@ await HandleIncomingAsync( } catch (OperationCanceledException) { - // normal à l’arrêt + // Expected while stopping. } catch (Exception ex) { - append("[GAMA] Bootstrap GAMA : " + ex.Message); + append("[GAMA] GAMA bootstrap: " + ex.Message); } } @@ -769,14 +775,14 @@ await HandleIncomingAsync( { GamaPort1000Lock.Release(); gamaPortLockHeld = false; - Dbg(append, sessionId, "direct", "Verrou port 1000 libéré."); + Dbg(append, sessionId, "direct", "Port 1000 lock released."); } } } if (!directGamaServer && IsEditorPreviewCaptureId(effectiveId)) { - append("[GAMA] Preview editor : disconnect_properly id=\"" + effectiveId + "\" avant fermeture."); + append("[GAMA] Editor preview: disconnect_properly id=\"" + effectiveId + "\" before closing."); try { using (CancellationTokenSource disconnectCts = @@ -788,17 +794,17 @@ await HandleIncomingAsync( } catch (Exception ex) { - append("[GAMA] disconnect_properly preview : " + ex.Message); + append("[GAMA] Preview disconnect_properly failed: " + ex.Message); } } else if (!directGamaServer && !state.SkipRemoteLoad) { - append("[GAMA] Fermeture propre de la connexion preview sans disconnect_properly pour éviter de purger le socket middleware actif."); + append("[GAMA] Closing the preview connection cleanly without disconnect_properly to avoid purging the active middleware socket."); } else if (!directGamaServer && state.SkipRemoteLoad) { - append("[GAMA] Expérience déjà ouverte : pas de disconnect_properly (le joueur « " + effectiveId + - " » reste dans GAMA)."); + append("[GAMA] Experiment already open: disconnect_properly is not sent (player '" + effectiveId + + "' remains in GAMA)."); } try @@ -813,7 +819,7 @@ await HandleIncomingAsync( } catch (Exception ex) { - append("[GAMA] CloseAsync : " + ex.Message); + append("[GAMA] CloseAsync failed: " + ex.Message); } if (!state.GotPrecision || !state.GotProperties || state.WorldFrameCount <= 0) @@ -823,13 +829,13 @@ await HandleIncomingAsync( result.Error = BuildMissingError(state); if (directGamaServer) { - result.Error += " En direct, si le modèle ne pousse pas de SimulationOutput : décochez « Capture directe », lancez simple.webplatform (8080), puis « Capturer comme au Play »."; + result.Error += " In direct mode, if the model does not emit SimulationOutput, clear 'Direct Capture', start simple.webplatform (8080), then use 'Capture as in Play Mode'."; } } - Dbg(append, sessionId, "capture", "ECHEC " + result.Error); + Dbg(append, sessionId, "capture", "FAILED " + result.Error); Dbg(append, sessionId, "capture", - "Etat final " + FormatCaptureStateSnapshot(state, effectiveId, ws.State)); + "Final state " + FormatCaptureStateSnapshot(state, effectiveId, ws.State)); result.LogTrail = logBuilder.ToString(); return result; } @@ -837,13 +843,18 @@ await HandleIncomingAsync( FinalizePreviewBestFrame(state, outputDirectory, result, append); Dbg(append, sessionId, "capture", - "SUCCÈS frames=" + state.WorldFrameCount + " bestTick=" + state.WorldBestFrameIndex + + "SUCCESS frames=" + state.WorldFrameCount + " bestTick=" + state.WorldBestFrameIndex + " dynamic=" + state.PreviewDynamicAgentsFound); result.WorldFrameCount = state.WorldFrameCount; result.BestWorldTickIndex = state.WorldBestFrameIndex; result.BestWorldJsonPath = state.WorldBestJsonPath; result.DynamicAgentsFound = state.PreviewDynamicAgentsFound; + if (!string.IsNullOrWhiteSpace(state.DirectExperimentId) && + !string.Equals(state.DirectExperimentId.Trim(), "0", StringComparison.Ordinal)) + { + result.ExperimentId = state.DirectExperimentId.Trim(); + } if (state.GeometryExportErrorDetected && string.IsNullOrWhiteSpace(result.PreviewWarning)) { @@ -853,22 +864,22 @@ await HandleIncomingAsync( if (!state.PreviewDynamicAgentsFound) { string dynamicWarning = - "Aucun agent correspondant à la regex dynamique (« " + state.DynamicSpeciesRegex + - " ») reçu dans les json_output. L'aperçu reste construit depuis le cache cumulatif."; + "No agents matching the dynamic regex ('" + state.DynamicSpeciesRegex + + "') were received in json_output. The preview is still built from the cumulative cache."; result.PreviewWarning = string.IsNullOrWhiteSpace(result.PreviewWarning) ? dynamicWarning : result.PreviewWarning + " " + dynamicWarning; - append("[GAMA][PREVIEW] AVERTISSEMENT : " + dynamicWarning); + append("[GAMA][PREVIEW] WARNING: " + dynamicWarning); } else { - append("[GAMA][PREVIEW] Agents dynamiques présents dans le cache cumulatif au tick " + + append("[GAMA][PREVIEW] Dynamic agents are present in the cumulative cache at tick " + state.WorldBestFrameIndex + "."); } if (!state.WorldHasAgents) { - append("[GAMA] Capture terminée sans agents dans le monde — parcourez les ticks (world_tick_*.json) dans l’aperçu statique."); + append("[GAMA] Capture completed with no agents in the world. Browse the ticks (world_tick_*.json) in the static preview."); } if (!directGamaServer) @@ -880,16 +891,16 @@ await HandleIncomingAsync( monitorPort, captureCts.Token, append, - "preview réussie") + "successful preview") .ConfigureAwait(false); if (!paused) { - append("[GAMA][PREVIEW] pause_experiment non confirmé (monitor " + monitorPort + ")."); + append("[GAMA][PREVIEW] pause_experiment was not confirmed (monitor " + monitorPort + ")."); } } catch (Exception ex) { - append("[GAMA][PREVIEW] Échec pause_experiment : " + ex.Message); + append("[GAMA][PREVIEW] pause_experiment failed: " + ex.Message); } } } @@ -905,7 +916,7 @@ private sealed class CaptureState public volatile bool MiddlewareAuthenticated; public volatile bool MiddlewareConnected; public bool LoggedMiddlewareInGameHint; - /// 0 = aucun, 1 = en cours, 2 = terminé (create_player OK ou sans bootstrap), 3 = échec. + /// 0 = none, 1 = in progress, 2 = complete (create_player succeeded or no bootstrap), 3 = failed. public volatile int GamaBootstrapPhase; public string MiddlewareOccupiedPlayerId; public bool LoggedPlayerSlotConflict; @@ -934,8 +945,8 @@ private sealed class CaptureState public bool PauseExperimentAfterPreview = true; public bool PreviewDynamicAgentsFound; /// - /// Après le warmup, ne pas terminer sur « cache stable » tant qu'aucun agent dynamique - /// (ex. espèce people) n'a été vu — évite d'arrêter quand seuls murs/routes sont en cache. + /// After warmup, do not stop on a stable cache until a dynamic agent + /// (for example, species people) has been seen. This avoids stopping when only walls/roads are cached. /// public float DynamicSpeciesGraceSeconds = 40f; public DateTime? LastPreviewCacheGrowthUtc; @@ -967,12 +978,12 @@ private sealed class CaptureState public int ImmediateInitBurstSendCount; public DateTime ImmediateInitBurstNextSendUtc = DateTime.MinValue; public DateTime ImmediateInitBurstEndUtc = DateTime.MinValue; - /// Expérience déjà ouverte : commandes sur GAMA:1000, JSON sur middleware. + /// Experiment already open: commands on GAMA:1000, JSON on the middleware. public volatile bool HybridGamaCommandChannel; - /// Preview Editor pilotée par Unity sur le socket joueur 8080. + /// Unity-managed editor preview on player socket 8080. public volatile bool ManagedFromUnity; - /// Ancien flux catalogue : l'expérience a été lancée via le monitor 8001. + /// Legacy catalog flow: the experiment was launched through monitor 8001. public volatile bool LaunchExperimentViaMonitor; public volatile bool HybridGamaCommandsStarted; public volatile bool HybridGamaCommandsDone; @@ -1028,7 +1039,7 @@ public void ExtendCaptureDeadline(float extraSeconds, Action append, str if (VerboseCaptureDebug && append != null && CaptureDeadlineUtc > before) { append("[GAMA][DBG][#" + DebugSessionId + "][deadline] +" + extraSeconds + "s (" + reason + - ") → fin " + CaptureDeadlineUtc.ToString("HH:mm:ss")); + ") -> end " + CaptureDeadlineUtc.ToString("HH:mm:ss")); } } @@ -1042,8 +1053,8 @@ public void TightenCaptureDeadline(DateTime latestUtc, Action append, st CaptureDeadlineUtc = latestUtc; if (append != null) { - append("[GAMA] Fin de capture anticipée (~" + - Math.Max(0, (int)(latestUtc - DateTime.UtcNow).TotalSeconds) + " s) : " + reason + "."); + append("[GAMA] Capture scheduled to end early (~" + + Math.Max(0, (int)(latestUtc - DateTime.UtcNow).TotalSeconds) + " s): " + reason + "."); } } @@ -1135,8 +1146,8 @@ private static async Task SendExecutableAskAsync( } /// - /// Expression GAMA via le middleware (type expression, comme PlayerManager). - /// Utile pour create_player / remove_player quand l'expérience tourne déjà dans GAMA. + /// GAMA expression through the middleware (expression type, like PlayerManager). + /// Useful for create_player / remove_player when the experiment is already running in GAMA. /// private static async Task SendMiddlewareExpressionAsync( ClientWebSocket ws, @@ -1183,7 +1194,7 @@ private static bool IsEditorPreviewCaptureId(string playerId) } /// - /// Se connecte au middleware comme le joueur donné, envoie disconnect_properly, puis ferme la connexion. + /// Connects to the middleware as the specified player, sends disconnect_properly, then closes the connection. /// public static async Task PurgeGhostPlayerAsync( string host, @@ -1194,7 +1205,7 @@ public static async Task PurgeGhostPlayerAsync( { if (string.IsNullOrWhiteSpace(ghostId)) { - return "Aucun id de joueur fantôme à purger."; + return "No ghost player ID to purge."; } Uri uri; @@ -1206,7 +1217,7 @@ public static async Task PurgeGhostPlayerAsync( } catch (Exception ex) { - return "URL middleware invalide : " + ex.Message; + return "Invalid middleware URL: " + ex.Message; } using (ClientWebSocket ws = new ClientWebSocket()) @@ -1220,7 +1231,7 @@ public static async Task PurgeGhostPlayerAsync( } catch (Exception ex) { - return "Connexion impossible au middleware : " + ex.Message; + return "Could not connect to the middleware: " + ex.Message; } try @@ -1246,11 +1257,11 @@ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "purge ghost", closeCts. } catch (Exception ex) { - return "Purge interrompue : " + ex.Message; + return "Purge interrupted: " + ex.Message; } } - return "Connexion fantôme \"" + ghostId + "\" fermée proprement côté middleware."; + return "Ghost connection \"" + ghostId + "\" was closed cleanly on the middleware."; } private static async Task SendExecutableExpressionAsync(ClientWebSocket ws, string expression, CancellationToken ct) @@ -1259,8 +1270,8 @@ private static async Task SendExecutableExpressionAsync(ClientWebSocket ws, stri } /// - /// Expression GAMA serveur (comme simple.webplatform ). - /// Le champ exp_id est obligatoire pour create_player / remove_player. + /// GAMA server expression (like simple.webplatform ). + /// The exp_id field is required for create_player / remove_player. /// private static async Task SendDirectGamaExpressionAsync( ClientWebSocket ws, @@ -1293,7 +1304,7 @@ private static async Task SendLoadAsync( { if (append != null) { - Dbg(append, sessionId, "send", "load IGNORÉ ws=" + (ws == null ? "null" : ws.State.ToString())); + Dbg(append, sessionId, "send", "load SKIPPED ws=" + (ws == null ? "null" : ws.State.ToString())); } return; @@ -1314,7 +1325,7 @@ private static async Task SendLoadAsync( await ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct).ConfigureAwait(false); } - /// Ports du serveur WebSocket GAMA intégré (pas simple.webplatform). + /// Ports used by the integrated GAMA WebSocket server (not simple.webplatform). public static bool IsGamaNativeWebSocketPort(string port) { if (string.IsNullOrWhiteSpace(port)) @@ -1347,12 +1358,12 @@ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "phase handoff", closeCt } catch (Exception ex) { - append("[GAMA] Fermeture socket phase 1 : " + ex.Message); + append("[GAMA] Closing phase 1 socket: " + ex.Message); } } /// - /// Phase 2 : écoute middleware (socket phase 1 réutilisé si possible) + commandes GAMA:1000 en parallèle. + /// Phase 2: listen to the middleware (reusing the phase 1 socket when possible) while sending GAMA:1000 commands in parallel. /// private static async Task RunHybridPhase2MiddlewareListenAndGamaCommandsAsync( string middlewareHost, @@ -1370,11 +1381,11 @@ private static async Task RunHybridPhase2MiddlewareListenAndGamaCommandsAsync( string hostNorm = string.IsNullOrWhiteSpace(middlewareHost) ? "localhost" : middlewareHost.Trim(); string portNorm = string.IsNullOrWhiteSpace(middlewarePort) ? "8080" : middlewarePort.Trim(); DateTime phaseDeadline = DateTime.UtcNow.AddSeconds(90); - state.TightenCaptureDeadline(phaseDeadline, null, "phase 2 hybride (max ~90 s)"); + state.TightenCaptureDeadline(phaseDeadline, null, "hybrid phase 2 (max ~90 s)"); - append("[GAMA] Phase 2 : écoute middleware + commandes GAMA:1000 (parallèle)."); + append("[GAMA] Phase 2: listening to middleware while sending GAMA:1000 commands in parallel."); Dbg(append, sessionId, "phase2", "listen∥gama (socket phase1=" + - (phase1WebSocket != null && phase1WebSocket.State == WebSocketState.Open ? "oui" : "non") + ")"); + (phase1WebSocket != null && phase1WebSocket.State == WebSocketState.Open ? "yes" : "no") + ")"); using (CancellationTokenSource listenCts = CancellationTokenSource.CreateLinkedTokenSource(ct)) { @@ -1418,11 +1429,11 @@ private static async Task RunHybridPhase2MiddlewareListenAndGamaCommandsAsync( if (!state.MiddlewareAuthenticated) { - append("[GAMA] Phase 2 : pas encore in_game sur le middleware — commandes GAMA:1000 quand même."); + append("[GAMA] Phase 2: middleware is not in_game yet; sending GAMA:1000 commands anyway."); } else { - append("[GAMA] Phase 2 : middleware in_game — envoi create_init_player + send_init_data sur GAMA:1000."); + append("[GAMA] Phase 2: middleware is in_game; sending create_init_player + send_init_data on GAMA:1000."); if (reusePhase1Socket) { try @@ -1430,11 +1441,11 @@ private static async Task RunHybridPhase2MiddlewareListenAndGamaCommandsAsync( await SendExecutableAskAsync( phase1WebSocket, "send_init_data", connectionId, listenCts.Token, sessionId, append) .ConfigureAwait(false); - append("[GAMA] Phase 2 : send_init_data aussi envoyé sur le middleware (8080)."); + append("[GAMA] Phase 2: send_init_data was also sent on the middleware (8080)."); } catch (Exception ex) { - append("[GAMA] Phase 2 : send_init_data middleware — " + ex.Message); + append("[GAMA] Phase 2 middleware send_init_data: " + ex.Message); } } } @@ -1447,13 +1458,13 @@ await RunHybridGamaCommandChannelAsync( } catch (OperationCanceledException) when (ct.IsCancellationRequested) { - append("[GAMA] Phase 2 GAMA:1000 annulée (nouvelle capture ou fermeture du panneau)."); + append("[GAMA] Phase 2 GAMA:1000 cancelled (new capture or panel closed)."); } state.TightenCaptureDeadline( DateTime.UtcNow.AddSeconds(state.IsCaptureComplete ? 2 : 30), append, - state.IsCaptureComplete ? "JSON reçus" : "fin phase 2 sans json_output"); + state.IsCaptureComplete ? "JSON received" : "phase 2 ended without json_output"); DateTime listenUntil = DateTime.UtcNow.AddSeconds(state.IsCaptureComplete ? 1 : 30); if (phaseDeadline < listenUntil) @@ -1461,7 +1472,7 @@ await RunHybridGamaCommandChannelAsync( listenUntil = phaseDeadline; } - append("[GAMA] Phase 2 : attente json_output middleware jusqu’à " + + append("[GAMA] Phase 2: waiting for middleware json_output for up to " + Math.Max(0, (int)(listenUntil - DateTime.UtcNow).TotalSeconds) + " s…"); while (!state.IsCaptureComplete && DateTime.UtcNow < listenUntil && !listenCts.IsCancellationRequested) { @@ -1511,12 +1522,12 @@ private static async Task RunMiddlewareJsonListenWithReconnectAsync( await ws.ConnectAsync(mwUri, ct).ConfigureAwait(false); if (reconnectAttempt > 0) { - append("[GAMA] Phase 2 : middleware reconnecté (" + reconnectAttempt + ")."); + append("[GAMA] Phase 2: middleware reconnected (" + reconnectAttempt + ")."); } } catch (Exception ex) { - append("[GAMA] Phase 2 : connexion middleware — " + ex.Message); + append("[GAMA] Phase 2 middleware connection: " + ex.Message); reconnectAttempt++; await Task.Delay(400, ct).ConfigureAwait(false); continue; @@ -1540,7 +1551,7 @@ await ws.SendAsync( } catch (Exception ex) { - append("[GAMA] Phase 2 : handshake — " + ex.Message); + append("[GAMA] Phase 2 handshake: " + ex.Message); } if (reconnectAttempt == 0) @@ -1561,7 +1572,7 @@ await RunMiddlewareJsonReceiveLoopAsync( reconnectAttempt++; Dbg(append, sessionId, "phase2-listen", - "reconnexion middleware #" + reconnectAttempt + " (socket fermé trop tôt)"); + "middleware reconnection #" + reconnectAttempt + " (socket closed too early)"); await Task.Delay(350, ct).ConfigureAwait(false); } } @@ -1629,12 +1640,12 @@ private static async Task RunMiddlewareJsonReceiveLoopAsync( } catch (Exception ex) { - append("[GAMA] Phase 2 écoute middleware : " + ex.Message); + append("[GAMA] Phase 2 middleware listener: " + ex.Message); } } /// - /// Commandes GAMA:1000 : create_init_player + send_init_data (SimulationOutput relayé par le middleware vers 8080). + /// GAMA:1000 commands: create_init_player + send_init_data (SimulationOutput is relayed by the middleware to 8080). /// private static async Task RunHybridGamaCommandChannelAsync( string gamaHost, @@ -1661,7 +1672,7 @@ private static async Task RunHybridGamaCommandChannelAsync( } catch (Exception ex) { - append("[GAMA] Phase 2 GAMA:1000 : verrou port 1000 indisponible — " + ex.Message); + append("[GAMA] Phase 2 GAMA:1000: port 1000 lock unavailable: " + ex.Message); state.HybridGamaCommandsDone = true; return; } @@ -1678,7 +1689,7 @@ private static async Task RunHybridGamaCommandChannelAsync( using (ClientWebSocket gamaWs = new ClientWebSocket()) { Uri gamaUri = new Uri("ws://" + gamaHost + ":1000/"); - append("[GAMA] Phase 2 GAMA:1000 : connexion " + gamaUri); + append("[GAMA] Phase 2 GAMA:1000: connecting to " + gamaUri); await gamaWs.ConnectAsync(gamaUri, channelCts.Token).ConfigureAwait(false); Task receiveTask = ReceiveGamaDirectJsonLoopAsync( @@ -1690,7 +1701,7 @@ private static async Task RunHybridGamaCommandChannelAsync( await SendDirectGamaExpressionAsync( gamaWs, "do create_init_player(\"" + escapedId + "\");", expId, channelCts.Token) .ConfigureAwait(false); - append("[GAMA] Phase 2 GAMA:1000 : create_init_player(\"" + connectionId + "\")."); + append("[GAMA] Phase 2 GAMA:1000: create_init_player(\"" + connectionId + "\")."); await Task.Delay(2000, channelCts.Token).ConfigureAwait(false); for (int i = 0; i < 3 && !state.IsCaptureComplete && !channelCts.IsCancellationRequested; i++) @@ -1700,7 +1711,7 @@ await SendExecutableAskAsync( .ConfigureAwait(false); initSent++; state.HybridGamaInitSentCount = initSent; - append("[GAMA] Phase 2 GAMA:1000 : send_init_data (" + initSent + "/3)."); + append("[GAMA] Phase 2 GAMA:1000: send_init_data (" + initSent + "/3)."); if (i < 2) { await Task.Delay(2000, channelCts.Token).ConfigureAwait(false); @@ -1714,7 +1725,7 @@ await SendExecutableAskAsync( gamaWs, "player_ready_to_receive_geometries", connectionId, channelCts.Token, sessionId, append) .ConfigureAwait(false); state.HybridGamaGeomReadySent = true; - append("[GAMA] Phase 2 GAMA:1000 : player_ready_to_receive_geometries."); + append("[GAMA] Phase 2 GAMA:1000: player_ready_to_receive_geometries."); } if (lockTaken) @@ -1760,7 +1771,7 @@ await gamaWs.CloseAsync( if (initSent > 0) { - append("[GAMA] Phase 2 GAMA:1000 terminée (" + initSent + " send_init_data, create_init_player envoyé)."); + append("[GAMA] Phase 2 GAMA:1000 completed (" + initSent + " send_init_data, create_init_player sent)."); } } catch (OperationCanceledException) @@ -1769,7 +1780,7 @@ await gamaWs.CloseAsync( } catch (Exception ex) { - append("[GAMA] Phase 2 GAMA:1000 : " + ex.Message); + append("[GAMA] Phase 2 GAMA:1000: " + ex.Message); } finally { @@ -1865,7 +1876,7 @@ private static async Task ReceiveGamaDirectJsonLoopAsync( : contentToken?.ToString(Formatting.None); if (!string.IsNullOrWhiteSpace(content)) { - CapLog(append, sessionId, "phase2-1000", "SimulationOutput reçu."); + CapLog(append, sessionId, "phase2-1000", "SimulationOutput received."); try { TryProcessGamaOutputPayload(JToken.Parse(content), outputDirectory, result, append, state); @@ -1913,25 +1924,25 @@ private static async Task TryDirectCreatePlayerAsync( int sid = state.DebugSessionId; if (!state.DirectGamaMode) { - Dbg(append, sid, "create_player", "SKIP pas en mode direct"); + Dbg(append, sid, "create_player", "SKIP not in direct mode"); return; } if (state.DirectCreatePlayerSent) { - Dbg(append, sid, "create_player", "SKIP déjà marqué envoyé/confirmé"); + Dbg(append, sid, "create_player", "SKIP already marked sent/confirmed"); return; } if (!state.DirectLoadCompleted) { - Dbg(append, sid, "create_player", "SKIP load pas terminé"); + Dbg(append, sid, "create_player", "SKIP load not complete"); return; } if (!state.DirectPlayCompleted) { - Dbg(append, sid, "create_player", "SKIP play/sim pas prête status=" + state.LastSimulationStatus); + Dbg(append, sid, "create_player", "SKIP play/simulation not ready status=" + state.LastSimulationStatus); return; } @@ -1945,7 +1956,7 @@ private static async Task TryDirectCreatePlayerAsync( if (!state.DirectCreatePlayerSent) { state.DirectCreatePlayerSent = true; - append("[GAMA] create_player abandonné après 20 tentatives."); + append("[GAMA] create_player abandoned after 20 attempts."); } return; @@ -1958,12 +1969,12 @@ private static async Task TryDirectCreatePlayerAsync( { string expr = "do create_player(\"" + connectionId + "\");"; await SendDirectGamaExpressionAsync(ws, expr, state.DirectExperimentId, ct).ConfigureAwait(false); - append("[GAMA] create_player tentative " + state.DirectCreatePlayerAttempts + + append("[GAMA] create_player attempt " + state.DirectCreatePlayerAttempts + " (exp_id=" + state.DirectExperimentId + ")."); } catch (Exception ex) { - append("[GAMA] create_player : " + ex.Message); + append("[GAMA] create_player failed: " + ex.Message); } } @@ -1977,7 +1988,7 @@ private static async Task SendPlayAsync( { if (append != null) { - Dbg(append, sessionId, "send", "play IGNORÉ ws=" + (ws == null ? "null" : ws.State.ToString())); + Dbg(append, sessionId, "send", "play SKIPPED ws=" + (ws == null ? "null" : ws.State.ToString())); } return; @@ -1997,7 +2008,7 @@ private static async Task SendPlayAsync( } /// - /// Démarre load/play/create_player sur le serveur GAMA (port 1000) pendant qu'une capture middleware écoute sur 8080. + /// Starts load/play/create_player on the GAMA server (port 1000) while a middleware capture listens on 8080. /// private static async Task RunGamaSimulationBootstrapAsync( int sessionId, @@ -2014,17 +2025,17 @@ private static async Task RunGamaSimulationBootstrapAsync( append(skipRemoteLoad ? "[GAMA] Bootstrap create_player GAMA ws://" + host + ":" + port + "/ id=\"" + connectionId + "\"" : "[GAMA] Bootstrap simulation GAMA ws://" + host + ":" + port + "/ → " + experimentName); - Dbg(append, sessionId, "bootstrap", "Attente verrou port GAMA (évite 2 load WebSocket en parallèle)…"); + Dbg(append, sessionId, "bootstrap", "Waiting for GAMA port lock (prevents two parallel WebSocket loads)..."); bool lockTaken = false; try { await GamaPort1000Lock.WaitAsync(TimeSpan.FromSeconds(90), ct).ConfigureAwait(false); lockTaken = true; - Dbg(append, sessionId, "bootstrap", "Verrou port GAMA acquis."); + Dbg(append, sessionId, "bootstrap", "GAMA port lock acquired."); } catch (Exception ex) { - append("[GAMA] Bootstrap : impossible d'obtenir le verrou GAMA — " + ex.Message); + append("[GAMA] Bootstrap: could not acquire the GAMA lock: " + ex.Message); return; } @@ -2039,7 +2050,7 @@ await RunGamaSimulationBootstrapCoreAsync( if (lockTaken) { GamaPort1000Lock.Release(); - Dbg(append, sessionId, "bootstrap", "Verrou port GAMA libéré."); + Dbg(append, sessionId, "bootstrap", "GAMA port lock released."); } if (mainCaptureState != null && mainCaptureState.GamaBootstrapPhase == 1) @@ -2061,7 +2072,7 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( CancellationToken ct, Action append) { - Dbg(append, sessionId, "bootstrap", "Core démarré model=" + modelPath + " exp=" + experimentName); + Dbg(append, sessionId, "bootstrap", "Core started model=" + modelPath + " exp=" + experimentName); Uri uri; try @@ -2070,7 +2081,7 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( } catch (Exception ex) { - append("[GAMA] Bootstrap : URL invalide — " + ex.Message); + append("[GAMA] Bootstrap: invalid URL: " + ex.Message); return; } @@ -2084,12 +2095,12 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( } catch (Exception ex) { - append("[GAMA] Bootstrap : connexion impossible — " + ex.Message + " (GAMA est-il ouvert ?)"); - Dbg(append, sessionId, "bootstrap", "ConnectAsync échec : " + ex); + append("[GAMA] Bootstrap: could not connect: " + ex.Message + " (is GAMA open?)"); + Dbg(append, sessionId, "bootstrap", "ConnectAsync failed: " + ex); return; } - Dbg(append, sessionId, "bootstrap", "WebSocket ouvert state=" + ws.State); + Dbg(append, sessionId, "bootstrap", "WebSocket opened state=" + ws.State); CaptureState boot = new CaptureState { @@ -2108,7 +2119,7 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( if (skipRemoteLoad) { - Dbg(append, sessionId, "bootstrap", "skipRemoteLoad=true → create_player sans load/play"); + Dbg(append, sessionId, "bootstrap", "skipRemoteLoad=true -> create_player without load/play"); } byte[] buffer = new byte[64 * 1024]; @@ -2121,7 +2132,7 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( { if (boot.DirectCreatePlayerConfirmedUtc.HasValue) { - append("[GAMA] Bootstrap terminé (create_player confirmé)."); + append("[GAMA] Bootstrap completed (create_player confirmed)."); Dbg(append, sessionId, "bootstrap", "OK " + FormatCaptureStateSnapshot(boot, connectionId, ws.State)); if (mainCaptureState != null) { @@ -2153,12 +2164,12 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( if (boot.DirectLoadSent && !boot.DirectLoadCompleted && idleSlices % 20 == 0) { - append("[GAMA] Bootstrap : toujours pas de Load confirmé — cliquez Yes dans GAMA si une boîte de dialogue est ouverte."); + append("[GAMA] Bootstrap: Load is still not confirmed. Click Yes in GAMA if a dialog is open."); } if (!boot.DirectPlaySent && boot.DirectLoadCompleted && !boot.DirectPlayCompleted) { - Dbg(append, sessionId, "bootstrap", "Relance play (load OK, play pas encore envoyé)."); + Dbg(append, sessionId, "bootstrap", "Retrying play (load OK, play not sent yet)."); try { await SendPlayAsync(ws, linked.Token, sessionId).ConfigureAwait(false); @@ -2166,7 +2177,7 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( } catch (Exception ex) { - Dbg(append, sessionId, "bootstrap", "play échec : " + ex.Message); + Dbg(append, sessionId, "bootstrap", "play failed: " + ex.Message); } } @@ -2181,16 +2192,16 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( if (rr.MessageType == WebSocketMessageType.Close) { CapLog(append, sessionId, "bootstrap", - "WebSocket FERMÉ status=" + rr.CloseStatus + " desc=" + (rr.CloseStatusDescription ?? "") + + "WebSocket CLOSED status=" + rr.CloseStatus + " desc=" + (rr.CloseStatusDescription ?? "") + " | loadSent=" + boot.DirectLoadSent + " loadOK=" + boot.DirectLoadCompleted + " playSent=" + boot.DirectPlaySent + " createOK=" + boot.DirectCreatePlayerConfirmedUtc.HasValue); if (boot.DirectLoadSent && !boot.DirectLoadCompleted) { - append("[GAMA] Bootstrap : GAMA a fermé la connexion après load — dialogue « fermer simulation » ? Cochez « Expérience déjà ouverte » si la sim tourne déjà."); + append("[GAMA] Bootstrap: GAMA closed the connection after load. Is a close-simulation dialog open? Select 'Experiment already open' if the simulation is already running."); } else if (!boot.DirectLoadSent && !boot.SkipRemoteLoad) { - append("[GAMA] Bootstrap : connexion fermée avant load — GAMA occupé (autre client/port 1000) ? Purgez Player_* et réessayez."); + append("[GAMA] Bootstrap: connection closed before load. Is GAMA busy with another client on port 1000? Purge Player_* and try again."); } break; @@ -2199,7 +2210,7 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( pending.Append(Encoding.UTF8.GetString(buffer, 0, rr.Count)); if (!rr.EndOfMessage) { - Dbg(append, sessionId, "bootstrap", "Fragment partiel (" + rr.Count + " bytes), attente fin message…"); + Dbg(append, sessionId, "bootstrap", "Partial fragment (" + rr.Count + " bytes), waiting for end of message..."); continue; } @@ -2214,7 +2225,7 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( } catch (Exception ex) { - Dbg(append, sessionId, "bootstrap", "JSON non parseable : " + ex.Message); + Dbg(append, sessionId, "bootstrap", "Could not parse JSON: " + ex.Message); continue; } @@ -2229,24 +2240,24 @@ private static async Task RunGamaSimulationBootstrapCoreAsync( boot.DirectPlayCompleted = true; boot.DirectRunningSinceUtc = DateTime.UtcNow; boot.LastSimulationStatus = "ASSUMED_OPEN"; - CapLog(append, sessionId, "bootstrap", "ConnectionSuccessful → create_player (sans load)"); + CapLog(append, sessionId, "bootstrap", "ConnectionSuccessful -> create_player (without load)"); await TryDirectCreatePlayerAsync(ws, boot, append, linked.Token, force: true).ConfigureAwait(false); } else { boot.DirectLoadSent = true; CapLog(append, sessionId, "bootstrap", - "ConnectionSuccessful → envoi load IMMÉDIAT " + experimentName + " | ws=" + ws.State); - append("[GAMA] Bootstrap : envoi load " + experimentName); + "ConnectionSuccessful -> sending load IMMEDIATELY " + experimentName + " | ws=" + ws.State); + append("[GAMA] Bootstrap: sending load " + experimentName); try { await SendLoadAsync(ws, modelPath, experimentName, linked.Token, sessionId, append) .ConfigureAwait(false); - CapLog(append, sessionId, "bootstrap", "load envoyé, attente CommandExecutedSuccessfully…"); + CapLog(append, sessionId, "bootstrap", "load sent, waiting for CommandExecutedSuccessfully..."); } catch (Exception ex) { - CapLog(append, sessionId, "bootstrap", "ÉCHEC envoi load : " + ex.Message); + CapLog(append, sessionId, "bootstrap", "FAILED to send load: " + ex.Message); } } @@ -2257,8 +2268,8 @@ await SendLoadAsync(ws, modelPath, experimentName, linked.Token, sessionId, appe { boot.DirectLoadPending = false; boot.DirectLoadSent = true; - CapLog(append, sessionId, "bootstrap", "load pending (filet) → envoi load"); - append("[GAMA] Bootstrap : envoi load " + experimentName); + CapLog(append, sessionId, "bootstrap", "load pending (safety net) -> sending load"); + append("[GAMA] Bootstrap: sending load " + experimentName); try { await SendLoadAsync(ws, modelPath, experimentName, linked.Token, sessionId, append).ConfigureAwait(false); @@ -2284,7 +2295,7 @@ await SendLoadAsync(ws, modelPath, experimentName, linked.Token, sessionId, appe { await SendPlayAsync(ws, linked.Token, sessionId).ConfigureAwait(false); boot.DirectPlaySent = true; - append("[GAMA] Bootstrap : play envoyé."); + append("[GAMA] Bootstrap: play sent."); } } else if (commandType == "play") @@ -2296,7 +2307,7 @@ await SendLoadAsync(ws, modelPath, experimentName, linked.Token, sessionId, appe { boot.DirectCreatePlayerSent = true; boot.DirectCreatePlayerConfirmedUtc = DateTime.UtcNow; - append("[GAMA] Bootstrap : create_player confirmé."); + append("[GAMA] Bootstrap: create_player confirmed."); if (mainCaptureState != null) { mainCaptureState.GamaBootstrapPhase = 2; @@ -2310,7 +2321,7 @@ await SendLoadAsync(ws, modelPath, experimentName, linked.Token, sessionId, appe if (type == "UnableToExecuteRequest" || type == "GamaServerError" || type == "RuntimeError") { - append("[GAMA] Bootstrap : " + type + " → " + (json["content"]?.ToString(Formatting.None) ?? "")); + append("[GAMA] Bootstrap: " + type + " -> " + (json["content"]?.ToString(Formatting.None) ?? "")); } else if (type == "SimulationStatus") { @@ -2336,23 +2347,23 @@ await SendLoadAsync(ws, modelPath, experimentName, linked.Token, sessionId, appe } else { - Dbg(append, sessionId, "bootstrap", "Type non géré en bootstrap : " + type); + Dbg(append, sessionId, "bootstrap", "Unhandled bootstrap type: " + type); } } double elapsed = (DateTime.UtcNow - startedUtc).TotalSeconds; if (!boot.DirectCreatePlayerConfirmedUtc.HasValue) { - append("[GAMA] Bootstrap : create_player non confirmé (simulation ou dialogue GAMA bloquant)."); + append("[GAMA] Bootstrap: create_player was not confirmed (simulation or blocking GAMA dialog)."); Dbg(append, sessionId, "bootstrap", - "FIN après " + elapsed.ToString("0") + "s | " + FormatCaptureStateSnapshot(boot, connectionId, ws.State)); + "END after " + elapsed.ToString("0") + "s | " + FormatCaptureStateSnapshot(boot, connectionId, ws.State)); } } } /// - /// Répète send_init_data comme en LOADING_DATA, puis envoie - /// player_ready_to_receive_geometries une fois precision+properties reçus (comme passage en GAME). + /// Repeats send_init_data like in LOADING_DATA, then sends + /// player_ready_to_receive_geometries once precision+properties are received (like entering GAME). /// private static async Task RunMiddlewarePumpAsync( ClientWebSocket ws, @@ -2386,7 +2397,7 @@ private static async Task RunMiddlewarePumpAsync( { state.AutoPurgeAttemptCount++; CapLog(append, sid, "auto-purge", - "Tentative " + state.AutoPurgeAttemptCount + "/4 : remove_player GAMA « " + ghost + " » (sans 2e WebSocket)…"); + "Attempt " + state.AutoPurgeAttemptCount + "/4: remove_player GAMA '" + ghost + "' (without a second WebSocket)..."); if (ws.State == WebSocketState.Open) { try @@ -2395,11 +2406,11 @@ private static async Task RunMiddlewarePumpAsync( await SendMiddlewareExpressionAsync( ws, "do remove_player(\"" + escapedGhost + "\");", ct, sid, append) .ConfigureAwait(false); - CapLog(append, sid, "auto-purge", "remove_player(\"" + ghost + "\") envoyé."); + CapLog(append, sid, "auto-purge", "remove_player(\"" + ghost + "\") sent."); } catch (Exception ex) { - CapLog(append, sid, "auto-purge", "remove_player : " + ex.Message); + CapLog(append, sid, "auto-purge", "remove_player failed: " + ex.Message); } } @@ -2421,7 +2432,7 @@ await SendMiddlewareExpressionAsync( state.MiddlewareOccupiedPlayerId = null; state.MiddlewareAuthenticated = true; CapLog(append, sid, "auto-purge", - "json_state in_game=\"" + ghost + "\" = id de capture (même IP middleware) → OK."); + "json_state in_game=\"" + ghost + "\" = capture ID (same middleware IP) -> OK."); } } @@ -2431,7 +2442,7 @@ await SendMiddlewareExpressionAsync( { result.Error = BuildMissingError(state); state.CaptureAbortRequested = true; - CapLog(append, sid, "pump", "ABORT slot joueur toujours occupé après purges."); + CapLog(append, sid, "pump", "ABORT player slot still occupied after purges."); break; } if (VerboseCaptureDebug && @@ -2446,7 +2457,7 @@ await SendMiddlewareExpressionAsync( if (ws.State != WebSocketState.Open) { - Dbg(append, state.DebugSessionId, "pump", "STOP ws fermé"); + Dbg(append, state.DebugSessionId, "pump", "STOP WebSocket closed"); break; } @@ -2492,7 +2503,7 @@ await SendExecutableAskAsync( } catch (Exception ex) { - append("[GAMA][CAPTURE][8080] burst : " + ex.Message); + append("[GAMA][CAPTURE][8080] burst failed: " + ex.Message); } continue; @@ -2503,11 +2514,11 @@ await SendExecutableAskAsync( !state.LoggedPlayerSlotConflict) { state.LoggedPlayerSlotConflict = true; - append("[GAMA] Slot joueur occupé par « " + state.MiddlewareOccupiedPlayerId + - " » — arrêtez Unity Play, purguez ce joueur, puis relancez la capture (id « " + connectionId + " »)."); + append("[GAMA] Player slot occupied by '" + state.MiddlewareOccupiedPlayerId + + "'. Stop Unity Play Mode, purge this player, then start capture again (id '" + connectionId + "')."); if (state.SkipRemoteLoad) { - append("[GAMA] Astuce : avec l’expérience lancée dans GAMA (bouton Yes), gardez « Expérience déjà ouverte » coché."); + append("[GAMA] Tip: when the experiment was launched in GAMA (Yes button), keep 'Experiment already open' selected."); } } @@ -2515,8 +2526,8 @@ await SendExecutableAskAsync( state.DebugPumpTicks % 25 == 0 && !state.IsCaptureComplete) { Dbg(append, state.DebugSessionId, "hybrid", - "pump Play-like middleware (init=" + state.HybridGamaInitSentCount + - ", secours1000=" + (state.HybridGamaCommandsDone ? "fini" : "en cours") + ")…"); + "Play-like middleware pump (init=" + state.HybridGamaInitSentCount + + ", fallback1000=" + (state.HybridGamaCommandsDone ? "complete" : "in progress") + ")..."); } if (state.SkipRemoteLoad && @@ -2530,7 +2541,7 @@ await SendExecutableAskAsync( state.MiddlewareCreatePlayerSent = true; state.MiddlewareInitDataAllowedUtc = DateTime.UtcNow.AddSeconds(1.5); string escapedId = connectionId.Replace("\\", "\\\\").Replace("\"", "\\\""); - append("[GAMA] create_player via middleware (expérience déjà ouverte) id=\"" + connectionId + "\"."); + append("[GAMA] create_player through middleware (experiment already open) id=\"" + connectionId + "\"."); try { await SendMiddlewareExpressionAsync( @@ -2539,7 +2550,7 @@ await SendMiddlewareExpressionAsync( } catch (Exception ex) { - append("[GAMA] create_player middleware : " + ex.Message); + append("[GAMA] Middleware create_player failed: " + ex.Message); } } @@ -2553,14 +2564,14 @@ await SendMiddlewareExpressionAsync( { if (state.DebugPumpTicks % 20 == 0) { - Dbg(append, state.DebugSessionId, "pump", "attente bootstrap GAMA (create_player/load)…"); + Dbg(append, state.DebugSessionId, "pump", "waiting for GAMA bootstrap (create_player/load)..."); } continue; } else { - append("[GAMA] Bootstrap GAMA trop long — envoi send_init_data quand même."); + append("[GAMA] GAMA bootstrap is taking too long; sending send_init_data anyway."); state.GamaBootstrapPhase = 3; } } @@ -2584,13 +2595,13 @@ await SendMiddlewareExpressionAsync( loggedAuthFallback = true; if (state.SkipRemoteLoad) { - append("[GAMA] Slot middleware libre — envoi de send_init_data (expérience GAMA déjà ouverte)."); + append("[GAMA] Middleware slot is free; sending send_init_data (GAMA experiment already open)."); } else { append(waitConnected - ? "[GAMA] Middleware connecté (in_game peut être false) — envoi de send_init_data." - : "[GAMA] Pas de json_state utile après 15 s — envoi de send_init_data quand même."); + ? "[GAMA] Middleware connected (in_game may be false); sending send_init_data." + : "[GAMA] No useful json_state after 15 s; sending send_init_data anyway."); } } } @@ -2600,18 +2611,18 @@ await SendMiddlewareExpressionAsync( { state.DirectLoadPending = false; state.DirectLoadSent = true; - state.ExtendCaptureDeadline(240f, append, "load (depuis pump)"); - append("[GAMA] Envoi du load depuis la pompe (évite conflit send/receive). Si GAMA demande de fermer la sim → Yes."); + state.ExtendCaptureDeadline(240f, append, "load (from pump)"); + append("[GAMA] Sending load from the pump (avoids a send/receive conflict). If GAMA asks to close the simulation, click Yes."); try { await SendLoadAsync(ws, state.DirectModelPath, state.DirectExperimentName, ct, state.DebugSessionId, append) .ConfigureAwait(false); - append("[GAMA] Demande load envoyée: " + state.DirectExperimentName); + append("[GAMA] Load request sent: " + state.DirectExperimentName); } catch (Exception ex) { - append("[GAMA] load : " + ex.Message); - Dbg(append, state.DebugSessionId, "direct", "échec envoi load : " + ex.Message); + append("[GAMA] load failed: " + ex.Message); + Dbg(append, state.DebugSessionId, "direct", "failed to send load: " + ex.Message); } } @@ -2621,11 +2632,11 @@ await SendLoadAsync(ws, state.DirectModelPath, state.DirectExperimentName, ct, s { await SendPlayAsync(ws, ct, state.DebugSessionId, append).ConfigureAwait(false); state.DirectPlaySent = true; - append("[GAMA] Commande play envoyée après load."); + append("[GAMA] play command sent after load."); } catch (Exception ex) { - append("[GAMA] play : " + ex.Message); + append("[GAMA] play failed: " + ex.Message); } } @@ -2635,7 +2646,7 @@ await SendLoadAsync(ws, state.DirectModelPath, state.DirectExperimentName, ct, s { if (state.DebugPumpTicks % 30 == 0) { - Dbg(append, state.DebugSessionId, "create_player", "pump attend DirectPlayCompleted…"); + Dbg(append, state.DebugSessionId, "create_player", "pump waiting for DirectPlayCompleted..."); } continue; @@ -2649,7 +2660,7 @@ await SendLoadAsync(ws, state.DirectModelPath, state.DirectExperimentName, ct, s if (state.DebugPumpTicks % 30 == 0) { Dbg(append, state.DebugSessionId, "create_player", - "pump attend RUNNING/PAUSED (status=" + state.LastSimulationStatus + ")"); + "pump waiting for RUNNING/PAUSED (status=" + state.LastSimulationStatus + ")"); } continue; @@ -2669,7 +2680,7 @@ await SendLoadAsync(ws, state.DirectModelPath, state.DirectExperimentName, ct, s if (!loggedDirectInitStrategy) { loggedDirectInitStrategy = true; - append("[GAMA] Mode direct : attente SimulationOutput après create_player (play si PAUSED, puis send_init_data espacé)."); + append("[GAMA] Direct mode: waiting for SimulationOutput after create_player (play if PAUSED, then spaced send_init_data calls)."); } if (string.Equals(state.LastSimulationStatus, "PAUSED", StringComparison.OrdinalIgnoreCase) && @@ -2679,11 +2690,11 @@ await SendLoadAsync(ws, state.DirectModelPath, state.DirectExperimentName, ct, s try { await SendPlayAsync(ws, ct).ConfigureAwait(false); - append("[GAMA] Reprise play (simulation en pause après create_player)."); + append("[GAMA] Resuming play (simulation paused after create_player)."); } catch (Exception ex) { - append("[GAMA] play (reprise) : " + ex.Message); + append("[GAMA] play (resume): " + ex.Message); } } @@ -2705,7 +2716,7 @@ await SendExecutableAskAsync( } catch (Exception ex) { - append("[GAMA] send_init_data : " + ex.Message); + append("[GAMA] send_init_data failed: " + ex.Message); } } } @@ -2759,7 +2770,7 @@ await SendExecutableAskAsync( if (state.DebugPumpTicks % 25 == 0) { Dbg(append, state.DebugSessionId, "middleware", - "attente create_player GAMA avant send_init_data (mode piloté par Unity)…"); + "waiting for GAMA create_player before send_init_data (Unity-managed mode)..."); } continue; @@ -2780,8 +2791,8 @@ await SendExecutableAskAsync( } catch (Exception ex) { - append("[GAMA] send_init_data : " + ex.Message); - Dbg(append, state.DebugSessionId, "middleware", "send_init_data exception : " + ex.Message); + append("[GAMA] send_init_data failed: " + ex.Message); + Dbg(append, state.DebugSessionId, "middleware", "send_init_data exception: " + ex.Message); } } @@ -2797,11 +2808,11 @@ await SendExecutableAskAsync( await SendExecutableAskAsync( ws, "player_ready_to_receive_geometries", connectionId, ct, state.DebugSessionId, append) .ConfigureAwait(false); - append("[GAMA] player_ready_to_receive_geometries (mode expérience déjà ouverte, comme au Play)."); + append("[GAMA] player_ready_to_receive_geometries (experiment-already-open mode, as in Play Mode)."); } catch (Exception ex) { - append("[GAMA] player_ready_to_receive_geometries : " + ex.Message); + append("[GAMA] player_ready_to_receive_geometries failed: " + ex.Message); } } @@ -2815,19 +2826,19 @@ await SendExecutableAskAsync( .ConfigureAwait(false); if (state.WorldFrameCount == 0) { - append("[GAMA] Demande player_ready_to_receive_geometries (comme au Play)."); + append("[GAMA] Requesting player_ready_to_receive_geometries (as in Play Mode)."); } } catch (Exception ex) { - append("[GAMA] player_ready_to_receive_geometries : " + ex.Message); + append("[GAMA] player_ready_to_receive_geometries failed: " + ex.Message); } } } } catch (OperationCanceledException) { - // fin normale (timeout ou annulation) + // Normal end (timeout or cancellation). } } @@ -2870,7 +2881,7 @@ private static async Task HandleIncomingAsync( string type = (string)json["type"]; if (string.IsNullOrEmpty(type)) { - Dbg(append, state.DebugSessionId, "in", "message sans champ type"); + Dbg(append, state.DebugSessionId, "in", "message has no type field"); return; } @@ -2878,7 +2889,7 @@ private static async Task HandleIncomingAsync( if (type == "ConnectionSuccessful") { - append("[GAMA] ConnectionSuccessful reçu."); + append("[GAMA] ConnectionSuccessful received."); if (state.DirectGamaMode && state.DirectLoadRequested && !state.DirectLoadSent) { if (state.SkipRemoteLoad) @@ -2895,13 +2906,13 @@ private static async Task HandleIncomingAsync( state.LastSimulationStatus = "ASSUMED_OPEN"; } - append("[GAMA] Mode sans load : expérience supposée déjà ouverte dans GAMA — pas d'envoi play, tentative create_player."); - Dbg(append, state.DebugSessionId, "direct", "SkipRemoteLoad=true → pas de commande load/play"); + append("[GAMA] No-load mode: experiment assumed to be open in GAMA; play is not sent, attempting create_player."); + Dbg(append, state.DebugSessionId, "direct", "SkipRemoteLoad=true -> no load/play command"); } else { state.DirectLoadPending = true; - append("[GAMA] Load programmé (envoi depuis la pompe, pas pendant la réception). Validez Yes si GAMA le demande."); + append("[GAMA] Load scheduled (sent from the pump, not while receiving). Click Yes if GAMA prompts you."); Dbg(append, state.DebugSessionId, "direct", "DirectLoadPending=true"); } } @@ -2932,7 +2943,7 @@ private static async Task HandleIncomingAsync( if (invalidMiddlewareOnGamaPort) { append("[GAMA] Port " + (state.DirectGamaMode ? "?" : "1000") + - " = serveur GAMA, pas le middleware Node. Cochez « Capture directe » ou lancez simple.webplatform sur le port 8080."); + " is the GAMA server, not the Node middleware. Select 'Direct Capture' or start simple.webplatform on port 8080."); result.Error = "Incorrect protocol: target port is GAMA Server ('connection' command refused). Use port 8080 with simple.webplatform, or check 'Direct GAMA GUI Capture' for port 1000."; return; } @@ -2948,7 +2959,7 @@ private static async Task HandleIncomingAsync( if (isRemovePlayer || (isCreatePlayer && simUnavailable)) { - append("[GAMA] Commande ignorée (simulation absente) : " + content); + append("[GAMA] Command ignored (simulation missing): " + content); return; } @@ -2961,14 +2972,14 @@ private static async Task HandleIncomingAsync( } state.LastSimulationStatus = "ASSUMED_OPEN"; - append("[GAMA] Play ignoré : GAMA indique « Controller is full ». On continue sur l'expérience déjà ouverte."); + append("[GAMA] play ignored: GAMA reports 'Controller is full'. Continuing with the already-open experiment."); await TryDirectCreatePlayerAsync(ws, state, append, ct, force: true).ConfigureAwait(false); return; } if (simUnavailable && state.DirectGamaMode) { - append("[GAMA] Simulation introuvable côté GAMA — validez « Yes » si GAMA demande de fermer l’expérience, puis relancez la capture."); + append("[GAMA] Simulation not found in GAMA. Click 'Yes' if GAMA asks to close the experiment, then start capture again."); state.ExtendCaptureDeadline(60f); return; } @@ -2985,9 +2996,9 @@ private static async Task HandleIncomingAsync( if (!state.LoggedUnityLinkerMissingHint) { state.LoggedUnityLinkerMissingHint = true; - append("[GAMA] send_init_data refusé : l'agent simulation[0].unity_linker[0] n'existe pas encore. " + - "Attente create_player / démarrage de l'expérience Unity (type: unity). " + - "Si le modèle n'est pas *-VR.gaml, la capture Unity n'est pas supportée."); + append("[GAMA] send_init_data refused: agent simulation[0].unity_linker[0] does not exist yet. " + + "Waiting for create_player / Unity experiment startup (type: unity). " + + "If the model is not *-VR.gaml, Unity capture is not supported."); } return; @@ -2995,18 +3006,18 @@ private static async Task HandleIncomingAsync( if (state.SendInitDataFailureCount <= 2) { - append("[GAMA] send_init_data refusé (" + content + ") — attente RUNNING ou sortie SimulationOutput."); + append("[GAMA] send_init_data refused (" + content + "); waiting for RUNNING or SimulationOutput."); } else if (!state.LoggedDirectInitHint && state.DirectGamaMode) { state.LoggedDirectInitHint = true; - append("[GAMA] send_init_data échoue en direct : lancez simple.webplatform (8080), décochez « Capture directe », ou arrêtez la sim dans GAMA puis réessayez."); + append("[GAMA] send_init_data failed in direct mode: start simple.webplatform (8080), clear 'Direct Capture', or stop the simulation in GAMA and try again."); } return; } - append("[GAMA] " + type + ": " + content + (isCreatePlayer ? " (nouvelle tentative create_player plus tard)" : string.Empty)); + append("[GAMA] " + type + ": " + content + (isCreatePlayer ? " (another create_player attempt will follow)" : string.Empty)); if (!isCreatePlayer && type != "UnableToExecuteRequest") { result.Error = type + ": " + content; @@ -3054,10 +3065,10 @@ private static async Task HandleIncomingAsync( { state.LoggedPlayerSlotConflict = true; CapLog(append, state.DebugSessionId, "slot", - "Conflit : in_game=\"" + reportedId + "\" ≠ capture=\"" + state.ConnectionId + - "\" (max_num_players=1). remove_player GAMA en cours…"); - append("[GAMA] Slot GAMA occupé par « " + reportedId + " » — libération via remove_player. " + - "Astuce : laissez l’ID connexion vide pour utiliser " + StaticInformation.getId() + " (comme au Play)."); + "Conflict: in_game=\"" + reportedId + "\" != capture=\"" + state.ConnectionId + + "\" (max_num_players=1). GAMA remove_player in progress..."); + append("[GAMA] GAMA player slot occupied by '" + reportedId + "'; releasing it through remove_player. " + + "Tip: leave the connection ID empty to use " + StaticInformation.getId() + " (as in Play Mode)."); } } @@ -3093,14 +3104,14 @@ private static async Task HandleIncomingAsync( } } - append("[GAMA] Client authentifié côté middleware (in_game=true, id=" + state.ConnectionId + ")." + - (state.ManagedFromUnity && EDITOR_PREVIEW_IMMEDIATE_INIT_BURST - ? " Burst send_init_data immédiat sur 8080 (pas d'attente create_player)." - : state.ManagedFromUnity - ? " Attente create_player GAMA avant send_init_data." - : state.SkipRemoteLoad - ? " Pompe middleware : send_init_data + player_ready (comme au Play, 8080 seul)." - : " Envoi de send_init_data.")); + append("[GAMA] Client authenticated on the middleware (in_game=true, id=" + state.ConnectionId + ")." + + (state.ManagedFromUnity && EDITOR_PREVIEW_IMMEDIATE_INIT_BURST + ? " Immediate send_init_data burst on 8080 (does not wait for create_player)." + : state.ManagedFromUnity + ? " Waiting for GAMA create_player before send_init_data." + : state.SkipRemoteLoad + ? " Middleware pump: send_init_data + player_ready (as in Play Mode, 8080 only)." + : " Sending send_init_data.")); if (state.SkipRemoteLoad && state.MiddlewarePlayPhaseStartedUtc == DateTime.MinValue) { state.MiddlewarePlayPhaseStartedUtc = DateTime.UtcNow; @@ -3111,13 +3122,13 @@ private static async Task HandleIncomingAsync( state.LoggedMiddlewareInGameHint = true; if (state.ManagedFromUnity && !state.LaunchExperimentViaMonitor) { - append("[GAMA] Middleware connecté mais in_game=false — séquence Play-like maintenue, init 8080 si la session ne passe pas in_game rapidement."); + append("[GAMA] Middleware connected but in_game=false; keeping the Play-like sequence and initializing on 8080 if the session does not enter in_game quickly."); } else { append(state.SkipRemoteLoad - ? "[GAMA] Middleware connecté (in_game=false) — attente create_player / send_init_data." - : "[GAMA] Middleware connecté mais in_game=false — démarrage auto de la sim GAMA (port 1000) si l’expérience est importée."); + ? "[GAMA] Middleware connected (in_game=false); waiting for create_player / send_init_data." + : "[GAMA] Middleware connected but in_game=false; automatically starting the GAMA simulation (port 1000) if the experiment is imported."); } } @@ -3133,12 +3144,12 @@ private static async Task HandleIncomingAsync( { state.DirectLoadCompleted = true; state.ExtendCaptureDeadline(120f); - append("[GAMA] Load confirmé par GAMA."); + append("[GAMA] Load confirmed by GAMA."); } else if (commandType == "play") { state.DirectPlayCompleted = true; - append("[GAMA] Play confirmé par GAMA."); + append("[GAMA] Play confirmed by GAMA."); await TryDirectCreatePlayerAsync(ws, state, append, ct, force: true).ConfigureAwait(false); } else if (commandType == "expression") @@ -3157,7 +3168,7 @@ private static async Task HandleIncomingAsync( } state.ExtendCaptureDeadline(Math.Max(90f, state.WorldPhaseExtraSeconds + 45f)); - append("[GAMA] create_player confirmé par GAMA. Fenêtre de capture prolongée pour réception des JSON."); + append("[GAMA] create_player confirmed by GAMA. Capture window extended to receive JSON."); } } else if (commandType == "ask" && @@ -3166,11 +3177,11 @@ private static async Task HandleIncomingAsync( state.SendInitDataSuccessCount++; if (state.SendInitDataSuccessCount == 1) { - append("[GAMA] send_init_data confirmé par GAMA, mais aucune donnée JSON n'est encore arrivée."); + append("[GAMA] send_init_data confirmed by GAMA, but no JSON data has arrived yet."); } else if (state.SendInitDataSuccessCount == 3 && state.DirectGamaMode && state.OtherOutputCount == 0) { - append("[GAMA] send_init_data confirmé plusieurs fois sans SimulationOutput/json_output. Le serveur GAMA exécute l'action mais ne publie pas les données Unity sur ce WebSocket."); + append("[GAMA] send_init_data confirmed multiple times without SimulationOutput/json_output. The GAMA server executes the action but does not publish Unity data on this WebSocket."); } } @@ -3227,7 +3238,7 @@ private static async Task HandleIncomingAsync( if (type != "json_output") { - Dbg(append, state.DebugSessionId, "in", "fin handler pour type=" + type + " (pas json_output)"); + Dbg(append, state.DebugSessionId, "in", "handler complete for type=" + type + " (not json_output)"); return; } @@ -3240,7 +3251,7 @@ private static async Task HandleIncomingAsync( } } - Dbg(append, state.DebugSessionId, "json", "json_output reçu"); + Dbg(append, state.DebugSessionId, "json", "json_output received"); ProcessJsonOutputContents(json["contents"], outputDirectory, result, append, state); } @@ -3249,13 +3260,13 @@ private static void LogRawIncomingMessage(string source, string text) const int chunkSize = 12000; if (string.IsNullOrEmpty(text)) { - Debug.Log("[GAMA][RAW][" + source + "] "); + GamaLog.Dev("[GAMA][RAW][" + source + "] "); return; } if (text.Length <= chunkSize) { - Debug.Log("[GAMA][RAW][" + source + "] " + text); + GamaLog.Dev("[GAMA][RAW][" + source + "] " + text); return; } @@ -3264,7 +3275,7 @@ private static void LogRawIncomingMessage(string source, string text) { int start = i * chunkSize; int length = Math.Min(chunkSize, text.Length - start); - Debug.Log("[GAMA][RAW][" + source + "] chunk " + (i + 1) + "/" + total + ": " + text.Substring(start, length)); + GamaLog.Dev("[GAMA][RAW][" + source + "] chunk " + (i + 1) + "/" + total + ": " + text.Substring(start, length)); } } @@ -3424,7 +3435,7 @@ private static void ProcessJsonOutputContents( { result.PrecisionJsonPath = written; state.GotPrecision = true; - append("[GAMA] precision.json capturé."); + append("[GAMA] precision.json captured."); Dbg(append, state.DebugSessionId, "json", "precision OK → " + written); } } @@ -3448,7 +3459,7 @@ private static void ProcessJsonOutputContents( state.PropertyMapById = new Dictionary(StringComparer.OrdinalIgnoreCase); } - append("[GAMA] properties.json capturé."); + append("[GAMA] properties.json captured."); Dbg(append, state.DebugSessionId, "json", "properties OK → " + written); } } @@ -3496,8 +3507,8 @@ private static void TryCaptureWorldFrame( if (!state.WorldPhaseStartedUtc.HasValue) { state.WorldPhaseStartedUtc = DateTime.UtcNow; - append("[GAMA][PREVIEW] Fenêtre d'aperçu cumulatif : warmup " + - state.WorldPhaseExtraSeconds.ToString("0") + " s, stabilité " + + append("[GAMA][PREVIEW] Cumulative preview window: warmup " + + state.WorldPhaseExtraSeconds.ToString("0") + " s, stability " + state.PreviewStableSeconds.ToString("0") + " s, max " + state.MaxWorldFrames + " ticks."); } @@ -3516,7 +3527,7 @@ private static void TryCaptureWorldFrame( if (merge.ExplicitReset) { - append("[GAMA][PREVIEW] reset explicite reçu : cache d'aperçu vidé avant fusion."); + append("[GAMA][PREVIEW] Explicit reset received: preview cache cleared before merging."); } append(GamaEditorPreviewCapture.FormatChunkSpeciesCountsLine(frameIndex, merge.ChunkSpeciesCounts)); @@ -3525,11 +3536,11 @@ private static void TryCaptureWorldFrame( if (merge.DynamicCacheAgentCount > 0) { state.PreviewDynamicAgentsFound = true; - append("[GAMA][PREVIEW] cache agents dynamiques (regex) : " + merge.DynamicCacheAgentCount); + append("[GAMA][PREVIEW] Dynamic-agent cache (regex): " + merge.DynamicCacheAgentCount); } if (merge.ReplacedDynamicAgentCount > 0) { - append("[GAMA][PREVIEW] positions dynamiques remplacées : " + merge.ReplacedDynamicAgentCount); + append("[GAMA][PREVIEW] Dynamic positions replaced: " + merge.ReplacedDynamicAgentCount); } if (merge.CacheGrew || !state.LastPreviewCacheGrowthUtc.HasValue) @@ -3565,10 +3576,10 @@ private static void TryCaptureWorldFrame( state.WorldHasAgents = true; } - append("[GAMA] " + tickFileName + " cumulatif (chunk " + merge.ChunkAgentCount + - " agent(s), " + merge.ChunkGeometryCount + " géométrie(s); cache " + + append("[GAMA] " + tickFileName + " cumulative (chunk " + merge.ChunkAgentCount + + " agent(s), " + merge.ChunkGeometryCount + " geometry item(s); cache " + merge.CacheAgentCount + " agent(s), +" + merge.NewAgentCount + - " nouveau(x), " + merge.UpdatedAgentCount + " maj). Chunk brut : " + chunkFileName + "."); + " new, " + merge.UpdatedAgentCount + " updated). Raw chunk: " + chunkFileName + "."); } private static void FinalizePreviewBestFrame( @@ -3601,12 +3612,12 @@ private static void FinalizePreviewBestFrame( { state.WorldBestJsonPath = bestPath; result.WorldJsonPath = bestPath; - append("[GAMA][PREVIEW] Aucun agent : aperçu cumulé = dernier tick reçu (tick " + state.LastWorldFrameIndex + ")."); + append("[GAMA][PREVIEW] No agents: cumulative preview uses the last received tick (tick " + state.LastWorldFrameIndex + ")."); } } catch (Exception ex) { - append("[GAMA][PREVIEW] Impossible de finaliser le dernier tick : " + ex.Message); + append("[GAMA][PREVIEW] Could not finalize the last tick: " + ex.Message); } } @@ -3629,7 +3640,7 @@ private static string WriteFile(string outputDirectory, string fileName, string } catch (Exception ex) { - Debug.LogWarning("[GAMA] Impossible d'écrire " + fileName + " : " + ex.Message); + GamaLog.Warning("[GAMA] Could not write " + fileName + ": " + ex.Message); return null; } } @@ -3702,13 +3713,13 @@ private static void DetectGeometryExportError( state.GeometryExportErrorDetected = true; state.GeometryExportErrorMessage = - "GAMA a levé une erreur pendant l'export des géométries. L'aperçu peut être incomplet."; + "GAMA raised an error while exporting geometries. The preview may be incomplete."; if (result != null && string.IsNullOrWhiteSpace(result.PreviewWarning)) { result.PreviewWarning = state.GeometryExportErrorMessage; } - append("[GAMA][PREVIEW] AVERTISSEMENT : " + state.GeometryExportErrorMessage); + append("[GAMA][PREVIEW] WARNING: " + state.GeometryExportErrorMessage); } private static string BuildMissingError(CaptureState state) @@ -3739,8 +3750,8 @@ private static string BuildMissingError(CaptureState state) { if (!state.LaunchExperimentViaMonitor) { - return "Impossible de générer la preview : aucune expérience sélectionnée ou prête côté GAMA. " + - "Sélectionnez l'expérience dans GAMA, puis réessayez."; + return "Could not generate the preview: no experiment is selected or ready in GAMA. " + + "Select the experiment in GAMA, then try again."; } return "Unity-managed capture: experiment launched via monitor but no json_output on 8080. " + diff --git a/Editor/GamaEditorLocalMiddleware.cs b/Editor/GamaEditorLocalMiddleware.cs index 48e923a..2d05aa3 100644 --- a/Editor/GamaEditorLocalMiddleware.cs +++ b/Editor/GamaEditorLocalMiddleware.cs @@ -308,13 +308,13 @@ private static void LogRawIncomingMessage(string source, string text) const int chunkSize = 12000; if (string.IsNullOrEmpty(text)) { - Debug.Log("[GAMA][RAW][" + source + "] "); + GamaLog.Dev("[GAMA][RAW][" + source + "] "); return; } if (text.Length <= chunkSize) { - Debug.Log("[GAMA][RAW][" + source + "] " + text); + GamaLog.Dev("[GAMA][RAW][" + source + "] " + text); return; } @@ -323,7 +323,7 @@ private static void LogRawIncomingMessage(string source, string text) { int start = i * chunkSize; int length = Math.Min(chunkSize, text.Length - start); - Debug.Log("[GAMA][RAW][" + source + "] chunk " + (i + 1) + "/" + total + ": " + text.Substring(start, length)); + GamaLog.Dev("[GAMA][RAW][" + source + "] chunk " + (i + 1) + "/" + total + ": " + text.Substring(start, length)); } } diff --git a/Editor/GamaEditorMiddlewareLauncher.cs b/Editor/GamaEditorMiddlewareLauncher.cs index fa09c83..ac8c568 100644 --- a/Editor/GamaEditorMiddlewareLauncher.cs +++ b/Editor/GamaEditorMiddlewareLauncher.cs @@ -4,8 +4,8 @@ using UnityEngine; /// -/// Génère un script de démarrage Node pour simple.webplatform avec LEARNING_PACKAGE_PATH -/// pointant vers le package Unity (sans modifier le dépôt middleware). +/// Generates a Node startup script for simple.webplatform with LEARNING_PACKAGE_PATH +/// pointing to the Unity package, without modifying the middleware repository. /// internal static class GamaEditorMiddlewareLauncher { @@ -43,13 +43,13 @@ public static bool TryWriteLauncherScript(string learningPackageRoot, out string if (string.IsNullOrWhiteSpace(learningPackageRoot) || !Directory.Exists(learningPackageRoot)) { - error = "Dossier learning package introuvable : " + learningPackageRoot; + error = "Learning package folder not found: " + learningPackageRoot; return false; } if (!TryResolveWebplatformRoot(out string webplatformRoot)) { - error = "Dépôt simple.webplatform introuvable (attendu sur le Bureau ou à côté du projet Unity)."; + error = "simple.webplatform repository not found (expected on the Desktop or next to the Unity project)."; return false; } @@ -77,7 +77,7 @@ public static bool TryWriteLauncherScript(string learningPackageRoot, out string } catch (Exception ex) { - error = "Impossible d'écrire le script de lancement : " + ex.Message; + error = "Could not write the startup script: " + ex.Message; return false; } } @@ -86,16 +86,16 @@ public static string BuildManualRestartHint(string learningPackageRoot) { if (!TryResolveWebplatformRoot(out string webplatformRoot)) { - return "Fermez le middleware actuel, puis relancez Node avec LEARNING_PACKAGE_PATH=\"" + + return "Close the current middleware, then restart Node with LEARNING_PACKAGE_PATH=\"" + (learningPackageRoot ?? "?") + "\"."; } - return "1) Fermez le terminal/processus Node qui écoute sur le port 8001.\n" + - "2) Ouvrez PowerShell dans : " + webplatformRoot + "\n" + - "3) Exécutez :\n" + + return "1) Close the terminal or Node process listening on port 8001.\n" + + "2) Open PowerShell in: " + webplatformRoot + "\n" + + "3) Run:\n" + " $env:LEARNING_PACKAGE_PATH=\"" + Path.GetFullPath(learningPackageRoot ?? string.Empty) + "\"\n" + " $env:EXTRA_LEARNING_PACKAGE_PATH=\"\"\n" + " npx tsx src/api/index.ts\n" + - "4) Dans Unity, « Diagnostiquer catalogue middleware » doit afficher le .gaml et l'expérience sélectionnés."; + "4) In Unity, 'Diagnose Middleware Catalog' should display the selected .gaml file and experiment."; } } diff --git a/Editor/GamaEditorMiddlewareOrchestrator.cs b/Editor/GamaEditorMiddlewareOrchestrator.cs index 5ce0eb1..45fc8ac 100644 --- a/Editor/GamaEditorMiddlewareOrchestrator.cs +++ b/Editor/GamaEditorMiddlewareOrchestrator.cs @@ -11,9 +11,9 @@ using UnityEngine; /// -/// Reproduit le rôle de l'UI web simple.webplatform (WebSocket monitor, port 8001 par défaut) : -/// sélection VU, launch_experiment, resume_experiment si besoin. -/// Sans modifier le dépôt middleware. +/// Replicates the simple.webplatform web UI workflow (WebSocket monitor, default port 8001): +/// VU selection, launch_experiment, and resume_experiment when needed. +/// Does not modify the middleware repository. /// internal static class GamaEditorMiddlewareOrchestrator { @@ -153,7 +153,7 @@ public static List GetListeningPidsOnTcpPort(int port, Action log = } catch (Exception ex) { - log?.Invoke("[GAMA][MW] netstat impossible : " + ex.Message); + log?.Invoke("[GAMA][MW] netstat failed: " + ex.Message); } return pids; @@ -203,7 +203,7 @@ public static bool KillProcessByPid(int pid, Action log = null) } catch (Exception ex) { - log?.Invoke("[GAMA][MW] taskkill PID=" + pid + " impossible : " + ex.Message); + log?.Invoke("[GAMA][MW] taskkill PID=" + pid + " failed: " + ex.Message); return false; } } @@ -251,7 +251,7 @@ void Append(string line) } else { - UnityEngine.Debug.Log(line); + GamaLog.Dev(line); } } catch @@ -263,12 +263,12 @@ void Append(string line) string hostNorm = string.IsNullOrWhiteSpace(host) ? "localhost" : host.Trim(); int port = monitorPort > 0 ? monitorPort : DefaultMonitorPort; Uri monitorUri = new Uri("ws://" + hostNorm + ":" + port + "/"); - Append("[GAMA][ORCH][DIAG] Connexion monitor " + monitorUri); + Append("[GAMA][ORCH][DIAG] Connecting to monitor " + monitorUri); if (!await IsTcpPortOpenAsync(hostNorm, port, 3000, ct).ConfigureAwait(false)) { diagnosis.Status = CatalogMatchStatus.MiddlewareNotReachable; - diagnosis.Error = "Monitor middleware injoignable sur ws://" + hostNorm + ":" + port + "/."; + diagnosis.Error = "The middleware monitor is unreachable at ws://" + hostNorm + ":" + port + "/."; diagnosis.LogTrail = trail.ToString(); return diagnosis; } @@ -287,7 +287,7 @@ void Append(string line) catch (Exception ex) { diagnosis.Status = CatalogMatchStatus.MiddlewareNotReachable; - diagnosis.Error = "Connexion monitor impossible : " + ex.Message; + diagnosis.Error = "Could not connect to the monitor: " + ex.Message; diagnosis.LogTrail = trail.ToString(); return diagnosis; } @@ -301,7 +301,7 @@ void Append(string line) if (catalog == null) { diagnosis.Status = CatalogMatchStatus.CatalogEmpty; - diagnosis.Error = "Catalogue non reçu (timeout monitor)."; + diagnosis.Error = "Catalog was not received (monitor timeout)."; diagnosis.LogTrail = trail.ToString(); session.Stop(); try { await receiveTask.ConfigureAwait(false); } catch { /* ignore */ } @@ -348,7 +348,7 @@ void Append(string line) diagnosis.Error = diagnosis.Success ? string.Empty - : "Catalogue mismatch: " + (lookup.Details ?? "No matching json_settings."); + : "Catalog mismatch: " + (lookup.Details ?? "No matching json_settings."); diagnosis.LogTrail = trail.ToString(); session.Stop(); @@ -373,7 +373,7 @@ void Append(string line) } /// - /// Attend que le TCP du monitor (ex. 8001) réponde après un démarrage de process Node. + /// Waits for the monitor TCP port (for example, 8001) to respond after a Node process starts. /// public static async Task WaitForMonitorReachableAsync( string host, @@ -398,7 +398,7 @@ public static async Task WaitForMonitorReachableAsync( } /// - /// Séquence monitor : get_simulation_informations → send_simulation → launch_experiment → resume si PAUSED. + /// Monitor sequence: get_simulation_informations → send_simulation → launch_experiment → resume when PAUSED. /// public static async Task StartMiddlewareManagedExperimentAsync( string host, @@ -421,7 +421,7 @@ void Append(string line) } else { - UnityEngine.Debug.Log(line); + GamaLog.Dev(line); } } catch @@ -434,12 +434,12 @@ void Append(string line) int port = monitorPort > 0 ? monitorPort : DefaultMonitorPort; Uri monitorUri = new Uri("ws://" + hostNorm + ":" + port + "/"); - Append("[GAMA][ORCH] Mode piloté Unity : monitor " + monitorUri + " (comme l'UI web, sans navigateur)."); + Append("[GAMA][ORCH] Unity-managed mode: monitor " + monitorUri + " (same workflow as the web UI, without a browser)."); if (!await IsTcpPortOpenAsync(hostNorm, port, 3000, ct).ConfigureAwait(false)) { - result.Error = "Monitor middleware injoignable sur ws://" + hostNorm + ":" + port + - "/. Lancez simple.webplatform en arrière-plan."; + result.Error = "The middleware monitor is unreachable at ws://" + hostNorm + ":" + port + + "/. Start simple.webplatform in the background."; result.LogTrail = trail.ToString(); return result; } @@ -457,12 +457,12 @@ void Append(string line) } catch (Exception ex) { - result.Error = "Connexion monitor impossible : " + ex.Message; + result.Error = "Could not connect to the monitor: " + ex.Message; result.LogTrail = trail.ToString(); return result; } - Append("[GAMA][ORCH] Connecté au monitor."); + Append("[GAMA][ORCH] Connected to the monitor."); MonitorSession session = new MonitorSession(ws, Append); Task receiveTask = session.RunReceiveLoopAsync(ct); @@ -475,8 +475,8 @@ void Append(string line) JArray catalog = await session.WaitForCatalogAsync(TimeSpan.FromSeconds(20), ct).ConfigureAwait(false); if (catalog == null) { - result.Error = "Catalogue VU non reçu du monitor (get_simulation_informations). " + - "Le lancement strict par catalogue est impossible; Play tentera de s'attacher à l'expérience monitor courante."; + result.Error = "The VU catalog was not received from the monitor (get_simulation_informations). " + + "A strict catalog launch is not possible; Play will try to attach to the monitor's current experiment."; session.Stop(); try { @@ -497,13 +497,13 @@ void Append(string line) LogCatalogDiagnostics(lookupCatalog, Append); if (!lookup.Found || lookup.Settings == null) { - string why = string.IsNullOrWhiteSpace(lookup.Details) ? "Aucun match strict experiment/model." : lookup.Details; + string why = string.IsNullOrWhiteSpace(lookup.Details) ? "No strict experiment/model match." : lookup.Details; string fallback = - "Le modèle est sélectionné dans Unity mais absent du catalogue middleware. " + - "Le monitor 8001 ne peut pas lancer cette expérience par catalogue. " + - "Play doit alors s'attacher à l'expérience GAMA/monitor déjà ouverte."; - result.Error = "Expérience Unity introuvable dans le catalogue middleware. " + why + " " + - "Sélection Unity attendue: experiment=\"" + (experimentName ?? "?") + "\", modelPath=\"" + + "The model is selected in Unity but is missing from the middleware catalog. " + + "Monitor 8001 cannot launch this experiment from the catalog. " + + "Play must instead attach to the GAMA experiment that is already open in the monitor."; + result.Error = "The Unity experiment was not found in the middleware catalog. " + why + " " + + "Expected Unity selection: experiment=\"" + (experimentName ?? "?") + "\", modelPath=\"" + (modelFilePathHint ?? "?") + "\". " + fallback; session.Stop(); try @@ -521,7 +521,7 @@ void Append(string line) JObject simulationSettings = lookup.Settings; result.SelectedModelIndex = lookup.ModelIndex; - Append("[GAMA][ORCH] VU sélectionné (match strict Unity) : name=" + simulationSettings["name"] + + Append("[GAMA][ORCH] VU selected (strict Unity match): name=" + simulationSettings["name"] + " exp=" + simulationSettings["experiment_name"] + " model=" + simulationSettings["model_file_path"] + " model_index=" + lookup.ModelIndex + @@ -541,7 +541,7 @@ void Append(string line) if (!session.LastGamaConnected) { - Append("[GAMA][ORCH] GAMA pas encore connecté côté middleware — lancez GAMA (Yes) puis réessayez."); + Append("[GAMA][ORCH] GAMA is not connected to the middleware yet. Start GAMA (choose Yes), then try again."); } Append("[GAMA][ORCH] → launch_experiment"); @@ -566,8 +566,8 @@ void Append(string line) string.Equals(expState, "NOTREADY", StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(expState)) { - result.Error = "launch_experiment n'a pas fait sortir l'expérience de NONE/NOTREADY (état=" + - (string.IsNullOrEmpty(expState) ? "?" : expState) + "). GAMA connecté au middleware ?"; + result.Error = "launch_experiment did not move the experiment out of NONE/NOTREADY (state=" + + (string.IsNullOrEmpty(expState) ? "?" : expState) + "). Is GAMA connected to the middleware?"; session.Stop(); try { @@ -584,7 +584,7 @@ void Append(string line) if (string.Equals(expState, "PAUSED", StringComparison.OrdinalIgnoreCase)) { - Append("[GAMA][ORCH] → resume_experiment (état PAUSED après load)"); + Append("[GAMA][ORCH] → resume_experiment (state is PAUSED after load)"); await session.SendAsync(new JObject { ["type"] = "resume_experiment" }, ct).ConfigureAwait(false); DateTime resumeDeadline = DateTime.UtcNow.AddSeconds(60); while (DateTime.UtcNow < resumeDeadline && !ct.IsCancellationRequested) @@ -602,7 +602,7 @@ void Append(string line) result.FinalExperimentState = session.LastExperimentState ?? expState; result.ExperimentId = session.LastExperimentId ?? string.Empty; result.Success = true; - Append("[GAMA][ORCH] Expérience prête : experiment_state=" + result.FinalExperimentState + + Append("[GAMA][ORCH] Experiment is ready: experiment_state=" + result.FinalExperimentState + (string.IsNullOrEmpty(result.ExperimentId) ? string.Empty : " exp_id=" + result.ExperimentId)); session.Stop(); @@ -661,7 +661,7 @@ void Append(string line) } else { - UnityEngine.Debug.Log(line); + GamaLog.Dev(line); } } catch @@ -678,8 +678,8 @@ void Append(string line) if (!await IsTcpPortOpenAsync(hostNorm, port, 3000, ct).ConfigureAwait(false)) { - result.Error = "Monitor middleware injoignable sur ws://" + hostNorm + ":" + port + - "/. Lancez simple.webplatform en arrière-plan."; + result.Error = "The middleware monitor is unreachable at ws://" + hostNorm + ":" + port + + "/. Start simple.webplatform in the background."; result.LogTrail = trail.ToString(); return result; } @@ -697,12 +697,12 @@ void Append(string line) } catch (Exception ex) { - result.Error = "Connexion monitor impossible : " + ex.Message; + result.Error = "Could not connect to the monitor: " + ex.Message; result.LogTrail = trail.ToString(); return result; } - Append("[GAMA][ORCH][PLAY] Connecté au monitor."); + Append("[GAMA][ORCH][PLAY] Connected to the monitor."); MonitorSession session = new MonitorSession(ws, Append); Task receiveTask = session.RunReceiveLoopAsync(ct); @@ -750,7 +750,7 @@ void Append(string line) if (string.Equals(expState, "PAUSED", StringComparison.OrdinalIgnoreCase)) { - Append("[GAMA][ORCH][PLAY] → resume_experiment (état PAUSED)"); + Append("[GAMA][ORCH][PLAY] → resume_experiment (state is PAUSED)"); await session.SendAsync(new JObject { ["type"] = "resume_experiment" }, ct).ConfigureAwait(false); DateTime resumeDeadline = DateTime.UtcNow.AddSeconds(45); while (DateTime.UtcNow < resumeDeadline && !ct.IsCancellationRequested) @@ -772,13 +772,13 @@ void Append(string line) !string.Equals(result.FinalExperimentState, "NOTREADY", StringComparison.OrdinalIgnoreCase); if (!result.Success) { - result.Error = "Aucune expérience GAMA active détectée par le monitor (état=" + - (string.IsNullOrEmpty(result.FinalExperimentState) ? "?" : result.FinalExperimentState) + - ") après tentative de lancement de la sélection monitor courante."; + result.Error = "No active GAMA experiment was detected by the monitor (state=" + + (string.IsNullOrEmpty(result.FinalExperimentState) ? "?" : result.FinalExperimentState) + + ") after attempting to launch the monitor's current selection."; } else { - Append("[GAMA][ORCH][PLAY] Expérience active détectée : experiment_state=" + result.FinalExperimentState + + Append("[GAMA][ORCH][PLAY] Active experiment detected: experiment_state=" + result.FinalExperimentState + (string.IsNullOrEmpty(result.ExperimentId) ? string.Empty : " exp_id=" + result.ExperimentId)); } @@ -836,7 +836,7 @@ private static JsonSettingsLookupResult TryFindJsonSettings( if (string.IsNullOrEmpty(expWanted) || (string.IsNullOrEmpty(pathWanted) && string.IsNullOrEmpty(fileWanted))) { - result.Details = "Sélection Unity incomplète (experimentName/modelPath)."; + result.Details = "The Unity selection is incomplete (experimentName/modelPath)."; return result; } @@ -913,14 +913,14 @@ private static JsonSettingsLookupResult TryFindJsonSettings( if (unityWantsPath && !string.IsNullOrWhiteSpace(solePath)) { result.Details = - "L'expérience \"" + experimentName + - "\" existe dans le catalogue middleware, mais le .gaml catalogué ne correspond pas à la sélection Unity (" + - (modelFilePathHint ?? "?") + "). Modèle dans le catalogue: " + solePath.Trim() + - ". Le lancement strict par catalogue sera remplacé par un attach-to-current-monitor si une expérience GAMA est déjà active."; + "Experiment \"" + experimentName + + "\" exists in the middleware catalog, but its cataloged .gaml file does not match the Unity selection (" + + (modelFilePathHint ?? "?") + "). Catalog model: " + solePath.Trim() + + ". The strict catalog launch will fall back to attach-to-current-monitor if a GAMA experiment is already active."; return result; } - // Pas de chemin Unity à appliquer, ou pas de model_file_path côté entrée : comportement legacy. + // No Unity path to apply, or the entry has no model_file_path: preserve legacy behavior. result.Settings = sole; if (result.Settings["model_index"] != null && result.Settings["model_index"].Type == JTokenType.Integer) { @@ -929,27 +929,27 @@ private static JsonSettingsLookupResult TryFindJsonSettings( result.Found = true; result.Details = - "Match fallback: experiment exact, sans contrainte model_path Unity exploitable."; + "Fallback match: exact experiment, with no usable Unity model_path constraint."; return result; } if (experimentOnlyMatches.Count > 1) { string listedModels = experimentModelPaths.Count == 0 - ? "(aucun model_file_path)" + ? "(no model_file_path)" : string.Join(" | ", experimentModelPaths); result.Details = - "Plusieurs json_settings pour experiment=\"" + experimentName + - "\" mais aucun model_path compatible avec \"" + modelFilePathHint + - "\". Modèles du catalogue: " + listedModels; + "Multiple json_settings entries exist for experiment=\"" + experimentName + + "\", but no model_path is compatible with \"" + modelFilePathHint + + "\". Catalog models: " + listedModels; return result; } string experimentsList = JoinOrNone(availableExperiments); string modelsList = JoinOrNone(availableModels); - result.Details = "Aucun json_settings avec experiment exact \"" + experimentName + "\". " + - "Experiments disponibles: " + experimentsList + ". " + - "Modèles disponibles: " + modelsList + "."; + result.Details = "No json_settings entry has the exact experiment \"" + experimentName + "\". " + + "Available experiments: " + experimentsList + ". " + + "Available models: " + modelsList + "."; return result; } @@ -966,7 +966,7 @@ private static JsonSettingsLookupResult TryFindJsonSettings( candidates[1].pathScore == best.pathScore) { result.Ambiguous = true; - result.Details = "Correspondance ambiguë: plusieurs json_settings matchent experiment/model sélectionnés."; + result.Details = "Ambiguous match: multiple json_settings entries match the selected experiment/model."; return result; } @@ -977,7 +977,7 @@ private static JsonSettingsLookupResult TryFindJsonSettings( } result.Found = true; - result.Details = "Match strict experiment/model appliqué."; + result.Details = "Strict experiment/model match applied."; return result; } @@ -1073,9 +1073,9 @@ private static bool ContainsModelLike(List availableModels, string reque } /// - /// Le monitor envoie un catalogue « light » (name, model_index) sans - /// experiment_name / model_file_path. On récupère le JSON complet via - /// get_simulation_by_index pour que le matching Unity soit correct. + /// The monitor sends a lightweight catalog (name, model_index) without + /// experiment_name / model_file_path. Fetches the full JSON through + /// get_simulation_by_index so Unity can match it correctly. /// private static async Task BuildEnrichedFlatCatalogAsync( MonitorSession session, @@ -1091,7 +1091,7 @@ private static async Task BuildEnrichedFlatCatalogAsync( if (miTok == null || miTok.Type != JTokenType.Integer) { flat.Add(entry); - append?.Invoke("[GAMA][ORCH][ENRICH] conserve entrée sans model_index (catalogue brut)."); + append?.Invoke("[GAMA][ORCH][ENRICH] Keeping entry without model_index (raw catalog)."); continue; } @@ -1115,7 +1115,7 @@ private static async Task BuildEnrichedFlatCatalogAsync( else { flat.Add(entry); - append?.Invoke("[GAMA][ORCH][ENRICH] échec enrichissement model_index=" + mi + " (fallback catalogue light)."); + append?.Invoke("[GAMA][ORCH][ENRICH] Failed to enrich model_index=" + mi + " (falling back to the lightweight catalog)."); } } @@ -1175,7 +1175,7 @@ private static void LogCatalogDiagnostics(JToken catalogRoot, Action app List entries = BuildCatalogDiagnostics(catalogRoot); if (entries.Count == 0) { - append("[GAMA][ORCH][CATALOG] Aucune entrée json_settings détectée."); + append("[GAMA][ORCH][CATALOG] No json_settings entries detected."); return; } @@ -1337,8 +1337,8 @@ public async Task WaitForMessageTypeAsync(string type, TimeSpan timeout, Cancell } /// - /// Récupère les réglages complets (incl. experiment_name, model_file_path) - /// pour un model_index du middleware. + /// Fetches the complete settings (including experiment_name and model_file_path) + /// for a middleware model_index. /// public async Task TryGetFullSimulationSettingsAsync(int modelIndex, CancellationToken ct) { @@ -1430,7 +1430,7 @@ public async Task RunReceiveLoopAsync(CancellationToken ct) } catch (Exception ex) { - _log("[GAMA][ORCH] Erreur réception monitor : " + ex.Message); + _log("[GAMA][ORCH] Monitor receive error: " + ex.Message); } } @@ -1465,7 +1465,7 @@ private void HandleMessage(string text) if (token is JArray rootArray) { - _log("[GAMA][ORCH][in] catalogue VU (tableau racine, " + rootArray.Count + " entrées)"); + _log("[GAMA][ORCH][in] VU catalog (root array, " + rootArray.Count + " entries)"); if (_catalogTcs != null) { _catalogTcs.TrySetResult(rootArray); @@ -1515,13 +1515,13 @@ private void HandleMessage(string text) } } - /// Met l'expérience en pause via le monitor. + /// Pauses the experiment through the monitor. public static async Task PauseExperimentAsync( string host, int monitorPort, CancellationToken ct, Action log = null, - string reason = "fin capture aperçu") + string reason = "preview capture completed") { string hostNorm = string.IsNullOrWhiteSpace(host) ? "localhost" : host.Trim(); int port = monitorPort > 0 ? monitorPort : DefaultMonitorPort; @@ -1538,12 +1538,12 @@ void Append(string line) // ignore } - UnityEngine.Debug.Log(line); + GamaLog.Dev(line); } if (!await IsTcpPortOpenAsync(hostNorm, port, 3000, ct).ConfigureAwait(false)) { - Append("[GAMA][ORCH] pause_experiment : monitor injoignable sur " + monitorUri); + Append("[GAMA][ORCH] pause_experiment: monitor is unreachable at " + monitorUri); return false; } @@ -1573,11 +1573,11 @@ void Append(string line) if (pauseConfirmed) { - Append("[GAMA][ORCH] pause_experiment confirmé : experiment_state=PAUSED"); + Append("[GAMA][ORCH] pause_experiment confirmed: experiment_state=PAUSED"); } else { - Append("[GAMA][ORCH] pause_experiment non confirmé : experiment_state=" + + Append("[GAMA][ORCH] pause_experiment was not confirmed: experiment_state=" + (string.IsNullOrEmpty(lastState) ? "?" : lastState)); } @@ -1611,7 +1611,7 @@ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "pause done", closeCts.T return pauseConfirmed; } - /// Relance l'expérience via le monitor. + /// Resumes the experiment through the monitor. public static async Task ResumeExperimentAsync( string host, int monitorPort, @@ -1634,12 +1634,12 @@ void Append(string line) // ignore } - UnityEngine.Debug.Log(line); + GamaLog.Dev(line); } if (!await IsTcpPortOpenAsync(hostNorm, port, 3000, ct).ConfigureAwait(false)) { - Append("[GAMA][ORCH] resume_experiment : monitor injoignable sur " + monitorUri); + Append("[GAMA][ORCH] resume_experiment: monitor is unreachable at " + monitorUri); return false; } @@ -1669,11 +1669,11 @@ void Append(string line) if (resumeConfirmed) { - Append("[GAMA][ORCH] resume_experiment confirmé : experiment_state=RUNNING"); + Append("[GAMA][ORCH] resume_experiment confirmed: experiment_state=RUNNING"); } else { - Append("[GAMA][ORCH] resume_experiment non confirmé : experiment_state=" + + Append("[GAMA][ORCH] resume_experiment was not confirmed: experiment_state=" + (string.IsNullOrEmpty(lastState) ? "?" : lastState)); } @@ -1735,12 +1735,12 @@ void Append(string line) // ignore } - UnityEngine.Debug.Log(line); + GamaLog.Dev(line); } if (!await IsTcpPortOpenAsync(hostNorm, port, 3000, ct).ConfigureAwait(false)) { - Append("[GAMA][ORCH] remove_player_headset : monitor injoignable sur " + monitorUri); + Append("[GAMA][ORCH] remove_player_headset: monitor is unreachable at " + monitorUri); return false; } diff --git a/Editor/GamaEditorPlayTargetResolver.cs b/Editor/GamaEditorPlayTargetResolver.cs index 5a40dba..2f7493a 100644 --- a/Editor/GamaEditorPlayTargetResolver.cs +++ b/Editor/GamaEditorPlayTargetResolver.cs @@ -4,7 +4,7 @@ using UnityEditor; using UnityEngine; -/// Résout modelPath + experiment pour Play / capture (Unity, pas l'app GAMA IDE). +/// Resolves modelPath + experiment for Play/capture in Unity, not in the GAMA IDE. internal static class GamaEditorPlayTargetResolver { private const string PlayModelPathPrefKey = "ProjectSimple.GamaUnity.Play.ModelPath"; @@ -14,7 +14,7 @@ public static bool TryResolve(out string modelPath, out string experimentName, o { modelPath = string.Empty; experimentName = string.Empty; - source = "aucune"; + source = "none"; GamaPanelWindow[] panels = Resources.FindObjectsOfTypeAll(); for (int i = 0; i < panels.Length; i++) @@ -22,14 +22,14 @@ public static bool TryResolve(out string modelPath, out string experimentName, o if (panels[i] != null && panels[i].TryGetOpenPanelSelection(out modelPath, out experimentName)) { - source = "panneau GAMA ouvert"; + source = "open GAMA Panel"; return true; } } if (GamaEditorRuntimeSelectionStore.TryLoad(out modelPath, out experimentName)) { - source = "sélection Unity enregistrée"; + source = "saved Unity selection"; return true; } @@ -38,7 +38,7 @@ public static bool TryResolve(out string modelPath, out string experimentName, o (session.activeGamaSelection || string.Equals(session.modelPath, "GAMA_ACTIVE_SELECTION", StringComparison.OrdinalIgnoreCase))) { - source = "aperçu GAMA actif (sélection monitor courante)"; + source = "active GAMA preview (current monitor selection)"; return false; } @@ -49,7 +49,7 @@ public static bool TryResolve(out string modelPath, out string experimentName, o { modelPath = session.modelPath; experimentName = session.experimentName; - source = "aperçu statique en scène" + (session.stale ? " (stale)" : string.Empty); + source = "static preview in the scene" + (session.stale ? " (stale)" : string.Empty); return true; } @@ -59,19 +59,19 @@ public static bool TryResolve(out string modelPath, out string experimentName, o { modelPath = playModel; experimentName = playExp; - source = "dernier Play réussi"; + source = "last successful Play session"; return true; } if (GamaEditorRuntimeSelectionStore.TryLoadFromGeneratedLearningPackage(out modelPath, out experimentName)) { - source = "dernier settings.json (capture middleware)"; + source = "latest settings.json (middleware capture)"; return true; } if (TryFromExperimentPathPref(out modelPath, out experimentName)) { - source = "Experiment Path (panneau GAMA)"; + source = "Experiment Path (GAMA Panel)"; return true; } @@ -124,7 +124,37 @@ private static bool TryFromExperimentPathPref(out string modelPath, out string e private static GamaPreviewSession FindPreviewSession() { - GameObject root = GameObject.Find("[GAMA] Static Experiment Preview"); - return root != null ? root.GetComponent() : null; + GamaPreviewSession[] sessions = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + GamaPreviewSession fallback = null; + for (int i = 0; i < sessions.Length; i++) + { + GamaPreviewSession session = sessions[i]; + if (session == null || session.gameObject == null) + { + continue; + } + + if (!session.stale && session.useThisPreviewForPlay) + { + return session; + } + + if (!session.stale && + string.Equals( + session.gameObject.name, + "[GAMA] Static Experiment Preview", + StringComparison.Ordinal)) + { + fallback = session; + } + else if (fallback == null) + { + fallback = session; + } + } + + return fallback; } } diff --git a/Editor/GamaEditorPreviewCapture.cs b/Editor/GamaEditorPreviewCapture.cs index 053120b..3df9ecf 100644 --- a/Editor/GamaEditorPreviewCapture.cs +++ b/Editor/GamaEditorPreviewCapture.cs @@ -6,7 +6,7 @@ using UnityEngine; /// -/// Comptage d'espèces par tick et sélection du meilleur frame d'aperçu (agents dynamiques tardifs). +/// Counts species per tick and selects the best preview frame, including late dynamic agents. /// internal static class GamaEditorPreviewCapture { diff --git a/Editor/GamaEditorPreviewOverrideApplier.cs b/Editor/GamaEditorPreviewOverrideApplier.cs index fbfd0d3..dcc17e8 100644 --- a/Editor/GamaEditorPreviewOverrideApplier.cs +++ b/Editor/GamaEditorPreviewOverrideApplier.cs @@ -44,26 +44,24 @@ public static void ApplyOverridesToCurrentPreview() { if (EditorApplication.isPlayingOrWillChangePlaymode) return; - GameObject root = GameObject.Find(PreviewRootName); + GameObject root = FindPreviewRootIncludingInactive(); if (root == null) { return; } - GamaPreviewSession session = root.GetComponent(); - GamaSpeciesRenderOverrides asset = session != null ? session.speciesOverrides : null; - if (asset == null) + if (!GamaSpeciesAppearanceEditorCoordinator.TryResolveActiveContext( + out GamaSpeciesAppearanceContext context)) { - asset = GamaSpeciesRenderOverridesEditorStore.GetOrCreateDefaultAsset(); + return; } + GamaSpeciesRenderOverrides asset = context.Asset; - if (asset == null || asset.entries == null) + if (asset == null) { return; } - NormalizePreviewContainerScales(root.transform); - GamaPreviewObject[] previewObjects = root.GetComponentsInChildren(true); Dictionary> objectsBySpecies = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); @@ -83,8 +81,8 @@ public static void ApplyOverridesToCurrentPreview() list.Add(obj); } - string modelPath = session != null ? session.modelPath ?? string.Empty : string.Empty; - string experimentName = session != null ? session.experimentName ?? string.Empty : string.Empty; + string modelPath = context.ModelPath; + string experimentName = context.ExperimentName; int totalUpdated = 0; foreach (KeyValuePair> pair in objectsBySpecies) @@ -95,21 +93,16 @@ public static void ApplyOverridesToCurrentPreview() continue; } - bool exactContext = session != null; - if (!asset.TryGetOverride(modelPath, experimentName, speciesName, out GamaSpeciesRenderOverrideEntry entry, exactContext) || - entry == null) - { - continue; - } - - string source = exactContext ? "context" : "contextless-fallback"; - if (!exactContext) + GamaSpeciesAppearanceStateStore.TryGetEntry( + context, + speciesName, + false, + out GamaSpeciesRenderOverrideEntry entry); + if (entry != null) { - Debug.LogWarning("[GAMA][OVERRIDE][WARN] contextless fallback used species=" + speciesName); + LogEditorOverridePickOnce(speciesName, modelPath, experimentName, entry, "context"); } - LogEditorOverridePickOnce(speciesName, modelPath, experimentName, entry, source); - List list = pair.Value; if (list == null || list.Count == 0) { @@ -129,15 +122,13 @@ public static void ApplyOverridesToCurrentPreview() if (updatedRenderers > 0) { - Debug.Log("[GAMA][PREVIEW] Applied prefab visuals species=" + speciesName + + GamaLog.Dev("[GAMA][PREVIEW] Applied prefab visuals species=" + speciesName + " objects=" + list.Count + " renderers=" + updatedRenderers); totalUpdated += list.Count; } } - RunActivePreviewSpreadDiagnostics(root.transform, "all-overrides"); - if (totalUpdated > 0) { SceneView.RepaintAll(); @@ -177,41 +168,33 @@ private static void ApplySpeciesOverrideToCurrentPreview( return; } - GameObject root = GameObject.Find(PreviewRootName); + GameObject root = FindPreviewRootIncludingInactive(); if (root == null) { return; } - NormalizePreviewContainerScales(root.transform); - if (entry == null) { - GamaPreviewSession session = root.GetComponent(); - GamaSpeciesRenderOverrides asset = session != null && session.speciesOverrides != null - ? session.speciesOverrides - : GamaSpeciesRenderOverridesEditorStore.GetOrCreateDefaultAsset(); - string modelPath = session != null ? session.modelPath ?? string.Empty : string.Empty; - string experimentName = session != null ? session.experimentName ?? string.Empty : string.Empty; - bool exactContext = session != null; - if (asset == null || - !asset.TryGetOverride(modelPath, experimentName, speciesName, out entry, exactContext) || - entry == null) + if (!GamaSpeciesAppearanceEditorCoordinator.TryResolveActiveContext( + out GamaSpeciesAppearanceContext context)) { return; } - - if (!exactContext) + GamaSpeciesAppearanceStateStore.TryGetEntry( + context, + speciesName, + false, + out entry); + if (entry != null) { - Debug.LogWarning("[GAMA][OVERRIDE][WARN] contextless fallback used species=" + speciesName); + LogEditorOverridePickOnce( + speciesName, + context.ModelPath, + context.ExperimentName, + entry, + "context"); } - - LogEditorOverridePickOnce( - speciesName, - modelPath, - experimentName, - entry, - exactContext ? "context" : "contextless-fallback"); } else { @@ -242,12 +225,11 @@ private static void ApplySpeciesOverrideToCurrentPreview( { if (!string.IsNullOrWhiteSpace(logAction)) { - Debug.Log("[GAMA][PREVIEW] " + logAction + " species=" + speciesName + + GamaLog.Dev("[GAMA][PREVIEW] " + logAction + " species=" + speciesName + " objects=" + updatedObjects + " renderers=" + updatedRenderers); } - RunActivePreviewSpreadDiagnostics(root.transform, "species=" + speciesName); SceneView.RepaintAll(); EditorApplication.QueuePlayerLoopUpdate(); } @@ -260,7 +242,7 @@ private static void LogEditorOverridePickOnce( GamaSpeciesRenderOverrideEntry entry, string source) { - string logKey = GamaSpeciesRenderOverrides.NormalizeKey(modelPath) + "|" + + string logKey = GamaSpeciesRenderOverrides.NormalizeModelPath(modelPath) + "|" + GamaSpeciesRenderOverrides.NormalizeKey(experimentName) + "|" + GamaSpeciesRenderOverrides.NormalizeKey(speciesName) + "|" + (source ?? string.Empty); @@ -272,7 +254,7 @@ private static void LogEditorOverridePickOnce( string prefab = entry != null && !string.IsNullOrWhiteSpace(entry.prefabResourcePath) ? entry.prefabResourcePath : (entry != null && entry.prefabOverride != null ? entry.prefabOverride.name : "none"); - Debug.Log("[GAMA][EDITOR][OVERRIDE_PICK] species=" + speciesName + + GamaLog.Dev("[GAMA][EDITOR][OVERRIDE_PICK] species=" + speciesName + " model=" + (modelPath ?? string.Empty) + " experiment=" + (experimentName ?? string.Empty) + " prefab=" + prefab + @@ -285,26 +267,37 @@ private static int ApplySpeciesVisualState( GamaSpeciesRenderOverrideEntry entry, bool rebuildVisual) { - if (previewObj == null || entry == null) + if (previewObj == null) { return 0; } Transform parent = previewObj.transform; + if (entry == null) + { + Transform staleVisual = parent.Find(VisualChildName); + if (staleVisual != null) + { + DestroyImmediateSafe(staleVisual.gameObject); + } + previewObj.ApplySpeciesOverride(null); + Renderer[] restoredRenderers = parent.GetComponentsInChildren(true); + return restoredRenderers != null ? restoredRenderers.Length : 0; + } + bool visible = ResolvePreviewVisible(entry); bool hasPrefabOverride = entry.prefabOverride != null; - previewObj.gameObject.SetActive(true); - EnsureStableScalePivot(previewObj); - if (hasPrefabOverride) { - previewObj.RestoreBaseLocalScaleIfCaptured(); - Transform visual = parent.Find(VisualChildName); - if (rebuildVisual || visual == null || PrefabUtility.GetCorrespondingObjectFromSource(visual.gameObject) != entry.prefabOverride) + previewObj.ApplySpeciesOverride(null); + bool overridesVisibility = entry.UsesPreviewVisibilityOverride(); + if (overridesVisibility) { - visual = EnsurePrefabVisual(parent, entry.prefabOverride); + previewObj.gameObject.SetActive(visible); } + Transform visual = EnsurePrefabVisual(parent, entry.prefabOverride); + if (visual == null) { return 0; @@ -312,7 +305,7 @@ private static int ApplySpeciesVisualState( ApplyVisualTransform(previewObj, visual, entry); int updated = SetOriginalGeometryRenderersEnabled(parent, visual, false); - updated += SetVisualRenderersState(visual, visible, entry); + updated += SetVisualRenderersState(visual, entry); return updated; } @@ -324,7 +317,7 @@ private static int ApplySpeciesVisualState( Transform fallbackVisual = existingVisual; bool existingIsPrefab = fallbackVisual != null && PrefabUtility.GetCorrespondingObjectFromSource(fallbackVisual.gameObject) != null; - if (fallbackVisual == null || existingIsPrefab) + if (fallbackVisual == null || existingIsPrefab || !HasCompleteVisualRendererBaselines(fallbackVisual)) { if (fallbackVisual != null) { @@ -335,8 +328,9 @@ private static int ApplySpeciesVisualState( } NormalizeFallbackVisualTransform(fallbackVisual); + EnsureVisualRendererBaselines(fallbackVisual); int updated = SetOriginalGeometryRenderersEnabled(parent, fallbackVisual, false); - updated += SetVisualRenderersState(fallbackVisual, visible, entry); + updated += SetVisualRenderersState(fallbackVisual, entry); return updated; } @@ -346,7 +340,8 @@ private static int ApplySpeciesVisualState( } previewObj.ApplySpeciesOverride(entry); - return SetOriginalGeometryRenderersEnabled(parent, null, visible); + Renderer[] originalRenderers = parent.GetComponentsInChildren(true); + return originalRenderers != null ? originalRenderers.Length : 0; } private static void NormalizePreviewContainerScales(Transform root) @@ -385,7 +380,7 @@ private static void NormalizePreviewContainerScales(Transform root) t.localScale = Vector3.one; EditorUtility.SetDirty(t); - Debug.Log("[GAMA][PREVIEW][SCALE] Reset preview container scale path=" + GetTransformPath(t)); + GamaLog.Dev("[GAMA][PREVIEW][SCALE] Reset preview container scale path=" + GetTransformPath(t)); } } @@ -482,11 +477,11 @@ private static void RunActivePreviewSpreadDiagnostics(Transform root, string rea " referenceOverflow=" + FormatFloat(referenceOverflow) + " scaleRange=" + FormatFloat(probe.MinObservedScale) + ".." + FormatFloat(probe.MaxObservedScale) + " scaledContainerObjects=" + probe.ScaledContainerObjectCount; - Debug.Log(line); + GamaLog.Dev(line); if (outsideReference || parentScaled) { - Debug.LogWarning("[GAMA][PREVIEW][SPREAD][ACTIVE][WARN] species=" + probe.SpeciesKey + + GamaLog.DevWarning("[GAMA][PREVIEW][SPREAD][ACTIVE][WARN] species=" + probe.SpeciesKey + " outsideReference=" + outsideReference + " parentScaled=" + parentScaled + " details={" + line + "}"); @@ -689,22 +684,6 @@ private void ObserveScale(Vector3 localScale) } } - private static void EnsureStableScalePivot(GamaPreviewObject previewObj) - { - if (previewObj == null) - { - return; - } - - if (previewObj.NormalizePivotToVisualAnchorForStableScale()) - { - EditorUtility.SetDirty(previewObj); - EditorUtility.SetDirty(previewObj.gameObject); - Debug.Log("[GAMA][PREVIEW][SCALE] Recentered preview pivot for species=" + - (string.IsNullOrWhiteSpace(previewObj.speciesName) ? "unknown" : previewObj.speciesName)); - } - } - private static Transform EnsurePrefabVisual(Transform parent, GameObject prefab) { if (parent == null || prefab == null) @@ -715,9 +694,12 @@ private static Transform EnsurePrefabVisual(Transform parent, GameObject prefab) Transform existingVisual = parent.Find(VisualChildName); if (existingVisual != null) { - GameObject sourcePrefab = PrefabUtility.GetCorrespondingObjectFromSource(existingVisual.gameObject); - if (sourcePrefab == prefab) + GamaPreviewBaseline existingBaseline = existingVisual.GetComponent(); + GameObject source = PrefabUtility.GetCorrespondingObjectFromSource(existingVisual.gameObject) as GameObject; + if ((source == prefab || (existingBaseline != null && existingBaseline.sourcePrefab == prefab)) && + HasCompleteVisualRendererBaselines(existingVisual)) { + EnsureVisualRendererBaselines(existingVisual); return existingVisual; } @@ -731,10 +713,65 @@ private static Transform EnsurePrefabVisual(Transform parent, GameObject prefab) } visual.name = VisualChildName; - visual.transform.SetParent(parent, true); + visual.transform.SetParent(parent, false); + GamaPreviewBaseline baseline = visual.GetComponent(); + if (baseline == null) + { + baseline = visual.AddComponent(); + } + baseline.localPosition = visual.transform.localPosition; + baseline.localRotation = visual.transform.localRotation; + baseline.localScale = visual.transform.localScale; + baseline.sourcePrefab = prefab; + + EnsureVisualRendererBaselines(visual.transform); return visual.transform; } + private static void EnsureVisualRendererBaselines(Transform visual) + { + if (visual == null) + { + return; + } + + Renderer[] renderers = visual.GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + { + Renderer renderer = renderers[i]; + if (renderer == null) + { + continue; + } + GamaPreviewRendererBaseline rendererBaseline = + renderer.GetComponent(); + if (rendererBaseline == null) + { + rendererBaseline = renderer.gameObject.AddComponent(); + } + rendererBaseline.Capture(renderer); + } + } + + private static bool HasCompleteVisualRendererBaselines(Transform visual) + { + if (visual == null) + { + return false; + } + + Renderer[] renderers = visual.GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + { + if (renderers[i] != null && + renderers[i].GetComponent() == null) + { + return false; + } + } + return true; + } + private static void ApplyVisualTransform( GamaPreviewObject previewObj, Transform visual, @@ -748,7 +785,9 @@ private static void ApplyVisualTransform( Vector3 worldAnchor = GetPreviewObjectWorldAnchor(previewObj); visual.position = worldAnchor + entry.GetEffectivePositionOffset(); visual.rotation = previewObj.transform.rotation * Quaternion.Euler(entry.GetEffectiveRotationOffsetEuler()); - visual.localScale = Vector3.one * Mathf.Max(0.0001f, entry.GetEffectiveScaleMultiplier()); + GamaPreviewBaseline baseline = visual.GetComponent(); + Vector3 baseScale = baseline != null ? baseline.localScale : visual.localScale; + visual.localScale = baseScale * Mathf.Max(0.0001f, entry.GetEffectiveScaleMultiplier()); EditorUtility.SetDirty(visual); } @@ -797,7 +836,7 @@ private static Vector3 GetPreviewObjectWorldAnchor(GamaPreviewObject previewObj) string species = string.IsNullOrWhiteSpace(previewObj.speciesName) ? "unknown" : previewObj.speciesName; if (MissingAnchorWarnings.Add(species)) { - Debug.LogWarning("[GAMA][PREVIEW] No valid visual anchor found for species=" + species + + GamaLog.DevWarning("[GAMA][PREVIEW] No valid visual anchor found for species=" + species + ". Prefab visuals for that species may be stacked until the preview builder stores coordinates."); } @@ -962,7 +1001,6 @@ private static int SetOriginalGeometryRenderersEnabled(Transform parent, Transfo private static int SetVisualRenderersState( Transform visualRoot, - bool visible, GamaSpeciesRenderOverrideEntry entry) { if (visualRoot == null) @@ -980,8 +1018,25 @@ private static int SetVisualRenderersState( continue; } - renderer.enabled = visible; - ApplyRendererColorOverride(renderer, entry != null, entry != null ? entry.color : Color.white); + GamaPreviewRendererBaseline baseline = renderer.GetComponent(); + baseline?.Restore(renderer); + if (entry != null && entry.materialOverride != null) + { + Material[] materials = renderer.sharedMaterials; + for (int materialIndex = 0; materialIndex < materials.Length; materialIndex++) + { + materials[materialIndex] = entry.materialOverride; + } + renderer.sharedMaterials = materials; + } + if (entry != null && entry.UsesPreviewVisibilityOverride()) + { + renderer.enabled = entry.GetEffectivePreviewVisible(); + } + if (entry != null && entry.overrideColor) + { + ApplyRendererColorOverride(renderer, true, entry.color); + } EditorUtility.SetDirty(renderer); count++; } @@ -1000,14 +1055,22 @@ private static void ApplyRendererColorOverride(Renderer renderer, bool overrideC renderer.GetPropertyBlock(block); if (!overrideColor) { - block.Clear(); - renderer.SetPropertyBlock(block); return; } block.SetColor("_BaseColor", color); block.SetColor("_Color", color); renderer.SetPropertyBlock(block); + + Material[] materials = renderer.sharedMaterials; + for (int i = 0; i < materials.Length; i++) + { + MaterialPropertyBlock indexedBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(indexedBlock, i); + indexedBlock.SetColor("_BaseColor", color); + indexedBlock.SetColor("_Color", color); + renderer.SetPropertyBlock(indexedBlock, i); + } } private static GameObject CreateFallbackPrimitive(Transform parent, string speciesName) @@ -1043,4 +1106,26 @@ private static void DestroyImmediateSafe(GameObject obj) UnityEngine.Object.DestroyImmediate(obj); } } + + private static GameObject FindPreviewRootIncludingInactive() + { + GamaPreviewSession session = GamaSpeciesAppearanceEditorCoordinator.FindCurrentPreviewSession(); + if (session != null && session.gameObject != null && session.gameObject.name == PreviewRootName) + { + return session.gameObject; + } + + Transform[] transforms = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.InstanceID); + for (int i = 0; i < transforms.Length; i++) + { + Transform transform = transforms[i]; + if (transform != null && transform.parent == null && transform.gameObject.name == PreviewRootName) + { + return transform.gameObject; + } + } + return null; + } } diff --git a/Editor/GamaEditorPreviewWorldAccumulator.cs b/Editor/GamaEditorPreviewWorldAccumulator.cs index 27f8dff..e9e97e7 100644 --- a/Editor/GamaEditorPreviewWorldAccumulator.cs +++ b/Editor/GamaEditorPreviewWorldAccumulator.cs @@ -91,6 +91,7 @@ public MergeResult Merge( result.ChunkAgentCount = agentCount; result.ChunkGeometryCount = pointsGeom != null ? pointsGeom.Count : 0; HashSet dynamicSpeciesReplacedThisChunk = new HashSet(StringComparer.OrdinalIgnoreCase); + Dictionary keyOccurrenceCounts = new Dictionary(StringComparer.Ordinal); for (int i = 0; i < agentCount; i++) { @@ -132,7 +133,14 @@ public MergeResult Merge( string keepName = ReadString(keepNames, i); JToken attr = ReadClone(attributes, i) ?? new JObject(); JToken rank = ReadClone(ranking, i) ?? new JValue(0); - string key = BuildStableKey(speciesKey, name, attr, pointLoc, pointGeom, i, usesPrefab); + string baseKey = BuildStableKey(speciesKey, name, attr, pointLoc, pointGeom, i, usesPrefab); + int occurrence = keyOccurrenceCounts.TryGetValue(baseKey, out int previousOccurrences) + ? previousOccurrences + : 0; + keyOccurrenceCounts[baseKey] = occurrence + 1; + string key = occurrence == 0 + ? baseKey + : baseKey + "|duplicate|" + occurrence; Increment(result.ChunkSpeciesCounts, speciesKey); @@ -412,10 +420,15 @@ private static string BuildStableKey( bool usesPrefab) { string species = string.IsNullOrWhiteSpace(speciesKey) ? "unknown" : speciesKey; - string attrId = TryReadAttributeString(attributes, "id", "gama_id", "agent_id", "uid", "uuid"); - if (!string.IsNullOrWhiteSpace(attrId)) + Attributes parsedAttributes = Attributes.FromToken(attributes); + if (GamaPreviewReuseIdentity.TryBuildStableAgentKey( + species, + name, + parsedAttributes, + out string stableAgentKey, + out _)) { - return species + "|id|" + attrId.Trim(); + return "identity|" + stableAgentKey; } if (!string.IsNullOrWhiteSpace(name)) @@ -436,43 +449,6 @@ private static string BuildStableKey( return species + "|index|" + localIndex; } - private static string TryReadAttributeString(JToken attributes, params string[] keys) - { - JObject obj = attributes as JObject; - if (obj == null) - { - return string.Empty; - } - - for (int i = 0; i < keys.Length; i++) - { - JToken token = null; - foreach (JProperty property in obj.Properties()) - { - if (property.Name.Equals(keys[i], StringComparison.OrdinalIgnoreCase)) - { - token = property.Value; - break; - } - } - - if (token == null || token.Type == JTokenType.Null) - { - continue; - } - - string value = token.Type == JTokenType.String - ? token.Value() - : token.ToString(Formatting.None); - if (!string.IsNullOrWhiteSpace(value)) - { - return value; - } - } - - return string.Empty; - } - private static PropertiesGAMA TryGetProperty(string propertyId, Dictionary propertyMap) { if (string.IsNullOrEmpty(propertyId) || propertyMap == null) diff --git a/Editor/GamaEditorRuntimeSelectionStore.cs b/Editor/GamaEditorRuntimeSelectionStore.cs index 802fc5b..de119e4 100644 --- a/Editor/GamaEditorRuntimeSelectionStore.cs +++ b/Editor/GamaEditorRuntimeSelectionStore.cs @@ -4,7 +4,7 @@ using UnityEditor; using UnityEngine; -/// Mémorise la dernière sélection Unity (modelPath + experiment) pour capture et Play. +/// Stores the latest Unity selection (modelPath + experiment) for capture and Play. internal static class GamaEditorRuntimeSelectionStore { private const string ModelPathKey = "ProjectSimple.GamaUnity.Panel.LastRuntimeModelPath"; @@ -22,11 +22,11 @@ public static void Save(string modelPath, string experimentName) string full = Path.GetFullPath(modelPath.Trim()); EditorPrefs.SetString(ModelPathKey, full); EditorPrefs.SetString(ExperimentKey, experimentName.Trim()); - Debug.Log("[GAMA][SYNC] Saved runtime selection model=" + full + " exp=" + experimentName.Trim()); + GamaLog.Dev("[GAMA][SYNC] Saved runtime selection model=" + full + " exp=" + experimentName.Trim()); } catch (Exception ex) { - Debug.LogWarning("[GAMA][SYNC] Impossible d'enregistrer la sélection : " + ex.Message); + GamaLog.Warning("[GAMA][SYNC] Could not save the selection: " + ex.Message); } } diff --git a/Editor/GamaEditorStaticPreviewFromJson.cs b/Editor/GamaEditorStaticPreviewFromJson.cs index c1523e9..a812840 100644 --- a/Editor/GamaEditorStaticPreviewFromJson.cs +++ b/Editor/GamaEditorStaticPreviewFromJson.cs @@ -36,12 +36,12 @@ public static bool TryBuild( geometryCount = 0; error = string.Empty; - Debug.Log("[GAMA][PREVIEW][BUILD] simulationManager=" + (simulationManager == null ? "null" : "ok")); - Debug.Log("[GAMA][PREVIEW][BUILD] precisionJson length=" + (precisionJson == null ? -1 : precisionJson.Length)); - Debug.Log("[GAMA][PREVIEW][BUILD] propertiesJson length=" + (propertiesJson == null ? -1 : propertiesJson.Length)); - Debug.Log("[GAMA][PREVIEW][BUILD] worldJson length=" + (worldJson == null ? -1 : worldJson.Length)); - Debug.Log("[GAMA][PREVIEW][BUILD] parent=" + (parent == null ? "null" : GetHierarchyPath(parent))); - Debug.Log("[GAMA][PREVIEW][BUILD] speciesOverrides=" + (speciesOverrides == null ? "null" : "ok")); + GamaLog.Dev("[GAMA][PREVIEW][BUILD] simulationManager=" + (simulationManager == null ? "null" : "ok")); + GamaLog.Dev("[GAMA][PREVIEW][BUILD] precisionJson length=" + (precisionJson == null ? -1 : precisionJson.Length)); + GamaLog.Dev("[GAMA][PREVIEW][BUILD] propertiesJson length=" + (propertiesJson == null ? -1 : propertiesJson.Length)); + GamaLog.Dev("[GAMA][PREVIEW][BUILD] worldJson length=" + (worldJson == null ? -1 : worldJson.Length)); + GamaLog.Dev("[GAMA][PREVIEW][BUILD] parent=" + (parent == null ? "null" : GetHierarchyPath(parent))); + GamaLog.Dev("[GAMA][PREVIEW][BUILD] speciesOverrides=" + (speciesOverrides == null ? "null" : "ok")); try { @@ -60,8 +60,8 @@ public static bool TryBuild( } catch (Exception ex) { - error = "Exception pendant la construction de la preview statique : " + ex.Message; - Debug.LogError("[GAMA][PREVIEW][BUILD] Exception: " + ex); + error = "Exception while building the static preview: " + ex.Message; + GamaLog.Error("[GAMA][PREVIEW][BUILD] Exception: " + ex); return false; } } @@ -86,56 +86,56 @@ private static bool TryBuildInternal( if (parent == null) { - error = "Parent de preview null : impossible de construire la hiérarchie."; + error = "The preview parent is null; the hierarchy cannot be built."; return false; } if (string.IsNullOrWhiteSpace(precisionJson)) { - error = "precisionJson vide : impossible de construire la preview."; + error = "precisionJson is empty; the preview cannot be built."; return false; } if (string.IsNullOrWhiteSpace(propertiesJson)) { - error = "propertiesJson vide : impossible de construire la preview."; + error = "propertiesJson is empty; the preview cannot be built."; return false; } if (string.IsNullOrWhiteSpace(worldJson)) { - error = "worldJson vide : impossible de construire la preview."; + error = "worldJson is empty; the preview cannot be built."; return false; } ConnectionParameter parameters = ConnectionParameter.CreateFromJSON(precisionJson); if (parameters == null || parameters.precision <= 0) { - error = "JSON « precision » invalide ou precision <= 0."; + error = "The precision JSON is invalid or precision is less than or equal to zero."; return false; } AllProperties allProperties = AllProperties.CreateFromJSON(propertiesJson); if (allProperties == null || allProperties.properties == null || allProperties.properties.Count == 0) { - error = "JSON « properties » invalide ou liste vide."; + error = "The properties JSON is invalid or its list is empty."; return false; } WorldJSONInfo world = WorldJSONInfo.CreateFromJSON(worldJson); bool hasAgents = world != null && world.names != null && world.names.Count > 0; bool hasGeometries = world != null && world.pointsGeom != null && world.pointsGeom.Count > 0; - Debug.Log("[GAMA][PREVIEW][BUILD] world agents=" + (world != null && world.names != null ? world.names.Count : 0) + + GamaLog.Dev("[GAMA][PREVIEW][BUILD] world agents=" + (world != null && world.names != null ? world.names.Count : 0) + " geometries=" + (world != null && world.pointsGeom != null ? world.pointsGeom.Count : 0)); if (world == null || (!hasAgents && !hasGeometries)) { - error = "JSON « monde » (pointsLoc / world) invalide ou vide (pas d’agents ni de géométries). Essayez un tick plus tard via le curseur."; + error = "The world JSON (pointsLoc/world) is invalid or empty (no agents or geometries). Try a later tick with the slider."; return false; } if (!hasAgents) { - Debug.LogWarning("[GAMA] Aperçu statique : aucun agent à ce tick — seules les géométries seront affichées."); + GamaLog.DevWarning("[GAMA] Static preview: this tick contains no agents; only geometries will be displayed."); } Dictionary propertyMap = new Dictionary(); @@ -161,7 +161,7 @@ private static bool TryBuildInternal( } else { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Aucun SimulationManager trouvé : CRS par défaut et overrides runtime ignorés."); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] No SimulationManager found; using the default CRS and ignoring runtime overrides."); } CoordinateConverter converter = new CoordinateConverter( @@ -199,7 +199,8 @@ private static bool TryBuildInternal( { try { - string name = world.names[i] ?? string.Empty; + string sourceAgentName = world.names[i] ?? string.Empty; + string name = sourceAgentName; if (string.IsNullOrWhiteSpace(name)) { name = "unknown_agent_" + i; @@ -208,7 +209,7 @@ private static bool TryBuildInternal( if (world.propertyID == null || i >= world.propertyID.Count) { skippedAgents++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=propertyID missing"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=propertyID missing"); continue; } @@ -217,7 +218,7 @@ private static bool TryBuildInternal( if (string.IsNullOrEmpty(propId) || !propertyMap.TryGetValue(propId, out prop) || prop == null) { skippedAgents++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=prop null propId=" + (propId ?? "")); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=prop null propId=" + (propId ?? "")); continue; } @@ -228,7 +229,7 @@ private static bool TryBuildInternal( } catch (Exception attrEx) { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Attributes invalid for agent i=" + i + " reason=" + attrEx.Message); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Attributes invalid for agent i=" + i + " reason=" + attrEx.Message); } bool hasVisualState = false; @@ -242,7 +243,7 @@ private static bool TryBuildInternal( } catch (Exception vsEx) { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] ResolveVisualState failed for agent i=" + i + " reason=" + vsEx.Message); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] ResolveVisualState failed for agent i=" + i + " reason=" + vsEx.Message); } if (!hasVisualState) { @@ -252,19 +253,19 @@ private static bool TryBuildInternal( if (!hasVisualState) { skippedAgents++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=visualState failed"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=visualState failed"); continue; } string speciesKey = null; try { speciesKey = GamaEditorPreviewCapture.ResolveSpeciesKey(propId, propertyMap); } - catch (Exception skEx) { Debug.LogWarning("[GAMA][PREVIEW][BUILD] ResolveSpeciesKey failed i=" + i + " reason=" + skEx.Message); } + catch (Exception skEx) { GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] ResolveSpeciesKey failed i=" + i + " reason=" + skEx.Message); } if (string.IsNullOrWhiteSpace(speciesKey)) speciesKey = name; if (string.IsNullOrWhiteSpace(speciesKey)) speciesKey = "unknown"; if (VerbosePreviewBuildDebug && i < 5) { - Debug.Log( + GamaLog.Dev( "[GAMA][PREVIEW][BUILD][AGENTDBG] i=" + i + " name=" + (name ?? "") + " propId=" + (propId ?? "") + @@ -279,7 +280,7 @@ private static bool TryBuildInternal( if (speciesParent == null) { skippedAgents++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=speciesParent null"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=speciesParent null"); continue; } @@ -288,7 +289,7 @@ private static bool TryBuildInternal( if (world.pointsLoc == null || cptPrefab >= world.pointsLoc.Count) { skippedAgents++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=pointsLoc insufficient"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=pointsLoc insufficient"); continue; } @@ -296,16 +297,19 @@ private static bool TryBuildInternal( if (pt == null || pt.Count < 3) { skippedAgents++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=invalid coordinates"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=invalid coordinates"); cptPrefab++; continue; } + GameObject sourcePrefabAsset = prop.prefabObj != null + ? prop.prefabObj + : GamaVisualUtility.ResolvePrefab(prop.prefab); GameObject obj = GamaVisualUtility.InstantiateVisual(name, prop, precision); if (obj == null) { skippedAgents++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=InstantiateVisual returned null"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " name=" + name + " reason=InstantiateVisual returned null"); cptPrefab++; continue; } @@ -321,7 +325,17 @@ private static bool TryBuildInternal( obj.transform.SetPositionAndRotation(pos, rotation); ApplyPrefabVisualState(obj, prop, visualState, precision); - GamaPreviewObject marker = AddPreviewObjectIdentity(obj, speciesKey, name, BuildIntListHash(pt)); + GamaPreviewObject marker = AddPreviewObjectIdentity( + obj, + speciesKey, + name, + sourceAgentName, + BuildIntListHash(pt), + propId, + prop, + attributes, + sourcePrefabAsset, + GamaPreviewRepresentationKind.Prefab); if (marker != null) { marker.SetVisualAnchorLocal(Vector3.zero); @@ -336,7 +350,7 @@ private static bool TryBuildInternal( if (world.pointsGeom == null || cptGeom >= world.pointsGeom.Count) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=pointsGeom insufficient"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=pointsGeom insufficient"); continue; } @@ -344,7 +358,7 @@ private static bool TryBuildInternal( if (rawGeom == null || rawGeom.Count < 2) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=invalid geometry data"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=invalid geometry data"); cptGeom++; continue; } @@ -353,7 +367,7 @@ private static bool TryBuildInternal( if (ptArr == null || ptArr.Length == 0) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=empty points"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=empty points"); cptGeom++; continue; } @@ -368,7 +382,7 @@ private static bool TryBuildInternal( if (polygonInputValid && polyGen == null) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=polyGen null"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=polyGen null"); cptGeom++; continue; } @@ -381,7 +395,7 @@ private static bool TryBuildInternal( if (obj == null) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=GeneratePolygons returned null"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip geometry i=" + i + " name=" + name + " reason=GeneratePolygons returned null"); cptGeom++; continue; } @@ -396,7 +410,19 @@ private static bool TryBuildInternal( ApplyPolygonVisualState(obj, prop, visualState, polygonBasePosition); GetSpreadProbe(spreadProbes, speciesKey).AddExpected(polygonBasePosition + visualState.PositionOffset); - GamaPreviewObject marker = AddPreviewObjectIdentity(obj, speciesKey, name, BuildIntListHash(rawGeom)); + GamaPreviewObject marker = AddPreviewObjectIdentity( + obj, + speciesKey, + name, + sourceAgentName, + BuildIntListHash(rawGeom), + propId, + prop, + attributes, + null, + polygonInputValid + ? GamaPreviewRepresentationKind.Geometry + : GamaPreviewRepresentationKind.Unknown); if (marker != null) { marker.SetVisualAnchorLocal(ResolvePreviewAnchorLocal(obj, rawGeom, converter, yOffsetGeom)); @@ -425,7 +451,7 @@ private static bool TryBuildInternal( catch (Exception ex) { skippedAgents++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " reason=" + ex); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip agent i=" + i + " reason=" + ex); continue; } } @@ -455,7 +481,7 @@ private static bool TryBuildInternal( if (ptArr == null || ptArr.Length == 0) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip standalone geometry g=" + g + " reason=empty points"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip standalone geometry g=" + g + " reason=empty points"); continue; } @@ -468,7 +494,7 @@ private static bool TryBuildInternal( if (polyGen == null) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip standalone geometry g=" + g + " reason=polyGen null"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip standalone geometry g=" + g + " reason=polyGen null"); continue; } @@ -478,7 +504,7 @@ private static bool TryBuildInternal( if (obj == null) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip standalone geometry g=" + g + " reason=GeneratePolygons returned null"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip standalone geometry g=" + g + " reason=GeneratePolygons returned null"); continue; } @@ -493,7 +519,7 @@ private static bool TryBuildInternal( catch (Exception ex) { skippedGeometries++; - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Skip standalone geometry g=" + g + " reason=" + ex); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Skip standalone geometry g=" + g + " reason=" + ex); continue; } } @@ -519,27 +545,24 @@ private static bool TryBuildInternal( } catch (Exception ex) { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] PlayerSpawn marker failed: " + ex.Message); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] PlayerSpawn marker failed: " + ex.Message); } } + DisableDuplicateReuseCandidates(parent); + prefabCount = builtAgents; geometryCount = builtGeometries; - Debug.Log( + GamaLog.Dev( "[GAMA][PREVIEW][BUILD] result builtAgents=" + builtAgents + " skippedAgents=" + skippedAgents + " builtGeometries=" + builtGeometries + " skippedGeometries=" + skippedGeometries); - if (builtAgents > 0 || builtGeometries > 0) - { - RunPreviewSpreadDiagnostics(parent, spreadProbes); - } - if (builtAgents == 0 && builtGeometries == 0) { - error = "Aucun objet preview construit. skippedAgents=" + skippedAgents + ", skippedGeometries=" + skippedGeometries; + error = "No preview objects were built. skippedAgents=" + skippedAgents + ", skippedGeometries=" + skippedGeometries; return false; } @@ -623,7 +646,7 @@ private static void RunPreviewSpreadDiagnostics( " referenceOverflow=" + FormatFloat(referenceOverflow) + " scaleRange=" + FormatFloat(probe.MinObservedScale) + ".." + FormatFloat(probe.MaxObservedScale) + " scaledContainerObjects=" + probe.ScaledContainerObjectCount; - Debug.Log(line); + GamaLog.Dev(line); bool countMismatch = probe.ExpectedCount > 0 && probe.ActualCount != probe.ExpectedCount; bool inflatedAgainstSource = expectedDiag > PreviewSpreadEpsilon && @@ -637,7 +660,7 @@ private static void RunPreviewSpreadDiagnostics( if (countMismatch || inflatedAgainstSource || outsideReference || parentScaled) { - Debug.LogWarning("[GAMA][PREVIEW][SPREAD][WARN] species=" + probe.SpeciesKey + + GamaLog.DevWarning("[GAMA][PREVIEW][SPREAD][WARN] species=" + probe.SpeciesKey + " countMismatch=" + countMismatch + " inflatedAgainstSource=" + inflatedAgainstSource + " outsideReference=" + outsideReference + @@ -881,7 +904,7 @@ private static void TryReadManagerCrs( } catch (Exception ex) { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] Lecture CRS SimulationManager impossible, valeurs par défaut utilisées : " + ex.Message); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] Could not read the SimulationManager CRS; using default values: " + ex.Message); } } @@ -922,7 +945,7 @@ private static Transform GetOrCreateSpeciesParent( { if (previewRoot == null) { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] GetOrCreateSpeciesParent: previewRoot is null"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] GetOrCreateSpeciesParent: previewRoot is null"); return null; } @@ -937,7 +960,7 @@ private static Transform GetOrCreateSpeciesParent( GameObject rootChild = GamaSceneUtility.GetOrCreateChild(previewRoot.gameObject, "GAMA"); if (rootChild == null) { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] GetOrCreateSpeciesParent: GetOrCreateChild returned null for GAMA root"); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] GetOrCreateSpeciesParent: GetOrCreateChild returned null for GAMA root"); return null; } @@ -959,7 +982,7 @@ private static Transform GetOrCreateSpeciesParent( } catch (Exception ex) { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] GetOrCreateSpeciesParent failed for key=" + key + " reason=" + ex.Message); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] GetOrCreateSpeciesParent failed for key=" + key + " reason=" + ex.Message); return null; } } @@ -979,7 +1002,6 @@ private static void ApplySpeciesOverrideIfAny( if (speciesOverrides.TryGetOverride(modelPath, experimentName, speciesKey, out GamaSpeciesRenderOverrideEntry entry, true) && entry != null) { LogPreviewOverridePickOnce(speciesKey, modelPath, experimentName, entry); - marker.NormalizePivotToVisualAnchorForStableScale(); marker.ApplySpeciesOverride(entry); } } @@ -990,7 +1012,7 @@ private static void LogPreviewOverridePickOnce( string experimentName, GamaSpeciesRenderOverrideEntry entry) { - string logKey = GamaSpeciesRenderOverrides.NormalizeKey(modelPath) + "|" + + string logKey = GamaSpeciesRenderOverrides.NormalizeModelPath(modelPath) + "|" + GamaSpeciesRenderOverrides.NormalizeKey(experimentName) + "|" + GamaSpeciesRenderOverrides.NormalizeKey(speciesKey); if (!OverridePickLogKeys.Add(logKey)) @@ -998,13 +1020,23 @@ private static void LogPreviewOverridePickOnce( return; } - Debug.Log("[GAMA][PREVIEW][OVERRIDE_PICK] species=" + speciesKey + + GamaLog.Dev("[GAMA][PREVIEW][OVERRIDE_PICK] species=" + speciesKey + " model=" + (modelPath ?? string.Empty) + " experiment=" + (experimentName ?? string.Empty) + " scale=" + (entry != null ? entry.GetEffectiveScaleMultiplier() : 1f)); } - private static GamaPreviewObject AddPreviewObjectIdentity(GameObject obj, string speciesKey, string agentId, string geometryHash) + private static GamaPreviewObject AddPreviewObjectIdentity( + GameObject obj, + string speciesKey, + string agentId, + string sourceAgentName, + string geometryHash, + string sourcePropertyId, + PropertiesGAMA sourceProperty, + Attributes attributes, + GameObject sourcePrefabAsset, + GamaPreviewRepresentationKind representationKind) { if (obj == null) { @@ -1018,14 +1050,104 @@ private static GamaPreviewObject AddPreviewObjectIdentity(GameObject obj, string } marker.previewOnly = true; - marker.canBeReusedAtRuntime = false; marker.speciesName = speciesKey ?? string.Empty; marker.agentId = agentId ?? string.Empty; marker.geometryHash = geometryHash ?? string.Empty; marker.sourceTick = -1; + marker.sourcePropertyId = sourcePropertyId ?? string.Empty; + marker.representationKind = representationKind; + marker.provenance = GamaPreviewProvenance.CapturedJson; + marker.sourcePrefabSignature = BuildSourcePrefabSignature( + sourcePropertyId, + sourceProperty, + representationKind); + marker.sourcePrefabAsset = representationKind == GamaPreviewRepresentationKind.Prefab + ? sourcePrefabAsset + : null; + + bool hasStableIdentity = GamaPreviewReuseIdentity.TryBuildStableAgentKey( + speciesKey, + sourceAgentName, + attributes, + out string stableAgentKey, + out _); + marker.stableAgentKey = hasStableIdentity ? stableAgentKey : string.Empty; + + bool hasCompatibleRepresentation = representationKind == GamaPreviewRepresentationKind.Prefab + ? marker.sourcePrefabAsset != null + : representationKind == GamaPreviewRepresentationKind.Geometry && + !string.IsNullOrWhiteSpace(marker.sourcePrefabSignature); + marker.canBeReusedAtRuntime = hasStableIdentity && hasCompatibleRepresentation; return marker; } + private static string BuildSourcePrefabSignature( + string sourcePropertyId, + PropertiesGAMA sourceProperty, + GamaPreviewRepresentationKind representationKind) + { + string normalizedPropertyId = SimulationManager.NormalizeKey(sourcePropertyId); + if (representationKind == GamaPreviewRepresentationKind.Geometry) + { + return "geometry:" + normalizedPropertyId; + } + + if (representationKind != GamaPreviewRepresentationKind.Prefab) + { + return string.Empty; + } + + return "prefab:" + normalizedPropertyId + ":" + SimulationManager.NormalizeKey( + sourceProperty != null ? sourceProperty.prefab : string.Empty); + } + + private static void DisableDuplicateReuseCandidates(Transform previewRoot) + { + if (previewRoot == null) + { + return; + } + + Dictionary> markersByStableKey = + new Dictionary>(StringComparer.Ordinal); + GamaPreviewObject[] markers = previewRoot.GetComponentsInChildren(true); + for (int i = 0; i < markers.Length; i++) + { + GamaPreviewObject marker = markers[i]; + if (marker == null || + marker.provenance != GamaPreviewProvenance.CapturedJson || + string.IsNullOrWhiteSpace(marker.stableAgentKey)) + { + continue; + } + + if (!markersByStableKey.TryGetValue(marker.stableAgentKey, out List matches)) + { + matches = new List(); + markersByStableKey.Add(marker.stableAgentKey, matches); + } + + matches.Add(marker); + } + + foreach (KeyValuePair> pair in markersByStableKey) + { + if (pair.Value.Count < 2) + { + continue; + } + + for (int i = 0; i < pair.Value.Count; i++) + { + pair.Value[i].canBeReusedAtRuntime = false; + } + + Debug.LogWarning( + "[GAMA][PREVIEW][REUSE] Duplicate stable agent identity '" + pair.Key + + "' found " + pair.Value.Count + " times. All matching preview objects are ineligible for runtime reuse."); + } + } + private static Vector3 ResolvePreviewAnchorLocal( GameObject obj, IList rawGeom, @@ -1114,7 +1236,7 @@ private static void LogInvalidGeometryFallback(string speciesKey) if (count == 1 || count == 10 || count % 100 == 0) { - Debug.LogWarning( + GamaLog.DevWarning( "[GAMA][PREVIEW][GEOMETRY] species=" + species + " invalidPolygonFallback=" + count); } @@ -1452,14 +1574,14 @@ public static bool TryReadFile(string path, out string content, out string readE readError = null; if (string.IsNullOrWhiteSpace(path)) { - readError = "Chemin vide."; + readError = "Path is empty."; return false; } string full = path.Trim().Trim('"'); if (!File.Exists(full)) { - readError = "Fichier introuvable: " + full; + readError = "File not found: " + full; return false; } diff --git a/Editor/GamaHeadless/GamaUnityHeadlessRunner.bat b/Editor/GamaHeadless/GamaUnityHeadlessRunner.bat index c05a88d..e7ab89a 100644 --- a/Editor/GamaHeadless/GamaUnityHeadlessRunner.bat +++ b/Editor/GamaHeadless/GamaUnityHeadlessRunner.bat @@ -2,27 +2,27 @@ setlocal EnableExtensions rem --------------------------------------------------------------------------- -rem GAMA Unity Plugin — lanceur fourni avec le package (com.project-simple.*) -rem Variables attendues (definies par l'editeur Unity) : -rem GAMA_HEADLESS_BAT chemin complet vers gama-headless.bat -rem GAMA_GAML_PATH chemin complet du fichier .gaml -rem GAMA_BATCH_NAME nom d'experience (utilise par -batch ; sinon -script) -rem GAMA_JSON_OUTPUT_DIR dossier de sortie pour les JSON / scripts auto-export -rem GAMA_HEADLESS_CWD (optionnel) repertoire de travail avant l'appel -rem GAMA_HEADLESS_MODE batch | script | custom (defaut : batch) -rem GAMA_HEADLESS_EXTRA (optionnel) arguments supplementaires -rem GAMA_HEADLESS_CUSTOM (mode=custom) ligne complete a executer apres /c +rem GAMA Unity Plugin - launcher included with the package (com.project-simple.*) +rem Expected variables (set by the Unity Editor): +rem GAMA_HEADLESS_BAT full path to gama-headless.bat +rem GAMA_GAML_PATH full path to the .gaml file +rem GAMA_BATCH_NAME experiment name (used by -batch; otherwise -script) +rem GAMA_JSON_OUTPUT_DIR output directory for JSON and auto-export scripts +rem GAMA_HEADLESS_CWD (optional) working directory before execution +rem GAMA_HEADLESS_MODE batch | script | custom (default: batch) +rem GAMA_HEADLESS_EXTRA (optional) additional arguments +rem GAMA_HEADLESS_CUSTOM (mode=custom) complete command line to execute after /c rem --------------------------------------------------------------------------- if "%GAMA_HEADLESS_BAT%"=="" ( if /I not "%GAMA_HEADLESS_MODE%"=="custom" ( - echo [GAMA Unity] GAMA_HEADLESS_BAT manquant. 1>&2 + echo [GAMA Unity] GAMA_HEADLESS_BAT is missing. 1>&2 exit /b 10 ) ) if "%GAMA_GAML_PATH%"=="" ( if /I not "%GAMA_HEADLESS_MODE%"=="custom" ( - echo [GAMA Unity] GAMA_GAML_PATH manquant. 1>&2 + echo [GAMA Unity] GAMA_GAML_PATH is missing. 1>&2 exit /b 11 ) ) @@ -38,12 +38,12 @@ if not "%GAMA_HEADLESS_BAT%"=="" ( if not "%GAMA_HEADLESS_CWD%"=="" ( pushd "%GAMA_HEADLESS_CWD%" 2>nul || ( - echo [GAMA Unity] GAMA_HEADLESS_CWD invalide : "%GAMA_HEADLESS_CWD%" 1>&2 + echo [GAMA Unity] GAMA_HEADLESS_CWD is invalid: "%GAMA_HEADLESS_CWD%" 1>&2 exit /b 13 ) ) else if not "%GAMA_HEADLESS_DIR%"=="" ( pushd "%GAMA_HEADLESS_DIR%" 2>nul || ( - echo [GAMA Unity] Impossible d'acceder au dossier headless. 1>&2 + echo [GAMA Unity] Cannot access the headless directory. 1>&2 exit /b 14 ) ) @@ -56,13 +56,13 @@ if /I "%MODE%"=="script" goto :do_script goto :do_batch :do_batch -echo [GAMA Unity] Mode batch : "%GAMA_HEADLESS_BAT%" -batch "%GAMA_BATCH_NAME%" "%GAMA_GAML_PATH%" %GAMA_HEADLESS_EXTRA% +echo [GAMA Unity] Batch mode: "%GAMA_HEADLESS_BAT%" -batch "%GAMA_BATCH_NAME%" "%GAMA_GAML_PATH%" %GAMA_HEADLESS_EXTRA% call "%GAMA_HEADLESS_BAT%" -batch "%GAMA_BATCH_NAME%" "%GAMA_GAML_PATH%" %GAMA_HEADLESS_EXTRA% set RC=%ERRORLEVEL% goto :done :do_script -echo [GAMA Unity] Mode script (experiment GUI/Unity) : "%GAMA_HEADLESS_BAT%" %GAMA_HEADLESS_EXTRA% "%GAMA_BATCH_NAME%" "%GAMA_GAML_PATH%" "%GAMA_JSON_OUTPUT_DIR%" +echo [GAMA Unity] Script mode (GUI/Unity experiment): "%GAMA_HEADLESS_BAT%" %GAMA_HEADLESS_EXTRA% "%GAMA_BATCH_NAME%" "%GAMA_GAML_PATH%" "%GAMA_JSON_OUTPUT_DIR%" if "%GAMA_JSON_OUTPUT_DIR%"=="" ( call "%GAMA_HEADLESS_BAT%" %GAMA_HEADLESS_EXTRA% "%GAMA_BATCH_NAME%" "%GAMA_GAML_PATH%" ) else ( @@ -72,7 +72,7 @@ set RC=%ERRORLEVEL% goto :done :do_custom -echo [GAMA Unity] Mode custom : %GAMA_HEADLESS_CUSTOM% +echo [GAMA Unity] Custom mode: %GAMA_HEADLESS_CUSTOM% call %GAMA_HEADLESS_CUSTOM% set RC=%ERRORLEVEL% goto :done diff --git a/Editor/GamaHeadlessPackagePaths.cs b/Editor/GamaHeadlessPackagePaths.cs index df9e0cc..3ff332b 100644 --- a/Editor/GamaHeadlessPackagePaths.cs +++ b/Editor/GamaHeadlessPackagePaths.cs @@ -3,7 +3,7 @@ using UnityEngine; /// -/// Résout le chemin absolu du lanceur livré dans le package. +/// Resolves the absolute path of the bundled with the package. /// internal static class GamaHeadlessPackagePaths { @@ -17,7 +17,7 @@ public static bool TryGetBundledRunnerBat(out string absolutePath, out string er string projectRoot = Directory.GetParent(Application.dataPath)?.FullName; if (string.IsNullOrEmpty(projectRoot)) { - error = "Impossible de déterminer la racine du projet Unity."; + error = "Could not determine the Unity project root."; return false; } @@ -59,7 +59,7 @@ public static bool TryGetBundledRunnerBat(out string absolutePath, out string er } } - error = "Lanceur « " + RunnerFileName + " » introuvable dans le package. Réinstallez com.project-simple.unity-plugin."; + error = "Runner '" + RunnerFileName + "' was not found in the package. Reinstall com.project-simple.unity-plugin."; return false; } } diff --git a/Editor/GamaLogPreferences.cs b/Editor/GamaLogPreferences.cs new file mode 100644 index 0000000..0c6ecc7 --- /dev/null +++ b/Editor/GamaLogPreferences.cs @@ -0,0 +1,22 @@ +using UnityEditor; + +[InitializeOnLoad] +internal static class GamaLogPreferences +{ + internal const string VerboseModePrefKey = "ProjectSimple.GamaUnity.Logging.VerboseMode"; + + static GamaLogPreferences() + { + GamaLog.SetVerboseEnabled(EditorPrefs.GetBool(VerboseModePrefKey, false)); + } + + internal static bool VerboseEnabled + { + get => GamaLog.VerboseEnabled; + set + { + GamaLog.SetVerboseEnabled(value); + EditorPrefs.SetBool(VerboseModePrefKey, value); + } + } +} diff --git a/Editor/GamaLogPreferences.cs.meta b/Editor/GamaLogPreferences.cs.meta new file mode 100644 index 0000000..44e3054 --- /dev/null +++ b/Editor/GamaLogPreferences.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8f47dc788d24cf7a1fb4d6b05595732 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/GamaPanelWindow.cs b/Editor/GamaPanelWindow.cs index 8a81453..78c3c00 100644 --- a/Editor/GamaPanelWindow.cs +++ b/Editor/GamaPanelWindow.cs @@ -55,12 +55,14 @@ public sealed class GamaPanelWindow : EditorWindow { "Setup Scene", "GAMA Preview", + "Troubleshooting", "Workspace Explorer (alpha)" }; private const int TabSetupScene = 0; private const int TabImportExperiment = 1; - private const int TabWorkspace = 2; + private const int TabTroubleshooting = 2; + private const int TabWorkspace = 3; private int selectedTab; private readonly GamaWorkspaceExplorerPanel workspaceExplorerPanel = new GamaWorkspaceExplorerPanel(); @@ -125,6 +127,8 @@ public sealed class GamaPanelWindow : EditorWindow private bool captureFlowActive; private string pendingPreviewPlayerCleanupId = string.Empty; private string lastPreviewCapturePlayerId = string.Empty; + private string lastPreviewMonitorExperimentId = string.Empty; + private bool lastPreviewCaptureUsedActiveGamaSelection; private System.Threading.CancellationTokenSource captureCts; private GamaEditorBackgroundProcess captureGamaProcess; private GamaEditorBackgroundProcess captureMiddlewareProcess; @@ -133,6 +137,7 @@ public sealed class GamaPanelWindow : EditorWindow private string experimentStatus = "Enter a .gaml experiment file or a workspace folder, then click Explore."; private Vector2 experimentScroll; private Vector2 agentsScroll; + private Vector2 troubleshootingScroll; private Vector2 workspaceScroll; private Vector2 workspaceExperimentListScroll; private Vector2 setupSceneScroll; @@ -390,12 +395,62 @@ private void OnGUI() case TabImportExperiment: DrawExperimentImportTab(); break; + case TabTroubleshooting: + DrawTroubleshootingTab(); + break; default: DrawWorkspaceTab(); break; } } + private void DrawTroubleshootingTab() + { + troubleshootingScroll = EditorGUILayout.BeginScrollView(troubleshootingScroll); + + EditorGUILayout.LabelField("Troubleshooting", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "Verbose mode adds detailed diagnostic messages to the Unity Console. It is disabled by default because frequent logging can affect Editor and Play Mode performance. Essential warnings, errors, and actionable UI messages remain enabled in both modes.", + MessageType.Info); + + EditorGUI.BeginChangeCheck(); + bool verboseEnabled = EditorGUILayout.ToggleLeft( + "Enable verbose mode", + GamaLogPreferences.VerboseEnabled, + EditorStyles.boldLabel); + if (EditorGUI.EndChangeCheck()) + { + GamaLogPreferences.VerboseEnabled = verboseEnabled; + } + + if (verboseEnabled) + { + EditorGUILayout.HelpBox( + "Verbose mode is enabled. The Unity Console may receive a large number of messages and performance may be reduced. Disable it after collecting the diagnostics you need.", + MessageType.Warning); + } + + EditorGUILayout.Space(12f); + EditorGUILayout.LabelField("Common Issues", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "No preview is generated: make sure GAMA and simple.webplatform are running, open the target experiment in GAMA, then try Generate Preview from GAMA again.", + MessageType.Info); + EditorGUILayout.HelpBox( + "Preview generation is unavailable during Play Mode. Stop Play Mode before starting a new editor preview capture.", + MessageType.Info); + EditorGUILayout.HelpBox( + "Repeated 'Reconnecting player of id ...' messages usually mean that a cancelled preview player is still registered in GAMA. Open GAMA Preview > Advanced Preview Settings and use Purge Ghost Player.", + MessageType.Info); + + EditorGUILayout.Space(8f); + if (GUILayout.Button("Open Unity Console", GUILayout.Height(28f), GUILayout.Width(180f))) + { + EditorApplication.ExecuteMenuItem("Window/General/Console"); + } + + EditorGUILayout.EndScrollView(); + } + private void EnsureWorkspaceExplorerHostReady() { if (workspaceExplorerHostReady) @@ -1018,11 +1073,34 @@ private void DrawHeadlessJsonExportSection() applyPreviewSettingsToPlay = EditorGUILayout.Toggle( "Apply Preview Settings to Play", applyPreviewSettingsToPlay); - speciesRenderOverridesAsset = (GamaSpeciesRenderOverrides)EditorGUILayout.ObjectField( + GamaSpeciesRenderOverrides previousOverridesAsset = speciesRenderOverridesAsset; + GamaSpeciesRenderOverrides selectedOverridesAsset = + (GamaSpeciesRenderOverrides)EditorGUILayout.ObjectField( "Species Render Overrides", speciesRenderOverridesAsset, typeof(GamaSpeciesRenderOverrides), false); + if (selectedOverridesAsset != previousOverridesAsset && selectedOverridesAsset != null) + { + string currentModelPath = string.Empty; + string currentExperimentName = string.Empty; + if (GamaSpeciesAppearanceEditorCoordinator.TryResolveActiveContext( + out GamaSpeciesAppearanceContext currentContext)) + { + currentModelPath = currentContext.ModelPath; + currentExperimentName = currentContext.ExperimentName; + } + speciesRenderOverridesAsset = selectedOverridesAsset; + GamaSpeciesAppearanceEditorCoordinator.SetActiveContext( + new GamaSpeciesAppearanceContext( + selectedOverridesAsset, + currentModelPath, + currentExperimentName)); + } + else + { + speciesRenderOverridesAsset = selectedOverridesAsset; + } using (new EditorGUI.DisabledScope(speciesRenderOverridesAsset != null)) { if (GUILayout.Button("Create GamaSpeciesRenderOverrides.asset", GUILayout.Height(22f))) @@ -1351,7 +1429,7 @@ private async System.Threading.Tasks.Task PurgeGhostPlayerInteractiveAsync(strin } captureRuntimeStatus = outcome; - UnityEngine.Debug.Log("[GAMA] " + outcome); + GamaLog.Dev("[GAMA] " + outcome); Repaint(); } @@ -1408,15 +1486,15 @@ private bool RestartMiddlewareForUnitySelection( lastMiddlewareLearningPackagePath = learningPackagePath; lastMiddlewareExtraLearningPackagePath = middlewareEnv["EXTRA_LEARNING_PACKAGE_PATH"] ?? string.Empty; - Debug.Log("[GAMA][SYNC] Restarting middleware with LEARNING_PACKAGE_PATH=" + learningPackagePath); - Debug.Log("[GAMA][MW] Starting simple.webplatform"); - Debug.Log("[GAMA][MW] FileName=" + cmdExe); - Debug.Log("[GAMA][MW] Arguments=" + cmdArguments); - Debug.Log("[GAMA][MW] WorkingDirectory=" + webplatformRoot); - Debug.Log("[GAMA][MW] LEARNING_PACKAGE_PATH=" + learningPackagePath); - Debug.Log("[GAMA][MW] EXTRA_LEARNING_PACKAGE_PATH=" + lastMiddlewareExtraLearningPackagePath); - Debug.Log("[GAMA][MW] MONITOR_WS_PORT=" + captureMonitorPort); - Debug.Log("[GAMA][MW] HEADSET_WS_PORT=" + playerPort); + GamaLog.Dev("[GAMA][SYNC] Restarting middleware with LEARNING_PACKAGE_PATH=" + learningPackagePath); + GamaLog.Dev("[GAMA][MW] Starting simple.webplatform"); + GamaLog.Dev("[GAMA][MW] FileName=" + cmdExe); + GamaLog.Dev("[GAMA][MW] Arguments=" + cmdArguments); + GamaLog.Dev("[GAMA][MW] WorkingDirectory=" + webplatformRoot); + GamaLog.Dev("[GAMA][MW] LEARNING_PACKAGE_PATH=" + learningPackagePath); + GamaLog.Dev("[GAMA][MW] EXTRA_LEARNING_PACKAGE_PATH=" + lastMiddlewareExtraLearningPackagePath); + GamaLog.Dev("[GAMA][MW] MONITOR_WS_PORT=" + captureMonitorPort); + GamaLog.Dev("[GAMA][MW] HEADSET_WS_PORT=" + playerPort); LogMiddlewareEnvironmentSnapshot(middlewareEnv); string startError; @@ -1436,7 +1514,7 @@ private bool RestartMiddlewareForUnitySelection( } lastMiddlewareProcessId = captureMiddlewareProcess.ProcessId; - Debug.Log("[GAMA][MW] New middleware PID=" + lastMiddlewareProcessId + " cwd=" + webplatformRoot); + GamaLog.Dev("[GAMA][MW] New middleware PID=" + lastMiddlewareProcessId + " cwd=" + webplatformRoot); captureRuntimeStatus = "Waiting for monitor ports " + captureMonitorPort + " and player " + playerPort + "..."; Repaint(); @@ -1480,12 +1558,12 @@ private bool RestartMiddlewareForUnitySelection( return false; } - List monitorListenerPids = GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(captureMonitorPort, Debug.Log); - List playerListenerPids = GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(playerPort, Debug.Log); + List monitorListenerPids = GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(captureMonitorPort, GamaLog.Dev); + List playerListenerPids = GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(playerPort, GamaLog.Dev); string monitorListener = monitorListenerPids.Count == 0 ? "?" : string.Join(",", monitorListenerPids); string playerListener = playerListenerPids.Count == 0 ? "?" : string.Join(",", playerListenerPids); - Debug.Log("[GAMA][MW] Monitor TCP ready on port " + captureMonitorPort + " (listener PID=" + monitorListener + ")."); - Debug.Log("[GAMA][MW] Player socket ready on port " + playerPort + " (listener PID=" + playerListener + ")."); + GamaLog.Dev("[GAMA][MW] Monitor TCP ready on port " + captureMonitorPort + " (listener PID=" + monitorListener + ")."); + GamaLog.Dev("[GAMA][MW] Player socket ready on port " + playerPort + " (listener PID=" + playerListener + ")."); return true; } @@ -1497,24 +1575,24 @@ private bool EnsureMiddlewarePortsFreeForRelaunch( { error = null; HashSet allPids = new HashSet(); - List monitorPids = GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(monitorPort, Debug.Log); - List playerPids = GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(playerPort, Debug.Log); + List monitorPids = GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(monitorPort, GamaLog.Dev); + List playerPids = GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(playerPort, GamaLog.Dev); for (int i = 0; i < monitorPids.Count; i++) { allPids.Add(monitorPids[i]); - Debug.Log("[GAMA][MW] Port " + monitorPort + " occupied by PID=" + monitorPids[i]); + GamaLog.Dev("[GAMA][MW] Port " + monitorPort + " occupied by PID=" + monitorPids[i]); } for (int i = 0; i < playerPids.Count; i++) { allPids.Add(playerPids[i]); - Debug.Log("[GAMA][MW] Port " + playerPort + " occupied by PID=" + playerPids[i]); + GamaLog.Dev("[GAMA][MW] Port " + playerPort + " occupied by PID=" + playerPids[i]); } if (allPids.Count == 0) { - Debug.Log("[GAMA][MW] Port " + monitorPort + " free"); - Debug.Log("[GAMA][MW] Port " + playerPort + " free"); + GamaLog.Dev("[GAMA][MW] Port " + monitorPort + " free"); + GamaLog.Dev("[GAMA][MW] Port " + playerPort + " free"); return true; } @@ -1535,8 +1613,8 @@ private bool EnsureMiddlewarePortsFreeForRelaunch( foreach (int pid in allPids) { - Debug.Log("[GAMA][MW] Stopping PID " + pid + "..."); - GamaEditorMiddlewareOrchestrator.KillProcessByPid(pid, Debug.Log); + GamaLog.Dev("[GAMA][MW] Stopping PID " + pid + "..."); + GamaEditorMiddlewareOrchestrator.KillProcessByPid(pid, GamaLog.Dev); } bool monitorClosed; @@ -1559,14 +1637,14 @@ private bool EnsureMiddlewarePortsFreeForRelaunch( if (!monitorClosed || !playerClosed) { error = "Ports " + monitorPort + "/" + playerPort + " remain occupied after taskkill. Monitor PIDs=" + - string.Join(", ", GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(monitorPort, Debug.Log)) + + string.Join(", ", GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(monitorPort, GamaLog.Dev)) + " player=" + - string.Join(", ", GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(playerPort, Debug.Log)); + string.Join(", ", GamaEditorMiddlewareOrchestrator.GetListeningPidsOnTcpPort(playerPort, GamaLog.Dev)); return false; } - Debug.Log("[GAMA][MW] Port " + monitorPort + " free"); - Debug.Log("[GAMA][MW] Port " + playerPort + " free"); + GamaLog.Dev("[GAMA][MW] Port " + monitorPort + " free"); + GamaLog.Dev("[GAMA][MW] Port " + playerPort + " free"); return true; } @@ -1616,7 +1694,7 @@ private static void LogMiddlewareProcessLine(string line, bool isStdErr) } string prefix = isStdErr ? "[GAMA][MW][stderr] " : "[GAMA][MW][stdout] "; - Debug.Log(prefix + line); + GamaLog.Dev(prefix + line); } private static void LogMiddlewareEnvironmentSnapshot(IDictionary middlewareEnv) @@ -1637,7 +1715,7 @@ private static void LogMiddlewareEnvironmentSnapshot(IDictionary kv.Key.StartsWith("LEARNING_", StringComparison.OrdinalIgnoreCase) || kv.Key.EndsWith("_WS_PORT", StringComparison.OrdinalIgnoreCase)) { - Debug.Log("[GAMA][MW] env " + kv.Key + "=" + (kv.Value ?? string.Empty)); + GamaLog.Dev("[GAMA][MW] env " + kv.Key + "=" + (kv.Value ?? string.Empty)); } } } @@ -1649,9 +1727,9 @@ private bool VerifyExistingMiddlewareReachable( { error = null; int playerPort = ResolveCapturePlayerPort(); - Debug.Log("[GAMA][CAPTURE][8080][INFO] EXTERNAL MIDDLEWARE MODE — no kill, no restart"); - Debug.Log("[GAMA][MW] Existing monitor connection ws://" + host + ":" + captureMonitorPort + "/"); - Debug.Log("[GAMA][MW] Existing player socket connection ws://" + host + ":" + playerPort + "/"); + GamaLog.Dev("[GAMA][CAPTURE][8080][INFO] EXTERNAL MIDDLEWARE MODE — no kill, no restart"); + GamaLog.Dev("[GAMA][MW] Existing monitor connection ws://" + host + ":" + captureMonitorPort + "/"); + GamaLog.Dev("[GAMA][MW] Existing player socket connection ws://" + host + ":" + playerPort + "/"); bool monitorReady; bool playerReady; @@ -1719,10 +1797,12 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) { pendingPreviewPlayerCleanupId = string.Empty; lastPreviewCapturePlayerId = string.Empty; + lastPreviewMonitorExperimentId = string.Empty; + lastPreviewCaptureUsedActiveGamaSelection = false; if (EditorApplication.isPlayingOrWillChangePlaymode) { captureRuntimeStatus = "Preview generation is disabled during Play mode. Stop Play mode before generating a new editor preview."; - Debug.LogWarning("[GAMA][PREVIEW] Capture ignored during Play mode to avoid stealing the runtime websocket player."); + GamaLog.DevWarning("[GAMA][PREVIEW] Capture ignored during Play mode to avoid stealing the runtime websocket player."); Repaint(); return; } @@ -1734,6 +1814,7 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) } bool selectedGamaPreviewMode = managedFromUnity && captureUseExternalMiddleware; + lastPreviewCaptureUsedActiveGamaSelection = selectedGamaPreviewMode; bool directOpenGamaPreviewMode = !managedFromUnity && captureUseLocalMiddleware && captureSkipRemoteLoad && @@ -1770,9 +1851,9 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) return; } - Debug.Log("[SYNC] UI selected model = " + uiSelectedModelPath); - Debug.Log("[SYNC] Runtime capture model = " + runtimeModelPath); - Debug.Log("[SYNC] Runtime experiment = " + runtimeExperimentName); + GamaLog.Dev("[SYNC] UI selected model = " + uiSelectedModelPath); + GamaLog.Dev("[SYNC] Runtime capture model = " + runtimeModelPath); + GamaLog.Dev("[SYNC] Runtime experiment = " + runtimeExperimentName); if (managedFromUnity) { @@ -1792,13 +1873,13 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) if (!captureUseLocalMiddleware && !captureSkipRemoteLoad && !managedFromUnity) { - UnityEngine.Debug.LogWarning( + GamaLog.DevWarning( "[GAMA] Capture with remote load (port 1000): prefer 'Managed by Unity' or 'Launch and capture'."); } captureFlowActive = true; int panelSession = System.Threading.Interlocked.Increment(ref GamaPanelWindowCaptureSessionCounter); - UnityEngine.Debug.Log("[GAMA][DBG][panel #" + panelSession + "] StartCaptureFlow launchGama=" + launchGama + + GamaLog.Dev("[GAMA][DBG][panel #" + panelSession + "] StartCaptureFlow launchGama=" + launchGama + " managed=" + managedFromUnity + " externalMw=" + (managedFromUnity && captureUseExternalMiddleware) + " directMw=" + captureUseLocalMiddleware + " port=" + (capturePort ?? "?") + @@ -1854,7 +1935,7 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) EditorUtility.DisplayDialog("Capture — incompatible Unity model", unityCompatError, "OK"); captureFlowActive = false; captureRuntimeStatus = unityCompatError; - UnityEngine.Debug.LogWarning("[GAMA] Capture refused: " + unityCompatError); + GamaLog.DevWarning("[GAMA] Capture refused: " + unityCompatError); return; } @@ -1871,7 +1952,7 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) } generatedLearningPackageRoot = learningRoot; - Debug.Log("[GAMA][SYNC] Generated middleware package root: " + generatedLearningPackageRoot); + GamaLog.Dev("[GAMA][SYNC] Generated middleware package root: " + generatedLearningPackageRoot); } captureCts?.Dispose(); @@ -1914,7 +1995,7 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) runtimeExperimentName, runtimeModelPath, captureCts.Token, - UnityEngine.Debug.Log) + GamaLog.Dev) .GetAwaiter() .GetResult(); } @@ -1922,7 +2003,7 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) { AbortCaptureIfRunning("Catalog diagnosis failed", purgePlayer: false); EditorUtility.DisplayDialog("Capture", - "Monitor catalog diagnosis impossible: " + ex.Message, + "Monitor catalog diagnosis failed: " + ex.Message, "OK"); captureFlowActive = false; return; @@ -1947,11 +2028,11 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) } else if (selectedGamaPreviewMode) { - Debug.Log("[GAMA][PREVIEW][SELECTED] Preview de l'expérience sélectionnée dans GAMA"); - Debug.Log("[GAMA][PREVIEW][SELECTED] Aucun catalogue middleware"); - Debug.Log("[GAMA][PREVIEW][SELECTED] No settings.json"); - Debug.Log("[GAMA][PREVIEW][SELECTED] No middleware restart"); - Debug.Log("[GAMA][PREVIEW][SELECTED] Play-like Runtime sequence"); + GamaLog.Dev("[GAMA][PREVIEW][SELECTED] Preview of the experiment selected in GAMA"); + GamaLog.Dev("[GAMA][PREVIEW][SELECTED] No middleware catalog"); + GamaLog.Dev("[GAMA][PREVIEW][SELECTED] No settings.json"); + GamaLog.Dev("[GAMA][PREVIEW][SELECTED] No middleware restart"); + GamaLog.Dev("[GAMA][PREVIEW][SELECTED] Play-like Runtime sequence"); } if (launchGama) @@ -2021,7 +2102,7 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) if (!captureUseLocalMiddleware && GamaEditorFirstTickCapture.IsGamaNativeWebSocketPort(port)) { - UnityEngine.Debug.LogWarning( + GamaLog.DevWarning( "[GAMA] Port " + port + " = integrated GAMA server: direct capture mode (load/play protocol). " + "For the Node middleware, launch simple.webplatform and use port 8080."); } @@ -2032,20 +2113,20 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) { if (selectedGamaPreviewMode) { - UnityEngine.Debug.Log( + GamaLog.Dev( "[GAMA] Preview from GAMA: external middleware ws://" + host + ":" + port + "/, Play-like sequence, no monitor catalog."); } else { - UnityEngine.Debug.Log( + GamaLog.Dev( "[GAMA] Managed by Unity: monitor ws://" + host + ":" + captureMonitorPort + "/ (launch_experiment) then headset ws://" + host + ":" + port + "/."); } } else if (captureSkipRemoteLoad && !captureUseLocalMiddleware) { - UnityEngine.Debug.Log( + GamaLog.Dev( "[GAMA] Experiment already open: 8080 middleware only (diagnostic, no launch monitor)."); } @@ -2079,12 +2160,12 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) false, captureStopWhenPreviewCacheStable, stableSec, - UnityEngine.Debug.Log, + GamaLog.Dev, captureCts.Token); captureRuntimeStatus = "Direct capture started to GAMA ws://localhost:" + gamaPort + "/ (min. " + (captureTimeoutMs / 1000) + " s, extended after load/create_player). id = " + id + "."; - UnityEngine.Debug.Log("[GAMA] Direct GAMA Capture: ws://localhost:" + gamaPort + "/ id=\"" + id + "\"."); + GamaLog.Dev("[GAMA] Direct GAMA Capture: ws://localhost:" + gamaPort + "/ id=\"" + id + "\"."); } else { @@ -2112,7 +2193,7 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) capturePauseExperimentAfterPreview, captureStopWhenPreviewCacheStable, stableSec, - UnityEngine.Debug.Log, + GamaLog.Dev, captureCts.Token); captureRuntimeStatus = "Middleware capture ws://" + host + ":" + port + "/ (min. " + (captureTimeoutMs / 1000) + @@ -2124,7 +2205,7 @@ private void StartCaptureFlow(bool launchGama, bool managedFromUnity = false) : captureSkipRemoteLoad ? "8080 Middleware only (diagnostic)." : "Automatic load/play/create_player if experiment is imported."); - UnityEngine.Debug.Log("[GAMA] Capture middleware id=\"" + id + "\" ws://" + host + ":" + port + "/"); + GamaLog.Dev("[GAMA] Capture middleware id=\"" + id + "\" ws://" + host + ":" + port + "/"); } if (captureTask == null) @@ -2198,6 +2279,8 @@ private bool TryResolveRuntimeSelection( private void InvalidateCaptureSelectionCache() { + lastPreviewMonitorExperimentId = string.Empty; + lastPreviewCaptureUsedActiveGamaSelection = false; staticPreviewPrecisionJsonPath = string.Empty; staticPreviewPropertiesJsonPath = string.Empty; staticPreviewWorldJsonPath = string.Empty; @@ -2235,7 +2318,7 @@ private void StartCatalogDiagnosis() } generatedLearningPackageRoot = learningRoot; - Debug.Log("[GAMA][SYNC] Generated middleware package root: " + generatedLearningPackageRoot); + GamaLog.Dev("[GAMA][SYNC] Generated middleware package root: " + generatedLearningPackageRoot); using (System.Threading.CancellationTokenSource restartCts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(120))) @@ -2278,7 +2361,7 @@ private void StartCatalogDiagnosis() runtimeExperimentName, runtimeModelPath, System.Threading.CancellationToken.None, - UnityEngine.Debug.Log); + GamaLog.Dev); } private void SyncSelectedModelWithMiddleware() @@ -2296,7 +2379,7 @@ private void SyncSelectedModelWithMiddleware() if (!EnsureGeneratedLearningPackage(runtimeModelPath, runtimeExperimentName, out string learningRoot, out string learningError)) { captureRuntimeStatus = learningError; - Debug.LogError("[GAMA][SYNC] " + learningError); + GamaLog.Error("[GAMA][SYNC] " + learningError); return; } @@ -2304,9 +2387,9 @@ private void SyncSelectedModelWithMiddleware() captureRuntimeStatus = "[GAMA][SYNC] Generated package: " + generatedLearningPackageRoot + " | Restart the middleware with LEARNING_PACKAGE_PATH including this folder."; - Debug.Log("[GAMA][SYNC] Selected model: " + runtimeModelPath); - Debug.Log("[GAMA][SYNC] Experiments found: " + runtimeExperimentName); - Debug.Log("[GAMA][SYNC] Generated middleware package: " + generatedLearningPackageRoot); + GamaLog.Dev("[GAMA][SYNC] Selected model: " + runtimeModelPath); + GamaLog.Dev("[GAMA][SYNC] Experiments found: " + runtimeExperimentName); + GamaLog.Dev("[GAMA][SYNC] Generated middleware package: " + generatedLearningPackageRoot); TryAssignGeneratedMiddlewareLauncherScript(); } @@ -2329,7 +2412,7 @@ private void ConfigureAndOfferMiddlewareRestart() } generatedLearningPackageRoot = learningRoot; - Debug.Log("[GAMA][SYNC] Generated middleware package root: " + generatedLearningPackageRoot); + GamaLog.Dev("[GAMA][SYNC] Generated middleware package root: " + generatedLearningPackageRoot); using (System.Threading.CancellationTokenSource restartCts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(120))) @@ -2356,7 +2439,7 @@ private void ConfigureAndOfferMiddlewareRestart() runtimeExperimentName, runtimeModelPath, restartCts.Token, - UnityEngine.Debug.Log) + GamaLog.Dev) .GetAwaiter() .GetResult(); @@ -2386,7 +2469,7 @@ private bool TryAssignGeneratedMiddlewareLauncherScript() middlewareScriptPath = launcherPath; EditorPrefs.SetString(GamaMiddlewareScriptPrefKey, middlewareScriptPath); - Debug.Log("[GAMA][MW] Middleware script registered: " + middlewareScriptPath); + GamaLog.Dev("[GAMA][MW] Middleware script registered: " + middlewareScriptPath); return true; } @@ -2432,8 +2515,8 @@ private bool EnsureGeneratedLearningPackage( "}"; File.WriteAllText(settingsPath, settingsJson); bool exists = File.Exists(settingsPath); - Debug.Log("[GAMA][SYNC] settings.json path=" + settingsPath); - Debug.Log("[GAMA][SYNC] settings.json exists=" + exists); + GamaLog.Dev("[GAMA][SYNC] settings.json path=" + settingsPath); + GamaLog.Dev("[GAMA][SYNC] settings.json exists=" + exists); if (!exists) { error = "settings.json not written: " + settingsPath; @@ -2443,11 +2526,11 @@ private bool EnsureGeneratedLearningPackage( JObject settings = JObject.Parse(File.ReadAllText(settingsPath)); string parsedExperiment = settings["experiment_name"]?.ToString() ?? string.Empty; string parsedModel = settings["model_file_path"]?.ToString() ?? string.Empty; - Debug.Log("[GAMA][SYNC] experiment_name=" + parsedExperiment); - Debug.Log("[GAMA][SYNC] model_file_path=" + parsedModel); + GamaLog.Dev("[GAMA][SYNC] experiment_name=" + parsedExperiment); + GamaLog.Dev("[GAMA][SYNC] model_file_path=" + parsedModel); lastGeneratedSettingsJsonPath = settingsPath; lastGeneratedSettingsJsonContent = settingsJson; - Debug.Log("[GAMA][SYNC] settings.json content:\n" + settingsJson); + GamaLog.Dev("[GAMA][SYNC] settings.json content:\n" + settingsJson); LogGeneratedPackageTree(root); if (!string.Equals(parsedExperiment.Trim(), runtimeExperimentName.Trim(), StringComparison.Ordinal)) @@ -2469,7 +2552,7 @@ private bool EnsureGeneratedLearningPackage( } catch (Exception ex) { - error = "Middleware learning package generation impossible: " + ex.Message; + error = "Middleware learning package generation failed: " + ex.Message; return false; } } @@ -2566,14 +2649,14 @@ private static void LogGeneratedPackageTree(string root) { if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) { - Debug.Log("[GAMA][SYNC] Generated package tree: (missing root)"); + GamaLog.Dev("[GAMA][SYNC] Generated package tree: (missing root)"); return; } - Debug.Log("[GAMA][SYNC] Generated package tree:"); + GamaLog.Dev("[GAMA][SYNC] Generated package tree:"); foreach (string file in Directory.GetFiles(root, "*", SearchOption.AllDirectories)) { - Debug.Log("[GAMA][SYNC] - " + file); + GamaLog.Dev("[GAMA][SYNC] - " + file); } } @@ -2678,7 +2761,7 @@ private void OnCaptureFinished(System.Threading.Tasks.Task(FindObjectsInactive.Include); if (manager == null) { - Debug.LogWarning("[GAMA][PREVIEW][BUILD] No SimulationManager in the scene: building with CRS/visual defaults."); + GamaLog.DevWarning("[GAMA][PREVIEW][BUILD] No SimulationManager in the scene: building with CRS/visual defaults."); } int prefabN; @@ -4278,7 +4399,7 @@ private bool TryApplyJsonStaticPreview(GameObject root, out bool success, out st ? " (tick " + staticPreviewWorldTickIndex + ")" : string.Empty; status = "Preview (JSON middleware)" + tickLabel + ": " + prefabN + " prefab(s), " + geomN + " geometry(s). CRS = SimulationManager coefficients."; - Debug.Log("[GAMA] " + status); + GamaLog.Dev("[GAMA] " + status); return true; } @@ -4290,7 +4411,7 @@ private void GenerateStaticPreview() } catch (Exception ex) { - Debug.LogError("[GAMA][PREVIEW][BUILD] Exception: " + ex); + GamaLog.Error("[GAMA][PREVIEW][BUILD] Exception: " + ex); captureRuntimeStatus = "Capture OK but preview build failed: " + ex.Message; experimentStatus = captureRuntimeStatus; } @@ -4298,8 +4419,7 @@ private void GenerateStaticPreview() private void GenerateStaticPreviewInternal() { - ClearStaticPreview(); - ResetPreviewTransformOffsetsBeforeNewPreview(); + ClearStaticPreview(false, false, false); int undoGroup = Undo.GetCurrentGroup(); Undo.SetCurrentGroupName("GAMA Static Experiment Preview"); @@ -4314,6 +4434,7 @@ private void GenerateStaticPreviewInternal() if (jsonOk) { ConfigurePreviewSession(root); + GamaEditorPreviewOverrideApplier.ApplyOverridesToCurrentPreview(); Selection.activeGameObject = root; SceneView.FrameLastActiveSceneView(); Undo.CollapseUndoOperations(undoGroup); @@ -4324,7 +4445,7 @@ private void GenerateStaticPreviewInternal() ? jsonStatus : jsonStatus + " species=" + speciesSummary; captureRuntimeStatus = "Static preview built: " + experimentStatus; - Debug.Log("[GAMA][PREVIEW][BUILD] " + captureRuntimeStatus); + GamaLog.Info("[GAMA] " + captureRuntimeStatus); return; } @@ -4332,7 +4453,7 @@ private void GenerateStaticPreviewInternal() Undo.CollapseUndoOperations(undoGroup); experimentStatus = jsonStatus ?? "JSON Error."; captureRuntimeStatus = "Capture successful, but preview build failed: " + experimentStatus; - Debug.LogError("[GAMA][PREVIEW][BUILD] " + captureRuntimeStatus); + GamaLog.Error("[GAMA][PREVIEW][BUILD] " + captureRuntimeStatus); return; } @@ -4355,11 +4476,6 @@ private void GenerateStaticPreviewInternal() for (int i = 0; i < agentOverrides.Count; i++) { GamaPanelAgentOverride agent = agentOverrides[i]; - if (IsPreviewHidden(agent)) - { - continue; - } - GameObject group = GamaSceneUtility.GetOrCreateChild(agentsRoot, SanitizeObjectName(agent.Name)); int sampleCount = ResolvePreviewSampleCount(agent); AddPreviewLabel(group, agent.Name, i, visibleAgentTypes, previewExtent); @@ -4390,11 +4506,12 @@ private void GenerateStaticPreviewInternal() Selection.activeGameObject = root; SceneView.FrameLastActiveSceneView(); ConfigurePreviewSession(root); + GamaEditorPreviewOverrideApplier.ApplyOverridesToCurrentPreview(); Undo.CollapseUndoOperations(undoGroup); EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene()); experimentStatus = "Static preview generated: " + resolvedPrefabCount + " prefab instance(s), " + fallbackCount + " fallback instance(s)."; string label = analysis != null && !string.IsNullOrWhiteSpace(analysis.Name) ? analysis.Name : "GAMA active selection"; - Debug.Log("[GAMA] Static experiment preview generated for " + label + "."); + GamaLog.Info("[GAMA] Static experiment preview generated for " + label + "."); } private static string BuildPreviewSpeciesSummary(GameObject root) @@ -4462,7 +4579,7 @@ private void UpdateAgentOverridesFromPreview(GameObject root) EnsureAgentUiDefaults(agent); } - Debug.Log("[GAMA][PREVIEW][UI] species rows rebuilt count=" + agentOverrides.Count.ToString(CultureInfo.InvariantCulture)); + GamaLog.Dev("[GAMA][PREVIEW][UI] species rows rebuilt count=" + agentOverrides.Count.ToString(CultureInfo.InvariantCulture)); } private void ConfigurePreviewSession(GameObject root) @@ -4482,7 +4599,12 @@ private void ConfigurePreviewSession(GameObject root) string experiment = analysis != null && !string.IsNullOrWhiteSpace(analysis.Name) ? analysis.Name : (!string.IsNullOrWhiteSpace(gamaHeadlessBatchName) ? gamaHeadlessBatchName : "unknown"); - bool activeGamaSelection = string.Equals(model, "GAMA_ACTIVE_SELECTION", StringComparison.Ordinal); + bool activeGamaSelection = lastPreviewCaptureUsedActiveGamaSelection || + string.Equals(model, "GAMA_ACTIVE_SELECTION", StringComparison.Ordinal); + if (activeGamaSelection) + { + model = "GAMA_ACTIVE_SELECTION"; + } session.modelPath = model; session.experimentName = experiment ?? string.Empty; @@ -4499,6 +4621,17 @@ private void ConfigurePreviewSession(GameObject root) session.playerId = ResolvePreviewSessionPlayerId(); session.selectionMode = activeGamaSelection ? "ActiveGamaSelection" : "UnitySelection"; session.activeGamaSelection = activeGamaSelection; + session.monitorExperimentId = NormalizeMonitorExperimentId(lastPreviewMonitorExperimentId); + if (!GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + session.modelPath, + session.experimentName, + session.activeGamaSelection, + session.monitorExperimentId, + out string stableExperimentKey)) + { + stableExperimentKey = string.Empty; + } + session.stableExperimentKey = stableExperimentKey; session.stale = false; session.useThisPreviewForPlay = false; session.experimentSignature = BuildPreviewSignature(session); @@ -4508,7 +4641,7 @@ private void ConfigurePreviewSession(GameObject root) } else { - Debug.Log("[GAMA][PREVIEW] Runtime selection store unmodified (mode " + session.selectionMode + ", model=" + model + ")."); + GamaLog.Dev("[GAMA][PREVIEW] Runtime selection store unmodified (mode " + session.selectionMode + ", model=" + model + ")."); } SimulationManager manager = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); @@ -4529,8 +4662,13 @@ private void ConfigurePreviewSession(GameObject root) session.speciesOverrides = ResolveSpeciesRenderOverridesAsset(); PropagateSessionToSpeciesWizards(root, session); EditorUtility.SetDirty(session); + GamaSpeciesAppearanceEditorCoordinator.SetActiveContext( + new GamaSpeciesAppearanceContext( + session.speciesOverrides, + session.modelPath, + session.experimentName)); - Debug.Log( + GamaLog.Dev( "[GAMA][PREVIEW] session model=" + session.modelPath + " exp=" + session.experimentName + " mode=" + session.selectionMode + @@ -4552,6 +4690,19 @@ private string ResolvePreviewSessionPlayerId() return StaticInformation.getId(); } + private static string NormalizeMonitorExperimentId(string experimentId) + { + if (string.IsNullOrWhiteSpace(experimentId)) + { + return string.Empty; + } + + string normalized = experimentId.Trim(); + return string.Equals(normalized, "0", StringComparison.Ordinal) + ? string.Empty + : normalized; + } + private string ResolvePreviewSessionModelPath() { string candidate = analysis != null && !string.IsNullOrWhiteSpace(analysis.SourcePath) @@ -4723,25 +4874,29 @@ private GameObject CreatePreviewInstance(GamaPanelAgentOverride agent, int sampl } previewObject.previewOnly = true; + previewObject.canBeReusedAtRuntime = false; previewObject.speciesName = agent.Name; previewObject.agentId = instance.name; previewObject.geometryHash = string.Empty; previewObject.sourceTick = staticPreviewWorldTickIndex; + previewObject.stableAgentKey = string.Empty; + previewObject.sourcePropertyId = string.Empty; + previewObject.sourcePrefabSignature = string.Empty; + previewObject.representationKind = resolvedPrefab + ? GamaPreviewRepresentationKind.Prefab + : GamaPreviewRepresentationKind.Unknown; + previewObject.provenance = GamaPreviewProvenance.Sample; - float scale = agent.OverrideScale ? agent.ScaleMultiplier : Mathf.Max(0.1f, agent.ScaleMultiplier); - instance.transform.localScale = Vector3.one * Mathf.Max(0.01f, scale); + instance.transform.localScale = Vector3.one; - if (agent.OverrideColor || !resolvedPrefab) - { - GamaVisualUtility.ApplyColor(instance, agent.OverrideColor ? agent.Color : GetDefaultAgentColor(agent.Name)); - } - - if (agent.OverrideVisibility) + if (!resolvedPrefab) { - instance.SetActive(agent.Visible); + GamaVisualUtility.ApplyColor(instance, agent.DefaultColor); } PlaceOnPreviewGround(instance); + previewObject.SetVisualAnchorLocal(Vector3.zero); + previewObject.CaptureBaseTransformIfNeeded(); return instance; } @@ -5057,14 +5212,33 @@ private string ResolvePreviewWorldJsonPath() return staticPreviewWorldJsonPath; } - private static void ClearStaticPreview() + private void ClearStaticPreview( + bool removePersistedAppearance = true, + bool clearUiState = true, + bool clearCaptureExperimentIdentity = true) { - GameObject existing = GameObject.Find(StaticPreviewRootName); + if (clearCaptureExperimentIdentity) + { + lastPreviewMonitorExperimentId = string.Empty; + lastPreviewCaptureUsedActiveGamaSelection = false; + } + + GamaSpeciesAppearanceEditorCoordinator.ClearActiveContext(removePersistedAppearance); + GamaPreviewSession previewSession = + GamaSpeciesAppearanceEditorCoordinator.FindCurrentPreviewSession(); + GameObject existing = previewSession != null + ? previewSession.gameObject + : GameObject.Find(StaticPreviewRootName); if (existing != null) { Undo.DestroyObjectImmediate(existing); EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene()); } + if (clearUiState) + { + agentOverrides?.Clear(); + } + Repaint(); } private static void ImportPrefabsToResources(string sourceFolder) @@ -5086,7 +5260,7 @@ private static void ImportPrefabsToResources(string sourceFolder) } catch (Exception exception) { - Debug.LogError("[GAMA] Error importing prefabs: " + exception.Message); + GamaLog.Error("[GAMA] Error importing prefabs: " + exception.Message); EditorUtility.DisplayDialog("Import Prefabs", "Failed to import prefabs. See console for details.\n\n" + exception.Message, "OK"); } } @@ -5231,7 +5405,6 @@ public GamaPanelAgentOverride(GamaPanelAgentInfo source) public bool OverrideColor; public Color DefaultColor; public bool DefaultsResolved; - public bool StoredOverrideLoaded; public Color Color; public bool OverrideScale; diff --git a/Editor/GamaPrefabAutoCopier.cs b/Editor/GamaPrefabAutoCopier.cs index 6e0d66b..0be13b6 100644 --- a/Editor/GamaPrefabAutoCopier.cs +++ b/Editor/GamaPrefabAutoCopier.cs @@ -91,6 +91,6 @@ private static void PerformCopy() } AssetDatabase.Refresh(); - Debug.Log("[GAMA] Successfully copied prefabs to " + TARGET_DIR); + GamaLog.Dev("[GAMA] Successfully copied prefabs to " + TARGET_DIR); } } diff --git a/Editor/GamaPreviewDebugProbe.cs b/Editor/GamaPreviewDebugProbe.cs index abb4feb..0a18144 100644 --- a/Editor/GamaPreviewDebugProbe.cs +++ b/Editor/GamaPreviewDebugProbe.cs @@ -9,17 +9,17 @@ public static void ProbeStaticPreview() { var root = FindPreviewRoot(); - Debug.Log("[GAMA][PROBE] Preview root = " + (root ? GetPath(root.transform) : "NULL")); + GamaLog.Dev("[GAMA][PROBE] Preview root = " + (root ? GetPath(root.transform) : "NULL")); if (!root) return; var gama = root.transform.Find("GAMA"); - Debug.Log("[GAMA][PROBE] GAMA child = " + (gama ? GetPath(gama) : "NULL")); + GamaLog.Dev("[GAMA][PROBE] GAMA child = " + (gama ? GetPath(gama) : "NULL")); if (gama != null) { - Debug.Log("[GAMA][PROBE] Children under GAMA:"); + GamaLog.Dev("[GAMA][PROBE] Children under GAMA:"); foreach (Transform child in gama) { @@ -28,7 +28,7 @@ public static void ProbeStaticPreview() } var pedestrian = root.transform.Find("GAMA/pedestrian"); - Debug.Log("[GAMA][PROBE] Direct path GAMA/pedestrian = " + (pedestrian ? GetPath(pedestrian) : "NULL")); + GamaLog.Dev("[GAMA][PROBE] Direct path GAMA/pedestrian = " + (pedestrian ? GetPath(pedestrian) : "NULL")); if (pedestrian != null) PrintSpecies(pedestrian); @@ -43,7 +43,7 @@ public static void ForcePedestrianRedX10() if (!root) { - Debug.LogWarning("[GAMA][PROBE] Cannot force: preview root not found"); + GamaLog.DevWarning("[GAMA][PROBE] Cannot force: preview root not found"); return; } @@ -51,7 +51,7 @@ public static void ForcePedestrianRedX10() if (!pedestrian) { - Debug.LogWarning("[GAMA][PROBE] Cannot force: GAMA/pedestrian not found"); + GamaLog.DevWarning("[GAMA][PROBE] Cannot force: GAMA/pedestrian not found"); return; } @@ -72,14 +72,14 @@ public static void ForcePedestrianRedX10() EditorUtility.SetDirty(pedestrian); SceneView.RepaintAll(); - Debug.Log("[GAMA][PROBE] Forced pedestrian red x10. Renderers found = " + renderers.Length); + GamaLog.Dev("[GAMA][PROBE] Forced pedestrian red x10. Renderers found = " + renderers.Length); } private static void PrintSpecies(Transform species) { var renderers = species.GetComponentsInChildren(true); - Debug.Log( + GamaLog.Dev( "[GAMA][PROBE] species=" + species.name + " path=" + GetPath(species) + " active=" + species.gameObject.activeInHierarchy + @@ -92,7 +92,7 @@ private static void PrintSpecies(Transform species) for (int i = 0; i < max; i++) { - Debug.Log( + GamaLog.Dev( "[GAMA][PROBE] renderer[" + i + "]=" + GetPath(renderers[i].transform) + " material=" + (renderers[i].sharedMaterial ? renderers[i].sharedMaterial.name : "NULL") diff --git a/Editor/GamaPreviewPlayModeGuard.cs b/Editor/GamaPreviewPlayModeGuard.cs index 938fe4e..54b36c3 100644 --- a/Editor/GamaPreviewPlayModeGuard.cs +++ b/Editor/GamaPreviewPlayModeGuard.cs @@ -29,6 +29,7 @@ private static void OnPlayModeStateChanged(PlayModeStateChange state) { if (state == PlayModeStateChange.ExitingEditMode) { + ClearPreviewReuseAuthorization(); GamaRuntimePreviewOverrideApplier.ClearRuntimeSessionOverrides(); EnsureStableUnityPlayId(); @@ -37,7 +38,7 @@ private static void OnPlayModeStateChanged(PlayModeStateChange state) TryRemoveRuntimePlayerFromUnity("Before new Unity Play", previousPlayerId); } - Debug.Log("[GAMA][PLAY] Unity Play player id: " + StaticInformation.getId()); + GamaLog.Dev("[GAMA][PLAY] Unity Play player id: " + StaticInformation.getId()); AssignSpeciesOverrideContextForPlay(); GameObject root = FindPreviewRoot(); @@ -50,16 +51,18 @@ private static void OnPlayModeStateChanged(PlayModeStateChange state) if (autoHide && wasActive) { root.SetActive(false); - Debug.Log("[GAMA][PREVIEW][PLAY] Static preview hidden before Play mode."); + GamaLog.Dev("[GAMA][PREVIEW][PLAY] Static preview hidden before Play mode."); } } - TryPrepareGamaForPlay(); + PlayPreparationResult preparation = TryPrepareGamaForPlay(); + AuthorizePreviewReuse(preparation); } else if (state == PlayModeStateChange.ExitingPlayMode) { string runtimePlayerId = StaticInformation.getId(); - GamaRuntimePreviewOverrideApplier.ClearRuntimeSessionOverrides(); + PrepareSimulationManagersForEditorPlayExit(); + RestorePersistedAppearanceBeforeLeavingPlay(); TryPauseGamaFromUnity("Unity Play stopped", 2); TryDisconnectRuntimePlayer("Unity Play stopped"); @@ -67,6 +70,7 @@ private static void OnPlayModeStateChanged(PlayModeStateChange state) } else if (state == PlayModeStateChange.EnteredEditMode) { + ClearPreviewReuseAuthorization(); GamaRuntimePreviewOverrideApplier.ClearRuntimeSessionOverrides(); if (SessionState.GetBool(SessionStateKey, false)) @@ -78,11 +82,12 @@ private static void OnPlayModeStateChanged(PlayModeStateChange state) if (autoHide && !root.activeSelf) { root.SetActive(true); - Debug.Log("[GAMA][PREVIEW][PLAY] Static preview restored after Play mode."); + GamaLog.Dev("[GAMA][PREVIEW][PLAY] Static preview restored after Play mode."); } } } SessionState.EraseBool(SessionStateKey); + GamaEditorPreviewOverrideApplier.ScheduleApplyOverridesToCurrentPreview(); } } @@ -141,12 +146,16 @@ private static void TryPauseGamaFromUnity(string reason, int attempts = 1) if (!paused) { - Debug.LogWarning("[GAMA][PLAY] " + reason + ", but pause_experiment was not confirmed on monitor " + monitorPort + "."); + GamaLog.Warning("[GAMA][PLAY] " + reason + ", but pause_experiment was not confirmed on monitor " + monitorPort + "."); + } + else + { + GamaLog.Info("[GAMA] GAMA experiment paused after Play Mode."); } } catch (Exception ex) { - Debug.LogWarning("[GAMA][PLAY] Failed to pause GAMA after " + reason + ": " + ex.Message); + GamaLog.Warning("[GAMA][PLAY] Failed to pause GAMA after " + reason + ": " + ex.Message); } } @@ -181,13 +190,13 @@ private static void TryResumeGamaFromUnity(string reason) .GetResult(); if (!resumed) { - Debug.LogWarning("[GAMA][PLAY] " + reason + ", but resume_experiment was not confirmed on monitor " + monitorPort + "."); + GamaLog.Warning("[GAMA][PLAY] " + reason + ", but resume_experiment was not confirmed on monitor " + monitorPort + "."); } } } catch (Exception ex) { - Debug.LogWarning("[GAMA][PLAY] Failed to resume GAMA after " + reason + ": " + ex.Message); + GamaLog.Warning("[GAMA][PLAY] Failed to resume GAMA after " + reason + ": " + ex.Message); } } @@ -203,11 +212,11 @@ private static void TryDisconnectRuntimePlayer(string reason) { manager.DisconnectProperlyAsync().GetAwaiter().GetResult(); Thread.Sleep(150); - Debug.Log("[GAMA][PLAY] " + reason + ": runtime websocket disconnected cleanly."); + GamaLog.Dev("[GAMA][PLAY] " + reason + ": runtime websocket disconnected cleanly."); } catch (Exception ex) { - Debug.LogWarning("[GAMA][PLAY] Failed to disconnect runtime websocket after " + reason + ": " + ex.Message); + GamaLog.DevWarning("[GAMA][PLAY] Failed to disconnect runtime websocket after " + reason + ": " + ex.Message); } } @@ -244,15 +253,15 @@ private static void TryRemoveRuntimePlayerFromUnity(string reason, string player monitorPort, playerId, cts.Token, - Debug.Log) + GamaLog.Dev) .GetAwaiter() .GetResult(); - Debug.Log("[GAMA][PLAY] " + reason + ": runtime player cleanup id=" + playerId + " removed=" + removed); + GamaLog.Dev("[GAMA][PLAY] " + reason + ": runtime player cleanup id=" + playerId + " removed=" + removed); } } catch (Exception ex) { - Debug.LogWarning("[GAMA][PLAY] Failed to remove runtime player after " + reason + ": " + ex.Message); + GamaLog.DevWarning("[GAMA][PLAY] Failed to remove runtime player after " + reason + ": " + ex.Message); } } @@ -283,44 +292,59 @@ private static void EnsureStableUnityPlayId() private static void AssignSpeciesOverrideContextForPlay() { - GamaSpeciesRenderOverrides asset = null; - string modelPath = string.Empty; - string experimentName = string.Empty; - - GamaPreviewSession session = FindCurrentPreviewSession(); - if (session != null) + if (GamaSpeciesAppearanceEditorCoordinator.TryResolveActiveContext( + out GamaSpeciesAppearanceContext context)) { - asset = session.speciesOverrides != null - ? session.speciesOverrides - : GamaSpeciesRenderOverridesEditorStore.GetOrCreateDefaultAsset(); - if (asset != null && session.speciesOverrides == null) - { - session.speciesOverrides = asset; - EditorUtility.SetDirty(session); - } - - modelPath = session.modelPath ?? string.Empty; - experimentName = session.experimentName ?? string.Empty; + GamaSpeciesAppearanceEditorCoordinator.SetActiveContext(context); } + } - if (asset == null) - { - asset = GamaSpeciesRenderOverridesEditorStore.GetOrCreateDefaultAsset(); + private static void RestorePersistedAppearanceBeforeLeavingPlay() + { + GamaSpeciesAppearanceContext context = GamaSpeciesAppearanceStateStore.ActiveContext; + IReadOnlyList overlayEntries = + GamaSpeciesAppearanceStateStore.GetRuntimeOverlayEntries(context); + List speciesNames = new List(); + for (int i = 0; i < overlayEntries.Count; i++) + { + string speciesName = overlayEntries[i] != null + ? overlayEntries[i].GetSpeciesName() + : string.Empty; + if (!string.IsNullOrWhiteSpace(speciesName) && !speciesNames.Contains(speciesName)) + { + speciesNames.Add(speciesName); + } } - if (asset == null) + GamaRuntimePreviewOverrideApplier.ClearRuntimeSessionOverrides(); + if (speciesNames.Count > 0) { - return; + GamaRuntimePreviewOverrideApplier.RefreshNow(); + SimulationManager[] managers = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < managers.Length; i++) + { + if (managers[i] == null) + { + continue; + } + for (int speciesIndex = 0; speciesIndex < speciesNames.Count; speciesIndex++) + { + managers[i].ApplyRuntimeSpeciesOverrideNow(speciesNames[speciesIndex]); + } + } } - SimulationManager[] managers = UnityEngine.Object.FindObjectsByType( - FindObjectsInactive.Include, - FindObjectsSortMode.None); - for (int i = 0; i < managers.Length; i++) + GamaRuntimeRendererAppearanceBaseline[] baselines = + UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < baselines.Length; i++) { - if (managers[i] != null) + if (baselines[i] != null) { - managers[i].SetSpeciesRenderOverridesContext(asset, modelPath, experimentName); + UnityEngine.Object.DestroyImmediate(baselines[i]); } } } @@ -359,13 +383,38 @@ private static GamaPreviewSession FindCurrentPreviewSession() return fallback; } - private static void TryPrepareGamaForPlay() + private readonly struct PlayPreparationResult + { + public readonly bool Success; + public readonly bool HasStrictTarget; + public readonly bool UsedMonitorFallback; + public readonly string ModelPath; + public readonly string ExperimentName; + public readonly string ExperimentId; + + public PlayPreparationResult( + bool success, + bool hasStrictTarget, + bool usedMonitorFallback, + string modelPath, + string experimentName, + string experimentId) + { + Success = success; + HasStrictTarget = hasStrictTarget; + UsedMonitorFallback = usedMonitorFallback; + ModelPath = modelPath ?? string.Empty; + ExperimentName = experimentName ?? string.Empty; + ExperimentId = experimentId ?? string.Empty; + } + } + + private static PlayPreparationResult TryPrepareGamaForPlay() { if (!EditorPrefs.GetBool(AutoLaunchGamaOnPlayPrefKey, true)) { - Debug.Log("[GAMA][PLAY] Auto-launch disabled; Play will only connect to the existing middleware state."); - return; - } + GamaLog.Dev("[GAMA][PLAY] Auto-launch disabled; Play will only connect to the existing middleware state."); + return default; } string host = EditorPrefs.GetString(GamaCaptureHostPrefKey, PlayerPrefs.GetString("IP", "localhost")); if (string.IsNullOrWhiteSpace(host)) @@ -393,16 +442,14 @@ private static void TryPrepareGamaForPlay() out string experimentName, out string source); - Debug.Log("[GAMA][PLAY] Preparing GAMA before Play. middleware=ws://" + host + ":" + playerPort + - "/ monitor=ws://" + host + ":" + monitorPort + "/ targetSource=" + source + - " model=" + (modelPath ?? string.Empty) + - " experiment=" + (experimentName ?? string.Empty)); + GamaLog.Info("[GAMA] Preparing GAMA experiment before Play Mode."); try { using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(130))) { CleanupEditorPreviewPlayersBeforePlay(host, playerPort, monitorPort, cts.Token); + bool usedMonitorFallback = !hasTarget; GamaEditorMiddlewareOrchestrator.ManagedExperimentResult result = hasTarget ? GamaEditorMiddlewareOrchestrator.StartMiddlewareManagedExperimentAsync( @@ -411,29 +458,31 @@ private static void TryPrepareGamaForPlay() experimentName, modelPath, cts.Token, - Debug.Log) + GamaLog.Dev) .GetAwaiter() .GetResult() : GamaEditorMiddlewareOrchestrator.LaunchCurrentMonitorExperimentAsync( host, monitorPort, cts.Token, - Debug.Log) + GamaLog.Dev) .GetAwaiter() .GetResult(); if (hasTarget && result != null && !result.Success && ShouldAttachToCurrentMonitorFallback(result.Error)) { - Debug.LogWarning("[GAMA][PLAY] Strict middleware catalog launch failed; attaching to current monitor experiment instead. " + - "The Unity selection remains model=" + (modelPath ?? string.Empty) + - " experiment=" + (experimentName ?? string.Empty) + - ". Error: " + (result.Error ?? "unknown")); + usedMonitorFallback = true; + GamaLog.DevWarning( + "[GAMA][PLAY] Strict middleware catalog launch failed; attaching to current monitor experiment instead. " + + "The Unity selection remains model=" + (modelPath ?? string.Empty) + + " experiment=" + (experimentName ?? string.Empty) + + ". Error: " + (result.Error ?? "unknown")); result = GamaEditorMiddlewareOrchestrator.LaunchCurrentMonitorExperimentAsync( host, monitorPort, cts.Token, - Debug.Log) + GamaLog.Dev) .GetAwaiter() .GetResult(); } @@ -446,28 +495,136 @@ private static void TryPrepareGamaForPlay() EditorPrefs.SetString(PlayExperimentPrefKey, experimentName); } - Debug.Log("[GAMA][PLAY] GAMA ready before Play: state=" + - (result.FinalExperimentState ?? string.Empty) + - (string.IsNullOrEmpty(result.ExperimentId) ? string.Empty : " exp_id=" + result.ExperimentId)); - return; - } + GamaLog.Info("[GAMA] GAMA experiment ready before Play Mode."); + return new PlayPreparationResult( + true, + hasTarget, + usedMonitorFallback, + modelPath, + experimentName, + result.ExperimentId); } string error = result != null && !string.IsNullOrWhiteSpace(result.Error) ? result.Error : "unknown reason"; if (hasTarget) { - Debug.LogWarning("[GAMA][PLAY] GAMA auto-launch failed before Play: " + error); + GamaLog.Warning("[GAMA][PLAY] GAMA auto-launch failed before Play: " + error); } else { - Debug.Log("[GAMA][PLAY] No Unity .gaml target for auto-launch; continuing Play attached to current GAMA/middleware state. " + error); + GamaLog.Dev("[GAMA][PLAY] No Unity .gaml target for auto-launch; continuing Play attached to current GAMA/middleware state. " + error); } } } catch (Exception ex) { - Debug.LogWarning("[GAMA][PLAY] GAMA auto-launch exception before Play: " + ex.Message); + GamaLog.Warning("[GAMA][PLAY] GAMA auto-launch exception before Play: " + ex.Message); + } + + return default; + } + + private static void AuthorizePreviewReuse(PlayPreparationResult preparation) + { + if (!preparation.Success) + { + return; + } + + if (EditorSettings.enterPlayModeOptionsEnabled && + (EditorSettings.enterPlayModeOptions & EnterPlayModeOptions.DisableSceneReload) != 0) + { + GamaLog.DevWarning( + "[GAMA][PREVIEW][REUSE] Preview GameObject reuse is disabled because Scene Reload is disabled. " + + "This protects the Edit Mode objects from runtime mutations."); + return; + } + + GamaPreviewSession session = FindCurrentPreviewSession(); + if (session == null || session.stale || string.IsNullOrWhiteSpace(session.stableExperimentKey)) + { + return; + } + + string expectedKey = string.Empty; + bool activeSelection = session.activeGamaSelection || + string.Equals( + session.modelPath, + "GAMA_ACTIVE_SELECTION", + StringComparison.OrdinalIgnoreCase); + + if (preparation.HasStrictTarget && !preparation.UsedMonitorFallback) + { + if (!GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + preparation.ModelPath, + preparation.ExperimentName, + false, + preparation.ExperimentId, + out expectedKey)) + { + return; + } + } + else + { + if (!activeSelection || + string.IsNullOrWhiteSpace(session.monitorExperimentId) || + string.IsNullOrWhiteSpace(preparation.ExperimentId) || + !string.Equals( + session.monitorExperimentId.Trim(), + preparation.ExperimentId.Trim(), + StringComparison.Ordinal)) + { + GamaLog.DevWarning( + "[GAMA][PREVIEW][REUSE] Active-monitor preview reuse was refused because the experiment id could not be matched exactly."); + return; + } + + expectedKey = session.stableExperimentKey; + } + + if (!string.Equals(session.stableExperimentKey, expectedKey, StringComparison.Ordinal)) + { + GamaLog.DevWarning( + "[GAMA][PREVIEW][REUSE] Preview reuse was refused because the launched experiment does not exactly match the loaded preview."); + return; + } + + session.reuseAuthorizedForPlay = true; + session.authorizedStableExperimentKey = expectedKey; + session.authorizedMonitorExperimentId = preparation.ExperimentId; + GamaLog.Dev("[GAMA][PREVIEW][REUSE] Existing preview GameObjects are eligible for this Play session."); + } + + private static void ClearPreviewReuseAuthorization() + { + GamaPreviewSession[] sessions = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < sessions.Length; i++) + { + GamaPreviewSession session = sessions[i]; + if (session == null) + { + continue; + } + + session.ClearRuntimeReuseAuthorization(); + } + } + + private static void PrepareSimulationManagersForEditorPlayExit() + { + SimulationManager[] managers = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < managers.Length; i++) + { + if (managers[i] != null) + { + managers[i].PrepareForEditorPlayExit(); + } } } @@ -478,8 +635,12 @@ private static bool ShouldAttachToCurrentMonitorFallback(string error) return false; } - return error.IndexOf("catalogue middleware", StringComparison.OrdinalIgnoreCase) >= 0 || + return error.IndexOf("middleware catalog", StringComparison.OrdinalIgnoreCase) >= 0 || error.IndexOf("catalog", StringComparison.OrdinalIgnoreCase) >= 0 || + error.IndexOf("not found", StringComparison.OrdinalIgnoreCase) >= 0 || + error.IndexOf("no strict match", StringComparison.OrdinalIgnoreCase) >= 0 || + error.IndexOf("missing from the middleware catalog", StringComparison.OrdinalIgnoreCase) >= 0 || + // Retain compatibility with errors produced by earlier package versions. error.IndexOf("introuvable", StringComparison.OrdinalIgnoreCase) >= 0 || error.IndexOf("Aucun match", StringComparison.OrdinalIgnoreCase) >= 0 || error.IndexOf("absent du catalogue", StringComparison.OrdinalIgnoreCase) >= 0; @@ -513,7 +674,7 @@ private static void CleanupEditorPreviewPlayersBeforePlay( { try { - Debug.Log("[GAMA][PLAY] Cleaning preview player before Play: " + id); + GamaLog.Dev("[GAMA][PLAY] Cleaning preview player before Play: " + id); string outcome = GamaEditorFirstTickCapture.PurgeGhostPlayerAsync( host, playerPort, @@ -522,11 +683,11 @@ private static void CleanupEditorPreviewPlayersBeforePlay( ct) .GetAwaiter() .GetResult(); - Debug.Log("[GAMA][PLAY] " + outcome); + GamaLog.Dev("[GAMA][PLAY] " + outcome); } catch (Exception ex) { - Debug.LogWarning("[GAMA][PLAY] Preview player websocket cleanup failed for " + id + ": " + ex.Message); + GamaLog.DevWarning("[GAMA][PLAY] Preview player websocket cleanup failed for " + id + ": " + ex.Message); } try @@ -536,14 +697,14 @@ private static void CleanupEditorPreviewPlayersBeforePlay( monitorPort, id, ct, - Debug.Log) + GamaLog.Dev) .GetAwaiter() .GetResult(); - Debug.Log("[GAMA][PLAY] Preview player monitor cleanup " + id + " removed=" + removed); + GamaLog.Dev("[GAMA][PLAY] Preview player monitor cleanup " + id + " removed=" + removed); } catch (Exception ex) { - Debug.LogWarning("[GAMA][PLAY] Preview player monitor cleanup failed for " + id + ": " + ex.Message); + GamaLog.DevWarning("[GAMA][PLAY] Preview player monitor cleanup failed for " + id + ": " + ex.Message); } } } diff --git a/Editor/GamaSpeciesAppearanceEditorCoordinator.cs b/Editor/GamaSpeciesAppearanceEditorCoordinator.cs new file mode 100644 index 0000000..47a2242 --- /dev/null +++ b/Editor/GamaSpeciesAppearanceEditorCoordinator.cs @@ -0,0 +1,372 @@ +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +[InitializeOnLoad] +public static class GamaSpeciesAppearanceEditorCoordinator +{ + private const string PreviewRootName = "[GAMA] Static Experiment Preview"; + + static GamaSpeciesAppearanceEditorCoordinator() + { + GamaSpeciesAppearanceStateStore.Changed += OnAppearanceChanged; + Undo.undoRedoPerformed += OnUndoRedoPerformed; + AssemblyReloadEvents.beforeAssemblyReload += ClearTransientStateBeforeReload; + EditorSceneManager.sceneOpened += OnSceneOpened; + EditorSceneManager.sceneClosing += OnSceneClosing; + EditorApplication.delayCall += SynchronizeFromScene; + } + + public static bool TryResolveActiveContext(out GamaSpeciesAppearanceContext context) + { + GamaPreviewSession session = FindCurrentPreviewSession(); + if (session != null) + { + GamaSpeciesRenderOverrides asset = session.speciesOverrides; + if (asset != null) + { + context = new GamaSpeciesAppearanceContext( + asset, + session.modelPath, + session.experimentName); + SetActiveContext(context, false); + return true; + } + } + + SimulationManager manager = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (manager != null && + manager.TryGetSpeciesRenderOverridesContext( + out GamaSpeciesRenderOverrides managerAsset, + out string modelPath, + out string experimentName) && + managerAsset != null) + { + context = new GamaSpeciesAppearanceContext(managerAsset, modelPath, experimentName); + SetActiveContext(context, false); + return true; + } + + context = default; + return false; + } + + public static void SetActiveContext( + GamaSpeciesAppearanceContext context, + bool propagateToScene = true) + { + if (!context.IsValid) + { + return; + } + + GamaSpeciesAppearanceStateStore.SetActiveContext(context); + if (!propagateToScene) + { + return; + } + + bool sceneChanged = false; + GamaPreviewSession session = FindCurrentPreviewSession(); + if (session != null && + (session.speciesOverrides != context.Asset || + session.modelPath != context.ModelPath || + session.experimentName != context.ExperimentName)) + { + if (!EditorApplication.isPlaying) + { + Undo.RecordObject(session, "Set GAMA species appearance context"); + } + session.speciesOverrides = context.Asset; + session.modelPath = context.ModelPath; + session.experimentName = context.ExperimentName; + EditorUtility.SetDirty(session); + sceneChanged = true; + } + + SimulationManager[] managers = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < managers.Length; i++) + { + SimulationManager manager = managers[i]; + if (manager == null) + { + continue; + } + + bool alreadyAssigned = manager.TryGetSpeciesRenderOverridesContext( + out GamaSpeciesRenderOverrides currentAsset, + out string currentModel, + out string currentExperiment) && + currentAsset == context.Asset && + GamaSpeciesRenderOverrides.NormalizeModelPath(currentModel) == + GamaSpeciesRenderOverrides.NormalizeModelPath(context.ModelPath) && + GamaSpeciesRenderOverrides.NormalizeKey(currentExperiment) == + GamaSpeciesRenderOverrides.NormalizeKey(context.ExperimentName); + if (alreadyAssigned) + { + continue; + } + + if (!EditorApplication.isPlaying) + { + Undo.RecordObject(manager, "Set GAMA species appearance context"); + } + if (manager.SetSpeciesRenderOverridesContext( + context.Asset, + context.ModelPath, + context.ExperimentName)) + { + EditorUtility.SetDirty(manager); + sceneChanged = true; + } + } + + if (sceneChanged && + !EditorApplication.isPlaying && + SceneManager.GetActiveScene().IsValid()) + { + EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene()); + } + } + + public static void ClearActiveContext(bool removePersistedEntries) + { + GamaSpeciesAppearanceContext context = GamaSpeciesAppearanceStateStore.ActiveContext; + if (!context.IsValid) + { + GamaPreviewSession session = FindCurrentPreviewSession(); + if (session != null && session.speciesOverrides != null) + { + context = new GamaSpeciesAppearanceContext( + session.speciesOverrides, + session.modelPath, + session.experimentName); + } + else + { + SimulationManager manager = UnityEngine.Object.FindFirstObjectByType( + FindObjectsInactive.Include); + if (manager != null && manager.TryGetSpeciesRenderOverridesContext( + out GamaSpeciesRenderOverrides managerAsset, + out string modelPath, + out string experimentName) && + managerAsset != null) + { + context = new GamaSpeciesAppearanceContext( + managerAsset, + modelPath, + experimentName); + } + } + } + + if (context.IsValid) + { + GamaSpeciesAppearanceStateStore.ClearContext(context, removePersistedEntries); + if (removePersistedEntries && !EditorApplication.isPlaying) + { + AssetDatabase.SaveAssets(); + } + } + else + { + GamaSpeciesAppearanceStateStore.ClearRuntimeOverlay(); + } + + bool sceneChanged = false; + SimulationManager[] managers = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < managers.Length; i++) + { + if (managers[i] == null) + { + continue; + } + + if (!managers[i].TryGetSpeciesRenderOverridesContext( + out GamaSpeciesRenderOverrides currentAsset, + out _, + out _) || + currentAsset == null) + { + continue; + } + + if (!EditorApplication.isPlaying) + { + Undo.RecordObject(managers[i], "Clear GAMA species appearance context"); + } + managers[i].SetSpeciesRenderOverridesContext(null, string.Empty, string.Empty); + EditorUtility.SetDirty(managers[i]); + sceneChanged = true; + } + + if (sceneChanged && + !EditorApplication.isPlaying && + SceneManager.GetActiveScene().IsValid()) + { + EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene()); + } + } + + public static GamaPreviewSession FindCurrentPreviewSession() + { + GamaPreviewSession[] sessions = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + + GamaPreviewSession fallback = null; + for (int i = 0; i < sessions.Length; i++) + { + GamaPreviewSession session = sessions[i]; + if (session == null) + { + continue; + } + if (session.useThisPreviewForPlay && !session.stale) + { + return session; + } + if (!session.stale && session.gameObject != null && session.gameObject.name == PreviewRootName) + { + fallback = session; + } + else if (fallback == null) + { + fallback = session; + } + } + return fallback; + } + + private static void SynchronizeFromScene() + { + if (TryResolveActiveContext(out GamaSpeciesAppearanceContext context)) + { + SetActiveContext(context, false); + } + } + + private static void OnAppearanceChanged(GamaSpeciesAppearanceChange change) + { + if (change.Kind == GamaSpeciesAppearanceChangeKind.EntryChanged) + { + SynchronizeWizardViews(change); + if (EditorApplication.isPlaying) + { + EditorApplication.delayCall += () => ApplyRuntimeChange(change.SpeciesName); + } + } + + if (!EditorApplication.isPlayingOrWillChangePlaymode) + { + GamaEditorPreviewOverrideApplier.ScheduleApplyOverridesToCurrentPreview(); + } + + GamaPanelWindow[] panels = Resources.FindObjectsOfTypeAll(); + for (int i = 0; i < panels.Length; i++) + { + panels[i]?.Repaint(); + } + if (ActiveEditorTracker.sharedTracker != null) + { + ActiveEditorTracker.sharedTracker.ForceRebuild(); + } + SceneView.RepaintAll(); + } + + private static void SynchronizeWizardViews(GamaSpeciesAppearanceChange change) + { + if (!change.Context.IsValid || string.IsNullOrWhiteSpace(change.SpeciesName)) + { + return; + } + + if (!GamaSpeciesAppearanceStateStore.TryGetEntry( + change.Context, + change.SpeciesName, + change.RuntimeOnly, + out GamaSpeciesRenderOverrideEntry entry) || + entry == null) + { + return; + } + + GamaSpeciesWizard[] wizards = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < wizards.Length; i++) + { + GamaSpeciesWizard wizard = wizards[i]; + if (wizard == null || wizard.overridesAsset != change.Context.Asset || + !string.Equals( + GamaSpeciesRenderOverrides.NormalizeModelPath(wizard.modelPath), + GamaSpeciesRenderOverrides.NormalizeModelPath(change.Context.ModelPath), + System.StringComparison.Ordinal) || + !string.Equals( + GamaSpeciesRenderOverrides.NormalizeKey(wizard.experimentName), + GamaSpeciesRenderOverrides.NormalizeKey(change.Context.ExperimentName), + System.StringComparison.Ordinal) || + !string.Equals( + GamaSpeciesRenderOverrides.NormalizeKey(wizard.speciesName), + GamaSpeciesRenderOverrides.NormalizeKey(change.SpeciesName), + System.StringComparison.Ordinal)) + { + continue; + } + + using (GamaSpeciesWizard.SuppressAssetWrites()) + { + wizard.PopulateFromEntry(entry); + } + EditorUtility.SetDirty(wizard); + } + } + + private static void ApplyRuntimeChange(string speciesName) + { + if (!EditorApplication.isPlaying || string.IsNullOrWhiteSpace(speciesName)) + { + return; + } + + GamaRuntimePreviewOverrideApplier.RefreshNow(); + SimulationManager[] managers = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < managers.Length; i++) + { + managers[i]?.ApplyRuntimeSpeciesOverrideNow(speciesName); + } + } + + private static void ClearTransientStateBeforeReload() + { + GamaSpeciesAppearanceStateStore.ClearRuntimeOverlay(); + } + + private static void OnSceneOpened(Scene scene, OpenSceneMode mode) + { + EditorApplication.delayCall += () => + { + SynchronizeFromScene(); + GamaEditorPreviewOverrideApplier.ScheduleApplyOverridesToCurrentPreview(); + }; + } + + private static void OnSceneClosing(Scene scene, bool removingScene) + { + GamaSpeciesAppearanceStateStore.ClearRuntimeOverlay(); + } + + private static void OnUndoRedoPerformed() + { + SynchronizeFromScene(); + GamaEditorPreviewOverrideApplier.ScheduleApplyOverridesToCurrentPreview(); + SceneView.RepaintAll(); + } +} diff --git a/Editor/GamaSpeciesAppearanceEditorCoordinator.cs.meta b/Editor/GamaSpeciesAppearanceEditorCoordinator.cs.meta new file mode 100644 index 0000000..f62ac10 --- /dev/null +++ b/Editor/GamaSpeciesAppearanceEditorCoordinator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c724e3915f2b4c8c8566ee4940e4a473 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/GamaSpeciesRenderOverridesEditorStore.cs b/Editor/GamaSpeciesRenderOverridesEditorStore.cs index 0e2b2cf..610bb65 100644 --- a/Editor/GamaSpeciesRenderOverridesEditorStore.cs +++ b/Editor/GamaSpeciesRenderOverridesEditorStore.cs @@ -26,7 +26,7 @@ public static GamaSpeciesRenderOverrides GetOrCreateDefaultAsset() GamaSpeciesRenderOverridesAsset created = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(created, DefaultAssetPath); AssetDatabase.SaveAssets(); - Debug.Log("[GAMA][WIZARD] Created default overrides asset: " + DefaultAssetPath); + GamaLog.Dev("[GAMA][WIZARD] Created default overrides asset: " + DefaultAssetPath); return created; #else return null; diff --git a/Editor/GamaSpeciesWizardEditor.cs b/Editor/GamaSpeciesWizardEditor.cs index cde3577..0b92872 100644 --- a/Editor/GamaSpeciesWizardEditor.cs +++ b/Editor/GamaSpeciesWizardEditor.cs @@ -7,8 +7,6 @@ public class GamaSpeciesWizardEditor : Editor { public override void OnInspectorGUI() { - NormalizeSelectedWizardContainerScales(); - EditorGUI.BeginChangeCheck(); serializedObject.Update(); @@ -44,23 +42,4 @@ public override void OnInspectorGUI() } } - private void NormalizeSelectedWizardContainerScales() - { - foreach (var t in targets) - { - GamaSpeciesWizard wizard = t as GamaSpeciesWizard; - if (wizard == null || wizard.transform == null) - { - continue; - } - - if ((wizard.transform.localScale - Vector3.one).sqrMagnitude <= 0.000001f) - { - continue; - } - - Undo.RecordObject(wizard.transform, "Reset GAMA species parent scale"); - wizard.NormalizeSpeciesContainerScale(); - } - } } diff --git a/Editor/GamaWorkspaceExplorerPanel.cs b/Editor/GamaWorkspaceExplorerPanel.cs index ad19f6e..c894ec4 100644 --- a/Editor/GamaWorkspaceExplorerPanel.cs +++ b/Editor/GamaWorkspaceExplorerPanel.cs @@ -61,7 +61,7 @@ public void OnGUI() } /// - /// Chemin, scan, statut et liste — utilisé aussi depuis au-dessus du repliable global. + /// Path, scan, status, and list; also used from above the global foldout. /// public void DrawCompactWorkspaceUi() { @@ -73,7 +73,7 @@ public void DrawCompactWorkspaceUi() } /// - /// Options secondaires du scan (sans repliable). Le repliable est géré par l’hôte ou par . + /// Secondary scan options without a foldout. The host or owns the foldout. /// public void DrawAdvancedScannerOptions() { @@ -201,7 +201,7 @@ private void CompleteAutoDetect() catch (System.Exception ex) { statusMessage = $"Unexpected auto-detection failure: {ex.GetType().Name}. You can still enter a path or use Browse."; - Debug.LogWarning($"[GAMA] Workspace auto-detection failed: {ex.Message}"); + GamaLog.DevWarning($"[GAMA] Workspace auto-detection failed: {ex.Message}"); return; } @@ -211,12 +211,12 @@ private void CompleteAutoDetect() workspacePath = result.WorkspacePath; EditorPrefs.SetString(WorkspacePathPrefKey, workspacePath); statusMessage = $"Workspace detected. Method: {result.Method}. Port: {portLabel}. Confidence: {result.Confidence}. Path: {workspacePath}"; - Debug.Log($"[GAMA] Workspace auto-detected via {result.Method} (port {portLabel}, {result.Confidence})."); + GamaLog.Dev($"[GAMA] Workspace auto-detected via {result.Method} (port {portLabel}, {result.Confidence})."); return; } statusMessage = $"Cannot auto-detect workspace. Method: {result.Method}. Port: {portLabel}. Confidence: {result.Confidence}. {result.Message}"; - Debug.LogWarning("[GAMA] Auto-detection failed. Manual path entry is still possible."); + GamaLog.DevWarning("[GAMA] Auto-detection failed. Manual path entry is still possible."); } private void ScanWorkspace() @@ -243,11 +243,11 @@ private void ScanWorkspace() if (errorCount > 0) { - Debug.LogWarning($"[GAMA] Workspace Explorer completed with {errorCount} scanning issue(s)."); + GamaLog.DevWarning($"[GAMA] Workspace Explorer completed with {errorCount} scanning issue(s)."); } else { - Debug.Log($"[GAMA] Workspace Explorer found {experiments.Count} experiment(s)."); + GamaLog.Dev($"[GAMA] Workspace Explorer found {experiments.Count} experiment(s)."); } } diff --git a/Editor/SimulationManagerInspector.cs b/Editor/SimulationManagerInspector.cs index d033c02..4ff6b76 100644 --- a/Editor/SimulationManagerInspector.cs +++ b/Editor/SimulationManagerInspector.cs @@ -285,6 +285,11 @@ private void DrawSpeciesOverview() AssignOverrideContextToTargetManager(asset, modelPath, experimentName); bool editRuntimeOnly = EditorApplication.isPlaying; + GamaSpeciesAppearanceContext appearanceContext = new GamaSpeciesAppearanceContext( + asset, + modelPath, + experimentName); + GamaSpeciesAppearanceEditorCoordinator.SetActiveContext(appearanceContext); bool assetChanged = false; bool runtimeChanged = false; List changedSpecies = new List(); @@ -300,15 +305,9 @@ private void DrawSpeciesOverview() SerializedProperty tag = entry.FindPropertyRelative("tag"); SerializedProperty importedVisible = entry.FindPropertyRelative("importedVisible"); SerializedProperty importedColor = entry.FindPropertyRelative("importedColor"); - SerializedProperty importedBaseScale = entry.FindPropertyRelative("importedBaseScale"); - SerializedProperty importedYOffset = entry.FindPropertyRelative("importedYOffset"); - SerializedProperty importedRotationOffsetY = entry.FindPropertyRelative("importedRotationOffsetY"); SerializedProperty hasOriginalImportDefaults = entry.FindPropertyRelative("hasOriginalImportDefaults"); SerializedProperty originalImportedVisible = entry.FindPropertyRelative("originalImportedVisible"); SerializedProperty originalImportedColor = entry.FindPropertyRelative("originalImportedColor"); - SerializedProperty originalImportedBaseScale = entry.FindPropertyRelative("originalImportedBaseScale"); - SerializedProperty originalImportedYOffset = entry.FindPropertyRelative("originalImportedYOffset"); - SerializedProperty originalImportedRotationOffsetY = entry.FindPropertyRelative("originalImportedRotationOffsetY"); string speciesName = propertyId != null ? propertyId.stringValue : "(unknown)"; string tagStr = tag != null && !string.IsNullOrEmpty(tag.stringValue) ? " [" + tag.stringValue + "]" : ""; @@ -327,18 +326,9 @@ private void DrawSpeciesOverview() importedVisible != null && originalImportedVisible != null && originalImportedVisible.boolValue != importedVisible.boolValue; - float defaultScaleMultiplier = ResolveOriginalScaleMultiplier( - useOriginalDefaults, - importedBaseScale, - originalImportedBaseScale); - Vector3 defaultPositionOffset = ResolveOriginalPositionOffset( - useOriginalDefaults, - importedYOffset, - originalImportedYOffset); - Vector3 defaultRotationOffsetEuler = ResolveOriginalRotationOffsetEuler( - useOriginalDefaults, - importedRotationOffsetY, - originalImportedRotationOffsetY); + const float defaultScaleMultiplier = 1f; + Vector3 defaultPositionOffset = Vector3.zero; + Vector3 defaultRotationOffsetEuler = Vector3.zero; EditorGUILayout.BeginVertical(EditorStyles.helpBox); @@ -349,13 +339,36 @@ private void DrawSpeciesOverview() EditorGUILayout.LabelField("Agent Attributes", EditorStyles.miniBoldLabel); EditorGUI.indentLevel++; - GamaSpeciesRenderOverrideEntry overrideEntry = editRuntimeOnly - ? GamaRuntimePreviewOverrideApplier.GetOrCreateRuntimeSessionOverride( - asset, - modelPath, - experimentName, - speciesName) - : asset.GetOrCreateEntry(modelPath, experimentName, speciesName); + GamaSpeciesRenderOverrideEntry persistedEntry = null; + bool hasPersistedEntry = !editRuntimeOnly && + GamaSpeciesAppearanceStateStore.TryGetEntry( + appearanceContext, + speciesName, + false, + out persistedEntry); + GamaSpeciesRenderOverrideEntry overrideEntry; + if (editRuntimeOnly) + { + overrideEntry = GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry( + appearanceContext, + speciesName, + true); + } + else if (hasPersistedEntry) + { + Undo.RecordObject(asset, "Edit GAMA species appearance"); + overrideEntry = persistedEntry; + } + else + { + overrideEntry = new GamaSpeciesRenderOverrideEntry + { + modelPath = modelPath, + experimentName = experimentName, + speciesName = speciesName, + speciesKey = speciesName + }; + } EditorGUI.BeginChangeCheck(); @@ -436,6 +449,21 @@ private void DrawSpeciesOverview() if (rowChanged) { + if (!editRuntimeOnly && !hasPersistedEntry) + { + Undo.RecordObject(asset, "Edit GAMA species appearance"); + GamaSpeciesRenderOverrideEntry storedEntry = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry( + appearanceContext, + speciesName, + false); + GamaSpeciesAppearanceStateStore.CopyEntryValues(storedEntry, overrideEntry); + overrideEntry = storedEntry; + } + GamaSpeciesAppearanceStateStore.NotifyEntryChanged( + appearanceContext, + speciesName, + editRuntimeOnly); if (editRuntimeOnly) { runtimeChanged = true; @@ -446,7 +474,7 @@ private void DrawSpeciesOverview() } TrackChangedSpecies(changedSpecies, speciesName, tag != null ? tag.stringValue : string.Empty); - Debug.Log($"[GAMA][OVERRIDES] GameManager editing species={speciesName} scale={overrideEntry.GetEffectiveScaleMultiplier()}"); + GamaLog.Dev($"[GAMA][OVERRIDES] GameManager editing species={speciesName} scale={overrideEntry.GetEffectiveScaleMultiplier()}"); } EditorGUI.indentLevel--; @@ -478,13 +506,8 @@ private void AssignOverrideContextToTargetManager( return; } - if (manager.SetSpeciesRenderOverridesContext(asset, modelPath, experimentName)) - { - if (!EditorApplication.isPlaying) - { - EditorUtility.SetDirty(manager); - } - } + GamaSpeciesAppearanceEditorCoordinator.SetActiveContext( + new GamaSpeciesAppearanceContext(asset, modelPath, experimentName)); } private static void TrackChangedSpecies(List changedSpecies, string speciesName, string tag) @@ -885,55 +908,6 @@ private static bool TryGetResourcesPath(GameObject prefab, out string resourcesP return !string.IsNullOrWhiteSpace(resourcesPath); } - private static float ResolveOriginalScaleMultiplier( - bool useOriginalDefaults, - SerializedProperty importedBaseScale, - SerializedProperty originalImportedBaseScale) - { - if (!useOriginalDefaults || importedBaseScale == null || originalImportedBaseScale == null) - { - return 1f; - } - - float currentScale = importedBaseScale.floatValue; - float originalScale = originalImportedBaseScale.floatValue; - if (Mathf.Abs(currentScale) < 0.0001f) - { - return 1f; - } - - float multiplier = originalScale / currentScale; - return Mathf.Abs(multiplier - 1f) > 0.0001f ? Mathf.Max(0.0001f, multiplier) : 1f; - } - - private static Vector3 ResolveOriginalPositionOffset( - bool useOriginalDefaults, - SerializedProperty importedYOffset, - SerializedProperty originalImportedYOffset) - { - if (!useOriginalDefaults || importedYOffset == null || originalImportedYOffset == null) - { - return Vector3.zero; - } - - float deltaY = originalImportedYOffset.floatValue - importedYOffset.floatValue; - return Mathf.Abs(deltaY) > 0.0001f ? new Vector3(0f, deltaY, 0f) : Vector3.zero; - } - - private static Vector3 ResolveOriginalRotationOffsetEuler( - bool useOriginalDefaults, - SerializedProperty importedRotationOffsetY, - SerializedProperty originalImportedRotationOffsetY) - { - if (!useOriginalDefaults || importedRotationOffsetY == null || originalImportedRotationOffsetY == null) - { - return Vector3.zero; - } - - float deltaY = originalImportedRotationOffsetY.floatValue - importedRotationOffsetY.floatValue; - return Mathf.Abs(deltaY) > 0.0001f ? new Vector3(0f, deltaY, 0f) : Vector3.zero; - } - private static void ResetSpeciesOverrideEntry( GamaSpeciesRenderOverrideEntry entry, Color defaultColor, @@ -996,39 +970,19 @@ private static bool TryResolvePreviewOverrideContext( out string modelPath, out string experimentName) { - asset = null; - modelPath = string.Empty; - experimentName = string.Empty; - - SimulationManager manager = UnityEngine.Object.FindFirstObjectByType(FindObjectsInactive.Include); - if (manager != null && - manager.TryGetSpeciesRenderOverridesContext(out asset, out modelPath, out experimentName) && - asset != null && - (!EditorApplication.isPlaying || !string.IsNullOrWhiteSpace(modelPath) || !string.IsNullOrWhiteSpace(experimentName))) + if (GamaSpeciesAppearanceEditorCoordinator.TryResolveActiveContext( + out GamaSpeciesAppearanceContext context)) { + asset = context.Asset; + modelPath = context.ModelPath; + experimentName = context.ExperimentName; return true; } - GamaPreviewSession session = FindCurrentPreviewSession(); - if (session != null) - { - asset = session.speciesOverrides != null - ? session.speciesOverrides - : GamaSpeciesRenderOverridesEditorStore.GetOrCreateDefaultAsset(); - - if (asset != null && session.speciesOverrides == null) - { - session.speciesOverrides = asset; - EditorUtility.SetDirty(session); - } - - modelPath = session.modelPath ?? string.Empty; - experimentName = session.experimentName ?? string.Empty; - return asset != null; - } - - asset = GamaSpeciesRenderOverridesEditorStore.GetOrCreateDefaultAsset(); - return asset != null; + asset = null; + modelPath = string.Empty; + experimentName = string.Empty; + return false; } private static GamaPreviewSession FindCurrentPreviewSession() diff --git a/README.md b/README.md index 42b82d8..cc7c345 100644 --- a/README.md +++ b/README.md @@ -169,17 +169,18 @@ The **GAMA Panel** is the main entry point for the demo workflow. It includes: -- **Setup Scene** - Prepares the Unity scene for GAMA communication and runtime visualization. - -- **Workspace Explorer** - Allows browsing local GAMA workspaces and experiments. - -- **Preview from the Open GAMA Experiment** - Generates a static preview from the experiment currently opened or selected in GAMA. - -- **Species Settings** - Lets users adjust visual settings per species. +- **Setup Scene:** Prepares the Unity scene for GAMA communication and runtime visualization. + +- **GAMA Preview:** Generates a static preview from the experiment currently opened or selected in GAMA. + +- **Troubleshooting:** Provides a global **Enable verbose mode** toggle, + common-issue guidance, and + quick access to the Unity Console. Verbose mode is disabled by default; + essential warnings, errors, and actionable UI messages remain available. + +- **Workspace Explorer:** Allows browsing local GAMA workspaces and experiments. + +- **Species Settings:** Lets users adjust visual settings per species. - **Advanced Sections** Contains diagnostics, captured JSON tools, and legacy catalogue-based workflows. @@ -305,8 +306,10 @@ The Simulation Manager Inspector is organized into compact sections: - **Performance and Streaming** Contains runtime performance options such as object pooling, update budgets, culling, and streaming. -- **Advanced Debug** - Contains troubleshooting and verbose diagnostic settings. +- **Advanced Debug:** Contains per-scene switches for specific diagnostics such as streaming and + agent update statistics. These are distinct from the global **Enable verbose + mode** toggle in **GAMA Panel > Troubleshooting**; verbose Console output + requires the global toggle to be enabled as well. The most important demo sections appear first. Advanced performance and debug options are collapsed by default. diff --git a/Runtime/Connection/ConnectionManager.cs b/Runtime/Connection/ConnectionManager.cs index c99c6e1..8a3d8a2 100644 --- a/Runtime/Connection/ConnectionManager.cs +++ b/Runtime/Connection/ConnectionManager.cs @@ -40,7 +40,7 @@ public class ConnectionManager : WebSocketConnector void Awake() { InitializeRuntimePlayId(); - Debug.Log("[GAMA][CONNECTION][ID] Runtime player id=" + StaticInformation.getId()); + GamaLog.Dev("[GAMA][CONNECTION][ID] Runtime player id=" + StaticInformation.getId()); Instance = this; UpdateConnectionState(ConnectionState.DISCONNECTED); } @@ -61,14 +61,14 @@ public void UpdateConnectionState(ConnectionState newState) { } switch (newState) { - case ConnectionState.PENDING: - break; - case ConnectionState.CONNECTED: - Debug.Log("[GAMA] WebSocket connected"); - break; - case ConnectionState.AUTHENTICATED: - Debug.Log("[GAMA] Player authenticated"); - break; + case ConnectionState.PENDING: + break; + case ConnectionState.CONNECTED: + GamaLog.Dev("[GAMA] Connected to simple.webplatform."); + break; + case ConnectionState.AUTHENTICATED: + GamaLog.Dev("[GAMA] Unity player authenticated."); + break; case ConnectionState.DISCONNECTED: break; default: @@ -84,7 +84,7 @@ public void UpdateConnectionState(ConnectionState newState) { protected override void HandleConnectionOpen() { string id = StaticInformation.getId(); - Debug.Log("[GAMA][CONNECTION][OPEN] id=" + id); + GamaLog.Dev("[GAMA][CONNECTION][OPEN] id=" + id); var jsonId = new Dictionary { {"type", "connection"}, { "id", id }, @@ -172,7 +172,7 @@ protected override void ManageMessage(string message) } catch (System.Exception ex) { - Debug.LogWarning("[GAMA] Error parsing message: " + ex.Message); + GamaLog.Warning("[GAMA] Error parsing message: " + ex.Message); } } @@ -192,7 +192,7 @@ private void AdoptMiddlewarePlayerIdIfNeeded(string serverPlayerId) if (IsUnityPlaySessionId(currentId)) { - Debug.LogWarning( + GamaLog.DevWarning( "[GAMA][CONNECTION][REBIND] Middleware reports player id=" + cleanServerPlayerId + " while Unity requested id=" + currentId + ". Keeping the current Unity Play id and treating the middleware id as stale."); @@ -201,7 +201,7 @@ private void AdoptMiddlewarePlayerIdIfNeeded(string serverPlayerId) if (StaticInformation.AdoptSessionId(cleanServerPlayerId)) { - Debug.LogWarning( + GamaLog.DevWarning( "[GAMA][CONNECTION][REBIND] Middleware reports player id=" + cleanServerPlayerId + " while Unity requested id=" + currentId + ". Adopting middleware id for this Play session."); @@ -242,7 +242,7 @@ private bool TryAdoptMiddlewareUnityPlayState(string serverPlayerId, bool connec } lastStalePlayerStateWarning = warningKey; - Debug.LogWarning( + GamaLog.DevWarning( "[GAMA][CONNECTION][REBIND] Middleware reports player id=" + cleanServerPlayerId + " while Unity requested id=" + currentId + ". Adopting the middleware id for this Play reconnect."); @@ -289,7 +289,7 @@ public async void TryConnectionToServer() { var socket = GetSocket(); if (socket == null) { - Debug.LogWarning("[GAMA][CONNECTION][START] socket not initialized yet; waiting for connector startup"); + GamaLog.DevWarning("[GAMA][CONNECTION][START] socket not initialized yet; waiting for connector startup"); return; } @@ -335,7 +335,7 @@ public void SendExecutableExpression(string expression) { /*, new Action((success) => { if (!success) { numErrors++; - Debug.LogError("ConnectionManager: Failed to send executable expression"); + GamaLog.Error("ConnectionManager: Failed to send executable expression"); if (numErrors > numErrorsBeforeDeconnection) { GetSocket().Close(); @@ -368,7 +368,7 @@ public void SendExecutableAsk(string action, Dictionary arguments if (!success) { numErrors++; - Debug.LogError("ConnectionManager: Failed to send executable ask"); + GamaLog.Error("ConnectionManager: Failed to send executable ask"); if (numErrors > numErrorsBeforeDeconnection) { GetSocket().Close(); diff --git a/Runtime/Connection/WebSocketConnector.cs b/Runtime/Connection/WebSocketConnector.cs index e901267..317bbe4 100644 --- a/Runtime/Connection/WebSocketConnector.cs +++ b/Runtime/Connection/WebSocketConnector.cs @@ -59,12 +59,12 @@ async void Start() } string url = "ws://" + host + ":" + port + "/"; - Debug.Log("[GAMA][CONNECTION][START] url=" + url); + GamaLog.Dev("[GAMA][CONNECTION][START] url=" + url); socket = new WebSocket(url); socket.OnOpen += () => { - Debug.Log("[GAMA][CONNECTION][OPEN]"); + GamaLog.Dev("[GAMA][CONNECTION][OPEN]"); HandleConnectionOpen(); }; @@ -79,13 +79,13 @@ async void Start() // Add OnError event listener socket.OnError += (string errMsg) => { - Debug.LogError("[GAMA][CONNECTION][ERROR] " + errMsg); + GamaLog.Error("[GAMA][CONNECTION][ERROR] " + errMsg); }; // Add OnClose event listener socket.OnClose += (WebSocketCloseCode code) => { - Debug.Log("[GAMA][CONNECTION][CLOSE] code=" + code); + GamaLog.Dev("[GAMA][CONNECTION][CLOSE] code=" + code); HandleConnectionClosed(); }; @@ -148,11 +148,14 @@ protected async Task SendMessageToServerAsync(string message) { if (!IsSocketOpen) { - float now = Time.realtimeSinceStartup; - if (now >= nextSocketClosedWarningTime) + if (GamaLog.VerboseEnabled) { - Debug.LogWarning("[GAMA][CONNECTION][WARN] socket not open; skipping send state=" + GetSocketStateForLog()); - nextSocketClosedWarningTime = now + SocketClosedWarningIntervalSeconds; + float now = Time.realtimeSinceStartup; + if (now >= nextSocketClosedWarningTime) + { + GamaLog.DevWarning("[GAMA][CONNECTION][WARN] socket not open; skipping send state=" + GetSocketStateForLog()); + nextSocketClosedWarningTime = now + SocketClosedWarningIntervalSeconds; + } } return; } @@ -176,13 +179,18 @@ protected WebSocket GetSocket() { private void LogReceivedMessage(string message) { + if (!GamaLog.VerboseEnabled) + { + return; + } + receivedMessageLogCount++; if (receivedMessageLogCount > 20 && receivedMessageLogCount % 100 != 0) { return; } - Debug.Log( + GamaLog.Dev( "[GAMA][CONNECTION][MESSAGE] type=" + ResolveMessageTypeForLog(message) + " length=" + (message != null ? message.Length : 0)); } @@ -235,11 +243,11 @@ protected async Task CloseSocketAsync() { await socket.Close(); } - } - catch (Exception exception) - { - Debug.LogWarning("WebSocketConnector: error while closing socket: " + exception.Message); - } + } + catch (Exception exception) + { + GamaLog.Warning("WebSocketConnector: error while closing socket: " + exception.Message); + } } } diff --git a/Runtime/LocalizationManager.cs b/Runtime/LocalizationManager.cs index b458094..a01e5ed 100644 --- a/Runtime/LocalizationManager.cs +++ b/Runtime/LocalizationManager.cs @@ -38,7 +38,7 @@ private void LoadLocalizationData() if (csvFile == null) { - Debug.LogError($"Localization CSV not found at 'Resources/{CsvFilePath}'."); + GamaLog.Error($"Localization CSV not found at 'Resources/{CsvFilePath}'."); return; } @@ -80,7 +80,7 @@ public void SetLanguage(string languageName) } else { - Debug.LogWarning($"Language '{languageName}' not found in localization data."); + GamaLog.Warning($"Language '{languageName}' not found in localization data."); } } @@ -91,7 +91,7 @@ public string GetLocalizedValue(string key) return localizedData[currentLanguage][key]; } - Debug.LogWarning($"Localization key '{key}' not found for language '{currentLanguage}'."); + GamaLog.Warning($"Localization key '{key}' not found for language '{currentLanguage}'."); return key; } -} \ No newline at end of file +} diff --git a/Runtime/Preview/GamaPreviewObject.cs b/Runtime/Preview/GamaPreviewObject.cs index 1561a9f..8c9ab82 100644 --- a/Runtime/Preview/GamaPreviewObject.cs +++ b/Runtime/Preview/GamaPreviewObject.cs @@ -1,21 +1,46 @@ using UnityEngine; using System.Collections.Generic; +using UnityEngine.Rendering; +public enum GamaPreviewRepresentationKind +{ + Unknown = 0, + Geometry = 1, + Prefab = 2 +} + +public enum GamaPreviewProvenance +{ + Unknown = 0, + CapturedJson = 1, + Grid = 2, + Sample = 3 +} [DisallowMultipleComponent] public class GamaPreviewObject : MonoBehaviour { + private const int CurrentBaseStateVersion = 2; + public bool previewOnly = true; public bool canBeReusedAtRuntime = false; public string speciesName = string.Empty; public string agentId = string.Empty; public string geometryHash = string.Empty; public int sourceTick = -1; + public GamaPreviewProvenance provenance = GamaPreviewProvenance.Unknown; + public GamaPreviewRepresentationKind representationKind = GamaPreviewRepresentationKind.Unknown; + public string stableAgentKey = string.Empty; + public string sourcePropertyId = string.Empty; + public string sourcePrefabSignature = string.Empty; + public GameObject sourcePrefabAsset; [SerializeField, HideInInspector] private bool hasBaseState = false; [SerializeField, HideInInspector] private Vector3 baseLocalPosition; [SerializeField, HideInInspector] private Quaternion baseLocalRotation; [SerializeField, HideInInspector] private Vector3 baseLocalScale; + [SerializeField, HideInInspector] private bool baseActiveSelf = true; + [SerializeField, HideInInspector] private int baseStateVersion = 0; [SerializeField, HideInInspector] private bool hasVisualAnchor = false; [SerializeField, HideInInspector] private Vector3 visualAnchorLocal = Vector3.zero; @@ -24,11 +49,58 @@ private class RendererBaseState { public Renderer renderer; public Material[] sharedMaterials; + public bool rendererEnabled; + public ShadowCastingMode shadowCastingMode; + public bool receiveShadows; + public int stateVersion; + public bool hasSerializedColorBaseline; + public bool rendererSupportsColor; + public bool rendererSupportsBaseColor; + public Color rendererColor = Color.white; + public Color rendererBaseColor = Color.white; + public bool[] materialSupportsColor; + public bool[] materialSupportsBaseColor; + public Color[] materialColors; + public Color[] materialBaseColors; + + // Unity does not serialize MaterialPropertyBlock. Keep an in-memory copy of + // every block so applying a color never discards unrelated shader values. + [System.NonSerialized] public bool hasCapturedPropertyBlocks; + [System.NonSerialized] public bool hadRendererPropertyBlock; + [System.NonSerialized] public MaterialPropertyBlock rendererPropertyBlock; + [System.NonSerialized] public bool[] hadMaterialPropertyBlocks; + [System.NonSerialized] public MaterialPropertyBlock[] materialPropertyBlocks; + [System.NonSerialized] public bool allowFullPropertyBlockCapture; } [SerializeField, HideInInspector] private List baseRenderers = new List(); + public bool IsEligibleForRuntimeReuse + { + get + { + if (!canBeReusedAtRuntime || + provenance != GamaPreviewProvenance.CapturedJson || + representationKind == GamaPreviewRepresentationKind.Unknown || + string.IsNullOrWhiteSpace(stableAgentKey)) + { + return false; + } + + if (representationKind == GamaPreviewRepresentationKind.Prefab) + { + // A Unity asset reference is the exact proof of which prefab was + // instantiated. A path/property hint alone is not sufficient: + // runtime bindings and per-agent attributes may resolve another + // prefab for the same GAMA property. + return sourcePrefabAsset != null && transform.Find("VisualOverride") == null; + } + + return !string.IsNullOrWhiteSpace(sourcePrefabSignature); + } + } + public void CaptureBaseTransformIfNeeded() { if (!hasBaseState) @@ -36,121 +108,201 @@ public void CaptureBaseTransformIfNeeded() baseLocalPosition = transform.localPosition; baseLocalRotation = transform.localRotation; baseLocalScale = transform.localScale; - + baseActiveSelf = gameObject.activeSelf; baseRenderers.Clear(); - Renderer[] renderers = GetComponentsInChildren(true); - foreach (Renderer r in renderers) - { - if (r != null) - { - RendererBaseState rs = new RendererBaseState(); - rs.renderer = r; - rs.sharedMaterials = r.sharedMaterials != null ? (Material[])r.sharedMaterials.Clone() : new Material[0]; - baseRenderers.Add(rs); - } - } - + baseStateVersion = CurrentBaseStateVersion; hasBaseState = true; } - } - public void SetVisualAnchorLocal(Vector3 localAnchor) - { - visualAnchorLocal = localAnchor; - hasVisualAnchor = true; - } + // Existing serialized previews predate the complete renderer baseline. + // Upgrade their missing fields once without replacing the transform or + // material baselines that were already captured. + bool upgradeExistingState = baseStateVersion < CurrentBaseStateVersion; + if (upgradeExistingState) + { + baseActiveSelf = gameObject.activeSelf; + baseStateVersion = CurrentBaseStateVersion; + } - public bool TryGetVisualAnchorLocal(out Vector3 localAnchor) - { - localAnchor = visualAnchorLocal; - return hasVisualAnchor; + CaptureMissingRendererBaseStates(upgradeExistingState); } - public bool NormalizePivotToVisualAnchorForStableScale() + private void CaptureMissingRendererBaseStates(bool upgradeExistingState) { - if (!hasBaseState || !hasVisualAnchor || visualAnchorLocal.sqrMagnitude <= 0.000001f) + Renderer[] renderers = GetComponentsInChildren(true); + foreach (Renderer renderer in renderers) { - return false; - } + if (renderer == null) + { + continue; + } - MeshFilter[] meshFilters = GetComponentsInChildren(true); - List editableFilters = new List(); - for (int i = 0; i < meshFilters.Length; i++) - { - MeshFilter filter = meshFilters[i]; - if (filter == null || IsUnderGeneratedVisual(filter.transform)) + // A generated custom-prefab replacement owns its own native-scale + // baseline. It is recreated by the editor applier and must not become + // part of the imported GAMA preview baseline, otherwise each refresh + // would retain a dead renderer entry from the previous replacement. + GamaPreviewBaseline generatedVisualBaseline = + renderer.GetComponentInParent(true); + if (generatedVisualBaseline != null && + generatedVisualBaseline.transform != transform && + generatedVisualBaseline.transform.IsChildOf(transform) && + generatedVisualBaseline.gameObject.name == "Visual") { continue; } - Mesh mesh = filter.sharedMesh; - if (mesh != null && mesh.vertexCount > 0) + RendererBaseState state = FindRendererBaseState(renderer); + if (state == null) { - editableFilters.Add(filter); + state = new RendererBaseState + { + renderer = renderer, + sharedMaterials = CloneSharedMaterials(renderer), + rendererEnabled = renderer.enabled, + shadowCastingMode = renderer.shadowCastingMode, + receiveShadows = renderer.receiveShadows, + stateVersion = CurrentBaseStateVersion + }; + state.allowFullPropertyBlockCapture = true; + baseRenderers.Add(state); } - } + else if (upgradeExistingState || state.stateVersion < CurrentBaseStateVersion) + { + if (state.sharedMaterials == null) + { + state.sharedMaterials = CloneSharedMaterials(renderer); + } - if (editableFilters.Count == 0) - { - return false; - } + state.rendererEnabled = renderer.enabled; + state.shadowCastingMode = renderer.shadowCastingMode; + state.receiveShadows = renderer.receiveShadows; + state.stateVersion = CurrentBaseStateVersion; + } - transform.localPosition = baseLocalPosition; - transform.localRotation = baseLocalRotation; - transform.localScale = baseLocalScale; + CaptureSerializedColorBaselineIfNeeded( + state, + state.allowFullPropertyBlockCapture); + CapturePropertyBlockBaselineIfNeeded(state); + } + } - Vector3 worldAnchor = transform.TransformPoint(visualAnchorLocal); - List worldVerticesByFilter = new List(editableFilters.Count); - for (int i = 0; i < editableFilters.Count; i++) + private RendererBaseState FindRendererBaseState(Renderer renderer) + { + for (int i = 0; i < baseRenderers.Count; i++) { - MeshFilter filter = editableFilters[i]; - Vector3[] vertices = filter.sharedMesh.vertices; - Vector3[] worldVertices = new Vector3[vertices.Length]; - for (int v = 0; v < vertices.Length; v++) + RendererBaseState state = baseRenderers[i]; + if (state != null && state.renderer == renderer) { - worldVertices[v] = filter.transform.TransformPoint(vertices[v]); + return state; } - - worldVerticesByFilter.Add(worldVertices); } - transform.position = worldAnchor; + return null; + } + + private static Material[] CloneSharedMaterials(Renderer renderer) + { + Material[] materials = renderer != null ? renderer.sharedMaterials : null; + return materials != null ? (Material[])materials.Clone() : new Material[0]; + } - for (int i = 0; i < editableFilters.Count; i++) + private static void CapturePropertyBlockBaselineIfNeeded(RendererBaseState state) + { + if (state == null || + state.renderer == null || + state.hasCapturedPropertyBlocks || + !state.allowFullPropertyBlockCapture) { - MeshFilter filter = editableFilters[i]; - Mesh mesh = filter.sharedMesh; - Vector3[] vertices = mesh.vertices; - Vector3[] worldVertices = worldVerticesByFilter[i]; - for (int v = 0; v < vertices.Length && v < worldVertices.Length; v++) - { - vertices[v] = filter.transform.InverseTransformPoint(worldVertices[v]); - } + return; + } - mesh.vertices = vertices; - mesh.RecalculateBounds(); + Renderer renderer = state.renderer; + state.rendererPropertyBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(state.rendererPropertyBlock); + state.hadRendererPropertyBlock = !state.rendererPropertyBlock.isEmpty; + + int materialCount = renderer.sharedMaterials != null ? renderer.sharedMaterials.Length : 0; + state.hadMaterialPropertyBlocks = new bool[materialCount]; + state.materialPropertyBlocks = new MaterialPropertyBlock[materialCount]; + for (int i = 0; i < materialCount; i++) + { + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, i); + state.hadMaterialPropertyBlocks[i] = !block.isEmpty; + state.materialPropertyBlocks[i] = block; } - baseLocalPosition = transform.localPosition; - visualAnchorLocal = Vector3.zero; - return true; + state.hasCapturedPropertyBlocks = true; } - private static bool IsUnderGeneratedVisual(Transform t) + private static void CaptureSerializedColorBaselineIfNeeded( + RendererBaseState state, + bool includeCurrentPropertyBlocks) { - Transform current = t; - while (current != null) + if (state == null || state.renderer == null || state.hasSerializedColorBaseline) { - string name = current.name; - if (name == "Visual" || name == "VisualOverride" || name == "InvalidGeometryFallback") + return; + } + + Renderer renderer = state.renderer; + Material[] materials = state.sharedMaterials ?? renderer.sharedMaterials ?? new Material[0]; + MaterialPropertyBlock rendererBlock = new MaterialPropertyBlock(); + if (includeCurrentPropertyBlocks) + { + renderer.GetPropertyBlock(rendererBlock); + } + + state.rendererSupportsColor = MaterialsSupportProperty(materials, ColorId); + state.rendererSupportsBaseColor = MaterialsSupportProperty(materials, BaseColorId); + state.rendererColor = ResolveBaselineColor( + includeCurrentPropertyBlocks ? rendererBlock : null, + materials, + ColorId); + state.rendererBaseColor = ResolveBaselineColor( + includeCurrentPropertyBlocks ? rendererBlock : null, + materials, + BaseColorId); + + int materialCount = materials.Length; + state.materialSupportsColor = new bool[materialCount]; + state.materialSupportsBaseColor = new bool[materialCount]; + state.materialColors = new Color[materialCount]; + state.materialBaseColors = new Color[materialCount]; + for (int i = 0; i < materialCount; i++) + { + Material material = materials[i]; + MaterialPropertyBlock block = new MaterialPropertyBlock(); + if (includeCurrentPropertyBlocks) { - return true; + renderer.GetPropertyBlock(block, i); } - current = current.parent; + Material[] oneMaterial = { material }; + state.materialSupportsColor[i] = material != null && material.HasProperty(ColorId); + state.materialSupportsBaseColor[i] = material != null && material.HasProperty(BaseColorId); + state.materialColors[i] = ResolveBaselineColor( + includeCurrentPropertyBlocks ? block : null, + oneMaterial, + ColorId); + state.materialBaseColors[i] = ResolveBaselineColor( + includeCurrentPropertyBlocks ? block : null, + oneMaterial, + BaseColorId); } - return false; + state.hasSerializedColorBaseline = true; + } + + public void SetVisualAnchorLocal(Vector3 localAnchor) + { + visualAnchorLocal = localAnchor; + hasVisualAnchor = true; + } + + public bool TryGetVisualAnchorLocal(out Vector3 localAnchor) + { + localAnchor = visualAnchorLocal; + return hasVisualAnchor; } public void RestoreBaseLocalScaleIfCaptured() @@ -163,39 +315,36 @@ public void RestoreBaseLocalScaleIfCaptured() public void ApplySpeciesOverride(GamaSpeciesRenderOverrideEntry entry) { - if (!hasBaseState) return; + CaptureBaseTransformIfNeeded(); - if (entry.UsesPositionOffsetOverride()) + if (entry != null && entry.UsesPositionOffsetOverride()) transform.localPosition = baseLocalPosition + entry.GetEffectivePositionOffset(); else transform.localPosition = baseLocalPosition; - if (entry.UsesRotationOffsetOverride()) + if (entry != null && entry.UsesRotationOffsetOverride()) transform.localRotation = baseLocalRotation * Quaternion.Euler(entry.GetEffectiveRotationOffsetEuler()); else transform.localRotation = baseLocalRotation; - if (entry.UsesScaleOverride()) + if (entry != null && entry.UsesScaleOverride()) transform.localScale = baseLocalScale * entry.GetEffectiveScaleMultiplier(); else transform.localScale = baseLocalScale; - bool previewVisible = entry.GetEffectivePreviewVisible(); - if (!previewVisible && entry.UsesPreviewVisibilityOverride()) - { - gameObject.SetActive(false); - return; - } - - gameObject.SetActive(true); + bool overridesPreviewVisibility = entry != null && entry.UsesPreviewVisibilityOverride(); + bool previewVisible = !overridesPreviewVisibility || entry.GetEffectivePreviewVisible(); + bool targetActiveSelf = overridesPreviewVisibility ? previewVisible : baseActiveSelf; foreach (RendererBaseState state in baseRenderers) { + if (state == null) continue; + Renderer r = state.renderer; if (r == null) continue; Material[] mats = state.sharedMaterials != null ? (Material[])state.sharedMaterials.Clone() : new Material[0]; - if (entry.materialOverride != null) + if (entry != null && entry.materialOverride != null) { for (int m = 0; m < mats.Length; m++) { @@ -204,61 +353,217 @@ public void ApplySpeciesOverride(GamaSpeciesRenderOverrideEntry entry) } r.sharedMaterials = mats; - ApplyRendererColorOverride(r, entry.overrideColor, entry.color); + RestorePropertyBlockBaseline(state); + if (entry != null && entry.overrideColor) + { + ApplyRendererColorOverride(r, entry.color); + } + + r.enabled = state.rendererEnabled; + r.shadowCastingMode = state.shadowCastingMode; + r.receiveShadows = state.receiveShadows; + + GamaSpeciesRenderMode renderMode = entry != null + ? entry.renderMode + : GamaSpeciesRenderMode.Default; - if (entry.renderMode == GamaSpeciesRenderMode.Hidden) + if (renderMode == GamaSpeciesRenderMode.Hidden) { r.enabled = false; } - else if (entry.renderMode == GamaSpeciesRenderMode.Wireframe) + else if (renderMode == GamaSpeciesRenderMode.Wireframe) { - r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + r.shadowCastingMode = ShadowCastingMode.Off; r.receiveShadows = false; - r.enabled = true; } - else + else if (renderMode != GamaSpeciesRenderMode.Default) { - r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On; + r.shadowCastingMode = ShadowCastingMode.On; r.receiveShadows = true; - r.enabled = true; } + + if (overridesPreviewVisibility) + { + r.enabled = previewVisible && renderMode != GamaSpeciesRenderMode.Hidden; + } + } + + if (gameObject.activeSelf != targetActiveSelf) + { + gameObject.SetActive(targetActiveSelf); } } private static readonly int ColorId = Shader.PropertyToID("_Color"); private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); - private static void ApplyRendererColorOverride(Renderer renderer, bool overrideColor, Color color) + private static void RestorePropertyBlockBaseline(RendererBaseState state) { - if (renderer == null) return; + Renderer renderer = state.renderer; + if (renderer == null) + { + return; + } - MaterialPropertyBlock block = new MaterialPropertyBlock(); - renderer.GetPropertyBlock(block); + if (!state.hasCapturedPropertyBlocks) + { + RestoreSerializedColorBaseline(state); + return; + } + + renderer.SetPropertyBlock(state.hadRendererPropertyBlock ? state.rendererPropertyBlock : null); - if (!overrideColor) + int materialCount = renderer.sharedMaterials != null ? renderer.sharedMaterials.Length : 0; + for (int i = 0; i < materialCount; i++) + { + bool hasBaseline = state.hadMaterialPropertyBlocks != null && + i < state.hadMaterialPropertyBlocks.Length && + state.hadMaterialPropertyBlocks[i]; + MaterialPropertyBlock block = hasBaseline && state.materialPropertyBlocks != null && + i < state.materialPropertyBlocks.Length + ? state.materialPropertyBlocks[i] + : null; + renderer.SetPropertyBlock(block, i); + } + } + + private static void RestoreSerializedColorBaseline(RendererBaseState state) + { + if (state == null || state.renderer == null || !state.hasSerializedColorBaseline) { - block.Clear(); - renderer.SetPropertyBlock(block); return; } - bool supportsBaseColor = false; - bool supportsColor = false; - if (renderer.sharedMaterials != null) + Renderer renderer = state.renderer; + MaterialPropertyBlock rendererBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(rendererBlock); + if (state.rendererSupportsColor) { - foreach (Material m in renderer.sharedMaterials) + rendererBlock.SetColor(ColorId, state.rendererColor); + } + if (state.rendererSupportsBaseColor) + { + rendererBlock.SetColor(BaseColorId, state.rendererBaseColor); + } + renderer.SetPropertyBlock(rendererBlock); + + int materialCount = renderer.sharedMaterials != null ? renderer.sharedMaterials.Length : 0; + for (int i = 0; i < materialCount; i++) + { + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, i); + if (state.materialSupportsColor != null && + i < state.materialSupportsColor.Length && + state.materialSupportsColor[i] && + state.materialColors != null && + i < state.materialColors.Length) + { + block.SetColor(ColorId, state.materialColors[i]); + } + if (state.materialSupportsBaseColor != null && + i < state.materialSupportsBaseColor.Length && + state.materialSupportsBaseColor[i] && + state.materialBaseColors != null && + i < state.materialBaseColors.Length) { - if (m != null) + block.SetColor(BaseColorId, state.materialBaseColors[i]); + } + renderer.SetPropertyBlock(block, i); + } + } + + private static bool MaterialsSupportProperty(Material[] materials, int propertyId) + { + if (materials == null) + { + return false; + } + for (int i = 0; i < materials.Length; i++) + { + if (materials[i] != null && materials[i].HasProperty(propertyId)) + { + return true; + } + } + return false; + } + + private static Color ResolveBaselineColor( + MaterialPropertyBlock block, + Material[] materials, + int propertyId) + { + if (block != null && block.HasColor(propertyId)) + { + return block.GetColor(propertyId); + } + + if (materials != null) + { + for (int i = 0; i < materials.Length; i++) + { + Material material = materials[i]; + if (material != null && material.HasProperty(propertyId)) { - if (m.HasProperty(BaseColorId)) supportsBaseColor = true; - if (m.HasProperty(ColorId)) supportsColor = true; + return material.GetColor(propertyId); } } } + return Color.white; + } - if (supportsBaseColor) block.SetColor(BaseColorId, color); - if (supportsColor || !supportsBaseColor) block.SetColor(ColorId, color); + private static void ApplyRendererColorOverride(Renderer renderer, Color color) + { + if (renderer == null) return; + Material[] materials = renderer.sharedMaterials; + bool supportsBaseColor = false; + bool supportsColor = false; + if (materials != null) + { + foreach (Material material in materials) + { + if (material == null) continue; + if (material.HasProperty(BaseColorId)) supportsBaseColor = true; + if (material.HasProperty(ColorId)) supportsColor = true; + } + } + + // Start from the restored renderer-level block and change only color keys. + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block); + SetColorProperties(block, supportsBaseColor, supportsColor, color); renderer.SetPropertyBlock(block); + + // A per-material block takes precedence over the renderer-level block. + // Overlay the color there as well while retaining every baseline value. + if (materials != null) + { + for (int i = 0; i < materials.Length; i++) + { + Material material = materials[i]; + bool materialSupportsBaseColor = material != null && material.HasProperty(BaseColorId); + bool materialSupportsColor = material != null && material.HasProperty(ColorId); + + MaterialPropertyBlock materialBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(materialBlock, i); + SetColorProperties( + materialBlock, + materialSupportsBaseColor, + materialSupportsColor, + color); + renderer.SetPropertyBlock(materialBlock, i); + } + } + } + + private static void SetColorProperties( + MaterialPropertyBlock block, + bool supportsBaseColor, + bool supportsColor, + Color color) + { + if (supportsBaseColor) block.SetColor(BaseColorId, color); + if (supportsColor || !supportsBaseColor) block.SetColor(ColorId, color); } } diff --git a/Runtime/Preview/GamaPreviewReuseIdentity.cs b/Runtime/Preview/GamaPreviewReuseIdentity.cs new file mode 100644 index 0000000..691abbc --- /dev/null +++ b/Runtime/Preview/GamaPreviewReuseIdentity.cs @@ -0,0 +1,184 @@ +using System; + +/// +/// Builds identities that are safe to compare between a captured editor preview +/// and the live GAMA data received in Play mode. Only semantic source identity is +/// used; cache paths, timestamps and capture signatures are deliberately excluded. +/// +public static class GamaPreviewReuseIdentity +{ + private static readonly string[] StableIdAttributeNames = + { + "id", + "gama_id", + "agent_id", + "uid", + "uuid" + }; + + public static string NormalizeModelPath(string modelPath) + { + return GamaSpeciesRenderOverrides.NormalizeModelPath(modelPath); + } + + public static string NormalizeExperimentName(string experimentName) + { + return GamaSpeciesRenderOverrides.NormalizeKey(experimentName); + } + + public static bool TryBuildStableExperimentKey( + string modelPath, + string experimentName, + bool activeSelection, + string monitorExperimentId, + out string key) + { + key = string.Empty; + string normalizedModel = NormalizeModelPath(modelPath); + string normalizedExperiment = NormalizeExperimentName(experimentName); + if (string.IsNullOrEmpty(normalizedModel) || string.IsNullOrEmpty(normalizedExperiment)) + { + return false; + } + + string normalizedMonitorId = NormalizeIdentityPart(monitorExperimentId); + if (activeSelection && string.IsNullOrEmpty(normalizedMonitorId)) + { + return false; + } + + key = "model=" + EncodePart(normalizedModel) + + "|experiment=" + EncodePart(normalizedExperiment) + + "|selection=" + (activeSelection ? "active" : "unity"); + if (activeSelection) + { + key += "|monitor=" + EncodePart(normalizedMonitorId); + } + + return true; + } + + public static bool TryBuildStableAgentKey( + string speciesName, + string worldName, + Attributes attributes, + out string key, + out string sourceId) + { + key = string.Empty; + sourceId = string.Empty; + + string normalizedSpecies = NormalizeIdentityPart(speciesName); + if (string.IsNullOrEmpty(normalizedSpecies)) + { + return false; + } + + string candidate; + if (attributes != null) + { + for (int i = 0; i < StableIdAttributeNames.Length; i++) + { + if (attributes.TryGetString(out candidate, StableIdAttributeNames[i]) && + TryAcceptAgentId(candidate, out sourceId)) + { + key = BuildAgentKey(normalizedSpecies, sourceId); + return true; + } + } + } + + if (!TryAcceptAgentId(worldName, out sourceId)) + { + return false; + } + + key = BuildAgentKey(normalizedSpecies, sourceId); + return true; + } + + public static bool IsSyntheticAgentName(string value) + { + string normalized = NormalizeIdentityPart(value); + if (string.IsNullOrEmpty(normalized) || + normalized == "unknown" || + normalized == "agent" || + normalized == "unknown_agent") + { + return true; + } + + const string agentPrefix = "agent_"; + const string unknownAgentPrefix = "unknown_agent_"; + string suffix = null; + if (normalized.StartsWith(unknownAgentPrefix, StringComparison.Ordinal)) + { + suffix = normalized.Substring(unknownAgentPrefix.Length); + } + else if (normalized.StartsWith(agentPrefix, StringComparison.Ordinal)) + { + suffix = normalized.Substring(agentPrefix.Length); + } + + if (suffix == null) + { + return false; + } + + if (suffix == "i" || suffix.Length == 0) + { + return true; + } + + for (int i = 0; i < suffix.Length; i++) + { + if (!char.IsDigit(suffix[i])) + { + return false; + } + } + + return true; + } + + internal static string NormalizeIdentityPart(string value) + { + return string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().Replace('\\', '/').ToLowerInvariant(); + } + + private static bool TryAcceptAgentId(string candidate, out string sourceId) + { + sourceId = NormalizeAgentId(candidate); + if (string.IsNullOrEmpty(sourceId) || IsSyntheticAgentName(sourceId)) + { + sourceId = string.Empty; + return false; + } + + return true; + } + + private static string BuildAgentKey(string normalizedSpecies, string sourceId) + { + return EncodePart(normalizedSpecies) + "::" + + EncodePart(NormalizeAgentId(sourceId)); + } + + private static string NormalizeAgentId(string value) + { + // GAMA IDs are opaque values. Their case can carry identity and must not + // be folded even though species/model matching is case-insensitive. + return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + } + + private static string EncodePart(string value) + { + return (value ?? string.Empty) + .Replace("%", "%25") + .Replace("|", "%7c") + .Replace("=", "%3d") + .Replace(":", "%3a"); + } +} diff --git a/Runtime/Preview/GamaPreviewReuseIdentity.cs.meta b/Runtime/Preview/GamaPreviewReuseIdentity.cs.meta new file mode 100644 index 0000000..e6c3a42 --- /dev/null +++ b/Runtime/Preview/GamaPreviewReuseIdentity.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3aa53ff7042f4ea6bbcf57ac0bc11e68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Preview/GamaPreviewReuseRegistry.cs b/Runtime/Preview/GamaPreviewReuseRegistry.cs new file mode 100644 index 0000000..3eb9af8 --- /dev/null +++ b/Runtime/Preview/GamaPreviewReuseRegistry.cs @@ -0,0 +1,788 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Rendering; + +/// +/// Claims preview instances for a single authorized Play session and restores +/// their editor-preview hierarchy and rendering state when they are released. +/// +public sealed class GamaPreviewReuseRegistry : IDisposable +{ + private const string PreviewRootName = "[GAMA] Static Experiment Preview"; + + private sealed class ClaimRecord + { + public WeakReference Owner; + public WeakReference Instance; + } + + private sealed class RendererSnapshot + { + public Renderer Renderer; + public Material[] SharedMaterials; + public bool Enabled; + public ShadowCastingMode ShadowCastingMode; + public bool ReceiveShadows; + public bool HadRendererPropertyBlock; + public MaterialPropertyBlock RendererPropertyBlock; + public bool[] HadMaterialPropertyBlocks; + public MaterialPropertyBlock[] MaterialPropertyBlocks; + + public static RendererSnapshot Capture(Renderer renderer) + { + RendererSnapshot snapshot = new RendererSnapshot + { + Renderer = renderer, + SharedMaterials = renderer.sharedMaterials != null + ? (Material[])renderer.sharedMaterials.Clone() + : new Material[0], + Enabled = renderer.enabled, + ShadowCastingMode = renderer.shadowCastingMode, + ReceiveShadows = renderer.receiveShadows, + RendererPropertyBlock = new MaterialPropertyBlock() + }; + + renderer.GetPropertyBlock(snapshot.RendererPropertyBlock); + snapshot.HadRendererPropertyBlock = !snapshot.RendererPropertyBlock.isEmpty; + + int materialCount = snapshot.SharedMaterials.Length; + snapshot.HadMaterialPropertyBlocks = new bool[materialCount]; + snapshot.MaterialPropertyBlocks = new MaterialPropertyBlock[materialCount]; + for (int i = 0; i < materialCount; i++) + { + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, i); + snapshot.HadMaterialPropertyBlocks[i] = !block.isEmpty; + snapshot.MaterialPropertyBlocks[i] = block; + } + + return snapshot; + } + + public void Restore() + { + if (Renderer == null) + { + return; + } + + Renderer.sharedMaterials = SharedMaterials != null + ? (Material[])SharedMaterials.Clone() + : new Material[0]; + Renderer.SetPropertyBlock(HadRendererPropertyBlock ? RendererPropertyBlock : null); + + int materialCount = Renderer.sharedMaterials != null + ? Renderer.sharedMaterials.Length + : 0; + for (int i = 0; i < materialCount; i++) + { + bool hasBlock = HadMaterialPropertyBlocks != null && + i < HadMaterialPropertyBlocks.Length && + HadMaterialPropertyBlocks[i]; + MaterialPropertyBlock block = hasBlock && + MaterialPropertyBlocks != null && + i < MaterialPropertyBlocks.Length + ? MaterialPropertyBlocks[i] + : null; + Renderer.SetPropertyBlock(block, i); + } + + Renderer.enabled = Enabled; + Renderer.shadowCastingMode = ShadowCastingMode; + Renderer.receiveShadows = ReceiveShadows; + } + } + + private sealed class ActiveStateSnapshot + { + public GameObject GameObject; + public bool ActiveSelf; + } + + private sealed class MeshFilterSnapshot + { + public MeshFilter MeshFilter; + public Mesh SharedMesh; + } + + private sealed class SkinnedMeshSnapshot + { + public SkinnedMeshRenderer Renderer; + public Mesh SharedMesh; + } + + private sealed class Candidate + { + public string AgentKey; + public string PropertyId; + public string PrefabSignature; + public GameObject PrefabAsset; + public GamaPreviewRepresentationKind Kind; + public GamaPreviewObject Marker; + public Transform OriginalParent; + public int OriginalSiblingIndex; + public Vector3 OriginalLocalPosition; + public Quaternion OriginalLocalRotation; + public Vector3 OriginalLocalScale; + public bool OriginalActiveSelf; + public string OriginalName; + public string OriginalTag; + public int OriginalLayer; + public ActiveStateSnapshot[] ActiveStates; + public RendererSnapshot[] Renderers; + public MeshFilterSnapshot[] MeshFilters; + public SkinnedMeshSnapshot[] SkinnedMeshes; + public HashSet OriginalGameObjectIds; + public HashSet OriginalComponentIds; + public bool Claimed; + + public GameObject Instance + { + get { return Marker != null ? Marker.gameObject : null; } + } + + public static Candidate Capture(GamaPreviewObject marker) + { + Transform markerTransform = marker.transform; + Transform[] transforms = marker.GetComponentsInChildren(true); + ActiveStateSnapshot[] activeStates = new ActiveStateSnapshot[transforms.Length]; + HashSet originalGameObjectIds = new HashSet(); + HashSet originalComponentIds = new HashSet(); + for (int i = 0; i < transforms.Length; i++) + { + GameObject gameObject = transforms[i].gameObject; + originalGameObjectIds.Add(gameObject.GetInstanceID()); + Component[] components = gameObject.GetComponents(); + for (int componentIndex = 0; componentIndex < components.Length; componentIndex++) + { + Component component = components[componentIndex]; + if (component != null) + { + originalComponentIds.Add(component.GetInstanceID()); + } + } + activeStates[i] = new ActiveStateSnapshot + { + GameObject = gameObject, + ActiveSelf = gameObject.activeSelf + }; + } + + Renderer[] renderers = marker.GetComponentsInChildren(true); + RendererSnapshot[] rendererSnapshots = new RendererSnapshot[renderers.Length]; + for (int i = 0; i < renderers.Length; i++) + { + rendererSnapshots[i] = RendererSnapshot.Capture(renderers[i]); + } + + MeshFilter[] meshFilters = marker.GetComponentsInChildren(true); + MeshFilterSnapshot[] meshFilterSnapshots = new MeshFilterSnapshot[meshFilters.Length]; + for (int i = 0; i < meshFilters.Length; i++) + { + meshFilterSnapshots[i] = new MeshFilterSnapshot + { + MeshFilter = meshFilters[i], + SharedMesh = meshFilters[i].sharedMesh + }; + } + + SkinnedMeshRenderer[] skinnedRenderers = + marker.GetComponentsInChildren(true); + SkinnedMeshSnapshot[] skinnedMeshSnapshots = + new SkinnedMeshSnapshot[skinnedRenderers.Length]; + for (int i = 0; i < skinnedRenderers.Length; i++) + { + skinnedMeshSnapshots[i] = new SkinnedMeshSnapshot + { + Renderer = skinnedRenderers[i], + SharedMesh = skinnedRenderers[i].sharedMesh + }; + } + + return new Candidate + { + AgentKey = NormalizeAgentKey(marker.stableAgentKey), + PropertyId = NormalizeCompatibilityValue(marker.sourcePropertyId), + PrefabSignature = NormalizeCompatibilityValue(marker.sourcePrefabSignature), + PrefabAsset = marker.sourcePrefabAsset, + Kind = marker.representationKind, + Marker = marker, + OriginalParent = markerTransform.parent, + OriginalSiblingIndex = markerTransform.GetSiblingIndex(), + OriginalLocalPosition = markerTransform.localPosition, + OriginalLocalRotation = markerTransform.localRotation, + OriginalLocalScale = markerTransform.localScale, + OriginalActiveSelf = marker.gameObject.activeSelf, + OriginalName = marker.gameObject.name, + OriginalTag = marker.gameObject.tag, + OriginalLayer = marker.gameObject.layer, + ActiveStates = activeStates, + Renderers = rendererSnapshots, + MeshFilters = meshFilterSnapshots, + SkinnedMeshes = skinnedMeshSnapshots, + OriginalGameObjectIds = originalGameObjectIds, + OriginalComponentIds = originalComponentIds + }; + } + + public bool IsCompatible( + string propertyId, + GamaPreviewRepresentationKind kind, + string prefabSignature, + GameObject prefabAsset) + { + if (kind == GamaPreviewRepresentationKind.Unknown || + Kind != kind || + string.IsNullOrEmpty(PropertyId) || + !string.Equals( + PropertyId, + NormalizeCompatibilityValue(propertyId), + StringComparison.Ordinal)) + { + return false; + } + + if (kind == GamaPreviewRepresentationKind.Prefab) + { + // Compare the actual resolved Unity asset, not a property/path + // hint. The same property can resolve to different prefabs via + // runtime bindings, translations, or agent attributes. + return PrefabAsset != null && + prefabAsset != null && + PrefabAsset == prefabAsset && + !string.IsNullOrWhiteSpace(prefabSignature); + } + + return !string.IsNullOrEmpty(PrefabSignature) && + string.Equals( + PrefabSignature, + NormalizeCompatibilityValue(prefabSignature), + StringComparison.Ordinal); + } + + public void Restore() + { + GameObject instance = Instance; + if (instance == null) + { + return; + } + + Transform instanceTransform = instance.transform; + instanceTransform.SetParent(OriginalParent, false); + instanceTransform.SetSiblingIndex(OriginalSiblingIndex); + instanceTransform.localPosition = OriginalLocalPosition; + instanceTransform.localRotation = OriginalLocalRotation; + instanceTransform.localScale = OriginalLocalScale; + instance.name = OriginalName; + instance.tag = OriginalTag; + instance.layer = OriginalLayer; + + if (MeshFilters != null) + { + for (int i = 0; i < MeshFilters.Length; i++) + { + MeshFilterSnapshot mesh = MeshFilters[i]; + if (mesh != null && mesh.MeshFilter != null) + { + mesh.MeshFilter.sharedMesh = mesh.SharedMesh; + } + } + } + + if (SkinnedMeshes != null) + { + for (int i = 0; i < SkinnedMeshes.Length; i++) + { + SkinnedMeshSnapshot mesh = SkinnedMeshes[i]; + if (mesh != null && mesh.Renderer != null) + { + mesh.Renderer.sharedMesh = mesh.SharedMesh; + } + } + } + + if (Renderers != null) + { + for (int i = 0; i < Renderers.Length; i++) + { + RendererSnapshot renderer = Renderers[i]; + if (renderer != null) + { + renderer.Restore(); + } + } + } + + RemoveRuntimeOnlyComponents(instance, OriginalGameObjectIds, OriginalComponentIds); + RemoveRuntimeOnlyChildren(instance, OriginalGameObjectIds); + + if (ActiveStates != null) + { + for (int i = 0; i < ActiveStates.Length; i++) + { + ActiveStateSnapshot state = ActiveStates[i]; + if (state == null || state.GameObject == null || state.GameObject == instance) + { + continue; + } + + if (state.GameObject.activeSelf != state.ActiveSelf) + { + state.GameObject.SetActive(state.ActiveSelf); + } + } + } + + if (instance.activeSelf != OriginalActiveSelf) + { + instance.SetActive(OriginalActiveSelf); + } + } + + private static void RemoveRuntimeOnlyComponents( + GameObject instance, + HashSet originalGameObjectIds, + HashSet originalComponentIds) + { + if (instance == null || originalGameObjectIds == null || originalComponentIds == null) + { + return; + } + + Transform[] transforms = instance.GetComponentsInChildren(true); + for (int i = 0; i < transforms.Length; i++) + { + GameObject gameObject = transforms[i].gameObject; + if (!originalGameObjectIds.Contains(gameObject.GetInstanceID())) + { + continue; + } + + Component[] components = gameObject.GetComponents(); + for (int componentIndex = 0; componentIndex < components.Length; componentIndex++) + { + Component component = components[componentIndex]; + if (component == null || + component is Transform || + originalComponentIds.Contains(component.GetInstanceID())) + { + continue; + } + + DisableRuntimeComponent(component); + DestroyRuntimeMutation(component); + } + } + } + + private static void RemoveRuntimeOnlyChildren( + GameObject instance, + HashSet originalGameObjectIds) + { + if (instance == null || originalGameObjectIds == null) + { + return; + } + + Transform[] transforms = instance.GetComponentsInChildren(true); + for (int i = transforms.Length - 1; i >= 0; i--) + { + Transform transform = transforms[i]; + if (transform == null || + transform == instance.transform || + originalGameObjectIds.Contains(transform.gameObject.GetInstanceID())) + { + continue; + } + + Transform parent = transform.parent; + if (parent != null && + !originalGameObjectIds.Contains(parent.gameObject.GetInstanceID())) + { + // The highest runtime-only ancestor owns this whole subtree. + continue; + } + + GameObject runtimeOnly = transform.gameObject; + runtimeOnly.SetActive(false); + transform.SetParent(null, false); + DestroyRuntimeMutation(runtimeOnly); + } + } + + private static void DisableRuntimeComponent(Component component) + { + Behaviour behaviour = component as Behaviour; + if (behaviour != null) + { + behaviour.enabled = false; + return; + } + + Collider collider = component as Collider; + if (collider != null) + { + collider.enabled = false; + return; + } + + Renderer renderer = component as Renderer; + if (renderer != null) + { + renderer.enabled = false; + } + } + + private static void DestroyRuntimeMutation(UnityEngine.Object mutation) + { + if (mutation == null) + { + return; + } + + if (Application.isPlaying) + { + UnityEngine.Object.Destroy(mutation); + } + else + { + UnityEngine.Object.DestroyImmediate(mutation); + } + } + } + + private static readonly object ClaimsLock = new object(); + private static readonly Dictionary GlobalClaims = + new Dictionary(); + + private readonly Dictionary candidatesByAgentKey = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary claimedByAgentKey = + new Dictionary(StringComparer.Ordinal); + private readonly GameObject previewRoot; + private bool disposed; + + private GamaPreviewReuseRegistry(GameObject previewRoot) + { + this.previewRoot = previewRoot; + BuildCandidateIndex(previewRoot); + } + + public GameObject PreviewRoot + { + get { return previewRoot; } + } + + public int AvailableCount + { + get { return candidatesByAgentKey.Count - claimedByAgentKey.Count; } + } + + public int ClaimedCount + { + get { return claimedByAgentKey.Count; } + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetGlobalClaims() + { + lock (ClaimsLock) + { + GlobalClaims.Clear(); + } + } + + public static bool TryCreate( + string expectedExperimentKey, + out GamaPreviewReuseRegistry registry) + { + registry = null; + if (string.IsNullOrWhiteSpace(expectedExperimentKey)) + { + return false; + } + + GamaPreviewSession[] sessions = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + GamaPreviewSession matchingSession = null; + for (int i = 0; i < sessions.Length; i++) + { + GamaPreviewSession session = sessions[i]; + if (!IsAuthorizedSession(session, expectedExperimentKey)) + { + continue; + } + + // Ambiguous roots must never share a live-data stream. + if (matchingSession != null) + { + return false; + } + + matchingSession = session; + } + + if (matchingSession == null) + { + return false; + } + + registry = new GamaPreviewReuseRegistry(matchingSession.gameObject); + return true; + } + + public bool TryTake( + string agentKey, + string propertyId, + GamaPreviewRepresentationKind kind, + string prefabSignature, + out GameObject instance) + { + return TryTake( + agentKey, + propertyId, + kind, + prefabSignature, + null, + out instance); + } + + public bool TryTake( + string agentKey, + string propertyId, + GamaPreviewRepresentationKind kind, + string prefabSignature, + GameObject prefabAsset, + out GameObject instance) + { + instance = null; + if (disposed) + { + return false; + } + + string normalizedAgentKey = NormalizeAgentKey(agentKey); + if (string.IsNullOrEmpty(normalizedAgentKey) || + !candidatesByAgentKey.TryGetValue(normalizedAgentKey, out Candidate candidate) || + candidate == null || + candidate.Claimed || + candidate.Instance == null || + !candidate.IsCompatible(propertyId, kind, prefabSignature, prefabAsset) || + !TryAcquireGlobalClaim(candidate.Instance)) + { + return false; + } + + candidate.Claimed = true; + claimedByAgentKey[normalizedAgentKey] = candidate; + instance = candidate.Instance; + return instance != null; + } + + public bool Release(string agentKey) + { + if (disposed) + { + return false; + } + + string normalizedAgentKey = NormalizeAgentKey(agentKey); + if (!claimedByAgentKey.TryGetValue(normalizedAgentKey, out Candidate candidate) || + candidate == null) + { + return false; + } + + try + { + RestoreCandidate(candidate); + } + finally + { + claimedByAgentKey.Remove(normalizedAgentKey); + } + return true; + } + + public void RestoreAll() + { + if (claimedByAgentKey.Count == 0) + { + return; + } + + Candidate[] claimed = new Candidate[claimedByAgentKey.Count]; + claimedByAgentKey.Values.CopyTo(claimed, 0); + for (int i = 0; i < claimed.Length; i++) + { + RestoreCandidate(claimed[i]); + } + + claimedByAgentKey.Clear(); + } + + public void Dispose() + { + if (disposed) + { + return; + } + + RestoreAll(); + disposed = true; + } + + private static bool IsAuthorizedSession( + GamaPreviewSession session, + string expectedExperimentKey) + { + if (session == null || + session.gameObject == null || + session.gameObject.name != PreviewRootName || + !session.gameObject.scene.IsValid() || + session.stale || + !session.reuseAuthorizedForPlay || + string.IsNullOrEmpty(session.stableExperimentKey) || + !string.Equals(session.stableExperimentKey, expectedExperimentKey, StringComparison.Ordinal) || + !string.Equals(session.authorizedStableExperimentKey, expectedExperimentKey, StringComparison.Ordinal) || + !session.TryGetStableExperimentKey(out string computedKey) || + !string.Equals(computedKey, expectedExperimentKey, StringComparison.Ordinal)) + { + return false; + } + + if (session.activeGamaSelection) + { + if (string.IsNullOrWhiteSpace(session.monitorExperimentId) || + !string.Equals( + session.monitorExperimentId, + session.authorizedMonitorExperimentId, + StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private void BuildCandidateIndex(GameObject root) + { + if (root == null) + { + return; + } + + HashSet duplicateKeys = new HashSet(StringComparer.Ordinal); + GamaPreviewObject[] markers = root.GetComponentsInChildren(true); + for (int i = 0; i < markers.Length; i++) + { + GamaPreviewObject marker = markers[i]; + if (marker == null || !marker.IsEligibleForRuntimeReuse) + { + continue; + } + + Candidate candidate = Candidate.Capture(marker); + if (string.IsNullOrEmpty(candidate.AgentKey) || duplicateKeys.Contains(candidate.AgentKey)) + { + continue; + } + + if (candidatesByAgentKey.ContainsKey(candidate.AgentKey)) + { + candidatesByAgentKey.Remove(candidate.AgentKey); + duplicateKeys.Add(candidate.AgentKey); + continue; + } + + candidatesByAgentKey.Add(candidate.AgentKey, candidate); + } + } + + private bool TryAcquireGlobalClaim(GameObject instance) + { + int instanceId = instance.GetInstanceID(); + lock (ClaimsLock) + { + if (GlobalClaims.TryGetValue(instanceId, out ClaimRecord existing)) + { + object existingInstance = existing.Instance != null ? existing.Instance.Target : null; + object existingOwner = existing.Owner != null ? existing.Owner.Target : null; + if (!ReferenceEquals(existingInstance, instance) || existingOwner == null) + { + GlobalClaims.Remove(instanceId); + } + else if (!ReferenceEquals(existingOwner, this)) + { + return false; + } + } + + GlobalClaims[instanceId] = new ClaimRecord + { + Owner = new WeakReference(this), + Instance = new WeakReference(instance) + }; + return true; + } + } + + private void RestoreCandidate(Candidate candidate) + { + if (candidate == null) + { + return; + } + + GameObject instance = candidate.Instance; + try + { + candidate.Restore(); + } + finally + { + candidate.Claimed = false; + ReleaseGlobalClaim(instance); + } + } + + private void ReleaseGlobalClaim(GameObject instance) + { + if (instance == null) + { + return; + } + + int instanceId = instance.GetInstanceID(); + lock (ClaimsLock) + { + if (!GlobalClaims.TryGetValue(instanceId, out ClaimRecord existing)) + { + return; + } + + object existingOwner = existing.Owner != null ? existing.Owner.Target : null; + object existingInstance = existing.Instance != null ? existing.Instance.Target : null; + if (ReferenceEquals(existingOwner, this) && ReferenceEquals(existingInstance, instance)) + { + GlobalClaims.Remove(instanceId); + } + } + } + + private static string NormalizeAgentKey(string value) + { + // The species portion is already canonicalized by the identity builder; + // the encoded agent ID remains an opaque, case-sensitive value. + return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + } + + private static string NormalizeCompatibilityValue(string value) + { + return GamaPreviewReuseIdentity.NormalizeIdentityPart(value); + } +} diff --git a/Runtime/Preview/GamaPreviewReuseRegistry.cs.meta b/Runtime/Preview/GamaPreviewReuseRegistry.cs.meta new file mode 100644 index 0000000..ee5b32f --- /dev/null +++ b/Runtime/Preview/GamaPreviewReuseRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9bc2f3a624ab48abb91283661e60affb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Preview/GamaPreviewSession.cs b/Runtime/Preview/GamaPreviewSession.cs index a75e179..7d1626d 100644 --- a/Runtime/Preview/GamaPreviewSession.cs +++ b/Runtime/Preview/GamaPreviewSession.cs @@ -31,6 +31,16 @@ public class GamaPreviewSession : MonoBehaviour public string previewCacheReference = string.Empty; public string selectionMode = string.Empty; public bool activeGamaSelection; + public string stableExperimentKey = string.Empty; + public string monitorExperimentId = string.Empty; + + [Header("Play reuse authorization")] + [HideInInspector] + public bool reuseAuthorizedForPlay; + [HideInInspector] + public string authorizedStableExperimentKey = string.Empty; + [HideInInspector] + public string authorizedMonitorExperimentId = string.Empty; [Header("Capture metadata")] public string captureTimestampUtc = string.Empty; @@ -45,4 +55,45 @@ public class GamaPreviewSession : MonoBehaviour public List speciesList = new List(); public List speciesCounts = new List(); public GamaSpeciesRenderOverrides speciesOverrides; + + public bool TryGetStableExperimentKey(out string key) + { + if (!GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + modelPath, + experimentName, + activeGamaSelection, + monitorExperimentId, + out key)) + { + key = string.Empty; + return false; + } + + return string.IsNullOrEmpty(stableExperimentKey) || + string.Equals(stableExperimentKey, key, StringComparison.Ordinal); + } + + public bool RefreshStableExperimentKey() + { + if (!GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + modelPath, + experimentName, + activeGamaSelection, + monitorExperimentId, + out string key)) + { + stableExperimentKey = string.Empty; + return false; + } + + stableExperimentKey = key; + return true; + } + + public void ClearRuntimeReuseAuthorization() + { + reuseAuthorizedForPlay = false; + authorizedStableExperimentKey = string.Empty; + authorizedMonitorExperimentId = string.Empty; + } } diff --git a/Runtime/Preview/GamaRuntimePreviewOverrideApplier.cs b/Runtime/Preview/GamaRuntimePreviewOverrideApplier.cs index c53462d..c33cbf4 100644 --- a/Runtime/Preview/GamaRuntimePreviewOverrideApplier.cs +++ b/Runtime/Preview/GamaRuntimePreviewOverrideApplier.cs @@ -5,20 +5,22 @@ public static class GamaRuntimePreviewOverrideApplier { private const string StaticPreviewRootName = "[GAMA] Static Experiment Preview"; - private const int RuntimeSessionScoreBonus = 1000000; private static Dictionary overridesBySpecies; - private static Dictionary runtimeSessionOverrides; private static bool initialized; private static bool runtimeContextAvailable; private static int logCount; private const int MaxLogs = 5; private const int MaxStartupOverrideLogs = 20; + static GamaRuntimePreviewOverrideApplier() + { + GamaSpeciesAppearanceStateStore.Changed += OnAppearanceStateChanged; + } + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetState() { overridesBySpecies = null; - runtimeSessionOverrides = null; initialized = false; runtimeContextAvailable = false; logCount = 0; @@ -30,47 +32,20 @@ public static GamaSpeciesRenderOverrideEntry GetOrCreateRuntimeSessionOverride( string experimentName, string speciesName) { - string normalizedSpecies = string.IsNullOrWhiteSpace(speciesName) ? "unknown" : speciesName.Trim(); - string key = BuildRuntimeSessionOverrideKey(modelPath, experimentName, normalizedSpecies); - if (runtimeSessionOverrides == null) - { - runtimeSessionOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - if (runtimeSessionOverrides.TryGetValue(key, out GamaSpeciesRenderOverrideEntry entry)) - { - return entry; - } - - GamaSpeciesRenderOverrideEntry sourceEntry = null; - if (sourceAsset != null && - !sourceAsset.TryGetOverride(modelPath, experimentName, normalizedSpecies, out sourceEntry, true)) - { - sourceAsset.TryGetOverride(modelPath, experimentName, normalizedSpecies, out sourceEntry); - } - - entry = CloneOverrideEntry(sourceEntry) ?? new GamaSpeciesRenderOverrideEntry(); - entry.modelPath = modelPath ?? string.Empty; - entry.experimentName = experimentName ?? string.Empty; - entry.speciesName = normalizedSpecies; - entry.speciesKey = normalizedSpecies; - if (entry.discreteColorRules == null) - { - entry.discreteColorRules = new List(); - } - - runtimeSessionOverrides[key] = entry; + GamaSpeciesAppearanceContext context = new GamaSpeciesAppearanceContext( + sourceAsset, + modelPath, + experimentName); + GamaSpeciesAppearanceStateStore.SetActiveContext(context); + GamaSpeciesRenderOverrideEntry entry = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(context, speciesName, true); initialized = false; return entry; } public static void ClearRuntimeSessionOverrides() { - if (runtimeSessionOverrides != null) - { - runtimeSessionOverrides.Clear(); - } - + GamaSpeciesAppearanceStateStore.ClearRuntimeOverlay(); overridesBySpecies = null; initialized = false; runtimeContextAvailable = false; @@ -97,20 +72,20 @@ public static bool TryGetOverride(string speciesKey, out GamaSpeciesRenderOverri } bool found = overridesBySpecies.TryGetValue(speciesKey, out entry); - if (found && logCount < MaxLogs) + if (found && GamaLog.VerboseEnabled && logCount < MaxLogs) { logCount++; - Debug.Log("[GAMA][RUNTIME][OVERRIDE] Applied species=" + speciesKey + " to an object."); + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] Applied species=" + speciesKey + " to an object."); } - else if (!runtimeContextAvailable && logCount < MaxLogs) + else if (!runtimeContextAvailable && GamaLog.VerboseEnabled && logCount < MaxLogs) { logCount++; - Debug.Log("[GAMA][RUNTIME][OVERRIDE] No species-only override for species=" + speciesKey); + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] No species-only override for species=" + speciesKey); } - else if (!found && logCount < MaxLogs) + else if (!found && GamaLog.VerboseEnabled && logCount < MaxLogs) { logCount++; - Debug.Log("[GAMA][RUNTIME][OVERRIDE] No override for species=" + speciesKey); + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] No override for species=" + speciesKey); } return found; } @@ -129,10 +104,10 @@ public static bool TryGetOverrideForProperty( if (entry != null) { - if (logCount < MaxLogs) + if (GamaLog.VerboseEnabled && logCount < MaxLogs) { logCount++; - Debug.Log("[GAMA][RUNTIME][OVERRIDE] Applied property=" + propertyId + + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] Applied property=" + propertyId + " tag=" + tag + " overrideSpecies=" + entry.GetSpeciesName() + " scale=" + entry.GetEffectiveScaleMultiplier()); @@ -141,10 +116,10 @@ public static bool TryGetOverrideForProperty( return true; } - if (logCount < MaxLogs) + if (GamaLog.VerboseEnabled && logCount < MaxLogs) { logCount++; - Debug.Log("[GAMA][RUNTIME][OVERRIDE] No override for property=" + propertyId + " tag=" + tag); + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] No override for property=" + propertyId + " tag=" + tag); } return false; @@ -181,54 +156,97 @@ private static void Initialize() experimentName = session.experimentName ?? string.Empty; } - if (asset == null && (runtimeSessionOverrides == null || runtimeSessionOverrides.Count == 0)) + if (asset == null) { - Debug.Log("[GAMA][RUNTIME][OVERRIDE] No overrides asset found on the SimulationManager or preview session."); + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] No overrides asset found on the SimulationManager or preview session."); return; } Dictionary bestScoresBySpecies = new Dictionary(StringComparer.OrdinalIgnoreCase); runtimeContextAvailable = !string.IsNullOrWhiteSpace(modelPath) || !string.IsNullOrWhiteSpace(experimentName); - Debug.Log("[GAMA][RUNTIME][CONTEXT] model=" + (modelPath ?? string.Empty) + + GamaLog.Dev("[GAMA][RUNTIME][CONTEXT] model=" + (modelPath ?? string.Empty) + " experiment=" + (experimentName ?? string.Empty)); if (!runtimeContextAvailable) { - Debug.LogWarning("[GAMA][RUNTIME][OVERRIDE_WARN] missing context; using species-only runtime overrides from the active SimulationManager."); + GamaLog.DevWarning("[GAMA][RUNTIME][OVERRIDE_WARN] missing context; using species-only runtime overrides from the active SimulationManager."); } - string wantedModel = GamaSpeciesRenderOverrides.NormalizeKey(modelPath); + string wantedModel = GamaSpeciesRenderOverrides.NormalizeModelPath(modelPath); string wantedExperiment = GamaSpeciesRenderOverrides.NormalizeKey(experimentName); - - if (asset != null && asset.entries != null) + GamaSpeciesAppearanceContext context = new GamaSpeciesAppearanceContext( + asset, + modelPath, + experimentName); + GamaSpeciesAppearanceStateStore.SetActiveContext(context); + + bool applyPersistedPreviewSettings = true; +#if UNITY_EDITOR + applyPersistedPreviewSettings = UnityEditor.EditorPrefs.GetBool( + "ProjectSimple.GamaUnity.Panel.ApplyPreviewSettingsToPlay", + true); +#endif + if (applyPersistedPreviewSettings && asset != null && asset.entries != null) { foreach (var e in asset.entries) { - TryAddRuntimeOverrideEntry( - e, - wantedModel, - wantedExperiment, - bestScoresBySpecies, - false, - 0); + TryAddExactRuntimeOverrideEntry(e, wantedModel, wantedExperiment, bestScoresBySpecies, 0); } } - if (runtimeSessionOverrides != null) + IReadOnlyList overlayEntries = + GamaSpeciesAppearanceStateStore.GetRuntimeOverlayEntries(context); + for (int i = 0; i < overlayEntries.Count; i++) { - foreach (GamaSpeciesRenderOverrideEntry e in runtimeSessionOverrides.Values) - { - TryAddRuntimeOverrideEntry( - e, - wantedModel, - wantedExperiment, - bestScoresBySpecies, - true, - RuntimeSessionScoreBonus); - } + TryAddExactRuntimeOverrideEntry( + overlayEntries[i], + wantedModel, + wantedExperiment, + bestScoresBySpecies, + 1000000); + } + + if (GamaLog.VerboseEnabled) + { + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] Loaded preview overrides: " + string.Join(",", overridesBySpecies.Keys)); + LogLoadedOverrides(bestScoresBySpecies, modelPath, experimentName); } + initialized = true; + } + + private static void TryAddExactRuntimeOverrideEntry( + GamaSpeciesRenderOverrideEntry entry, + string wantedModel, + string wantedExperiment, + Dictionary bestScoresBySpecies, + int scoreBonus) + { + if (entry == null) + { + return; + } + + string species = entry.GetSpeciesName(); + if (string.IsNullOrWhiteSpace(species) || + !IsExactRuntimeContextEntry( + entry, + wantedModel, + wantedExperiment, + GamaSpeciesRenderOverrides.NormalizeKey(species))) + { + return; + } + + int score = entry.GetOverrideMeaningScore() + scoreBonus; + bestScoresBySpecies[species] = score; + overridesBySpecies[species] = entry; + } - Debug.Log("[GAMA][RUNTIME][OVERRIDE] Loaded preview overrides: " + string.Join(",", overridesBySpecies.Keys)); - LogLoadedOverrides(bestScoresBySpecies, modelPath, experimentName); + private static void OnAppearanceStateChanged(GamaSpeciesAppearanceChange change) + { + overridesBySpecies = null; + initialized = false; + runtimeContextAvailable = false; + logCount = 0; } private static int GetRuntimeSelectionScore( @@ -313,7 +331,7 @@ private static void TryAddRuntimeOverrideEntry( private static string BuildRuntimeSessionOverrideKey(string modelPath, string experimentName, string speciesName) { - return GamaSpeciesRenderOverrides.NormalizeKey(modelPath) + "|" + + return GamaSpeciesRenderOverrides.NormalizeModelPath(modelPath) + "|" + GamaSpeciesRenderOverrides.NormalizeKey(experimentName) + "|" + GamaSpeciesRenderOverrides.NormalizeKey(speciesName); } @@ -401,7 +419,7 @@ private static bool IsExactRuntimeContextEntry( } return string.Equals( - GamaSpeciesRenderOverrides.NormalizeKey(entry.modelPath), + GamaSpeciesRenderOverrides.NormalizeModelPath(entry.modelPath), wantedModel, StringComparison.Ordinal) && string.Equals( @@ -425,7 +443,7 @@ private static bool IsActiveSelectionFallbackEntry( } return string.Equals( - GamaSpeciesRenderOverrides.NormalizeKey(entry.modelPath), + GamaSpeciesRenderOverrides.NormalizeModelPath(entry.modelPath), "gama_active_selection", StringComparison.Ordinal) && string.Equals( @@ -461,7 +479,7 @@ private static void LogLoadedOverrides(Dictionary scoresBySpecies, ? pickedScore : -1; - Debug.Log("[GAMA][RUNTIME][OVERRIDE_PICK] species=" + pair.Key + + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE_PICK] species=" + pair.Key + " pickedModel=" + (entry.modelPath ?? string.Empty) + " pickedExperiment=" + (entry.experimentName ?? string.Empty) + " requestedModel=" + (requestedModel ?? string.Empty) + @@ -470,7 +488,7 @@ private static void LogLoadedOverrides(Dictionary scoresBySpecies, " scale=" + entry.GetEffectiveScaleMultiplier() + " score=" + score); - Debug.Log("[GAMA][RUNTIME][OVERRIDES] species=" + pair.Key + + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDES] species=" + pair.Key + " prefab=" + prefab + " colorOverride=" + entry.overrideColor + " scale=" + entry.GetEffectiveScaleMultiplier() + @@ -503,10 +521,10 @@ private static bool TryGetOverrideNoLog(string speciesKey, out GamaSpeciesRender return true; } - if (!runtimeContextAvailable && logCount < MaxLogs) + if (!runtimeContextAvailable && GamaLog.VerboseEnabled && logCount < MaxLogs) { logCount++; - Debug.Log("[GAMA][RUNTIME][OVERRIDE] No species-only override for species=" + speciesKey); + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] No species-only override for species=" + speciesKey); } return false; diff --git a/Runtime/Preview/GamaSpeciesAppearanceStateStore.cs b/Runtime/Preview/GamaSpeciesAppearanceStateStore.cs new file mode 100644 index 0000000..9d248ba --- /dev/null +++ b/Runtime/Preview/GamaSpeciesAppearanceStateStore.cs @@ -0,0 +1,487 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +public readonly struct GamaSpeciesAppearanceContext : IEquatable +{ + public readonly GamaSpeciesRenderOverrides Asset; + public readonly string ModelPath; + public readonly string ExperimentName; + + public GamaSpeciesAppearanceContext( + GamaSpeciesRenderOverrides asset, + string modelPath, + string experimentName) + { + Asset = asset; + ModelPath = modelPath ?? string.Empty; + ExperimentName = experimentName ?? string.Empty; + } + + public bool IsValid => Asset != null; + + public bool Equals(GamaSpeciesAppearanceContext other) + { + return Asset == other.Asset && + string.Equals( + GamaSpeciesRenderOverrides.NormalizeModelPath(ModelPath), + GamaSpeciesRenderOverrides.NormalizeModelPath(other.ModelPath), + StringComparison.Ordinal) && + string.Equals( + GamaSpeciesRenderOverrides.NormalizeKey(ExperimentName), + GamaSpeciesRenderOverrides.NormalizeKey(other.ExperimentName), + StringComparison.Ordinal); + } + + public override bool Equals(object obj) + { + return obj is GamaSpeciesAppearanceContext other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + int hash = Asset != null ? Asset.GetInstanceID() : 0; + hash = hash * 397 ^ GamaSpeciesRenderOverrides.NormalizeModelPath(ModelPath).GetHashCode(); + hash = hash * 397 ^ GamaSpeciesRenderOverrides.NormalizeKey(ExperimentName).GetHashCode(); + return hash; + } + } + + public static bool operator ==(GamaSpeciesAppearanceContext left, GamaSpeciesAppearanceContext right) + { + return left.Equals(right); + } + + public static bool operator !=(GamaSpeciesAppearanceContext left, GamaSpeciesAppearanceContext right) + { + return !left.Equals(right); + } +} + +public enum GamaSpeciesAppearanceChangeKind +{ + EntryChanged, + ContextChanged, + ContextCleared, + RuntimeOverlayCleared +} + +public readonly struct GamaSpeciesAppearanceChange +{ + public readonly GamaSpeciesAppearanceChangeKind Kind; + public readonly GamaSpeciesAppearanceContext Context; + public readonly string SpeciesName; + public readonly bool RuntimeOnly; + + public GamaSpeciesAppearanceChange( + GamaSpeciesAppearanceChangeKind kind, + GamaSpeciesAppearanceContext context, + string speciesName, + bool runtimeOnly) + { + Kind = kind; + Context = context; + SpeciesName = speciesName ?? string.Empty; + RuntimeOnly = runtimeOnly; + } +} + +/// +/// Canonical access point for persisted species appearance and the temporary Play overlay. +/// Every lookup is exact for asset/model/experiment/species. +/// +public static class GamaSpeciesAppearanceStateStore +{ + private static readonly Dictionary runtimeOverlay = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static GamaSpeciesAppearanceContext activeContext; + + public static event Action Changed; + + public static GamaSpeciesAppearanceContext ActiveContext => activeContext; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetRuntimeState() + { + runtimeOverlay.Clear(); + activeContext = default; + } + + public static void SetActiveContext(GamaSpeciesAppearanceContext context) + { + if (activeContext == context) + { + return; + } + + if (activeContext.IsValid) + { + RemoveRuntimeOverlayEntries(activeContext); + } + + activeContext = context; + RaiseChanged(new GamaSpeciesAppearanceChange( + GamaSpeciesAppearanceChangeKind.ContextChanged, + context, + string.Empty, + false)); + } + + public static bool TryGetEntry( + GamaSpeciesAppearanceContext context, + string speciesName, + bool includeRuntimeOverlay, + out GamaSpeciesRenderOverrideEntry entry) + { + entry = null; + if (!context.IsValid || string.IsNullOrWhiteSpace(speciesName)) + { + return false; + } + + if (includeRuntimeOverlay && + runtimeOverlay.TryGetValue(BuildKey(context, speciesName), out entry) && + entry != null) + { + return true; + } + + return context.Asset.TryGetOverride( + context.ModelPath, + context.ExperimentName, + speciesName, + out entry, + true); + } + + public static GamaSpeciesRenderOverrideEntry GetOrCreateEditableEntry( + GamaSpeciesAppearanceContext context, + string speciesName, + bool runtimeOnly) + { + if (!context.IsValid || string.IsNullOrWhiteSpace(speciesName)) + { + return null; + } + + string normalizedSpecies = speciesName.Trim(); + if (!runtimeOnly) + { + return context.Asset.GetOrCreateEntry( + context.ModelPath, + context.ExperimentName, + normalizedSpecies); + } + + string key = BuildKey(context, normalizedSpecies); + if (runtimeOverlay.TryGetValue(key, out GamaSpeciesRenderOverrideEntry overlayEntry) && + overlayEntry != null) + { + return overlayEntry; + } + + context.Asset.TryGetOverride( + context.ModelPath, + context.ExperimentName, + normalizedSpecies, + out GamaSpeciesRenderOverrideEntry persisted, + true); + + overlayEntry = CloneEntry(persisted) ?? new GamaSpeciesRenderOverrideEntry(); + SetEntryIdentity(overlayEntry, context, normalizedSpecies); + NormalizeEntry(overlayEntry); + runtimeOverlay[key] = overlayEntry; + return overlayEntry; + } + + public static void NotifyEntryChanged( + GamaSpeciesAppearanceContext context, + string speciesName, + bool runtimeOnly) + { + if (!context.IsValid || string.IsNullOrWhiteSpace(speciesName)) + { + return; + } + + GamaSpeciesRenderOverrideEntry entry = GetOrCreateEditableEntry(context, speciesName, runtimeOnly); + if (entry == null) + { + return; + } + + SetEntryIdentity(entry, context, speciesName.Trim()); + NormalizeEntry(entry); + +#if UNITY_EDITOR + if (!runtimeOnly) + { + UnityEditor.EditorUtility.SetDirty(context.Asset); + } +#endif + + RaiseChanged(new GamaSpeciesAppearanceChange( + GamaSpeciesAppearanceChangeKind.EntryChanged, + context, + speciesName.Trim(), + runtimeOnly)); + } + + public static IReadOnlyList GetRuntimeOverlayEntries( + GamaSpeciesAppearanceContext context) + { + List result = new List(); + if (!context.IsValid) + { + return result; + } + + string prefix = BuildContextKey(context) + "|"; + foreach (KeyValuePair pair in runtimeOverlay) + { + if (pair.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && pair.Value != null) + { + result.Add(pair.Value); + } + } + + return result; + } + + public static void ClearRuntimeOverlay() + { + if (runtimeOverlay.Count == 0) + { + return; + } + + runtimeOverlay.Clear(); + RaiseChanged(new GamaSpeciesAppearanceChange( + GamaSpeciesAppearanceChangeKind.RuntimeOverlayCleared, + activeContext, + string.Empty, + true)); + } + + public static void ClearContext( + GamaSpeciesAppearanceContext context, + bool removePersistedEntries) + { + if (!context.IsValid) + { + return; + } + + RemoveRuntimeOverlayEntries(context); + + if (removePersistedEntries && context.Asset.entries != null) + { +#if UNITY_EDITOR + if (!Application.isPlaying) + { + UnityEditor.Undo.RecordObject(context.Asset, "Clear GAMA species appearance"); + } +#endif + string wantedModel = GamaSpeciesRenderOverrides.NormalizeModelPath(context.ModelPath); + string wantedExperiment = GamaSpeciesRenderOverrides.NormalizeKey(context.ExperimentName); + context.Asset.entries.RemoveAll(entry => + entry != null && + string.Equals( + GamaSpeciesRenderOverrides.NormalizeModelPath(entry.modelPath), + wantedModel, + StringComparison.Ordinal) && + string.Equals( + GamaSpeciesRenderOverrides.NormalizeKey(entry.experimentName), + wantedExperiment, + StringComparison.Ordinal)); +#if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(context.Asset); +#endif + } + + if (activeContext == context) + { + activeContext = default; + } + + RaiseChanged(new GamaSpeciesAppearanceChange( + GamaSpeciesAppearanceChangeKind.ContextCleared, + context, + string.Empty, + false)); + } + + public static void NormalizeEntry(GamaSpeciesRenderOverrideEntry entry) + { + if (entry == null) + { + return; + } + + // Migrate the legacy single visibility override before normalizing new + // split preview/runtime fields. Otherwise a saved hidden entry would be + // silently reset to visible the first time it is edited. + if (entry.overrideVisibility) + { + if (!entry.overridePreviewVisibility) + { + entry.overridePreviewVisibility = true; + entry.visibleInPreview = entry.visible; + } + if (!entry.overrideRuntimeVisibility) + { + entry.overrideRuntimeVisibility = true; + entry.visibleInRuntime = entry.visible; + } + } + + entry.scaleMultiplier = entry.overrideScaleMultiplier + ? Mathf.Max(0.0001f, entry.scaleMultiplier) + : 1f; + if (!entry.overridePositionOffset) + { + entry.positionOffset = Vector3.zero; + } + if (!entry.overrideRotationOffset) + { + entry.rotationOffsetEuler = Vector3.zero; + } + + if (!entry.overridePreviewVisibility) + { + entry.visibleInPreview = true; + } + if (!entry.overrideRuntimeVisibility) + { + entry.visibleInRuntime = true; + } + + // New writes use the split visibility fields only. + entry.overrideVisibility = false; + entry.visible = true; + } + + public static void CopyEntryValues( + GamaSpeciesRenderOverrideEntry destination, + GamaSpeciesRenderOverrideEntry source) + { + if (destination == null || source == null) + { + return; + } + + destination.modelPath = source.modelPath ?? string.Empty; + destination.experimentName = source.experimentName ?? string.Empty; + destination.speciesName = source.speciesName ?? string.Empty; + destination.speciesKey = source.speciesKey ?? string.Empty; + destination.prefabResourcePath = source.prefabResourcePath ?? string.Empty; + destination.prefabOverride = source.prefabOverride; + destination.materialOverride = source.materialOverride; + destination.overrideColor = source.overrideColor; + destination.color = source.color; + destination.overrideScaleMultiplier = source.overrideScaleMultiplier; + destination.overridePositionOffset = source.overridePositionOffset; + destination.overrideRotationOffset = source.overrideRotationOffset; + destination.positionOffset = source.positionOffset; + destination.rotationOffsetEuler = source.rotationOffsetEuler; + destination.scaleMultiplier = source.scaleMultiplier; + destination.overridePreviewVisibility = source.overridePreviewVisibility; + destination.visibleInPreview = source.visibleInPreview; + destination.overrideRuntimeVisibility = source.overrideRuntimeVisibility; + destination.visibleInRuntime = source.visibleInRuntime; + destination.renderMode = source.renderMode; + destination.notesDebug = source.notesDebug ?? string.Empty; + destination.overrideDynamicColor = source.overrideDynamicColor; + destination.dynamicColorMode = source.dynamicColorMode; + destination.dynamicColorAttribute = source.dynamicColorAttribute ?? string.Empty; + destination.continuousBaseColor = source.continuousBaseColor; + destination.continuousMinValue = source.continuousMinValue; + destination.continuousMaxValue = source.continuousMaxValue; + destination.continuousInvert = source.continuousInvert; + destination.continuousLightAmount = source.continuousLightAmount; + destination.continuousDarkAmount = source.continuousDarkAmount; + destination.fallbackToStaticColor = source.fallbackToStaticColor; + destination.overrideVisibility = source.overrideVisibility; + destination.visible = source.visible; + destination.discreteColorRules = new List(); + if (source.discreteColorRules != null) + { + for (int i = 0; i < source.discreteColorRules.Count; i++) + { + GamaDiscreteColorRule rule = source.discreteColorRules[i]; + if (rule != null) + { + destination.discreteColorRules.Add(new GamaDiscreteColorRule + { + value = rule.value, + color = rule.color + }); + } + } + } + } + + private static void SetEntryIdentity( + GamaSpeciesRenderOverrideEntry entry, + GamaSpeciesAppearanceContext context, + string speciesName) + { + entry.modelPath = context.ModelPath; + entry.experimentName = context.ExperimentName; + entry.speciesName = speciesName; + entry.speciesKey = speciesName; + } + + private static int RemoveRuntimeOverlayEntries(GamaSpeciesAppearanceContext context) + { + string prefix = BuildContextKey(context) + "|"; + List keys = new List(); + foreach (string key in runtimeOverlay.Keys) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + keys.Add(key); + } + } + + for (int i = 0; i < keys.Count; i++) + { + runtimeOverlay.Remove(keys[i]); + } + + return keys.Count; + } + + private static string BuildKey(GamaSpeciesAppearanceContext context, string speciesName) + { + return BuildContextKey(context) + "|" + GamaSpeciesRenderOverrides.NormalizeKey(speciesName); + } + + private static string BuildContextKey(GamaSpeciesAppearanceContext context) + { + int assetId = context.Asset != null ? context.Asset.GetInstanceID() : 0; + return assetId + "|" + + GamaSpeciesRenderOverrides.NormalizeModelPath(context.ModelPath) + "|" + + GamaSpeciesRenderOverrides.NormalizeKey(context.ExperimentName); + } + + private static GamaSpeciesRenderOverrideEntry CloneEntry(GamaSpeciesRenderOverrideEntry source) + { + if (source == null) + { + return null; + } + + GamaSpeciesRenderOverrideEntry clone = new GamaSpeciesRenderOverrideEntry(); + CopyEntryValues(clone, source); + return clone; + } + + private static void RaiseChanged(GamaSpeciesAppearanceChange change) + { + Changed?.Invoke(change); + } +} diff --git a/Runtime/Preview/GamaSpeciesAppearanceStateStore.cs.meta b/Runtime/Preview/GamaSpeciesAppearanceStateStore.cs.meta new file mode 100644 index 0000000..5346957 --- /dev/null +++ b/Runtime/Preview/GamaSpeciesAppearanceStateStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1e6a08ea84a34e6ba2ab32c82a6d53b9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Preview/GamaSpeciesRenderOverridesAsset.cs b/Runtime/Preview/GamaSpeciesRenderOverridesAsset.cs index cd8c6c4..3365b1c 100644 --- a/Runtime/Preview/GamaSpeciesRenderOverridesAsset.cs +++ b/Runtime/Preview/GamaSpeciesRenderOverridesAsset.cs @@ -1,10 +1,11 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using UnityEngine; /// -/// Overrides visuels persistants par espèce avec clé contextuelle model/experiment/species. +/// Persistent visual overrides per species, keyed by model/experiment/species context. /// public class GamaSpeciesRenderOverrides : ScriptableObject { @@ -37,7 +38,7 @@ public bool TryGetOverride( return false; } - string wantedModel = NormalizeKey(modelPath); + string wantedModel = NormalizeModelPath(modelPath); string wantedExperiment = NormalizeKey(experimentName); string wantedSpecies = NormalizeKey(speciesName); for (int i = 0; i < entries.Count; i++) @@ -48,7 +49,7 @@ public bool TryGetOverride( continue; } - if (string.Equals(NormalizeKey(candidate.modelPath), wantedModel, StringComparison.Ordinal) && + if (string.Equals(NormalizeModelPath(candidate.modelPath), wantedModel, StringComparison.Ordinal) && string.Equals(NormalizeKey(candidate.experimentName), wantedExperiment, StringComparison.Ordinal) && string.Equals(NormalizeKey(candidate.GetSpeciesName()), wantedSpecies, StringComparison.Ordinal)) { @@ -74,7 +75,7 @@ public bool TryGetOverride( return false; } - string wantedModel = NormalizeKey(modelPath); + string wantedModel = NormalizeModelPath(modelPath); string wantedExperiment = NormalizeKey(experimentName); string wantedSpecies = NormalizeKey(speciesName); @@ -122,7 +123,7 @@ public GamaSpeciesRenderOverrideEntry GetOrCreateEntry( string experimentName, string speciesName) { - string wantedModel = NormalizeKey(modelPath); + string wantedModel = NormalizeModelPath(modelPath); string wantedExperiment = NormalizeKey(experimentName); string wantedSpecies = NormalizeKey(speciesName); @@ -136,7 +137,7 @@ public GamaSpeciesRenderOverrideEntry GetOrCreateEntry( continue; } - if (string.Equals(NormalizeKey(candidate.modelPath), wantedModel, StringComparison.Ordinal) && + if (string.Equals(NormalizeModelPath(candidate.modelPath), wantedModel, StringComparison.Ordinal) && string.Equals(NormalizeKey(candidate.experimentName), wantedExperiment, StringComparison.Ordinal) && string.Equals(NormalizeKey(candidate.GetSpeciesName()), wantedSpecies, StringComparison.Ordinal)) { @@ -161,7 +162,7 @@ public void SetOrReplaceEntry(GamaSpeciesRenderOverrideEntry newEntry) return; } - string newModel = NormalizeKey(newEntry.modelPath); + string newModel = NormalizeModelPath(newEntry.modelPath); string newExperiment = NormalizeKey(newEntry.experimentName); string newSpecies = NormalizeKey(newEntry.GetSpeciesName()); @@ -173,7 +174,7 @@ public void SetOrReplaceEntry(GamaSpeciesRenderOverrideEntry newEntry) continue; } - if (string.Equals(NormalizeKey(existing.modelPath), newModel, StringComparison.Ordinal) && + if (string.Equals(NormalizeModelPath(existing.modelPath), newModel, StringComparison.Ordinal) && string.Equals(NormalizeKey(existing.experimentName), newExperiment, StringComparison.Ordinal) && string.Equals(NormalizeKey(existing.GetSpeciesName()), newSpecies, StringComparison.Ordinal)) { @@ -187,7 +188,74 @@ public void SetOrReplaceEntry(GamaSpeciesRenderOverrideEntry newEntry) public static string NormalizeKey(string value) { - return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + string normalized = value.Trim().Replace('\\', '/').ToLowerInvariant(); + if (normalized.IndexOf('/') < 0) + { + return normalized; + } + + bool isUnc = normalized.StartsWith("//", StringComparison.Ordinal); + bool isRooted = !isUnc && normalized.StartsWith("/", StringComparison.Ordinal); + string[] segments = normalized.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + List canonicalSegments = new List(segments.Length); + for (int i = 0; i < segments.Length; i++) + { + string segment = segments[i]; + if (segment == ".") + { + continue; + } + + if (segment == "..") + { + int lastIndex = canonicalSegments.Count - 1; + if (lastIndex >= 0 && + canonicalSegments[lastIndex] != ".." && + !canonicalSegments[lastIndex].EndsWith(":", StringComparison.Ordinal)) + { + canonicalSegments.RemoveAt(lastIndex); + } + else if (!isRooted && !isUnc) + { + canonicalSegments.Add(segment); + } + continue; + } + + canonicalSegments.Add(segment); + } + + string prefix = isUnc ? "//" : (isRooted ? "/" : string.Empty); + return prefix + string.Join("/", canonicalSegments); + } + + public static string NormalizeModelPath(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + string trimmed = value.Trim(); + if (trimmed.IndexOf("://", StringComparison.Ordinal) >= 0 || + string.Equals(trimmed, "GAMA_ACTIVE_SELECTION", StringComparison.OrdinalIgnoreCase)) + { + return NormalizeKey(trimmed); + } + + try + { + return NormalizeKey(Path.GetFullPath(trimmed)); + } + catch + { + return NormalizeKey(trimmed); + } } } @@ -283,7 +351,7 @@ public int GetMatchScore(string wantedModel, string wantedExperiment, string wan return -1; } - string currentModel = GamaSpeciesRenderOverrides.NormalizeKey(modelPath); + string currentModel = GamaSpeciesRenderOverrides.NormalizeModelPath(modelPath); string currentExperiment = GamaSpeciesRenderOverrides.NormalizeKey(experimentName); bool modelMatches = string.IsNullOrEmpty(currentModel) || string.Equals(currentModel, wantedModel, StringComparison.Ordinal); @@ -393,14 +461,11 @@ public bool HasStrongRuntimeOverride() overrideRotationOffset || overrideVisibility || overridePreviewVisibility || - overrideRuntimeVisibility || - positionOffset.sqrMagnitude > 0.0001f || - rotationOffsetEuler.sqrMagnitude > 0.0001f || - Math.Abs(scaleMultiplier - 1f) > 0.0001f; + overrideRuntimeVisibility; public bool UsesScaleOverride() { - return overrideScaleMultiplier || Math.Abs(scaleMultiplier - 1f) > 0.0001f; + return overrideScaleMultiplier; } public bool UsesDynamicColorOverride() @@ -588,12 +653,12 @@ private static Color MakeDarker(Color baseColor, float amount) public bool UsesPositionOffsetOverride() { - return overridePositionOffset || positionOffset.sqrMagnitude > 0.0001f; + return overridePositionOffset; } public bool UsesRotationOffsetOverride() { - return overrideRotationOffset || rotationOffsetEuler.sqrMagnitude > 0.0001f; + return overrideRotationOffset; } public float GetEffectiveScaleMultiplier() diff --git a/Runtime/Preview/GamaSpeciesWizard.cs b/Runtime/Preview/GamaSpeciesWizard.cs index ac931af..c8397b2 100644 --- a/Runtime/Preview/GamaSpeciesWizard.cs +++ b/Runtime/Preview/GamaSpeciesWizard.cs @@ -5,7 +5,7 @@ #endif /// -/// Parent d'espèce dans l'aperçu statique : applique les overrides à toutes les instances enfants. +/// Species parent in the static preview; applies overrides to every child instance. /// [ExecuteAlways] [DisallowMultipleComponent] @@ -21,10 +21,15 @@ public class GamaSpeciesWizard : MonoBehaviour public Material materialOverride; public bool colorOverrideEnabled; public Color colorOverride = Color.white; + public bool positionOffsetOverrideEnabled; public Vector3 positionOffset; + public bool rotationOffsetOverrideEnabled; public Vector3 rotationOffset; + public bool scaleOverrideEnabled; public float scaleMultiplier = 1f; + public bool previewVisibilityOverrideEnabled; public bool visibleInPreview = true; + public bool runtimeVisibilityOverrideEnabled; public bool visibleInRuntime = true; public GamaSpeciesRenderMode renderMode = GamaSpeciesRenderMode.Default; [TextArea(1, 4)] public string notesDebug = string.Empty; @@ -32,6 +37,9 @@ public class GamaSpeciesWizard : MonoBehaviour [Header("Storage")] public GamaSpeciesRenderOverrides overridesAsset; + [SerializeField, HideInInspector] private string prefabResourcePath = string.Empty; + [SerializeField, HideInInspector] private GameObject prefabPathSource; + [ContextMenu("Apply Overrides To Children")] public void ApplyOverridesToChildren() { @@ -40,7 +48,15 @@ public void ApplyOverridesToChildren() return; } - if (!overridesAsset.TryGetOverride(modelPath, experimentName, speciesName, out GamaSpeciesRenderOverrideEntry entry, true) || + GamaSpeciesAppearanceContext context = new GamaSpeciesAppearanceContext( + overridesAsset, + modelPath, + experimentName); + if (!GamaSpeciesAppearanceStateStore.TryGetEntry( + context, + speciesName, + Application.isPlaying, + out GamaSpeciesRenderOverrideEntry entry) || entry == null) { return; @@ -57,8 +73,6 @@ public int ApplyEntryToChildren(GamaSpeciesRenderOverrideEntry entry) return 0; } - NormalizeSpeciesContainerScale(); - int rendererCount = 0; for (int i = 0; i < transform.childCount; i++) { @@ -125,6 +139,11 @@ public void Dispose() private void OnValidate() { scaleMultiplier = Mathf.Max(0f, scaleMultiplier); + if (prefabPathSource != prefabOverride) + { + prefabPathSource = prefabOverride; + prefabResourcePath = ResolveResourcesPath(prefabOverride); + } if (overridesAsset == null && GetDefaultOverridesAsset != null) { overridesAsset = GetDefaultOverridesAsset.Invoke(); @@ -141,14 +160,14 @@ public void SaveParentTransformAsSpeciesOverride() { if (overridesAsset == null || string.IsNullOrWhiteSpace(speciesName)) { - Debug.LogWarning("[GAMA] Aucun asset d'overrides ou speciesName vide pour " + name + "."); + GamaLog.Warning("[GAMA] No overrides asset is assigned, or speciesName is empty, for " + name + "."); return; } SaveCurrentSettingsToAsset(); EditorUtility.SetDirty(overridesAsset); AssetDatabase.SaveAssets(); - Debug.Log("[GAMA][WIZARD] species=" + speciesName + " scale=" + scaleMultiplier + " color=" + colorOverride + " saved override"); + GamaLog.Dev("[GAMA][WIZARD] species=" + speciesName + " scale=" + scaleMultiplier + " color=" + colorOverride + " saved override"); } #endif @@ -159,7 +178,7 @@ public void ApplyCurrentSettingsToChildren() #if UNITY_EDITOR if (!string.IsNullOrWhiteSpace(speciesName)) { - Debug.Log("[GAMA][WIZARD] Applied editor color override species=" + speciesName + " color=" + colorOverride + " count=" + rendererCount); + GamaLog.Dev("[GAMA][WIZARD] Applied editor color override species=" + speciesName + " color=" + colorOverride + " count=" + rendererCount); } #endif } @@ -175,13 +194,20 @@ public void PopulateFromEntry(GamaSpeciesRenderOverrideEntry entry) modelPath = entry.modelPath; experimentName = entry.experimentName; prefabOverride = entry.prefabOverride; + prefabPathSource = entry.prefabOverride; + prefabResourcePath = entry.prefabResourcePath ?? string.Empty; materialOverride = entry.materialOverride; colorOverrideEnabled = entry.overrideColor; colorOverride = entry.color; + positionOffsetOverrideEnabled = entry.overridePositionOffset; positionOffset = entry.GetEffectivePositionOffset(); + rotationOffsetOverrideEnabled = entry.overrideRotationOffset; rotationOffset = entry.GetEffectiveRotationOffsetEuler(); + scaleOverrideEnabled = entry.overrideScaleMultiplier; scaleMultiplier = entry.GetEffectiveScaleMultiplier(); + previewVisibilityOverrideEnabled = entry.UsesPreviewVisibilityOverride(); visibleInPreview = entry.UsesPreviewVisibilityOverride() ? entry.GetEffectivePreviewVisible() : true; + runtimeVisibilityOverrideEnabled = entry.UsesRuntimeVisibilityOverride(); visibleInRuntime = entry.UsesRuntimeVisibilityOverride() ? entry.GetEffectiveRuntimeVisible() : true; renderMode = entry.renderMode; notesDebug = entry.notesDebug; @@ -189,7 +215,6 @@ public void PopulateFromEntry(GamaSpeciesRenderOverrideEntry entry) public GamaSpeciesRenderOverrideEntry BuildEntryFromCurrentSettings() { - bool hasVisibilityOverride = !visibleInPreview || !visibleInRuntime; return new GamaSpeciesRenderOverrideEntry { modelPath = modelPath, @@ -197,21 +222,22 @@ public GamaSpeciesRenderOverrideEntry BuildEntryFromCurrentSettings() speciesName = speciesName, speciesKey = speciesName, prefabOverride = prefabOverride, + prefabResourcePath = prefabResourcePath ?? string.Empty, materialOverride = materialOverride, overrideColor = colorOverrideEnabled, color = colorOverride, - overrideScaleMultiplier = Math.Abs(scaleMultiplier - 1f) > 0.0001f, - overridePositionOffset = positionOffset.sqrMagnitude > 0.0001f, - overrideRotationOffset = rotationOffset.sqrMagnitude > 0.0001f, + overrideScaleMultiplier = scaleOverrideEnabled, + overridePositionOffset = positionOffsetOverrideEnabled, + overrideRotationOffset = rotationOffsetOverrideEnabled, positionOffset = positionOffset, rotationOffsetEuler = rotationOffset, scaleMultiplier = Mathf.Max(0f, scaleMultiplier), - overridePreviewVisibility = hasVisibilityOverride, + overridePreviewVisibility = previewVisibilityOverrideEnabled, visibleInPreview = visibleInPreview, - overrideRuntimeVisibility = hasVisibilityOverride, + overrideRuntimeVisibility = runtimeVisibilityOverrideEnabled, visibleInRuntime = visibleInRuntime, - overrideVisibility = hasVisibilityOverride, - visible = visibleInRuntime, + overrideVisibility = false, + visible = true, renderMode = renderMode, notesDebug = notesDebug }; @@ -231,13 +257,90 @@ public void SaveCurrentSettingsToAsset() return; } - GamaSpeciesRenderOverrideEntry entry = BuildEntryFromCurrentSettings(); - overridesAsset.SetOrReplaceEntry(entry); + GamaSpeciesAppearanceContext context = new GamaSpeciesAppearanceContext( + overridesAsset, + modelPath, + experimentName); + GamaSpeciesAppearanceStateStore.SetActiveContext(context); + bool runtimeOnly = Application.isPlaying; #if UNITY_EDITOR - EditorUtility.SetDirty(overridesAsset); + if (!runtimeOnly) + { + Undo.RecordObject(overridesAsset, "Edit GAMA species appearance"); + } +#endif + GamaSpeciesRenderOverrideEntry entry = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry( + context, + speciesName, + runtimeOnly); + CopyCurrentSettingsToEntry(entry); + GamaSpeciesAppearanceStateStore.NotifyEntryChanged(context, speciesName, runtimeOnly); +#if UNITY_EDITOR + if (!runtimeOnly) + { + EditorUtility.SetDirty(overridesAsset); + } #endif - Debug.Log("[GAMA][WIZARD] species=" + speciesName + " scale=" + scaleMultiplier + " color=" + colorOverride + " saved override"); + GamaLog.Dev("[GAMA][WIZARD] species=" + speciesName + " scale=" + scaleMultiplier + " color=" + colorOverride + " saved override"); + } + + private void CopyCurrentSettingsToEntry(GamaSpeciesRenderOverrideEntry entry) + { + if (entry == null) + { + return; + } + + GamaSpeciesRenderOverrideEntry wizardValues = BuildEntryFromCurrentSettings(); + entry.modelPath = wizardValues.modelPath; + entry.experimentName = wizardValues.experimentName; + entry.speciesName = wizardValues.speciesName; + entry.speciesKey = wizardValues.speciesKey; + entry.prefabOverride = wizardValues.prefabOverride; + entry.prefabResourcePath = wizardValues.prefabResourcePath; + entry.materialOverride = wizardValues.materialOverride; + entry.overrideColor = wizardValues.overrideColor; + entry.color = wizardValues.color; + entry.overrideScaleMultiplier = wizardValues.overrideScaleMultiplier; + entry.scaleMultiplier = wizardValues.scaleMultiplier; + entry.overridePositionOffset = wizardValues.overridePositionOffset; + entry.positionOffset = wizardValues.positionOffset; + entry.overrideRotationOffset = wizardValues.overrideRotationOffset; + entry.rotationOffsetEuler = wizardValues.rotationOffsetEuler; + entry.overridePreviewVisibility = wizardValues.overridePreviewVisibility; + entry.visibleInPreview = wizardValues.visibleInPreview; + entry.overrideRuntimeVisibility = wizardValues.overrideRuntimeVisibility; + entry.visibleInRuntime = wizardValues.visibleInRuntime; + entry.overrideVisibility = false; + entry.visible = true; + entry.renderMode = wizardValues.renderMode; + entry.notesDebug = wizardValues.notesDebug; + } + +#if UNITY_EDITOR + private static string ResolveResourcesPath(GameObject prefab) + { + if (prefab == null) + { + return string.Empty; + } + + string assetPath = AssetDatabase.GetAssetPath(prefab).Replace('\\', '/'); + int resourcesIndex = assetPath.IndexOf("/Resources/", StringComparison.OrdinalIgnoreCase); + if (resourcesIndex < 0) + { + return string.Empty; + } + + string resourcePath = assetPath.Substring(resourcesIndex + "/Resources/".Length); + if (resourcePath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + resourcePath = resourcePath.Substring(0, resourcePath.Length - ".prefab".Length); + } + return resourcePath.Trim('/'); } +#endif public static int ApplyEntryToInstance(Transform instance, GamaSpeciesRenderOverrideEntry entry) { @@ -249,7 +352,6 @@ public static int ApplyEntryToInstance(Transform instance, GamaSpeciesRenderOver GamaPreviewObject previewObject = instance.GetComponent(); if (previewObject != null) { - previewObject.NormalizePivotToVisualAnchorForStableScale(); previewObject.ApplySpeciesOverride(entry); Renderer[] previewRenderers = instance.GetComponentsInChildren(true); return previewRenderers != null ? previewRenderers.Length : 0; @@ -262,16 +364,17 @@ public static int ApplyEntryToInstance(Transform instance, GamaSpeciesRenderOver baseline.localPosition = instance.localPosition; baseline.localRotation = instance.localRotation; baseline.localScale = instance.localScale; + baseline.activeSelf = instance.gameObject.activeSelf; } bool previewVisible = entry.GetEffectivePreviewVisible(); - if (!previewVisible && entry.UsesPreviewVisibilityOverride()) + bool overridesVisibility = entry.UsesPreviewVisibilityOverride(); + instance.gameObject.SetActive(overridesVisibility ? previewVisible : baseline.activeSelf); + if (!instance.gameObject.activeSelf) { - instance.gameObject.SetActive(false); return 0; } - instance.gameObject.SetActive(true); instance.localPosition = baseline.localPosition + entry.GetEffectivePositionOffset(); instance.localRotation = baseline.localRotation * Quaternion.Euler(entry.GetEffectiveRotationOffsetEuler()); instance.localScale = baseline.localScale * entry.GetEffectiveScaleMultiplier(); @@ -290,8 +393,9 @@ public static int ApplyEntryToInstance(Transform instance, GamaSpeciesRenderOver if (rendererBaseline == null) { rendererBaseline = renderer.gameObject.AddComponent(); - rendererBaseline.Capture(renderer.sharedMaterials); } + rendererBaseline.Capture(renderer); + rendererBaseline.Restore(renderer); Material[] mats = rendererBaseline.CloneSharedMaterials(); if (entry.materialOverride != null) @@ -334,8 +438,6 @@ private static void ApplyRendererColorOverride(Renderer renderer, bool overrideC if (!overrideColor) { - block.Clear(); - renderer.SetPropertyBlock(block); return; } @@ -352,6 +454,23 @@ private static void ApplyRendererColorOverride(Renderer renderer, bool overrideC } renderer.SetPropertyBlock(block); + + Material[] materials = renderer.sharedMaterials; + for (int i = 0; i < materials.Length; i++) + { + Material material = materials[i]; + MaterialPropertyBlock indexedBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(indexedBlock, i); + if (material != null && material.HasProperty(BaseColorId)) + { + indexedBlock.SetColor(BaseColorId, color); + } + if (material == null || material.HasProperty(ColorId) || !material.HasProperty(BaseColorId)) + { + indexedBlock.SetColor(ColorId, color); + } + renderer.SetPropertyBlock(indexedBlock, i); + } } private static bool MaterialArraySupportsProperty(Material[] materials, int propertyId) @@ -380,31 +499,147 @@ public sealed class GamaPreviewBaseline : MonoBehaviour public Vector3 localPosition; public Quaternion localRotation = Quaternion.identity; public Vector3 localScale = Vector3.one; + public bool activeSelf = true; + public GameObject sourcePrefab; } [DisallowMultipleComponent] public sealed class GamaPreviewRendererBaseline : MonoBehaviour { + private static readonly int ColorId = Shader.PropertyToID("_Color"); + private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); + + [SerializeField] private bool captured; [SerializeField] private Material[] sharedMaterials; + [SerializeField] private bool rendererEnabled; + [SerializeField] private UnityEngine.Rendering.ShadowCastingMode shadowCastingMode; + [SerializeField] private bool receiveShadows; + [SerializeField] private Color rendererColor = Color.white; + [SerializeField] private Color rendererBaseColor = Color.white; + [SerializeField] private Color[] materialColors = Array.Empty(); + [SerializeField] private Color[] materialBaseColors = Array.Empty(); + + [NonSerialized] private bool hasInMemoryPropertyBlocks; + [NonSerialized] private bool hadRendererPropertyBlock; + [NonSerialized] private MaterialPropertyBlock rendererPropertyBlock; + [NonSerialized] private bool[] hadMaterialPropertyBlocks; + [NonSerialized] private MaterialPropertyBlock[] materialPropertyBlocks; + + public void Capture(Renderer renderer) + { + if (captured || renderer == null) + { + return; + } - public void Capture(Material[] currentSharedMaterials) + Material[] currentSharedMaterials = renderer.sharedMaterials; + sharedMaterials = currentSharedMaterials != null + ? (Material[])currentSharedMaterials.Clone() + : Array.Empty(); + rendererEnabled = renderer.enabled; + shadowCastingMode = renderer.shadowCastingMode; + receiveShadows = renderer.receiveShadows; + + rendererPropertyBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(rendererPropertyBlock); + hadRendererPropertyBlock = !rendererPropertyBlock.isEmpty; + rendererColor = ResolveBaselineColor(rendererPropertyBlock, sharedMaterials, ColorId); + rendererBaseColor = ResolveBaselineColor(rendererPropertyBlock, sharedMaterials, BaseColorId); + + materialColors = new Color[sharedMaterials.Length]; + materialBaseColors = new Color[sharedMaterials.Length]; + hadMaterialPropertyBlocks = new bool[sharedMaterials.Length]; + materialPropertyBlocks = new MaterialPropertyBlock[sharedMaterials.Length]; + for (int i = 0; i < sharedMaterials.Length; i++) + { + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, i); + hadMaterialPropertyBlocks[i] = !block.isEmpty; + materialPropertyBlocks[i] = block; + Material[] oneMaterial = { sharedMaterials[i] }; + materialColors[i] = ResolveBaselineColor(block, oneMaterial, ColorId); + materialBaseColors[i] = ResolveBaselineColor(block, oneMaterial, BaseColorId); + } + + hasInMemoryPropertyBlocks = true; + captured = true; + } + + public void Restore(Renderer renderer) { - if (currentSharedMaterials == null) + if (!captured || renderer == null) { - sharedMaterials = Array.Empty(); return; } - sharedMaterials = (Material[])currentSharedMaterials.Clone(); + renderer.sharedMaterials = sharedMaterials != null + ? (Material[])sharedMaterials.Clone() + : Array.Empty(); + renderer.enabled = rendererEnabled; + renderer.shadowCastingMode = shadowCastingMode; + renderer.receiveShadows = receiveShadows; + + if (hasInMemoryPropertyBlocks) + { + renderer.SetPropertyBlock(hadRendererPropertyBlock ? rendererPropertyBlock : null); + for (int i = 0; i < renderer.sharedMaterials.Length; i++) + { + bool hadBlock = hadMaterialPropertyBlocks != null && + i < hadMaterialPropertyBlocks.Length && + hadMaterialPropertyBlocks[i]; + MaterialPropertyBlock block = materialPropertyBlocks != null && + i < materialPropertyBlocks.Length + ? materialPropertyBlocks[i] + : null; + renderer.SetPropertyBlock(hadBlock ? block : null, i); + } + return; + } + + // MaterialPropertyBlock is native/non-serializable. After a domain reload, + // retain all current unrelated values and restore only the color keys that + // this feature can modify, including per-material-index blocks. + MaterialPropertyBlock rendererBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(rendererBlock); + rendererBlock.SetColor(ColorId, rendererColor); + rendererBlock.SetColor(BaseColorId, rendererBaseColor); + renderer.SetPropertyBlock(rendererBlock); + for (int i = 0; i < renderer.sharedMaterials.Length; i++) + { + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, i); + block.SetColor(ColorId, i < materialColors.Length ? materialColors[i] : Color.white); + block.SetColor(BaseColorId, i < materialBaseColors.Length ? materialBaseColors[i] : Color.white); + renderer.SetPropertyBlock(block, i); + } } public Material[] CloneSharedMaterials() { - if (sharedMaterials == null) + return sharedMaterials != null ? (Material[])sharedMaterials.Clone() : Array.Empty(); + } + + private static Color ResolveBaselineColor( + MaterialPropertyBlock block, + Material[] materials, + int propertyId) + { + if (block != null && block.HasColor(propertyId)) { - return Array.Empty(); + return block.GetColor(propertyId); } - return (Material[])sharedMaterials.Clone(); + if (materials != null) + { + for (int i = 0; i < materials.Length; i++) + { + Material material = materials[i]; + if (material != null && material.HasProperty(propertyId)) + { + return material.GetColor(propertyId); + } + } + } + return Color.white; } } diff --git a/Runtime/Resources/Localization/LocalizationData.csv b/Runtime/Resources/Localization/LocalizationData.csv index fa61d0a..6705dd6 100644 --- a/Runtime/Resources/Localization/LocalizationData.csv +++ b/Runtime/Resources/Localization/LocalizationData.csv @@ -1,6 +1,6 @@ -Key,English,French -welcome,Welcome to the SIMPLE VR experience,Bienvenue sur l'expérience VR SIMPLE -loading,Loading data...,Chargement des données... -connected,Connected to server,Connecté au serveur -disconnected,Disconnected,Déconnecté -error,An error occurred,Une erreur est survenue +Key,English +welcome,Welcome to the SIMPLE VR experience +loading,Loading data... +connected,Connected to server +disconnected,Disconnected +error,An error occurred diff --git a/Runtime/Simulation/GamaInitializer.cs b/Runtime/Simulation/GamaInitializer.cs index 24497b9..ea024c3 100644 --- a/Runtime/Simulation/GamaInitializer.cs +++ b/Runtime/Simulation/GamaInitializer.cs @@ -37,7 +37,7 @@ public static void SetupPlayer(global::SimulationManager simManager, bool create if (player != null) { - Debug.Log("[GAMA] Auto-assigned player object: " + player.name); + GamaLog.Dev("[GAMA] Auto-assigned player object: " + player.name); field.SetValue(simManager, player); } } @@ -63,7 +63,7 @@ public static void SetupGround(global::SimulationManager simManager, bool create if (ground != null) { - Debug.Log("[GAMA] Auto-assigned ground object: " + ground.name); + GamaLog.Dev("[GAMA] Auto-assigned ground object: " + ground.name); field.SetValue(simManager, ground); } } diff --git a/Runtime/Simulation/GamaSceneUtility.cs b/Runtime/Simulation/GamaSceneUtility.cs index c28ce4c..0310372 100644 --- a/Runtime/Simulation/GamaSceneUtility.cs +++ b/Runtime/Simulation/GamaSceneUtility.cs @@ -58,7 +58,7 @@ public static bool TrySetTag(GameObject gameObject, string tag) { if (MissingTagsLogged.Add(normalizedTag)) { - Debug.LogWarning("[GAMA] Tag '" + normalizedTag + "' is not defined and could not be created automatically."); + GamaLog.Warning("[GAMA] Tag '" + normalizedTag + "' is not defined and could not be created automatically."); } return false; @@ -101,7 +101,7 @@ public static Component GetOrAddComponent(GameObject gameObject, Type componentT } catch (Exception ex) { - Debug.LogWarning("[GAMA] Could not add component " + componentType.FullName + " to " + gameObject.name + ": " + ex.Message); + GamaLog.Warning("[GAMA] Could not add component " + componentType.FullName + " to " + gameObject.name + ": " + ex.Message); return null; } } @@ -200,7 +200,7 @@ public static bool TryCreateTag(string tag) { if (MissingTagsLogged.Add(tag)) { - Debug.LogWarning("[GAMA] Could not create Unity tag '" + tag + "': " + exception.Message); + GamaLog.Warning("[GAMA] Could not create Unity tag '" + tag + "': " + exception.Message); } return false; diff --git a/Runtime/Simulation/GamaVisualUtility.cs b/Runtime/Simulation/GamaVisualUtility.cs index 3acec10..d9961a2 100644 --- a/Runtime/Simulation/GamaVisualUtility.cs +++ b/Runtime/Simulation/GamaVisualUtility.cs @@ -140,8 +140,8 @@ public static Color32 GetColor(PropertiesGAMA prop) } /// - /// Vrai si les properties GAMA contiennent une couleur utilisable (liste rgb / champs red,green,blue, etc.). - /// Les prefabs sans RGB dans le message retournent faux. + /// True when the GAMA properties contain a usable color (RGB list; red, green, and blue fields; etc.). + /// Prefabs whose message does not contain RGB data return false. /// public static bool PropertiesMessageIncludesExplicitTint(PropertiesGAMA prop) { @@ -835,7 +835,7 @@ private static void WarnMissingPrefabOnce(string prefabPath, string objectName, if (MissingPrefabWarnings.Add(prefabPath)) { - Debug.LogWarning("[GAMA] Prefab '" + prefabPath + "' not found for '" + objectName + + GamaLog.Warning("[GAMA] Prefab '" + prefabPath + "' not found for '" + objectName + "'. Generated procedural fallback '" + fallbackName + "' with GAMA visual properties."); } } diff --git a/Runtime/Simulation/SimulationManager.AgentSettings.cs b/Runtime/Simulation/SimulationManager.AgentSettings.cs index b7018d8..dd8a088 100644 --- a/Runtime/Simulation/SimulationManager.AgentSettings.cs +++ b/Runtime/Simulation/SimulationManager.AgentSettings.cs @@ -193,7 +193,7 @@ public bool SetSpeciesRenderOverridesContext( speciesRenderOverrides = asset; speciesRenderOverridesModelPath = modelPath; speciesRenderOverridesExperimentName = experimentName; - Debug.Log("[GAMA][RUNTIME][CONTEXT] model=" + speciesRenderOverridesModelPath + + GamaLog.Dev("[GAMA][RUNTIME][CONTEXT] model=" + speciesRenderOverridesModelPath + " experiment=" + speciesRenderOverridesExperimentName); return true; } @@ -323,7 +323,7 @@ private static void LogMissingDynamicColorAttributesOnce( return; } - Debug.LogWarning("[GAMA][RUNTIME][DYNAMIC_COLOR] attributes missing propertyID=" + propertyId + + GamaLog.DevWarning("[GAMA][RUNTIME][DYNAMIC_COLOR] attributes missing propertyID=" + propertyId + " attribute=" + attribute + " fallback=static_color"); } @@ -364,7 +364,7 @@ private void TrackAgentDefaults( if (logWhenAgentEntriesCapReached && !maxEntriesWarningLogged) { maxEntriesWarningLogged = true; - Debug.LogWarning( + GamaLog.Warning( "[GAMA] SimulationManager agent settings reached maxAgentEntries=" + maxAgentEntries + ". New runtime agents will not be tracked automatically."); @@ -581,7 +581,7 @@ private bool EnsureRegexCompiled() catch (ArgumentException) { regexInvalid = true; - Debug.LogWarning("[GAMA] Invalid agentNameRegex on rule '" + label + "': " + pattern); + GamaLog.Warning("[GAMA] Invalid agentNameRegex on rule '" + label + "': " + pattern); return false; } } diff --git a/Runtime/Simulation/SimulationManager.PrefabSettings.cs b/Runtime/Simulation/SimulationManager.PrefabSettings.cs index caa64e2..0ffccf4 100644 --- a/Runtime/Simulation/SimulationManager.PrefabSettings.cs +++ b/Runtime/Simulation/SimulationManager.PrefabSettings.cs @@ -409,7 +409,7 @@ private void LogMissingPrefab(PropertiesGAMA property, List candidateKey ? string.Join(", ", candidateKeys.ToArray()) : "(none)"; - Debug.LogWarning( + GamaLog.Warning( "[GAMA] Could not resolve prefab for property '" + (property != null ? property.id : "(null)") + "' (source='" + @@ -578,7 +578,7 @@ public bool TryResolveManualPrefab( string warningKey = propertyId + "::" + previewOverride.prefabResourcePath; if (MissingPreviewOverridePrefabWarnings.Add(warningKey)) { - Debug.LogWarning("[GAMA][RUNTIME][PREFAB] species=" + propertyId + + GamaLog.Warning("[GAMA][RUNTIME][PREFAB] species=" + propertyId + " cannot load prefabResourcePath=" + previewOverride.prefabResourcePath); } } diff --git a/Runtime/Simulation/SimulationManager.cs b/Runtime/Simulation/SimulationManager.cs index 2ad7045..7675440 100644 --- a/Runtime/Simulation/SimulationManager.cs +++ b/Runtime/Simulation/SimulationManager.cs @@ -21,6 +21,8 @@ private sealed class RuntimeAgentRecord public string AgentId; public GameObject Root; public GameObject VisualRoot; + public bool IsAdoptedPreview; + public string PreviewReuseKey; public bool IsDynamic; public int LastSeenTick; public bool CurrentlyVisible = true; @@ -181,12 +183,21 @@ private sealed class RuntimeImportProfile protected Dictionary resolvedPrefabSignatures; private Transform runtimeAgentsRoot; + private bool runtimeAgentsRootOwned; private Dictionary runtimeSpeciesParents; + private readonly HashSet ownedRuntimeSpeciesParents = new HashSet(); protected Dictionary> geometryMap; private readonly HashSet suppressedFollowedGeometryPropertyWarnings = new HashSet(StringComparer.OrdinalIgnoreCase); private readonly Dictionary runtimeAgentRecords = - new Dictionary(StringComparer.OrdinalIgnoreCase); + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary adoptedPreviewKeysByRuntimeKey = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary cachedStableAgentKeyCounts = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary cachedFallbackRuntimeKeyCounts = + new Dictionary(StringComparer.Ordinal); + private WorldJSONInfo identityCountSourceWorld; private readonly Dictionary runtimeSyncCountersBySpecies = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> runtimeAttributeNamesBySpecies = @@ -218,7 +229,7 @@ private sealed class RuntimeImportProfile private int peopleAttributeDebugLogCount; private bool loggedMissingMainCameraForStreaming; private readonly Dictionary missingAgentTickCounts = new Dictionary(); - private readonly Dictionary lastImportSignatureByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary lastImportSignatureByName = new Dictionary(StringComparer.Ordinal); private readonly RuntimeImportCounters detachedRuntimeImportCounters = new RuntimeImportCounters(); private RuntimeImportProfile currentImportProfile; private int runtimeLiveTickSerial; @@ -228,6 +239,10 @@ private sealed class RuntimeImportProfile private float nextOutgoingPlayerWarningLogTime; private const float OutgoingPlayerPositionLogIntervalSeconds = 1f; private const float OutgoingPlayerWarningLogIntervalSeconds = 1f; + private GamaPreviewReuseRegistry previewReuseRegistry; + private bool previewReuseInitializationAttempted; + private bool previewReuseRestoreInProgress; + private bool previewReuseConnectionWasAuthenticated; protected List SelectedObjects; @@ -310,7 +325,7 @@ void Awake() hasSimulator = UnityEngine.Object.FindFirstObjectByType() != null; connectionID["id"] = ConnectionManager.Instance != null ? ConnectionManager.Instance.GetConnectionId() : StaticInformation.getId(); - Debug.Log("[GAMA] SimulationManager initialized"); + GamaLog.Dev("[GAMA] SimulationManager initialized"); Instance = this; TrySubscribeConnectionManager(); SelectedObjects = new List(); @@ -328,7 +343,7 @@ void Awake() if (player == null) { - Debug.LogError("[GAMA] SimulationManager could not find or create a player object."); + GamaLog.Error("[GAMA] SimulationManager could not find or create a player object."); enabled = false; return; } @@ -346,26 +361,168 @@ void Awake() void OnEnable() { + if (previewReuseRegistry == null) + { + previewReuseInitializationAttempted = false; + } TrySubscribeConnectionManager(); } void OnDisable() { + PrepareForEditorPlayExit(); UnsubscribeConnectionEvents(); } void OnDestroy() { + PrepareForEditorPlayExit(); UnsubscribeConnectionEvents(); DrainPrefabPools(); } - - void Start() + + /// + /// Restores every static preview object claimed by this manager. Editor play + /// guards call this before leaving Play Mode; OnDisable/OnDestroy are fallback + /// paths for normal teardown and scene changes. + /// + public void PrepareForEditorPlayExit() + { + if (previewReuseRestoreInProgress) + { + return; + } + + previewReuseRestoreInProgress = true; + try + { + HashSet adoptedObjects = new HashSet(); + HashSet runtimeOnlyObjects = new HashSet(); + + foreach (KeyValuePair pair in runtimeAgentRecords) + { + RuntimeAgentRecord record = pair.Value; + if (record == null || record.Root == null) + { + continue; + } + + if (record.IsAdoptedPreview || adoptedPreviewKeysByRuntimeKey.ContainsKey(pair.Key)) + { + adoptedObjects.Add(record.Root); + } + else + { + runtimeOnlyObjects.Add(record.Root); + } + } + + if (geometryMap != null) + { + foreach (KeyValuePair> pair in geometryMap) + { + GameObject obj; + if (!TryReadRuntimeObject(pair.Value, out obj) || obj == null) + { + continue; + } + + if (adoptedPreviewKeysByRuntimeKey.ContainsKey(pair.Key) || adoptedObjects.Contains(obj)) + { + adoptedObjects.Add(obj); + } + else + { + runtimeOnlyObjects.Add(obj); + } + } + } + + foreach (GameObject adopted in adoptedObjects) + { + RemoveManagedRuntimeListeners(adopted); + if (toFollow != null) + { + toFollow.Remove(adopted); + } + } + + // The restore must happen before any runtime hierarchy is destroyed: + // claimed objects are currently children of that hierarchy. + if (previewReuseRegistry != null) + { + previewReuseRegistry.RestoreAll(); + } + + foreach (GameObject runtimeOnly in runtimeOnlyObjects) + { + if (runtimeOnly == null || adoptedObjects.Contains(runtimeOnly)) + { + continue; + } + + // Destroy only objects tracked by this manager. Even an owned + // hierarchy can be shared by a second manager that found it by + // name, so deleting the whole root before all managers restore + // their claims would be unsafe. + DestroyManagedRuntimeObject(runtimeOnly, true); + } + + DrainPrefabPools(true); + + if (geometryMap != null) geometryMap.Clear(); + runtimeAgentRecords.Clear(); + adoptedPreviewKeysByRuntimeKey.Clear(); + if (toFollow != null) toFollow.Clear(); + if (SelectedObjects != null) SelectedObjects.Clear(); + previousPrefabPositions.Clear(); + previousPrefabPropertyIds.Clear(); + prefabHeadingSourcePositions.Clear(); + prefabHeadingSourcePropertyIds.Clear(); + consumedPrefabHeadingSources.Clear(); + missingAgentTickCounts.Clear(); + lastImportSignatureByName.Clear(); + runtimeSyncCountersBySpecies.Clear(); + runtimeAttributeNamesBySpecies.Clear(); + invalidGeometryFallbackCounts.Clear(); + cachedStableAgentKeyCounts.Clear(); + cachedFallbackRuntimeKeyCounts.Clear(); + identityCountSourceWorld = null; + toRemove.Clear(); + pendingWorldUpdateRemovalPass = false; + pendingWorldAgentIndex = 0; + pendingWorldPrefabIndex = 0; + pendingWorldGeomIndex = 0; + infoWorld = null; + currentImportProfile = null; + previewReuseRegistry = null; + previewReuseInitializationAttempted = false; + RestoreStaticPreviewHiddenByRuntimeData(); + staticPreviewHiddenAfterRuntimeData = false; + } + finally + { + previewReuseRestoreInProgress = false; + } + } + + void Start() { visualStateCache = new Dictionary(StringComparer.Ordinal); resolvedPrefabSignatures = new Dictionary(StringComparer.Ordinal); runtimeSpeciesParents = new Dictionary(StringComparer.OrdinalIgnoreCase); + runtimeAgentsRoot = null; + runtimeAgentsRootOwned = false; + ownedRuntimeSpeciesParents.Clear(); runtimeAgentRecords.Clear(); + adoptedPreviewKeysByRuntimeKey.Clear(); + cachedStableAgentKeyCounts.Clear(); + cachedFallbackRuntimeKeyCounts.Clear(); + identityCountSourceWorld = null; + previewReuseRegistry = null; + previewReuseInitializationAttempted = false; + previewReuseRestoreInProgress = false; + previewReuseConnectionWasAuthenticated = false; runtimeSyncCountersBySpecies.Clear(); runtimeAttributeNamesBySpecies.Clear(); invalidGeometryFallbackCounts.Clear(); @@ -417,7 +574,7 @@ void FixedUpdate() InitGroundParameters(); handleGroundParametersRequested = false; - // Debug.Log("handleGroundParametersRequested: " + handleGroundParametersRequested); + // GamaLog.Dev("handleGroundParametersRequested: " + handleGroundParametersRequested); } @@ -479,7 +636,7 @@ void FixedUpdate() if (IsGameState(GameState.GAME)) { - // Debug.Log("readyToSendPosition: " + readyToSendPosition + " readyToSendPositionInit:" + readyToSendPositionInit + " TimerSendPosition: "+ TimerSendPosition); + // GamaLog.Dev("readyToSendPosition: " + readyToSendPosition + " readyToSendPositionInit:" + readyToSendPositionInit + " TimerSendPosition: "+ TimerSendPosition); if ((readyToSendPosition && TimerSendPosition <= 0.0f)|| readyToSendPositionInit) UpdatePlayerPosition(); UpdateGameToFollowPosition(); @@ -515,7 +672,7 @@ private void Update() } /* if (TryReconnectButton != null && TryReconnectButton.action.triggered) { - Debug.Log("TryReconnectButton activated"); + GamaLog.Dev("TryReconnectButton activated"); TryReconnect(); }*/ @@ -790,6 +947,7 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) if (infoWorld == null || infoWorld.names == null || infoWorld.propertyID == null) { CompleteImportProfileIfNeeded(true); + ClearRuntimeAgentIdentityCountCache(); infoWorld = null; return true; } @@ -802,9 +960,9 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) LogPlayerSetPosition("gama_world_position", XROrigin.localPosition, pos); XROrigin.localPosition = pos; } - else + else if (GamaLog.VerboseEnabled) { - Debug.Log("[GAMA][PLAYER][KEEP_POSITION] ignored GAMA initial player position=" + FormatVector(pos) + + GamaLog.Dev("[GAMA][PLAYER][KEEP_POSITION] ignored GAMA initial player position=" + FormatVector(pos) + " current=" + FormatVector(XROrigin.localPosition)); } @@ -840,6 +998,13 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) if (toRemove != null) RemoveKeptRuntimeAgentNames(toRemove, infoWorld.keepNames); + Dictionary stableAgentKeyCounts; + Dictionary fallbackRuntimeKeyCounts; + BuildRuntimeAgentIdentityCounts( + infoWorld, + out stableAgentKeyCounts, + out fallbackRuntimeKeyCounts); + for (int i = startAgentIndex; i < infoWorld.names.Count; i++) { string name = infoWorld.names[i]; @@ -860,7 +1025,25 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) GamaAgentVisualState visualState = ResolveAgentVisualState(name, prop, attributes); string speciesName = ResolveRuntimeSpeciesName(prop, propId); RegisterObservedRuntimeAttributes(propId, speciesName, attributes); - string agentKey = MakeRuntimeAgentKey(speciesName, name); + string stableAgentKey; + bool hasStableAgentKey = GamaPreviewReuseIdentity.TryBuildStableAgentKey( + NormalizePreviewReuseSpeciesKey(speciesName), + name, + attributes, + out stableAgentKey, + out _); + int stableKeyCount = 0; + bool hasUniqueStableAgentKey = + hasStableAgentKey && + stableAgentKeyCounts.TryGetValue(stableAgentKey, out stableKeyCount) && + stableKeyCount == 1; + + string fallbackRuntimeKey = MakeRuntimeAgentKey(speciesName, name); + int fallbackKeyCount = 0; + fallbackRuntimeKeyCounts.TryGetValue(fallbackRuntimeKey, out fallbackKeyCount); + string agentKey = hasUniqueStableAgentKey + ? stableAgentKey + : fallbackRuntimeKey + (fallbackKeyCount > 1 ? "::index=" + i : string.Empty); bool dynamicUpdate = !initGame; GameObject obj = null; @@ -873,7 +1056,13 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) } GAMAPoint pointLoc = infoWorld.pointsLoc[cptPrefab]; - string desiredPrefabSignature = ResolvePrefabSignature(prop, attributes); + GameObject desiredPrefabAsset; + string desiredPrefabSignature; + TryResolvePrefabAsset( + prop, + attributes, + out desiredPrefabAsset, + out desiredPrefabSignature); int importSignature = ComputeImportSignature( agentKey, propId, @@ -896,7 +1085,27 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) if (!geometryMap.ContainsKey(agentKey)) { - obj = instantiatePrefab(name, agentKey, speciesName, prop, attributes, desiredPrefabSignature, initGame); + if (TryAdoptPreviewAgent( + agentKey, + propId, + prop, + hasUniqueStableAgentKey ? stableAgentKey : string.Empty, + GamaPreviewRepresentationKind.Prefab, + desiredPrefabSignature, + desiredPrefabAsset, + out obj)) + { + ConfigureAdoptedPreviewAgent( + obj, + name, + agentKey, + speciesName, + prop); + } + else + { + obj = instantiatePrefab(name, agentKey, speciesName, prop, attributes, desiredPrefabSignature, initGame); + } } else @@ -915,11 +1124,11 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) geometryMap.Remove(agentKey); previousPrefabPositions.Remove(agentKey); previousPrefabPropertyIds.Remove(agentKey); + ReleaseRuntimeAgentObject(agentKey, obj2); UnregisterRuntimeAgent(agentKey); if (toFollow != null && toFollow.Contains(obj2)) toFollow.Remove(obj2); - ReleasePrefabInstance(obj2); obj = instantiatePrefab(name, agentKey, speciesName, prop, attributes, desiredPrefabSignature, initGame); } @@ -930,9 +1139,20 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) basePos.y += prop.yOffsetF; Vector3 pos = basePos + visualState.PositionOffset; Quaternion baseRotation = ResolvePrefabHeadingRotation(agentKey, prop, pt, basePos); + if (adoptedPreviewKeysByRuntimeKey.ContainsKey(agentKey)) + { + Quaternion nativePrefabRotation = GetPrefabBaseRotation(obj); + if (string.IsNullOrWhiteSpace(GetPrefabSignature(obj))) + { + Quaternion visualHeading = baseRotation * Quaternion.Euler(visualState.RotationOffsetEuler); + nativePrefabRotation = Quaternion.Inverse(visualHeading) * obj.transform.rotation; + } + SetPrefabSignature(obj, desiredPrefabSignature, nativePrefabRotation); + } Quaternion rotation = ComposePrefabRuntimeRotation(baseRotation, visualState, obj); - if (agentKey.ToLower().Contains("car") || agentKey.ToLower().Contains("voiture") || agentKey.ToLower().Contains("vehicle")) { - Debug.Log($"[GAMA][ROTATION] {agentKey} pt[3]={(pt.Count > 3 ? pt[3].ToString() : "N/A")} baseRot={baseRotation.eulerAngles} finalRot={rotation.eulerAngles}"); + if (GamaLog.VerboseEnabled && + (agentKey.ToLower().Contains("car") || agentKey.ToLower().Contains("voiture") || agentKey.ToLower().Contains("vehicle"))) { + GamaLog.Dev($"[GAMA][ROTATION] {agentKey} pt[3]={(pt.Count > 3 ? pt[3].ToString() : "N/A")} baseRot={baseRotation.eulerAngles} finalRot={rotation.eulerAngles}"); } obj.transform.SetPositionAndRotation(pos, rotation); @@ -1016,29 +1236,65 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) if(!geometryMap.ContainsKey(agentKey)) { - obj = polygonInputValid - ? polyGen.GeneratePolygons(false, name, pt, prop, parameters.precision) - : new GameObject(name); - if (polygonInputValid && hasComputedWorldAnchor) - { - RecenterPolygonMeshForStableScale(obj, computedWorldAnchor); - } - - if(prop.hasCollider) + bool adoptedPreview = polygonInputValid && TryAdoptPreviewAgent( + agentKey, + propId, + prop, + hasUniqueStableAgentKey ? stableAgentKey : string.Empty, + GamaPreviewRepresentationKind.Geometry, + BuildPreviewReuseSourceSignature( + GamaPreviewRepresentationKind.Geometry, + propId, + prop), + null, + out obj); + if (adoptedPreview) { - MeshFilter meshFilter = obj.GetComponent(); - if (meshFilter != null && meshFilter.sharedMesh != null) + ConfigureAdoptedPreviewAgent( + obj, + name, + agentKey, + speciesName, + prop); + if (polygonInputValid) { - MeshCollider mc = obj.AddComponent(); - mc.sharedMesh = meshFilter.sharedMesh; - if (prop.isGrabable) mc.convex = true; + polyGen.UpdatePolygon(obj, pt); + if (hasComputedWorldAnchor) + { + RecenterPolygonMeshForStableScale(obj, computedWorldAnchor); + } } } - instantiateGO(obj, name, prop); - ParentRuntimeAgent(obj, speciesName); - if (geometryMap != null) + else { - geometryMap[agentKey] = new List { obj, prop }; + obj = polygonInputValid + ? polyGen.GeneratePolygons(false, name, pt, prop, parameters.precision) + : new GameObject(name); + if (polygonInputValid && hasComputedWorldAnchor) + { + RecenterPolygonMeshForStableScale(obj, computedWorldAnchor); + } + + if(prop.hasCollider) + { + MeshFilter meshFilter = obj.GetComponent(); + if (meshFilter != null && meshFilter.sharedMesh != null) + { + MeshCollider mc = obj.GetComponent(); + if (mc == null) + { + mc = obj.AddComponent(); + } + mc.sharedMesh = meshFilter.sharedMesh; + if (prop.isGrabable) mc.convex = true; + } + } + instantiateGO(obj, name, prop); + ParentRuntimeAgent(obj, speciesName); + if (geometryMap != null) + { + geometryMap[agentKey] = new List { obj, prop }; + } } } @@ -1104,6 +1360,7 @@ bool GenerateGeometries(bool initGame, HashSet toRemove) AdditionalInitAfterGeomLoading(); CompleteImportProfileIfNeeded(true); + ClearRuntimeAgentIdentityCountCache(); infoWorld = null; return true; } @@ -1127,7 +1384,7 @@ public void UpdateGameState(GameState newState) case GameState.LOADING_DATA: if (!loadedAlready) { - Debug.Log("[GAMA] Loading initial data from middleware"); + GamaLog.Dev("[GAMA] Loading initial data from middleware"); TrySendExecutableAsk("send_init_data", connectionID, "initial data"); TimerSendInit = TimeSendInit; @@ -1145,7 +1402,7 @@ public void UpdateGameState(GameState newState) break; case GameState.CRASH: - Debug.LogWarning("[GAMA] Simulation crashed"); + GamaLog.Warning("[GAMA] Simulation crashed"); break; default: @@ -1422,7 +1679,7 @@ private bool IsSuspiciousOutgoingPlayerPosition(Transform source, PlayerPosition suspicious = true; } - if (suspicious) + if (GamaLog.VerboseEnabled && suspicious) { LogOutgoingWarning("[GAMA][OUT][WARN] suspicious player position source=" + resolvedSource + " unityPos=" + FormatVector(unityPos) + @@ -1434,7 +1691,8 @@ private bool IsSuspiciousOutgoingPlayerPosition(Transform source, PlayerPosition private void WarnIfRootAndCameraDiverge(PlayerPositionSource resolvedSource, Transform source) { - if (source == null || + if (!GamaLog.VerboseEnabled || + source == null || Camera.main == null || resolvedSource == PlayerPositionSource.MainCamera || resolvedSource == PlayerPositionSource.ExplicitTransform) @@ -1453,7 +1711,7 @@ private void WarnIfRootAndCameraDiverge(PlayerPositionSource resolvedSource, Tra private void LogOutgoingPlayerPosition(PlayerPositionSource source, Vector3 unityPos, List gamaPos, int angle) { - if (!logOutgoingPlayerPosition) + if (!GamaLog.VerboseEnabled || !logOutgoingPlayerPosition) { return; } @@ -1470,7 +1728,7 @@ private void LogOutgoingPlayerPosition(PlayerPositionSource source, Vector3 unit string gama = gamaPos != null && gamaPos.Count >= 3 ? "(" + gamaPos[0] + "," + gamaPos[1] + "," + gamaPos[2] + ")" : "(missing)"; - Debug.Log("[GAMA][OUT][PLAYER_POS] source=" + source + + GamaLog.Dev("[GAMA][OUT][PLAYER_POS] source=" + source + " unityPos=" + FormatVector(unityPos) + " gamaPos=" + gama + " angle=" + angle + @@ -1480,6 +1738,11 @@ private void LogOutgoingPlayerPosition(PlayerPositionSource source, Vector3 unit private void LogOutgoingSkip(string reason, string action) { + if (!GamaLog.VerboseEnabled) + { + return; + } + float now = Time.unscaledTime; if (now < nextOutgoingPlayerWarningLogTime) { @@ -1487,11 +1750,16 @@ private void LogOutgoingSkip(string reason, string action) } nextOutgoingPlayerWarningLogTime = now + OutgoingPlayerWarningLogIntervalSeconds; - Debug.LogWarning("[GAMA][OUT][SKIP] reason=" + reason + " action=" + action); + GamaLog.DevWarning("[GAMA][OUT][SKIP] reason=" + reason + " action=" + action); } private void LogOutgoingWarning(string message) { + if (!GamaLog.VerboseEnabled) + { + return; + } + float now = Time.unscaledTime; if (now < nextOutgoingPlayerWarningLogTime) { @@ -1499,12 +1767,17 @@ private void LogOutgoingWarning(string message) } nextOutgoingPlayerWarningLogTime = now + OutgoingPlayerWarningLogIntervalSeconds; - Debug.LogWarning(message); + GamaLog.DevWarning(message); } private void LogPlayerSetPosition(string reason, Vector3 oldPosition, Vector3 newPosition) { - Debug.Log("[GAMA][PLAYER][SET_POSITION] reason=" + reason + + if (!GamaLog.VerboseEnabled) + { + return; + } + + GamaLog.Dev("[GAMA][PLAYER][SET_POSITION] reason=" + reason + " old=" + FormatVector(oldPosition) + " new=" + FormatVector(newPosition)); } @@ -1532,10 +1805,15 @@ private static string FormatVector(Vector3 value) private void instantiateGO(GameObject obj, String name, PropertiesGAMA prop) { + if (obj == null || prop == null) + { + return; + } + obj.name = name; if (ShouldSendFollowedGeometryToGama(prop)) { - if (!toFollow.Contains(obj)) + if (toFollow != null && !toFollow.Contains(obj)) { toFollow.Add(obj); } @@ -1547,58 +1825,60 @@ private void instantiateGO(GameObject obj, String name, PropertiesGAMA prop) if (prop.tag != null && !string.IsNullOrEmpty(prop.tag)) GamaSceneUtility.TrySetTag(obj, prop.tag); - if (prop.isInteractable) - { - if (interactionManager == null) - interactionManager = GameObject.FindFirstObjectByType(); - - UnityEngine.XR.Interaction.Toolkit.Interactables.XRBaseInteractable interaction = null; - if (prop.isGrabable) - { - - interaction = obj.AddComponent(); - Rigidbody rb = obj.GetComponent(); - if (prop.constraints != null && prop.constraints.Count == 6) - { - if (prop.constraints[0]) - rb.constraints = rb.constraints | RigidbodyConstraints.FreezePositionX; - if (prop.constraints[1]) - rb.constraints = rb.constraints | RigidbodyConstraints.FreezePositionY; - if (prop.constraints[2]) - rb.constraints = rb.constraints | RigidbodyConstraints.FreezePositionZ; - if (prop.constraints[3]) - rb.constraints = rb.constraints | RigidbodyConstraints.FreezeRotationX; - if (prop.constraints[4]) - rb.constraints = rb.constraints | RigidbodyConstraints.FreezeRotationY; - if (prop.constraints[5]) - rb.constraints = rb.constraints | RigidbodyConstraints.FreezeRotationZ; - } - - - - } - else - { - - interaction = obj.AddComponent(); - - - } - - if (interaction.colliders.Count == 0) - { - Collider[] cs = obj.GetComponentsInChildren(); - if (cs != null) - { + if (prop.isInteractable) + { + if (interactionManager == null) + interactionManager = GameObject.FindFirstObjectByType(); + + // Static preview objects may already have been configured during an + // earlier live pass. Reuse the existing interactable and replace only + // this manager's callbacks so one object never accumulates duplicate + // XR components or listeners across release/re-adoption cycles. + UnityEngine.XR.Interaction.Toolkit.Interactables.XRBaseInteractable interaction = + obj.GetComponent(); + if (interaction == null && prop.isGrabable) + { + interaction = obj.AddComponent(); + } + else if (interaction == null) + { + interaction = obj.AddComponent(); + } + + Rigidbody rb = obj.GetComponent(); + if (prop.isGrabable && rb != null && prop.constraints != null && prop.constraints.Count == 6) + { + if (prop.constraints[0]) + rb.constraints = rb.constraints | RigidbodyConstraints.FreezePositionX; + if (prop.constraints[1]) + rb.constraints = rb.constraints | RigidbodyConstraints.FreezePositionY; + if (prop.constraints[2]) + rb.constraints = rb.constraints | RigidbodyConstraints.FreezePositionZ; + if (prop.constraints[3]) + rb.constraints = rb.constraints | RigidbodyConstraints.FreezeRotationX; + if (prop.constraints[4]) + rb.constraints = rb.constraints | RigidbodyConstraints.FreezeRotationY; + if (prop.constraints[5]) + rb.constraints = rb.constraints | RigidbodyConstraints.FreezeRotationZ; + } + + if (interaction.colliders.Count == 0) + { + Collider[] cs = obj.GetComponentsInChildren(true); + if (cs != null) + { foreach (Collider c in cs) { interaction.colliders.Add(c); } } - } - interaction.interactionManager = interactionManager; - interaction.ProcessInteractable(XRInteractionUpdateOrder.UpdatePhase.Dynamic); - interaction.selectEntered.AddListener(SelectInteraction); + } + interaction.interactionManager = interactionManager; + interaction.ProcessInteractable(XRInteractionUpdateOrder.UpdatePhase.Dynamic); + interaction.selectEntered.RemoveListener(SelectInteraction); + interaction.firstHoverEntered.RemoveListener(HoverEnterInteraction); + interaction.hoverExited.RemoveListener(HoverExitInteraction); + interaction.selectEntered.AddListener(SelectInteraction); interaction.firstHoverEntered.AddListener(HoverEnterInteraction); interaction.hoverExited.AddListener(HoverExitInteraction); @@ -1612,13 +1892,18 @@ private static bool ShouldSendFollowedGeometryToGama(PropertiesGAMA prop) private void LogSuppressedFollowedGeometrySync(PropertiesGAMA prop) { + if (!GamaLog.VerboseEnabled) + { + return; + } + string propertyId = string.IsNullOrWhiteSpace(prop.id) ? "(unknown)" : prop.id; if (!suppressedFollowedGeometryPropertyWarnings.Add(propertyId)) { return; } - Debug.LogWarning("[GAMA][OUT][FOLLOW] suppressed propertyID=" + propertyId + + GamaLog.DevWarning("[GAMA][OUT][FOLLOW] suppressed propertyID=" + propertyId + " reason=not_unity_controlled toFollow=True isInteractable=" + prop.isInteractable + " isGrabable=" + prop.isGrabable); } @@ -1695,23 +1980,190 @@ private GameObject instantiatePrefab( return obj; } - + + private bool TryAdoptPreviewAgent( + string runtimeKey, + string propertyId, + PropertiesGAMA prop, + string stableAgentKey, + GamaPreviewRepresentationKind representationKind, + string sourceSignature, + GameObject sourcePrefabAsset, + out GameObject obj) + { + obj = null; + if (string.IsNullOrWhiteSpace(runtimeKey) || + string.IsNullOrWhiteSpace(stableAgentKey) || + prop == null || + !TryInitializePreviewReuseRegistry()) + { + return false; + } + + if (!previewReuseRegistry.TryTake( + stableAgentKey, + propertyId, + representationKind, + sourceSignature, + sourcePrefabAsset, + out obj) || + obj == null) + { + obj = null; + return false; + } + + adoptedPreviewKeysByRuntimeKey[runtimeKey] = stableAgentKey; + return true; + } + + private bool TryInitializePreviewReuseRegistry() + { + if (previewReuseRegistry != null) + { + return true; + } + + if (previewReuseInitializationAttempted) + { + return false; + } + + previewReuseInitializationAttempted = true; + GamaPreviewSession[] sessions = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + + string expectedExperimentKey = string.Empty; + int authorizedSessionCount = 0; + for (int i = 0; i < sessions.Length; i++) + { + GamaPreviewSession session = sessions[i]; + if (session == null || + session.stale || + !session.reuseAuthorizedForPlay || + string.IsNullOrWhiteSpace(session.authorizedStableExperimentKey) || + string.IsNullOrWhiteSpace(session.stableExperimentKey) || + !string.Equals( + session.authorizedStableExperimentKey, + session.stableExperimentKey, + StringComparison.Ordinal) || + (session.activeGamaSelection && + (string.IsNullOrWhiteSpace(session.authorizedMonitorExperimentId) || + !string.Equals( + session.authorizedMonitorExperimentId, + session.monitorExperimentId, + StringComparison.Ordinal)))) + { + continue; + } + + string stableExperimentKey; + if (!session.TryGetStableExperimentKey(out stableExperimentKey) || + !string.Equals( + session.authorizedStableExperimentKey, + stableExperimentKey, + StringComparison.Ordinal)) + { + continue; + } + + authorizedSessionCount++; + expectedExperimentKey = stableExperimentKey; + } + + // Multiple authorized snapshots are ambiguous even when their text keys + // happen to match. Reuse remains disabled instead of guessing a root. + if (authorizedSessionCount != 1 || string.IsNullOrWhiteSpace(expectedExperimentKey)) + { + return false; + } + + return GamaPreviewReuseRegistry.TryCreate(expectedExperimentKey, out previewReuseRegistry) && + previewReuseRegistry != null; + } + + private static string BuildPreviewReuseSourceSignature( + GamaPreviewRepresentationKind representationKind, + string propertyId, + PropertiesGAMA prop) + { + string normalizedPropertyId = NormalizeKey(propertyId); + if (representationKind == GamaPreviewRepresentationKind.Prefab) + { + return "prefab:" + normalizedPropertyId + ":" + + NormalizeKey(prop != null ? prop.prefab : string.Empty); + } + + return "geometry:" + normalizedPropertyId; + } + + private void ConfigureAdoptedPreviewAgent( + GameObject obj, + string name, + string runtimeKey, + string speciesName, + PropertiesGAMA prop) + { + if (obj == null || prop == null) + { + return; + } + + obj.name = name; + obj.SetActive(true); + EnableGpuInstancing(obj); + EnsureColliderSetup(obj, prop); + instantiateGO(obj, name, prop); + if (groupRuntimeAgentsBySpecies) + { + ParentRuntimeAgent(obj, string.IsNullOrWhiteSpace(speciesName) ? prop.id : speciesName); + } + else + { + // Detach the claimed object before hiding the remaining static + // snapshot. The registry retains its original parent for restoration. + obj.transform.SetParent(null, true); + HideStaticPreviewAfterRuntimeData(); + } + + if (geometryMap != null) + { + geometryMap[runtimeKey] = new List { obj, prop }; + } + } + private void ParentRuntimeAgent(GameObject obj, string speciesKey) { - if (!groupRuntimeAgentsBySpecies || obj == null) return; + if (obj == null) return; + if (!groupRuntimeAgentsBySpecies) + { + HideStaticPreviewAfterRuntimeData(); + return; + } if (runtimeAgentsRoot == null) { GameObject rootObj = GameObject.Find("[GAMA] Runtime Live Agents"); - if (rootObj == null) - { - rootObj = new GameObject("[GAMA] Runtime Live Agents"); - Debug.Log("[GAMA][RUNTIME] Created runtime hierarchy root: [GAMA] Runtime Live Agents"); + if (rootObj == null) + { + rootObj = new GameObject("[GAMA] Runtime Live Agents"); + runtimeAgentsRootOwned = true; + GamaLog.Dev("[GAMA][RUNTIME] Created runtime hierarchy root: [GAMA] Runtime Live Agents"); + } + else + { + runtimeAgentsRootOwned = false; + } + + runtimeAgentsRoot = rootObj.transform; + + if (runtimeAgentsRootOwned) + { + runtimeAgentsRoot.position = Vector3.zero; + runtimeAgentsRoot.rotation = Quaternion.identity; + runtimeAgentsRoot.localScale = Vector3.one; } - runtimeAgentsRoot = rootObj.transform; - runtimeAgentsRoot.position = Vector3.zero; - runtimeAgentsRoot.rotation = Quaternion.identity; - runtimeAgentsRoot.localScale = Vector3.one; } string safeSpecies = string.IsNullOrWhiteSpace(speciesKey) ? "unknown" : speciesKey.Trim(); @@ -1722,24 +2174,30 @@ private void ParentRuntimeAgent(GameObject obj, string speciesKey) runtimeSpeciesParents = new Dictionary(StringComparer.OrdinalIgnoreCase); } - if (!runtimeSpeciesParents.TryGetValue(safeSpecies, out speciesParent) || speciesParent == null) - { - Transform existingParent = runtimeAgentsRoot.Find(safeSpecies); - if (existingParent != null) - { + if (!runtimeSpeciesParents.TryGetValue(safeSpecies, out speciesParent) || speciesParent == null) + { + bool speciesParentOwned = false; + Transform existingParent = runtimeAgentsRoot.Find(safeSpecies); + if (existingParent != null) + { speciesParent = existingParent; } else { - GameObject parentObj = new GameObject(safeSpecies); - parentObj.transform.SetParent(runtimeAgentsRoot, false); - speciesParent = parentObj.transform; - } - - speciesParent.position = Vector3.zero; - speciesParent.rotation = Quaternion.identity; - speciesParent.localScale = Vector3.one; - runtimeSpeciesParents[safeSpecies] = speciesParent; + GameObject parentObj = new GameObject(safeSpecies); + parentObj.transform.SetParent(runtimeAgentsRoot, false); + speciesParent = parentObj.transform; + ownedRuntimeSpeciesParents.Add(speciesParent); + speciesParentOwned = true; + } + + if (speciesParentOwned) + { + speciesParent.localPosition = Vector3.zero; + speciesParent.localRotation = Quaternion.identity; + speciesParent.localScale = Vector3.one; + } + runtimeSpeciesParents[safeSpecies] = speciesParent; } obj.transform.SetParent(speciesParent, true); @@ -1771,6 +2229,102 @@ private static string MakeRuntimeAgentKey(string speciesName, string agentId) return species + "::" + id; } + private void BuildRuntimeAgentIdentityCounts( + WorldJSONInfo world, + out Dictionary stableKeyCounts, + out Dictionary fallbackKeyCounts) + { + stableKeyCounts = cachedStableAgentKeyCounts; + fallbackKeyCounts = cachedFallbackRuntimeKeyCounts; + if (ReferenceEquals(identityCountSourceWorld, world)) + { + return; + } + + identityCountSourceWorld = world; + stableKeyCounts.Clear(); + fallbackKeyCounts.Clear(); + if (world == null || world.names == null || world.propertyID == null) + { + return; + } + + int count = Mathf.Min(world.names.Count, world.propertyID.Count); + for (int i = 0; i < count; i++) + { + string name = world.names[i]; + if (string.IsNullOrWhiteSpace(name)) + { + name = "agent_" + i; + } + + string propertyId = world.propertyID[i]; + PropertiesGAMA prop; + if (string.IsNullOrWhiteSpace(propertyId) || + propertyMap == null || + !propertyMap.TryGetValue(propertyId, out prop) || + prop == null) + { + continue; + } + + string speciesName = ResolveRuntimeSpeciesName(prop, propertyId); + IncrementRuntimeIdentityCount( + fallbackKeyCounts, + MakeRuntimeAgentKey(speciesName, name)); + + string stableAgentKey; + if (GamaPreviewReuseIdentity.TryBuildStableAgentKey( + NormalizePreviewReuseSpeciesKey(speciesName), + name, + world.GetAttributesAt(i), + out stableAgentKey, + out _)) + { + IncrementRuntimeIdentityCount(stableKeyCounts, stableAgentKey); + } + } + } + + private static void IncrementRuntimeIdentityCount( + Dictionary counts, + string key) + { + if (counts == null || string.IsNullOrWhiteSpace(key)) + { + return; + } + + int count; + counts.TryGetValue(key, out count); + counts[key] = count + 1; + } + + private void ClearRuntimeAgentIdentityCountCache() + { + cachedStableAgentKeyCounts.Clear(); + cachedFallbackRuntimeKeyCounts.Clear(); + identityCountSourceWorld = null; + } + + private static string NormalizePreviewReuseSpeciesKey(string speciesName) + { + string value = string.IsNullOrWhiteSpace(speciesName) + ? "unknown" + : speciesName.Trim(); + char[] chars = value.ToCharArray(); + for (int i = 0; i < chars.Length; i++) + { + char c = chars[i]; + if (!char.IsLetterOrDigit(c) && c != '_' && c != '-') + { + chars[i] = '_'; + } + } + + return new string(chars); + } + private void RemoveKeptRuntimeAgentNames(HashSet removalSet, List keepNames) { if (removalSet == null || keepNames == null || keepNames.Count == 0) @@ -1794,8 +2348,8 @@ private void RemoveKeptRuntimeAgentNames(HashSet removalSet, List(); - if (childCollider != null && childCollider.bounds.extents.x == 0) - { - childCollider = null; - } - - if (childCollider == null) - { - child.AddComponent(); + GameObject child = l.renderers[0].gameObject; + Collider childCollider = child.GetComponent(); + if (childCollider == null) + { + child.AddComponent(); } } return; } - - Collider collider = obj.GetComponent(); - if (collider != null && collider.bounds.extents.x == 0) - { - collider = null; - } - - if (collider == null) + + Collider collider = obj.GetComponent(); + if (collider == null) { obj.AddComponent(); } @@ -2441,8 +3084,8 @@ private void ReleasePrefabInstance(GameObject instance) stack.Push(instance); } - private void DrainPrefabPools() - { + private void DrainPrefabPools(bool destroyImmediately = false) + { foreach (KeyValuePair> kv in prefabPools) { Stack stack = kv.Value; @@ -2453,11 +3096,11 @@ private void DrainPrefabPools() while (stack.Count > 0) { - GameObject pooled = stack.Pop(); - if (pooled != null) - { - UnityEngine.Object.Destroy(pooled); - } + GameObject pooled = stack.Pop(); + if (pooled != null) + { + DestroyManagedRuntimeObject(pooled, destroyImmediately); + } } } @@ -2465,17 +3108,33 @@ private void DrainPrefabPools() gpuInstancingTouchedMaterials.Clear(); prefabDistanceCulled.Clear(); prefabStreamingKeys.Clear(); - if (prefabPoolRoot != null) - { - UnityEngine.Object.Destroy(prefabPoolRoot.gameObject); - prefabPoolRoot = null; - } - - if (runtimeAgentsRoot != null) - { - UnityEngine.Object.Destroy(runtimeAgentsRoot.gameObject); - runtimeAgentsRoot = null; - } + if (prefabPoolRoot != null) + { + DestroyManagedRuntimeObject(prefabPoolRoot.gameObject, destroyImmediately); + prefabPoolRoot = null; + } + + if (runtimeAgentsRoot != null) + { + Transform[] ownedParents = new Transform[ownedRuntimeSpeciesParents.Count]; + ownedRuntimeSpeciesParents.CopyTo(ownedParents); + for (int i = 0; i < ownedParents.Length; i++) + { + Transform ownedParent = ownedParents[i]; + if (ownedParent != null && ownedParent.childCount == 0) + { + DestroyManagedRuntimeObject(ownedParent.gameObject, destroyImmediately); + } + } + + if (runtimeAgentsRootOwned && runtimeAgentsRoot != null && runtimeAgentsRoot.childCount == 0) + { + DestroyManagedRuntimeObject(runtimeAgentsRoot.gameObject, destroyImmediately); + } + runtimeAgentsRoot = null; + } + runtimeAgentsRootOwned = false; + ownedRuntimeSpeciesParents.Clear(); if (runtimeSpeciesParents != null) { @@ -2525,6 +3184,23 @@ private void EnableGpuInstancing(GameObject root) } } + private static void DestroyManagedRuntimeObject(GameObject obj, bool immediately) + { + if (obj == null) + { + return; + } + +#if UNITY_EDITOR + if (immediately) + { + UnityEngine.Object.DestroyImmediate(obj); + return; + } +#endif + UnityEngine.Object.Destroy(obj); + } + private bool TryGetRuntimeAgentObjectByAgentId(string agentId, out GameObject obj) { obj = null; @@ -2544,7 +3220,7 @@ private bool TryGetRuntimeAgentObjectByAgentId(string agentId, out GameObject ob foreach (RuntimeAgentRecord record in runtimeAgentRecords.Values) { if (record == null || - !string.Equals(record.AgentId, agentId, StringComparison.OrdinalIgnoreCase) || + !string.Equals(record.AgentId, agentId, StringComparison.Ordinal) || record.Root == null) { continue; @@ -2921,7 +3597,7 @@ private void ApplyAgentVisualState( bool hasVisualOverridePrefab = !prefabAgent && (visualState.PrefabOverride != null || !string.IsNullOrEmpty(visualState.PrefabResourcePath)); - bool keepLogicalRootScaleStable = hasVisualOverridePrefab && IsVegetationCell(prop, obj); + bool keepLogicalRootScaleStable = hasVisualOverridePrefab; obj.transform.localScale = keepLogicalRootScaleStable ? Vector3.one : new Vector3(scale, scale, scale); @@ -2944,6 +3620,15 @@ private void ApplyAgentVisualState( UnityEngine.Object.Destroy(staleVisualOverride.gameObject); } } + else if (!hasVisualOverridePrefab) + { + Transform staleVisualOverride = obj.transform.Find("VisualOverride"); + if (staleVisualOverride != null) + { + staleVisualOverride.gameObject.SetActive(false); + UnityEngine.Object.Destroy(staleVisualOverride.gameObject); + } + } if (hasVisualOverridePrefab) { @@ -2955,7 +3640,7 @@ private void ApplyAgentVisualState( if (visualOverride != null) { GamaRuntimePrefabSignature sig = visualOverride.GetComponent(); - if (sig == null || sig.signature != visualSignature) + if (sig == null || sig.signature != visualSignature || !sig.hasBaseLocalScale) { UnityEngine.Object.Destroy(visualOverride.gameObject); visualOverride = null; @@ -2980,9 +3665,11 @@ private void ApplyAgentVisualState( GamaRuntimePrefabSignature sig = visual.AddComponent(); sig.signature = visualSignature; + sig.baseLocalScale = visual.transform.localScale; + sig.hasBaseLocalScale = true; visualOverride = visual.transform; } - else + else if (GamaLog.VerboseEnabled) { string species = prop != null ? prop.id : "unknown"; string warningKey = "missing-runtime-prefab:" + species + ":" + visualState.PrefabResourcePath; @@ -2994,7 +3681,7 @@ private void ApplyAgentVisualState( if (debugLogCounts[warningKey] < 1) { debugLogCounts[warningKey]++; - Debug.LogWarning("[GAMA][RUNTIME][PREFAB] species=" + species + + GamaLog.DevWarning("[GAMA][RUNTIME][PREFAB] species=" + species + " cannot load prefabResourcePath=" + visualState.PrefabResourcePath); } } @@ -3011,42 +3698,45 @@ private void ApplyAgentVisualState( visualOverride.position = ResolveCurrentVisualWorldAnchor(obj); } visualOverride.rotation = visualRotation; - visualOverride.localScale = ResolveVisualOverrideLocalScale(scale, visualState, keepLogicalRootScaleStable); - - string speciesKey = prop != null ? prop.id : "unknown"; - if (!debugLogCounts.ContainsKey(speciesKey)) debugLogCounts[speciesKey] = 0; - - if (debugLogCounts[speciesKey] < 5) - { - debugLogCounts[speciesKey]++; - Debug.Log($"[GAMA][RUNTIME][PREFAB] species={speciesKey} id={obj.name} agentRootPos={obj.transform.position:F3} visualPos={visualOverride.position:F3} scale={visualOverride.localScale:F3} prefab={visualSignature}"); - } + visualOverride.localScale = ResolveVisualOverrideLocalScale(visualOverride, visualState); - if (!debugSummaryLogged.ContainsKey(speciesKey)) + if (GamaLog.VerboseEnabled) { - debugSummaryLogged[speciesKey] = true; - Debug.Log($"[GAMA][RUNTIME][PREFAB] species={speciesKey} prefab={visualSignature} scale={visualState.ScaleMultiplier}"); - } + string speciesKey = prop != null ? prop.id : "unknown"; + if (!debugLogCounts.ContainsKey(speciesKey)) debugLogCounts[speciesKey] = 0; - if (keepLogicalRootScaleStable) - { - string scaleLogKey = "visual-scale:" + speciesKey; - if (!debugLogCounts.ContainsKey(scaleLogKey)) debugLogCounts[scaleLogKey] = 0; - if (debugLogCounts[scaleLogKey] < 5) + if (debugLogCounts[speciesKey] < 5) + { + debugLogCounts[speciesKey]++; + GamaLog.Dev($"[GAMA][RUNTIME][PREFAB] species={speciesKey} id={obj.name} agentRootPos={obj.transform.position:F3} visualPos={visualOverride.position:F3} scale={visualOverride.localScale:F3} prefab={visualSignature}"); + } + + if (!debugSummaryLogged.ContainsKey(speciesKey)) { - debugLogCounts[scaleLogKey]++; - Debug.Log($"[GAMA][RUNTIME][SCALE] species={speciesKey} id={obj.name} parentScale={obj.transform.localScale:F3} visualScale={visualOverride.localScale:F3}"); + debugSummaryLogged[speciesKey] = true; + GamaLog.Dev($"[GAMA][RUNTIME][PREFAB] species={speciesKey} prefab={visualSignature} scale={visualState.ScaleMultiplier}"); + } + + if (keepLogicalRootScaleStable) + { + string scaleLogKey = "visual-scale:" + speciesKey; + if (!debugLogCounts.ContainsKey(scaleLogKey)) debugLogCounts[scaleLogKey] = 0; + if (debugLogCounts[scaleLogKey] < 5) + { + debugLogCounts[scaleLogKey]++; + GamaLog.Dev($"[GAMA][RUNTIME][SCALE] species={speciesKey} id={obj.name} parentScale={obj.transform.localScale:F3} visualScale={visualOverride.localScale:F3}"); + } } } } } - if (visualState.HasColor) + if (visualState.HasColor) { bool isRealPrefab = prefabAgent && !GetPrefabSignature(obj).StartsWith("placeholder:"); if (visualOverride != null) isRealPrefab = true; - if (!isRealPrefab || visualState.HasManualColorOverride || visualState.HasAttributeColor) + if (!isRealPrefab || visualState.HasManualColorOverride || visualState.HasAttributeColor) { if (visualOverride != null) { @@ -3055,40 +3745,32 @@ private void ApplyAgentVisualState( else { ChangeColor(obj, visualState.Color); - } - } - } - - SetRenderersEnabled(obj, visualState.Visible, visualOverride); - } - - private static Vector3 ResolveVisualOverrideLocalScale( - float rootScale, - GamaAgentVisualState visualState, - bool keepLogicalRootScaleStable) - { - if (keepLogicalRootScaleStable) + } + } + else + { + RestoreRuntimeColor(visualOverride != null ? visualOverride.gameObject : obj); + } + } + else { - return Vector3.one * Mathf.Max(0f, rootScale); + RestoreRuntimeColor(visualOverride != null ? visualOverride.gameObject : obj); } - float parentScale = Mathf.Max(0.0001f, rootScale); - float targetWorldScale = Mathf.Max(0f, visualState.ScaleMultiplier); - return Vector3.one * (targetWorldScale / parentScale); - } - - private static bool IsVegetationCell(PropertiesGAMA prop, GameObject obj) - { - return ContainsVegetationCell(prop != null ? prop.id : null) || - ContainsVegetationCell(prop != null ? prop.tag : null) || - ContainsVegetationCell(prop != null ? prop.prefab : null) || - ContainsVegetationCell(obj != null ? obj.name : null); + SetRenderersEnabled(obj, visualState.Visible, visualOverride); } - private static bool ContainsVegetationCell(string value) + private static Vector3 ResolveVisualOverrideLocalScale( + Transform visualOverride, + GamaAgentVisualState visualState) { - return !string.IsNullOrWhiteSpace(value) && - value.IndexOf("vegetation_cell", StringComparison.OrdinalIgnoreCase) >= 0; + GamaRuntimePrefabSignature marker = visualOverride != null + ? visualOverride.GetComponent() + : null; + Vector3 baseLocalScale = marker != null && marker.hasBaseLocalScale + ? marker.baseLocalScale + : Vector3.one; + return baseLocalScale * Mathf.Max(0f, visualState.ScaleMultiplier); } private static Vector3 ResolveRuntimeBasePosition(RuntimeAgentRecord record, GameObject root) @@ -3180,11 +3862,13 @@ public void ApplyRuntimeSpeciesOverrideNow(string speciesName) toFollow.Remove(root); } - ReleasePrefabInstance(root); + ReleaseRuntimeAgentObject(key, root); root = instantiatePrefab(record.AgentId, key, record.SpeciesName, prop, record.LastAttributes, desiredSignature, initGame: false); entry[0] = root; record.Root = root; record.VisualRoot = ResolveRuntimeVisualRoot(root); + record.IsAdoptedPreview = false; + record.PreviewReuseKey = string.Empty; } root.transform.SetPositionAndRotation( @@ -3242,7 +3926,7 @@ public void ApplyRuntimeSpeciesOverrideNow(string speciesName) updated++; } - Debug.Log("[GAMA][RUNTIME][OVERRIDE] refreshed species=" + speciesName + " agents=" + updated); + GamaLog.Dev("[GAMA][RUNTIME][OVERRIDE] refreshed species=" + speciesName + " agents=" + updated); } private static bool RuntimeRecordMatchesSpeciesSelection(RuntimeAgentRecord record, string speciesSelection) @@ -3596,6 +4280,11 @@ private static bool IsRuntimeAuxiliaryVisual(Transform transform) private void LogInvalidGeometryFallback(string speciesName) { + if (!GamaLog.VerboseEnabled) + { + return; + } + string species = string.IsNullOrWhiteSpace(speciesName) ? "unknown" : speciesName.Trim(); int count = 0; invalidGeometryFallbackCounts.TryGetValue(species, out count); @@ -3604,33 +4293,53 @@ private void LogInvalidGeometryFallback(string speciesName) if (count == 1 || count == 10 || count % 100 == 0) { - Debug.LogWarning( + GamaLog.DevWarning( "[GAMA][RUNTIME][GEOMETRY] species=" + species + " invalidPolygonFallback=" + count); } } private static void SetRenderersEnabled(GameObject obj, bool visible, Transform visualOverride) - { + { Renderer[] renderers = obj.GetComponentsInChildren(true); - for (int i = 0; i < renderers.Length; i++) - { - if (visualOverride != null) - { - if (renderers[i].transform == visualOverride || renderers[i].transform.IsChildOf(visualOverride)) - { - renderers[i].enabled = visible; - } - else - { - renderers[i].enabled = false; - } - } - else - { - renderers[i].enabled = visible; - } - } + for (int i = 0; i < renderers.Length; i++) + { + Renderer renderer = renderers[i]; + GamaRuntimeRendererAppearanceBaseline baseline = + renderer.GetComponent(); + if (baseline == null) + { + baseline = renderer.gameObject.AddComponent(); + } + baseline.Capture(renderer); + + if (visualOverride != null) + { + if (renderer.transform == visualOverride || renderer.transform.IsChildOf(visualOverride)) + { + if (visible) + { + baseline.RestoreRendererState(renderer); + } + else + { + renderer.enabled = false; + } + } + else + { + renderer.enabled = false; + } + } + else if (visible) + { + baseline.RestoreRendererState(renderer); + } + else + { + renderer.enabled = false; + } + } } private void UpdatePrefabViewportStreaming(float deltaTime) @@ -3724,9 +4433,9 @@ private void UpdatePrefabViewportStreaming(float deltaTime) EmitPrefabStreamingDiagnostic(processed, total); } - private void EmitPrefabStreamingDiagnostic(int processedThisTick, int totalPrefabAgents) - { - if (!logPrefabStreamingStats) + private void EmitPrefabStreamingDiagnostic(int processedThisTick, int totalPrefabAgents) + { + if (!GamaLog.VerboseEnabled || !logPrefabStreamingStats) { return; } @@ -3738,7 +4447,7 @@ private void EmitPrefabStreamingDiagnostic(int processedThisTick, int totalPrefa } prefabStreamingLastDiagTime = now; - Debug.Log( + GamaLog.Dev( "[GAMA] Prefab streaming tick: evaluated=" + processedThisTick + " round_robin_total=" + totalPrefabAgents + " budget=" + prefabStreamingBudgetPerTick + @@ -3748,7 +4457,7 @@ private void EmitPrefabStreamingDiagnostic(int processedThisTick, int totalPrefa private void EmitAgentUpdateBudgetDiagnostic(int processedThisTick, int totalAgents, int nextAgentIndex) { - if (!logAgentUpdateBudgetStats) + if (!GamaLog.VerboseEnabled || !logAgentUpdateBudgetStats) { return; } @@ -3760,7 +4469,7 @@ private void EmitAgentUpdateBudgetDiagnostic(int processedThisTick, int totalAge } agentUpdateBudgetLastDiagTime = now; - Debug.Log( + GamaLog.Dev( "[GAMA] Agent update budget tick: processed=" + processedThisTick + " total=" + totalAgents + " next_index=" + nextAgentIndex + @@ -3769,7 +4478,7 @@ private void EmitAgentUpdateBudgetDiagnostic(int processedThisTick, int totalAge private void EmitRuntimeSyncSummaryIfNeeded() { - if (!logAgentUpdateBudgetStats || runtimeSyncCountersBySpecies.Count == 0) + if (!GamaLog.VerboseEnabled || !logAgentUpdateBudgetStats || runtimeSyncCountersBySpecies.Count == 0) { return; } @@ -3783,7 +4492,7 @@ private void EmitRuntimeSyncSummaryIfNeeded() } int active = CountActiveDynamicAgents(pair.Key); - Debug.Log( + GamaLog.Dev( "[GAMA][RUNTIME][SYNC] tick=" + runtimeLiveTickSerial + " species=" + pair.Key + " active=" + active + @@ -3885,7 +4594,7 @@ private Camera GetPrefabStreamingCamera() if (!loggedMissingMainCameraForStreaming) { loggedMissingMainCameraForStreaming = true; - Debug.LogWarning("[GAMA] Streaming culling disabled because Camera.main is missing. Tag the runtime game camera as MainCamera."); + GamaLog.Warning("[GAMA] Streaming culling disabled because Camera.main is missing. Tag the runtime game camera as MainCamera."); } return null; @@ -4018,10 +4727,10 @@ record != null && { GetRuntimeSyncCounters(record.SpeciesName).Removed++; } - UnregisterRuntimeAgent(id); if (toFollow.Contains(obj)) toFollow.Remove(obj); - ReleasePrefabInstance(obj); + ReleaseRuntimeAgentObject(id, obj); + UnregisterRuntimeAgent(id); } foreach (string id in geometryMap.Keys) @@ -4058,6 +4767,7 @@ private void HandleConnectionStateChanged(ConnectionState state) // player has been added to the simulation by the middleware if (state == ConnectionState.AUTHENTICATED) { + previewReuseConnectionWasAuthenticated = true; if (manager != null && !manager.IsCurrentPlayerAuthenticated) { runtimePlayerBootstrapConfirmed = false; @@ -4067,31 +4777,64 @@ private void HandleConnectionStateChanged(ConnectionState state) else { runtimePlayerBootstrapConfirmed = true; - Debug.Log("[GAMA] Authenticated, loading simulation data"); + GamaLog.Info("[GAMA] Loading simulation data."); UpdateGameState(GameState.LOADING_DATA); } } else if (state == ConnectionState.CONNECTED) { + bool returnedFromAuthenticatedState = previewReuseConnectionWasAuthenticated; + if (returnedFromAuthenticatedState) + { + RevokePreviewReuseForConnectionChange(); + } runtimePlayerBootstrapConfirmed = false; runtimePlayerBootstrapAttempts = 0; nextRuntimePlayerBootstrapTime = 0f; - if (IsGameState(GameState.MENU)) + if (returnedFromAuthenticatedState) + { + loadedAlready = false; + GamaLog.Info("[GAMA] Experiment connection ended; waiting to authenticate the next runtime session."); + UpdateGameState(GameState.WAITING); + } + else if (IsGameState(GameState.MENU)) { - Debug.Log("[GAMA] Connected to middleware"); + GamaLog.Info("[GAMA] Connected to simple.webplatform."); UpdateGameState(GameState.WAITING); } } else if (state == ConnectionState.DISCONNECTED) { - Debug.Log("[GAMA] Disconnected from middleware, resetting state"); - runtimePlayerBootstrapConfirmed = false; + RevokePreviewReuseForConnectionChange(); + GamaLog.Info("[GAMA] Disconnected from simple.webplatform."); runtimePlayerBootstrapConfirmed = false; runtimePlayerBootstrapAttempts = 0; nextRuntimePlayerBootstrapTime = 0f; loadedAlready = false; UpdateGameState(GameState.MENU); } } + + private void RevokePreviewReuseForConnectionChange() + { + GamaPreviewSession[] sessions = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < sessions.Length; i++) + { + if (sessions[i] != null) + { + sessions[i].ClearRuntimeReuseAuthorization(); + } + } + + PrepareForEditorPlayExit(); + + // Reuse is a one-shot authorization made before this Play session. A + // reconnect or experiment restart must build fresh runtime objects until + // the next explicit Play launch validates the experiment again. + previewReuseInitializationAttempted = true; + previewReuseConnectionWasAuthenticated = false; + } private bool TrySubscribeConnectionManager() { @@ -4116,7 +4859,7 @@ private bool TrySubscribeConnectionManager() subscribedConnectionManager.OnConnectionAttempted += HandleConnectionAttempted; subscribedConnectionManager.OnConnectionStateChanged += HandleConnectionStateChanged; SyncConnectionIdFromManager(); - Debug.Log("[GAMA][RUNTIME][CONNECTION] subscribed to ConnectionManager"); + GamaLog.Dev("[GAMA][RUNTIME][CONNECTION] subscribed to ConnectionManager"); if (subscribedConnectionManager.IsConnectionState(ConnectionState.AUTHENTICATED)) { HandleConnectionStateChanged(ConnectionState.AUTHENTICATED); @@ -4179,19 +4922,22 @@ private bool CanSendRuntimeAsk(string sendLabel, string action = null) return true; } - float now = Time.unscaledTime; - if (now >= nextSocketClosedWarningTime) + if (GamaLog.VerboseEnabled) { - string reason = manager == null ? "connection_manager_missing" : "socket_not_open"; - if (!string.IsNullOrWhiteSpace(action)) + float now = Time.unscaledTime; + if (now >= nextSocketClosedWarningTime) { - Debug.LogWarning("[GAMA][OUT][SKIP] reason=" + reason + " action=" + action); - } - else - { - Debug.LogWarning("[GAMA][RUNTIME][CONNECTION] " + reason + "; skipping " + sendLabel + " send"); + string reason = manager == null ? "connection_manager_missing" : "socket_not_open"; + if (!string.IsNullOrWhiteSpace(action)) + { + GamaLog.DevWarning("[GAMA][OUT][SKIP] reason=" + reason + " action=" + action); + } + else + { + GamaLog.DevWarning("[GAMA][RUNTIME][CONNECTION] " + reason + "; skipping " + sendLabel + " send"); + } + nextSocketClosedWarningTime = now + SocketClosedWarningIntervalSeconds; } - nextSocketClosedWarningTime = now + SocketClosedWarningIntervalSeconds; } return false; @@ -4223,11 +4969,43 @@ private void HideStaticPreviewAfterRuntimeData() previewRoot.SetActive(false); staticPreviewHiddenAfterRuntimeData = true; - Debug.Log("[GAMA][RUNTIME] Static preview hidden after live runtime data arrived."); + GamaLog.Dev("[GAMA][RUNTIME] Static preview hidden after live runtime data arrived."); + } + + private void RestoreStaticPreviewHiddenByRuntimeData() + { + if (!staticPreviewHiddenAfterRuntimeData) + { + return; + } + + GamaPreviewSession[] sessions = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < sessions.Length; i++) + { + GamaPreviewSession session = sessions[i]; + if (session == null || + session.gameObject == null || + session.gameObject.name != "[GAMA] Static Experiment Preview") + { + continue; + } + + if (!session.gameObject.activeSelf) + { + session.gameObject.SetActive(true); + } + } } private void LogRuntimeFlow(WorldJSONInfo world) { + if (!GamaLog.VerboseEnabled) + { + return; + } + runtimeFlowLogCount++; if (runtimeFlowLogCount > 20 && runtimeFlowLogCount % 100 != 0) { @@ -4236,7 +5014,7 @@ private void LogRuntimeFlow(WorldJSONInfo world) int names = world != null && world.names != null ? world.names.Count : 0; int propertyIds = world != null && world.propertyID != null ? world.propertyID.Count : 0; - Debug.Log("[GAMA][RUNTIME][FLOW] received json_output names=" + names + " propertyIDs=" + propertyIds); + GamaLog.Dev("[GAMA][RUNTIME][FLOW] received json_output names=" + names + " propertyIDs=" + propertyIds); } private RuntimeImportProfile AnalyzeRuntimeImport(WorldJSONInfo world, int messageBytes, long parseMs) @@ -4293,7 +5071,7 @@ private bool HasLargeSpecies(RuntimeImportProfile profile) private void LogRuntimeImportProfile(RuntimeImportProfile profile) { - if (profile == null) + if (!GamaLog.VerboseEnabled || profile == null) { return; } @@ -4308,7 +5086,7 @@ private void LogRuntimeImportProfile(RuntimeImportProfile profile) return; } - Debug.Log( + GamaLog.Dev( "[GAMA][PERF][STREAM] isInit=" + profile.IsInit + " bytes=" + profile.MessageBytes + " names=" + profile.NamesCount + @@ -4320,11 +5098,11 @@ private void LogRuntimeImportProfile(RuntimeImportProfile profile) { if (profile.IsLarge || pair.Value >= largeSpeciesThreshold) { - Debug.Log("[GAMA][PERF][SPECIES] propertyID=" + pair.Key + " count=" + pair.Value + " mode=" + largeSpeciesMode); + GamaLog.Dev("[GAMA][PERF][SPECIES] propertyID=" + pair.Key + " count=" + pair.Value + " mode=" + largeSpeciesMode); } } - Debug.Log("[GAMA][PERF][JSON] parseMs=" + profile.ParseMs + " applyMs=0"); + GamaLog.Dev("[GAMA][PERF][JSON] parseMs=" + profile.ParseMs + " applyMs=0"); } private void BeginImportApplyIfNeeded() @@ -4346,18 +5124,33 @@ private void CompleteImportProfileIfNeeded(bool completed) ? (long)((Time.realtimeSinceStartup - currentImportProfile.ApplyStartedAt) * 1000f) : 0L; - foreach (KeyValuePair pair in currentImportProfile.ImportCountersByPropertyId) + if (GamaLog.VerboseEnabled) { - RuntimeImportCounters counters = pair.Value; - Debug.Log( - "[GAMA][PERF][IMPORT] propertyID=" + pair.Key + - " created=" + counters.Created + - " updated=" + counters.Updated + - " skippedUnchanged=" + counters.SkippedUnchanged + - " deferred=" + counters.Deferred); + foreach (KeyValuePair pair in currentImportProfile.ImportCountersByPropertyId) + { + RuntimeImportCounters counters = pair.Value; + GamaLog.Dev( + "[GAMA][PERF][IMPORT] propertyID=" + pair.Key + + " created=" + counters.Created + + " updated=" + counters.Updated + + " skippedUnchanged=" + counters.SkippedUnchanged + + " deferred=" + counters.Deferred); + } + } + + if (currentImportProfile.IsInit) + { + GamaLog.Info( + "[GAMA] Initial import complete: " + currentImportProfile.NamesCount + + " agent(s), " + currentImportProfile.PointsGeomCount + + " geometries, " + currentImportProfile.PointsLocCount + + " prefab position(s)."); } - Debug.Log("[GAMA][PERF][JSON] parseMs=" + currentImportProfile.ParseMs + " applyMs=" + applyMs); + if (GamaLog.VerboseEnabled) + { + GamaLog.Dev("[GAMA][PERF][JSON] parseMs=" + currentImportProfile.ParseMs + " applyMs=" + applyMs); + } currentImportProfile = null; } @@ -4460,14 +5253,21 @@ private static MaterialPropertyBlock SharedColorPropertyBlock } } - static public void ChangeColor(GameObject obj, Color color) + static public void ChangeColor(GameObject obj, Color color) { Renderer[] renderers = obj.gameObject.GetComponentsInChildren(true); int[] colorIds = ColorPropertyIds; for (int i = 0; i < renderers.Length; i++) { - Renderer renderer = renderers[i]; - MaterialPropertyBlock colorPropertyBlock = SharedColorPropertyBlock; + Renderer renderer = renderers[i]; + GamaRuntimeRendererAppearanceBaseline baseline = + renderer.GetComponent(); + if (baseline == null) + { + baseline = renderer.gameObject.AddComponent(); + } + baseline.Capture(renderer); + MaterialPropertyBlock colorPropertyBlock = SharedColorPropertyBlock; bool applied = false; for (int c = 0; c < colorIds.Length; c++) { @@ -4495,14 +5295,37 @@ static public void ChangeColor(GameObject obj, Color color) } } - if (!applied) - { - renderer.GetPropertyBlock(colorPropertyBlock); - colorPropertyBlock.SetColor(colorIds[1], color); - renderer.SetPropertyBlock(colorPropertyBlock); - colorPropertyBlock.Clear(); - } - } + if (!applied) + { + renderer.GetPropertyBlock(colorPropertyBlock); + colorPropertyBlock.SetColor(colorIds[1], color); + renderer.SetPropertyBlock(colorPropertyBlock); + colorPropertyBlock.Clear(); + } + + Material[] indexedMaterials = renderer.sharedMaterials; + for (int m = 0; m < indexedMaterials.Length; m++) + { + Material material = indexedMaterials[m]; + MaterialPropertyBlock indexedBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(indexedBlock, m); + bool indexedApplied = false; + for (int c = 0; c < colorIds.Length; c++) + { + int propertyId = colorIds[c]; + if (material != null && material.HasProperty(propertyId)) + { + indexedBlock.SetColor(propertyId, color); + indexedApplied = true; + } + } + if (!indexedApplied) + { + indexedBlock.SetColor(colorIds[1], color); + } + renderer.SetPropertyBlock(indexedBlock, m); + } + } } protected virtual void AdditionalInitAfterGeomLoading() { @@ -4678,7 +5501,7 @@ private void HandleConnectionAttempted(bool success) { if (IsGameState(GameState.MENU)) { - Debug.Log("[GAMA] Connected to middleware"); + GamaLog.Dev("[GAMA] Connected to middleware"); UpdateGameState(GameState.WAITING); } @@ -4691,6 +5514,22 @@ private void HandleConnectionAttempted(bool success) } } + private static void RestoreRuntimeColor(GameObject obj) + { + if (obj == null) + { + return; + } + + Renderer[] renderers = obj.GetComponentsInChildren(true); + for (int i = 0; i < renderers.Length; i++) + { + GamaRuntimeRendererAppearanceBaseline baseline = + renderers[i].GetComponent(); + baseline?.Restore(renderers[i]); + } + } + private void TryBootstrapRuntimePlayer() { if (runtimePlayerBootstrapConfirmed) @@ -4728,10 +5567,10 @@ private void TryBootstrapRuntimePlayer() if (runtimePlayerBootstrapAttempts >= RuntimePlayerBootstrapMaxAttempts) { - if (runtimePlayerBootstrapAttempts == RuntimePlayerBootstrapMaxAttempts) + if (GamaLog.VerboseEnabled && runtimePlayerBootstrapAttempts == RuntimePlayerBootstrapMaxAttempts) { runtimePlayerBootstrapAttempts++; - Debug.LogWarning("[GAMA][RUNTIME][BOOTSTRAP] create_player did not authenticate after " + + GamaLog.DevWarning("[GAMA][RUNTIME][BOOTSTRAP] create_player did not authenticate after " + RuntimePlayerBootstrapMaxAttempts + " attempts. Check simple.webplatform/GAMA logs."); } return; @@ -4747,7 +5586,7 @@ private void TryBootstrapRuntimePlayer() string expression = "do create_player(\"" + EscapeGamlString(id) + "\");"; runtimePlayerBootstrapAttempts++; nextRuntimePlayerBootstrapTime = now + RuntimePlayerBootstrapRetrySeconds; - Debug.Log("[GAMA][RUNTIME][BOOTSTRAP] create_player attempt " + runtimePlayerBootstrapAttempts + + GamaLog.Dev("[GAMA][RUNTIME][BOOTSTRAP] create_player attempt " + runtimePlayerBootstrapAttempts + "/" + RuntimePlayerBootstrapMaxAttempts + " id=" + id); manager.SendExecutableExpression(expression); } @@ -4794,11 +5633,86 @@ public GameState GetCurrentState() } [DisallowMultipleComponent] -public class GamaRuntimePrefabSignature : MonoBehaviour -{ - public string signature; - public Quaternion baseRotation = Quaternion.identity; -} +public class GamaRuntimePrefabSignature : MonoBehaviour +{ + public string signature; + public Quaternion baseRotation = Quaternion.identity; + public Vector3 baseLocalScale = Vector3.one; + public bool hasBaseLocalScale; +} + +[DisallowMultipleComponent] +public sealed class GamaRuntimeRendererAppearanceBaseline : MonoBehaviour +{ + [NonSerialized] private bool captured; + [NonSerialized] private bool hadPropertyBlock; + [NonSerialized] private MaterialPropertyBlock propertyBlock; + [NonSerialized] private bool rendererEnabled; + [NonSerialized] private UnityEngine.Rendering.ShadowCastingMode shadowCastingMode; + [NonSerialized] private bool receiveShadows; + [NonSerialized] private bool[] hadMaterialPropertyBlocks; + [NonSerialized] private MaterialPropertyBlock[] materialPropertyBlocks; + + public void Capture(Renderer renderer) + { + if (captured || renderer == null) + { + return; + } + + propertyBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(propertyBlock); + hadPropertyBlock = !propertyBlock.isEmpty; + int materialCount = renderer.sharedMaterials != null ? renderer.sharedMaterials.Length : 0; + hadMaterialPropertyBlocks = new bool[materialCount]; + materialPropertyBlocks = new MaterialPropertyBlock[materialCount]; + for (int i = 0; i < materialCount; i++) + { + MaterialPropertyBlock block = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(block, i); + hadMaterialPropertyBlocks[i] = !block.isEmpty; + materialPropertyBlocks[i] = block; + } + rendererEnabled = renderer.enabled; + shadowCastingMode = renderer.shadowCastingMode; + receiveShadows = renderer.receiveShadows; + captured = true; + } + + public void Restore(Renderer renderer) + { + if (!captured || renderer == null) + { + return; + } + + renderer.SetPropertyBlock(hadPropertyBlock ? propertyBlock : null); + int materialCount = renderer.sharedMaterials != null ? renderer.sharedMaterials.Length : 0; + for (int i = 0; i < materialCount; i++) + { + bool hadBlock = hadMaterialPropertyBlocks != null && + i < hadMaterialPropertyBlocks.Length && + hadMaterialPropertyBlocks[i]; + MaterialPropertyBlock block = materialPropertyBlocks != null && + i < materialPropertyBlocks.Length + ? materialPropertyBlocks[i] + : null; + renderer.SetPropertyBlock(hadBlock ? block : null, i); + } + } + + public void RestoreRendererState(Renderer renderer) + { + if (!captured || renderer == null) + { + return; + } + + renderer.enabled = rendererEnabled; + renderer.shadowCastingMode = shadowCastingMode; + renderer.receiveShadows = receiveShadows; + } +} // ############################################################ diff --git a/Runtime/Simulation/SimulationManagerSolo.cs b/Runtime/Simulation/SimulationManagerSolo.cs index d7dfaea..403e63f 100644 --- a/Runtime/Simulation/SimulationManagerSolo.cs +++ b/Runtime/Simulation/SimulationManagerSolo.cs @@ -350,7 +350,7 @@ private void InitializeHotspots() if (debugOptions.logHotspotInitialization) { - Debug.Log("[GAMA][SOLO] Initialized " + initializedCount + " hotspot(s) from GAMA parameters."); + GamaLog.Dev("[GAMA][SOLO] Initialized " + initializedCount + " hotspot(s) from GAMA parameters."); } } @@ -464,7 +464,7 @@ private void SendAskToGama(string askName, string objectId) if (debugOptions.logGamaAsks) { - Debug.Log("[GAMA][SOLO] Sent executable ask '" + askName + "' with " + gamaAsks.idArgumentKey + "=" + objectId); + GamaLog.Dev("[GAMA][SOLO] Sent executable ask '" + askName + "' with " + gamaAsks.idArgumentKey + "=" + objectId); } } @@ -481,7 +481,7 @@ private void ApplyColor(GameObject obj, Color color) } catch (Exception exception) { - Debug.LogWarning("[GAMA][SOLO] Failed to apply color to " + obj.name + ": " + exception.GetBaseException().Message); + GamaLog.DevWarning("[GAMA][SOLO] Failed to apply color to " + obj.name + ": " + exception.GetBaseException().Message); } } @@ -492,6 +492,6 @@ private void LogIgnored(string message) return; } - Debug.Log("[GAMA][SOLO] " + message); + GamaLog.Dev("[GAMA][SOLO] " + message); } -} \ No newline at end of file +} diff --git a/Runtime/Utils/GamaLog.cs b/Runtime/Utils/GamaLog.cs new file mode 100644 index 0000000..8825c46 --- /dev/null +++ b/Runtime/Utils/GamaLog.cs @@ -0,0 +1,44 @@ +using UnityEngine; + +public static class GamaLog +{ + private static volatile bool verboseEnabled = false; + + public static bool VerboseEnabled => verboseEnabled; + + public static void SetVerboseEnabled(bool enabled) + { + verboseEnabled = enabled; + } + + public static void Info(string message) + { + Debug.Log(message); + } + + public static void Dev(string message) + { + if (VerboseEnabled) + { + Debug.Log(message); + } + } + + public static void Warning(string message) + { + Debug.LogWarning(message); + } + + public static void DevWarning(string message) + { + if (VerboseEnabled) + { + Debug.LogWarning(message); + } + } + + public static void Error(string message) + { + Debug.LogError(message); + } +} diff --git a/Runtime/Utils/GamaLog.cs.meta b/Runtime/Utils/GamaLog.cs.meta new file mode 100644 index 0000000..05e1848 --- /dev/null +++ b/Runtime/Utils/GamaLog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c35ccf8e65b4d948b2ae65cc0f195d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Utils/PolyExtruderLight.cs b/Runtime/Utils/PolyExtruderLight.cs index b5864fa..ca3453a 100644 --- a/Runtime/Utils/PolyExtruderLight.cs +++ b/Runtime/Utils/PolyExtruderLight.cs @@ -35,7 +35,7 @@ public void createPrism(string name, float height, Vector2[] points, Color32 col if (!TryBuildCombinedMesh(originalPolygonVertices, extrusionHeightY, prismName, out Mesh mesh)) { - Debug.LogWarning("[PolyExtruderLight] createPrism failed. Invalid polygon for " + prismName); + GamaLog.DevWarning("[PolyExtruderLight] createPrism failed. Invalid polygon for " + prismName); return; } @@ -59,7 +59,7 @@ public void updatePrism(MeshFilter meshFilter, Vector2[] points) originalPolygonVertices = SanitizePoints(points); if (!TryBuildCombinedMesh(originalPolygonVertices, extrusionHeightY, prismName, out Mesh mesh)) { - Debug.LogWarning("[PolyExtruderLight] updatePrism failed. Invalid polygon for " + prismName); + GamaLog.DevWarning("[PolyExtruderLight] updatePrism failed. Invalid polygon for " + prismName); return; } diff --git a/Samples~/Code Example/SendReceiveMessageExample.cs b/Samples~/Code Example/SendReceiveMessageExample.cs index 2c2939d..1b8228d 100644 --- a/Samples~/Code Example/SendReceiveMessageExample.cs +++ b/Samples~/Code Example/SendReceiveMessageExample.cs @@ -28,12 +28,12 @@ protected override void OtherUpdate() {"id",ConnectionManager.Instance.GetConnectionId() }, {"mes", mes }}; - Debug.Log("sent to GAMA: " + mes); + GamaLog.Dev("Sent to GAMA: " + mes); ConnectionManager.Instance.SendExecutableAsk("receive_message", args); } if (message != null) { - Debug.Log("received from GAMA: cycle " + message.cycle); + GamaLog.Dev("Received from GAMA: cycle " + message.cycle); message = null; } diff --git a/Samples~/Code Example/SimulationMultiPlayerExample.cs b/Samples~/Code Example/SimulationMultiPlayerExample.cs index dd9d22b..ae36134 100644 --- a/Samples~/Code Example/SimulationMultiPlayerExample.cs +++ b/Samples~/Code Example/SimulationMultiPlayerExample.cs @@ -55,7 +55,7 @@ protected override void OtherUpdate() protected override void HoverEnterInteraction(HoverEnterEventArgs ev) { - Debug.Log("HoverEnterInteraction"); + GamaLog.Dev("HoverEnterInteraction"); GameObject obj = ev.interactableObject.transform.gameObject; if (obj.tag.Equals("selectable")) @@ -65,7 +65,7 @@ protected override void HoverEnterInteraction(HoverEnterEventArgs ev) protected override void HoverExitInteraction(HoverExitEventArgs ev) { - Debug.Log("HoverExitInteraction"); + GamaLog.Dev("HoverExitInteraction"); GameObject obj = ev.interactableObject.transform.gameObject; if (obj.tag.Equals("selectable")) { @@ -76,7 +76,7 @@ protected override void HoverExitInteraction(HoverExitEventArgs ev) protected override void SelectInteraction(SelectEnterEventArgs ev) { - Debug.Log("SelectInteraction"); + GamaLog.Dev("SelectInteraction"); if (remainingTime <= 0.0) { GameObject obj = ev.interactableObject.transform.gameObject; diff --git a/Tests.meta b/Tests.meta new file mode 100644 index 0000000..271701f --- /dev/null +++ b/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3c0274fe298f48c8a1d4a70cb4c88783 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor.meta b/Tests/Editor.meta new file mode 100644 index 0000000..c5a0d0f --- /dev/null +++ b/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 30cb31037376454b88812472dd80d183 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/GamaPreviewReuseCaptureTests.cs b/Tests/Editor/GamaPreviewReuseCaptureTests.cs new file mode 100644 index 0000000..2b5aa65 --- /dev/null +++ b/Tests/Editor/GamaPreviewReuseCaptureTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; + +public class GamaPreviewReuseCaptureTests +{ + [Test] + public void Accumulator_PreservesEveryAgentWhenStableIdentityIsDuplicated() + { + Type accumulatorType = typeof(GamaPanelWindow).Assembly.GetType( + "GamaEditorPreviewWorldAccumulator", + true); + object accumulator = Activator.CreateInstance(accumulatorType, true); + MethodInfo merge = accumulatorType.GetMethod( + "Merge", + BindingFlags.Instance | BindingFlags.Public); + MethodInfo toWorldJson = accumulatorType.GetMethod( + "ToWorldJson", + BindingFlags.Instance | BindingFlags.Public); + + Assert.That(merge, Is.Not.Null); + Assert.That(toWorldJson, Is.Not.Null); + + string chunkJson = @"{ + ""names"": [""alice"", ""bob""], + ""keepNames"": [""alice"", ""bob""], + ""propertyID"": [""people"", ""people""], + ""pointsLoc"": [ + { ""c"": [10, 20, 0] }, + { ""c"": [30, 40, 0] } + ], + ""pointsGeom"": [], + ""offsetYGeom"": [], + ""attributes"": [ + { ""id"": ""shared-id"" }, + { ""id"": ""shared-id"" } + ], + ""ranking"": [0, 1] +}"; + + Type jsonObjectType = merge.GetParameters()[0].ParameterType; + MethodInfo parseJson = jsonObjectType.GetMethod( + "Parse", + BindingFlags.Static | BindingFlags.Public, + null, + new[] { typeof(string) }, + null); + Assert.That(parseJson, Is.Not.Null); + object chunk = parseJson.Invoke(null, new object[] { chunkJson }); + + Dictionary propertyMap = + new Dictionary(StringComparer.Ordinal) + { + ["people"] = new PropertiesGAMA + { + id = "people", + hasPrefab = true, + prefab = "People/Person" + } + }; + + object mergeResult = merge.Invoke( + accumulator, + new[] { chunk, (object)12, propertyMap, null }); + FieldInfo cacheAgentCount = mergeResult.GetType().GetField( + "CacheAgentCount", + BindingFlags.Instance | BindingFlags.Public); + Assert.That(cacheAgentCount, Is.Not.Null); + Assert.That((int)cacheAgentCount.GetValue(mergeResult), Is.EqualTo(2)); + + string accumulatedJson = (string)toWorldJson.Invoke(accumulator, null); + WorldJSONInfo accumulatedWorld = WorldJSONInfo.CreateFromJSON(accumulatedJson); + + Assert.That(accumulatedWorld, Is.Not.Null); + Assert.That(accumulatedWorld.names, Is.EqualTo(new[] { "alice", "bob" })); + Assert.That(accumulatedWorld.propertyID, Is.EqualTo(new[] { "people", "people" })); + Assert.That(accumulatedWorld.attributes, Has.Count.EqualTo(2)); + + Assert.That( + accumulatedWorld.attributes[0].TryGetString(out string firstId, "id"), + Is.True); + Assert.That( + accumulatedWorld.attributes[1].TryGetString(out string secondId, "id"), + Is.True); + Assert.That(firstId, Is.EqualTo("shared-id")); + Assert.That(secondId, Is.EqualTo("shared-id")); + } +} diff --git a/Tests/Editor/GamaPreviewReuseCaptureTests.cs.meta b/Tests/Editor/GamaPreviewReuseCaptureTests.cs.meta new file mode 100644 index 0000000..6188085 --- /dev/null +++ b/Tests/Editor/GamaPreviewReuseCaptureTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d47fc36d8eaa4a479bd4e57ce9e37d63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/GamaPreviewReuseIdentityRegistryTests.cs b/Tests/Editor/GamaPreviewReuseIdentityRegistryTests.cs new file mode 100644 index 0000000..b93367e --- /dev/null +++ b/Tests/Editor/GamaPreviewReuseIdentityRegistryTests.cs @@ -0,0 +1,510 @@ +using System; +using NUnit.Framework; +using UnityEngine; + +public class GamaPreviewReuseIdentityRegistryTests +{ + private const string PreviewRootName = "[GAMA] Static Experiment Preview"; + + private GameObject previewRoot; + private GamaPreviewSession session; + private string experimentKey; + + [SetUp] + public void SetUp() + { + previewRoot = new GameObject(PreviewRootName); + session = previewRoot.AddComponent(); + session.modelPath = "C:/gama/models/reuse-test.gaml"; + session.experimentName = "Main"; + session.activeGamaSelection = false; + Assert.That(session.RefreshStableExperimentKey(), Is.True); + experimentKey = session.stableExperimentKey; + session.reuseAuthorizedForPlay = true; + session.authorizedStableExperimentKey = experimentKey; + previewRoot.SetActive(false); + } + + [TearDown] + public void TearDown() + { + if (previewRoot != null) + { + UnityEngine.Object.DestroyImmediate(previewRoot); + } + + GamaPreviewSession[] remainingSessions = UnityEngine.Object.FindObjectsByType( + FindObjectsInactive.Include, + FindObjectsSortMode.None); + for (int i = 0; i < remainingSessions.Length; i++) + { + GamaPreviewSession remaining = remainingSessions[i]; + if (remaining != null && remaining.gameObject.name == PreviewRootName) + { + UnityEngine.Object.DestroyImmediate(remaining.gameObject); + } + } + } + + [Test] + public void ExperimentIdentity_NormalizesSemanticContextOnly() + { + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + "C:\\gama\\models\\nested\\..\\reuse-test.gaml", + " MAIN ", + false, + "ignored-monitor", + out string first), + Is.True); + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + "c:/gama/models/reuse-test.gaml", + "main", + false, + string.Empty, + out string second), + Is.True); + + Assert.That(first, Is.EqualTo(second)); + Assert.That(first, Does.Not.Contain("ignored-monitor")); + Assert.That(first, Does.Not.Contain("timestamp")); + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + "c:/gama/models/reuse-test.gaml", + "other", + false, + string.Empty, + out string otherExperiment), + Is.True); + Assert.That(otherExperiment, Is.Not.EqualTo(first)); + } + + [Test] + public void ActiveSelection_RequiresMonitorExperimentIdentity() + { + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + "GAMA_ACTIVE_SELECTION", + "main", + true, + string.Empty, + out _), + Is.False); + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + "GAMA_ACTIVE_SELECTION", + "main", + true, + "experiment-42", + out string first), + Is.True); + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableExperimentKey( + "GAMA_ACTIVE_SELECTION", + "main", + true, + "experiment-43", + out string second), + Is.True); + Assert.That(second, Is.Not.EqualTo(first)); + } + + [Test] + public void AgentIdentity_PrefersStableAttributesAndRejectsSyntheticFallbacks() + { + WorldJSONInfo world = WorldJSONInfo.CreateFromJSON( + "{\"attributes\":[{\"id\":\"agent_7\",\"gama_id\":\"G-17\",\"uuid\":\"U-99\"}]}"); + Attributes attributes = world.GetAttributesAt(0); + + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableAgentKey( + "Wolf", + "agent_4", + attributes, + out string key, + out string sourceId), + Is.True); + Assert.That(sourceId, Is.EqualTo("G-17")); + Assert.That(key, Is.EqualTo("wolf::G-17")); + + Assert.That(GamaPreviewReuseIdentity.IsSyntheticAgentName("agent_0"), Is.True); + Assert.That(GamaPreviewReuseIdentity.IsSyntheticAgentName("unknown_agent_i"), Is.True); + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableAgentKey( + "wolf", + "unknown_agent_12", + null, + out _, + out _), + Is.False); + + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableAgentKey( + "wolf", + "Luna", + null, + out string wolfKey, + out _), + Is.True); + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableAgentKey( + "fox", + "Luna", + null, + out string foxKey, + out _), + Is.True); + Assert.That(foxKey, Is.Not.EqualTo(wolfKey)); + + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableAgentKey( + "wolf", + "A", + null, + out string upperCaseId, + out _), + Is.True); + Assert.That( + GamaPreviewReuseIdentity.TryBuildStableAgentKey( + "WOLF", + "a", + null, + out string lowerCaseId, + out _), + Is.True); + Assert.That(upperCaseId, Is.EqualTo("wolf::A")); + Assert.That(lowerCaseId, Is.EqualTo("wolf::a")); + Assert.That(lowerCaseId, Is.Not.EqualTo(upperCaseId)); + } + + [Test] + public void TryCreate_FindsInactiveRootAndRequiresExactAuthorization() + { + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out var registry), Is.True); + Assert.That(registry.PreviewRoot, Is.SameAs(previewRoot)); + registry.Dispose(); + + session.authorizedStableExperimentKey = experimentKey + "-mismatch"; + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out _), Is.False); + + session.authorizedStableExperimentKey = experimentKey; + session.stale = true; + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out _), Is.False); + } + + [Test] + public void ActiveSession_RequiresExactAuthorizedMonitorId() + { + session.modelPath = "GAMA_ACTIVE_SELECTION"; + session.experimentName = "main"; + session.activeGamaSelection = true; + session.monitorExperimentId = "Monitor-A"; + Assert.That(session.RefreshStableExperimentKey(), Is.True); + experimentKey = session.stableExperimentKey; + session.authorizedStableExperimentKey = experimentKey; + session.authorizedMonitorExperimentId = "monitor-a"; + + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out _), Is.False); + + session.authorizedMonitorExperimentId = "Monitor-A"; + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out var registry), Is.True); + registry.Dispose(); + } + + [Test] + public void DuplicateStableAgentKeys_AreAllRefused() + { + AddReusableMarker("wolf::17", "wolf-shape", GamaPreviewRepresentationKind.Geometry); + AddReusableMarker("wolf::17", "wolf-shape-copy", GamaPreviewRepresentationKind.Geometry); + + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out var registry), Is.True); + Assert.That(registry.AvailableCount, Is.Zero); + Assert.That( + registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:wolf-shape", + out _), + Is.False); + registry.Dispose(); + } + + [Test] + public void TryTake_RequiresExactPropertyRepresentationAndSignature() + { + AddReusableMarker("wolf::17", "wolf-shape", GamaPreviewRepresentationKind.Geometry); + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out var registry), Is.True); + + Assert.That( + registry.TryTake( + "wolf::17", + "other-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:wolf-shape", + out _), + Is.False); + Assert.That( + registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Prefab, + "geometry:wolf-shape", + out _), + Is.False); + Assert.That( + registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:other-shape", + out _), + Is.False); + Assert.That( + registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:wolf-shape", + out GameObject instance), + Is.True); + Assert.That(instance, Is.Not.Null); + registry.Dispose(); + } + + [Test] + public void TryTake_PrefabRequiresExactResolvedUnityAsset() + { + GameObject prefabA = new GameObject("wolf-prefab-a"); + GameObject prefabB = new GameObject("wolf-prefab-b"); + GamaPreviewReuseRegistry registry = null; + try + { + AddReusableMarker( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Prefab, + prefabA); + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out registry), Is.True); + + Assert.That( + registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Prefab, + "resources:wolf-prefab-b", + prefabB, + out _), + Is.False); + Assert.That( + registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Prefab, + "resources:wolf-prefab-a", + prefabA, + out GameObject instance), + Is.True); + Assert.That(instance, Is.Not.Null); + } + finally + { + registry?.Dispose(); + UnityEngine.Object.DestroyImmediate(prefabA); + UnityEngine.Object.DestroyImmediate(prefabB); + } + } + + [Test] + public void TryTake_RequiresOrdinalExactAgentIdCase() + { + AddReusableMarker("wolf::A", "wolf-shape", GamaPreviewRepresentationKind.Geometry); + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out var registry), Is.True); + + try + { + Assert.That( + registry.TryTake( + "wolf::a", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:wolf-shape", + out _), + Is.False); + Assert.That( + registry.TryTake( + "wolf::A", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:wolf-shape", + out GameObject instance), + Is.True); + Assert.That(instance, Is.Not.Null); + } + finally + { + registry.Dispose(); + } + } + + [Test] + public void Release_RestoresHierarchyTransformActiveAndRendererState_ThenRetakesSameInstance() + { + GameObject before = new GameObject("before"); + before.transform.SetParent(previewRoot.transform, false); + GamaPreviewObject marker = AddReusableMarker( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry); + GameObject after = new GameObject("after"); + after.transform.SetParent(previewRoot.transform, false); + + GameObject instance = marker.gameObject; + Transform originalParent = instance.transform.parent; + int originalSibling = instance.transform.GetSiblingIndex(); + Vector3 originalPosition = new Vector3(1f, 2f, 3f); + Quaternion originalRotation = Quaternion.Euler(10f, 20f, 30f); + Vector3 originalScale = new Vector3(2f, 3f, 4f); + instance.transform.localPosition = originalPosition; + instance.transform.localRotation = originalRotation; + instance.transform.localScale = originalScale; + + Renderer renderer = instance.GetComponent(); + renderer.enabled = false; + int propertyId = Shader.PropertyToID("_GamaReuseBaseline"); + MaterialPropertyBlock baselineBlock = new MaterialPropertyBlock(); + baselineBlock.SetFloat(propertyId, 7f); + renderer.SetPropertyBlock(baselineBlock); + MeshFilter meshFilter = instance.GetComponent(); + Mesh originalMesh = meshFilter.sharedMesh; + + GameObject nested = new GameObject("nested"); + nested.transform.SetParent(instance.transform, false); + nested.SetActive(false); + + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out var registry), Is.True); + Assert.That( + registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:wolf-shape", + out GameObject firstTake), + Is.True); + int originalInstanceId = firstTake.GetInstanceID(); + + GameObject runtimeRoot = new GameObject("runtime-root"); + Mesh runtimeMesh = new Mesh { name = "runtime-only-mesh" }; + try + { + instance.name = "runtime-name"; + firstTake.transform.SetParent(runtimeRoot.transform, false); + firstTake.transform.localPosition = Vector3.one * 99f; + firstTake.transform.localRotation = Quaternion.identity; + firstTake.transform.localScale = Vector3.one * 9f; + firstTake.SetActive(false); + nested.SetActive(true); + renderer.enabled = true; + MaterialPropertyBlock runtimeBlock = new MaterialPropertyBlock(); + runtimeBlock.SetFloat(propertyId, 99f); + renderer.SetPropertyBlock(runtimeBlock); + meshFilter.sharedMesh = runtimeMesh; + firstTake.AddComponent(); + GameObject runtimeVisual = new GameObject("VisualOverride"); + runtimeVisual.transform.SetParent(firstTake.transform, false); + + Assert.That(registry.Release("wolf::17"), Is.True); + Assert.That(instance.name, Is.EqualTo("wolf::17")); + Assert.That(instance.transform.parent, Is.SameAs(originalParent)); + Assert.That(instance.transform.GetSiblingIndex(), Is.EqualTo(originalSibling)); + Assert.That(instance.transform.localPosition, Is.EqualTo(originalPosition)); + Assert.That(Quaternion.Angle(instance.transform.localRotation, originalRotation), Is.LessThan(0.001f)); + Assert.That(instance.transform.localScale, Is.EqualTo(originalScale)); + Assert.That(instance.activeSelf, Is.True); + Assert.That(nested.activeSelf, Is.False); + Assert.That(renderer.enabled, Is.False); + Assert.That(meshFilter.sharedMesh, Is.SameAs(originalMesh)); + Assert.That(firstTake.GetComponent(), Is.Null); + Assert.That(firstTake.transform.Find("VisualOverride"), Is.Null); + MaterialPropertyBlock restoredBlock = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(restoredBlock); + Assert.That(restoredBlock.GetFloat(propertyId), Is.EqualTo(7f)); + + Assert.That( + registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:wolf-shape", + out GameObject secondTake), + Is.True); + Assert.That(secondTake.GetInstanceID(), Is.EqualTo(originalInstanceId)); + registry.RestoreAll(); + registry.RestoreAll(); + Assert.That(registry.ClaimedCount, Is.Zero); + Assert.That(instance.transform.parent, Is.SameAs(originalParent)); + } + finally + { + registry.Dispose(); + UnityEngine.Object.DestroyImmediate(runtimeMesh); + UnityEngine.Object.DestroyImmediate(runtimeRoot); + } + } + + [Test] + public void GlobalClaim_PreventsTwoRegistriesFromTakingSameMarker() + { + AddReusableMarker("wolf::17", "wolf-shape", GamaPreviewRepresentationKind.Geometry); + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out var first), Is.True); + Assert.That(GamaPreviewReuseRegistry.TryCreate(experimentKey, out var second), Is.True); + + try + { + Assert.That(TryTakeWolf(first, out GameObject firstInstance), Is.True); + Assert.That(TryTakeWolf(second, out _), Is.False); + Assert.That(first.Release("wolf::17"), Is.True); + Assert.That(TryTakeWolf(second, out GameObject secondInstance), Is.True); + Assert.That(secondInstance, Is.SameAs(firstInstance)); + } + finally + { + first.Dispose(); + second.Dispose(); + } + } + + private GamaPreviewObject AddReusableMarker( + string stableAgentKey, + string propertyId, + GamaPreviewRepresentationKind kind, + GameObject sourcePrefabAsset = null) + { + GameObject instance = GameObject.CreatePrimitive(PrimitiveType.Cube); + instance.name = stableAgentKey; + instance.transform.SetParent(previewRoot.transform, false); + GamaPreviewObject marker = instance.AddComponent(); + marker.previewOnly = true; + marker.canBeReusedAtRuntime = true; + marker.provenance = GamaPreviewProvenance.CapturedJson; + marker.representationKind = kind; + marker.stableAgentKey = stableAgentKey; + marker.sourcePropertyId = propertyId; + marker.sourcePrefabSignature = kind == GamaPreviewRepresentationKind.Prefab + ? "prefab:" + propertyId + ":wolf-prefab" + : "geometry:" + propertyId; + marker.sourcePrefabAsset = sourcePrefabAsset; + return marker; + } + + private static bool TryTakeWolf(GamaPreviewReuseRegistry registry, out GameObject instance) + { + return registry.TryTake( + "wolf::17", + "wolf-shape", + GamaPreviewRepresentationKind.Geometry, + "geometry:wolf-shape", + out instance); + } +} diff --git a/Tests/Editor/GamaPreviewReuseIdentityRegistryTests.cs.meta b/Tests/Editor/GamaPreviewReuseIdentityRegistryTests.cs.meta new file mode 100644 index 0000000..0b2479a --- /dev/null +++ b/Tests/Editor/GamaPreviewReuseIdentityRegistryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 824a609823d84b07b33b3ad0b20b46d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/GamaSpeciesAppearanceStateStoreTests.cs b/Tests/Editor/GamaSpeciesAppearanceStateStoreTests.cs new file mode 100644 index 0000000..f65ddb2 --- /dev/null +++ b/Tests/Editor/GamaSpeciesAppearanceStateStoreTests.cs @@ -0,0 +1,369 @@ +using NUnit.Framework; +using System.Collections; +using System.IO; +using System.Reflection; +using UnityEngine; + +public class GamaSpeciesAppearanceStateStoreTests +{ + private GamaSpeciesRenderOverridesAsset asset; + + [SetUp] + public void SetUp() + { + GamaSpeciesAppearanceStateStore.ClearRuntimeOverlay(); + asset = ScriptableObject.CreateInstance(); + } + + [TearDown] + public void TearDown() + { + GamaSpeciesAppearanceStateStore.ClearRuntimeOverlay(); + if (asset != null) + { + Object.DestroyImmediate(asset); + } + } + + [Test] + public void ExactContext_DoesNotLeakBetweenExperiments() + { + GamaSpeciesAppearanceContext first = Context("model.gaml", "first"); + GamaSpeciesAppearanceContext second = Context("model.gaml", "second"); + + GamaSpeciesRenderOverrideEntry firstEntry = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(first, "wolf", false); + firstEntry.overrideScaleMultiplier = true; + firstEntry.scaleMultiplier = 2f; + GamaSpeciesAppearanceStateStore.NotifyEntryChanged(first, "wolf", false); + + Assert.That( + GamaSpeciesAppearanceStateStore.TryGetEntry(second, "wolf", false, out _), + Is.False); + Assert.That( + GamaSpeciesAppearanceStateStore.TryGetEntry(first, "wolf", false, out var resolved), + Is.True); + Assert.That(resolved.GetEffectiveScaleMultiplier(), Is.EqualTo(2f)); + } + + [Test] + public void ExactContext_NormalizesWindowsSeparatorsAndRelativeSegments() + { + GamaSpeciesAppearanceContext first = Context("C:\\models\\nested\\..\\demo.gaml", "MAIN"); + GamaSpeciesAppearanceContext equivalent = Context("c:/models/demo.gaml", "main"); + + GamaSpeciesRenderOverrideEntry entry = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(first, "wolf", false); + entry.overrideColor = true; + entry.color = Color.cyan; + GamaSpeciesAppearanceStateStore.NotifyEntryChanged(first, "wolf", false); + + Assert.That(first, Is.EqualTo(equivalent)); + Assert.That( + GamaSpeciesAppearanceStateStore.TryGetEntry(equivalent, "wolf", false, out var resolved), + Is.True); + Assert.That(resolved, Is.SameAs(entry)); + + GamaSpeciesAppearanceContext relative = Context("relative-model.gaml", "main"); + GamaSpeciesAppearanceContext absolute = Context(Path.GetFullPath("relative-model.gaml"), "MAIN"); + Assert.That(relative, Is.EqualTo(absolute)); + } + + [Test] + public void RuntimeOverlay_IsSharedAndDoesNotMutatePersistentEntry() + { + GamaSpeciesAppearanceContext context = Context("model.gaml", "main"); + GamaSpeciesRenderOverrideEntry persisted = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(context, "wolf", false); + persisted.overrideScaleMultiplier = true; + persisted.scaleMultiplier = 2f; + GamaSpeciesAppearanceStateStore.NotifyEntryChanged(context, "wolf", false); + + GamaSpeciesRenderOverrideEntry firstOverlay = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(context, "wolf", true); + GamaSpeciesRenderOverrideEntry secondOverlay = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(context, "wolf", true); + Assert.That(secondOverlay, Is.SameAs(firstOverlay)); + + firstOverlay.scaleMultiplier = 3f; + GamaSpeciesAppearanceStateStore.NotifyEntryChanged(context, "wolf", true); + + Assert.That(persisted.scaleMultiplier, Is.EqualTo(2f)); + Assert.That( + GamaSpeciesAppearanceStateStore.TryGetEntry(context, "wolf", true, out var effective), + Is.True); + Assert.That(effective.scaleMultiplier, Is.EqualTo(3f)); + + GamaSpeciesAppearanceStateStore.ClearRuntimeOverlay(); + Assert.That( + GamaSpeciesAppearanceStateStore.TryGetEntry(context, "wolf", true, out effective), + Is.True); + Assert.That(effective, Is.SameAs(persisted)); + } + + [Test] + public void ScaleNormalization_IsAbsoluteAcrossOneTwoOne() + { + GamaSpeciesAppearanceContext context = Context("model.gaml", "main"); + GamaSpeciesRenderOverrideEntry entry = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(context, "wolf", false); + + entry.overrideScaleMultiplier = true; + entry.scaleMultiplier = 2f; + GamaSpeciesAppearanceStateStore.NotifyEntryChanged(context, "wolf", false); + Assert.That(entry.GetEffectiveScaleMultiplier(), Is.EqualTo(2f)); + + entry.overrideScaleMultiplier = false; + entry.scaleMultiplier = 2f; + GamaSpeciesAppearanceStateStore.NotifyEntryChanged(context, "wolf", false); + Assert.That(entry.scaleMultiplier, Is.EqualTo(1f)); + Assert.That(entry.UsesScaleOverride(), Is.False); + Assert.That(entry.GetEffectiveScaleMultiplier(), Is.EqualTo(1f)); + } + + [Test] + public void ClearContext_RemovesOnlyExactPersistentAndTemporaryEntries() + { + GamaSpeciesAppearanceContext first = Context("model.gaml", "first"); + GamaSpeciesAppearanceContext second = Context("model.gaml", "second"); + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(first, "wolf", false); + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(first, "wolf", true); + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(second, "wolf", false); + + GamaSpeciesAppearanceStateStore.ClearContext(first, true); + + Assert.That( + GamaSpeciesAppearanceStateStore.TryGetEntry(first, "wolf", true, out _), + Is.False); + Assert.That( + GamaSpeciesAppearanceStateStore.TryGetEntry(second, "wolf", false, out _), + Is.True); + } + + [Test] + public void NormalizeEntry_MigratesLegacyVisibilityBeforeClearingLegacyFields() + { + GamaSpeciesRenderOverrideEntry entry = new GamaSpeciesRenderOverrideEntry + { + overrideVisibility = true, + visible = false, + overridePreviewVisibility = false, + overrideRuntimeVisibility = false + }; + + GamaSpeciesAppearanceStateStore.NormalizeEntry(entry); + + Assert.That(entry.overridePreviewVisibility, Is.True); + Assert.That(entry.visibleInPreview, Is.False); + Assert.That(entry.overrideRuntimeVisibility, Is.True); + Assert.That(entry.visibleInRuntime, Is.False); + Assert.That(entry.overrideVisibility, Is.False); + } + + [Test] + public void WizardEdit_PreservesDynamicResourceAndExplicitVisibleOverrides() + { + GamaSpeciesAppearanceContext context = Context("model.gaml", "main"); + GamaSpeciesRenderOverrideEntry entry = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(context, "wolf", false); + entry.prefabResourcePath = "Animals/Wolf"; + entry.overrideDynamicColor = true; + entry.dynamicColorMode = GamaDynamicColorMode.Discrete; + entry.dynamicColorAttribute = "mood"; + entry.discreteColorRules.Add(new GamaDiscreteColorRule { value = "calm", color = Color.blue }); + entry.overridePreviewVisibility = true; + entry.visibleInPreview = true; + entry.overrideRuntimeVisibility = true; + entry.visibleInRuntime = true; + + GameObject go = new GameObject("wizard"); + try + { + GamaSpeciesWizard wizard = go.AddComponent(); + wizard.overridesAsset = asset; + wizard.PopulateFromEntry(entry); + wizard.scaleOverrideEnabled = true; + wizard.scaleMultiplier = 2f; + wizard.SaveCurrentSettingsToAsset(); + + Assert.That(entry.prefabResourcePath, Is.EqualTo("Animals/Wolf")); + Assert.That(entry.overrideDynamicColor, Is.True); + Assert.That(entry.dynamicColorMode, Is.EqualTo(GamaDynamicColorMode.Discrete)); + Assert.That(entry.dynamicColorAttribute, Is.EqualTo("mood")); + Assert.That(entry.discreteColorRules.Count, Is.EqualTo(1)); + Assert.That(entry.overridePreviewVisibility, Is.True); + Assert.That(entry.visibleInPreview, Is.True); + Assert.That(entry.overrideScaleMultiplier, Is.True); + Assert.That(entry.scaleMultiplier, Is.EqualTo(2f)); + + wizard.scaleOverrideEnabled = false; + wizard.previewVisibilityOverrideEnabled = false; + wizard.visibleInPreview = false; + wizard.SaveCurrentSettingsToAsset(); + + Assert.That(entry.overrideScaleMultiplier, Is.False); + Assert.That(entry.scaleMultiplier, Is.EqualTo(1f)); + Assert.That(entry.overridePreviewVisibility, Is.False); + Assert.That(entry.visibleInPreview, Is.True); + Assert.That(entry.overrideRuntimeVisibility, Is.True); + Assert.That(entry.visibleInRuntime, Is.True); + Assert.That(entry.GetEffectiveScaleMultiplier(), Is.EqualTo(1f)); + } + finally + { + Object.DestroyImmediate(go); + } + } + + [Test] + public void InactivePreview_ContextClearRestoresNeutralBaseline() + { + GameObject root = new GameObject("[GAMA] Static Experiment Preview"); + GameObject child = GameObject.CreatePrimitive(PrimitiveType.Cube); + try + { + GamaSpeciesAppearanceContext context = Context("model.gaml", "main"); + GamaPreviewSession session = root.AddComponent(); + session.speciesOverrides = asset; + session.modelPath = context.ModelPath; + session.experimentName = context.ExperimentName; + session.stale = false; + child.transform.SetParent(root.transform, false); + GamaPreviewObject preview = child.AddComponent(); + preview.speciesName = "wolf"; + preview.CaptureBaseTransformIfNeeded(); + + GamaSpeciesRenderOverrideEntry entry = + GamaSpeciesAppearanceStateStore.GetOrCreateEditableEntry(context, "wolf", false); + entry.overrideScaleMultiplier = true; + entry.scaleMultiplier = 2f; + GamaSpeciesAppearanceStateStore.NotifyEntryChanged(context, "wolf", false); + root.SetActive(false); + + GamaEditorPreviewOverrideApplier.ApplyOverridesToCurrentPreview(); + Assert.That(child.transform.localScale, Is.EqualTo(Vector3.one * 2f)); + + GamaSpeciesAppearanceStateStore.ClearContext(context, true); + GamaEditorPreviewOverrideApplier.ApplyOverridesToCurrentPreview(); + Assert.That(child.transform.localScale, Is.EqualTo(Vector3.one)); + } + finally + { + Object.DestroyImmediate(root); + } + } + + [Test] + public void PreviewObject_ApplyIsIdempotentAndNeutralRestoresBaseline() + { + GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); + try + { + go.transform.localScale = new Vector3(2f, 3f, 4f); + Renderer renderer = go.GetComponent(); + renderer.enabled = false; + int unrelatedProperty = Shader.PropertyToID("_GamaBaselineTest"); + MaterialPropertyBlock baselineBlock = new MaterialPropertyBlock(); + baselineBlock.SetFloat(unrelatedProperty, 17f); + renderer.SetPropertyBlock(baselineBlock); + int colorProperty = Shader.PropertyToID("_Color"); + MaterialPropertyBlock indexedBaseline = new MaterialPropertyBlock(); + indexedBaseline.SetFloat(unrelatedProperty, 23f); + indexedBaseline.SetColor(colorProperty, Color.green); + renderer.SetPropertyBlock(indexedBaseline, 0); + + GamaPreviewObject preview = go.AddComponent(); + preview.CaptureBaseTransformIfNeeded(); + + GamaSpeciesRenderOverrideEntry entry = new GamaSpeciesRenderOverrideEntry + { + overrideScaleMultiplier = true, + scaleMultiplier = 2f, + overridePreviewVisibility = true, + visibleInPreview = true, + overrideColor = true, + color = Color.red + }; + + preview.ApplySpeciesOverride(entry); + preview.ApplySpeciesOverride(entry); + Assert.That(go.transform.localScale, Is.EqualTo(new Vector3(4f, 6f, 8f))); + Assert.That(renderer.enabled, Is.True); + + // Simulate the non-serialized half of a domain reload. Serialized + // color baselines must still restore indexed MPBs without adopting red. + FieldInfo statesField = typeof(GamaPreviewObject).GetField( + "baseRenderers", + BindingFlags.Instance | BindingFlags.NonPublic); + IList states = (IList)statesField.GetValue(preview); + object state = states[0]; + state.GetType().GetField("hasCapturedPropertyBlocks").SetValue(state, false); + state.GetType().GetField("allowFullPropertyBlockCapture").SetValue(state, false); + state.GetType().GetField("rendererPropertyBlock").SetValue(state, null); + state.GetType().GetField("materialPropertyBlocks").SetValue(state, null); + + entry.overrideScaleMultiplier = false; + entry.overridePreviewVisibility = false; + entry.overrideColor = false; + GamaSpeciesAppearanceStateStore.NormalizeEntry(entry); + preview.ApplySpeciesOverride(entry); + + Assert.That(go.transform.localScale, Is.EqualTo(new Vector3(2f, 3f, 4f))); + Assert.That(renderer.enabled, Is.False); + MaterialPropertyBlock restored = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(restored); + Assert.That(restored.GetFloat(unrelatedProperty), Is.EqualTo(17f)); + MaterialPropertyBlock restoredIndexed = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(restoredIndexed, 0); + Assert.That(restoredIndexed.GetFloat(unrelatedProperty), Is.EqualTo(23f)); + Assert.That(restoredIndexed.GetColor(colorProperty), Is.EqualTo(Color.green)); + } + finally + { + Object.DestroyImmediate(go); + } + } + + [Test] + public void RuntimeColorBaseline_RestoresIndexedPropertyBlock() + { + GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); + try + { + Renderer renderer = go.GetComponent(); + Material material = renderer.sharedMaterial; + int colorProperty = material != null && material.HasProperty("_BaseColor") + ? Shader.PropertyToID("_BaseColor") + : Shader.PropertyToID("_Color"); + int unrelatedProperty = Shader.PropertyToID("_GamaRuntimeBaselineTest"); + MaterialPropertyBlock baseline = new MaterialPropertyBlock(); + baseline.SetColor(colorProperty, Color.green); + baseline.SetFloat(unrelatedProperty, 31f); + renderer.SetPropertyBlock(baseline, 0); + + SimulationManager.ChangeColor(go, Color.red); + MaterialPropertyBlock changed = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(changed, 0); + Assert.That(changed.GetColor(colorProperty), Is.EqualTo(Color.red)); + + GamaRuntimeRendererAppearanceBaseline appearanceBaseline = + renderer.GetComponent(); + Assert.That(appearanceBaseline, Is.Not.Null); + appearanceBaseline.Restore(renderer); + + MaterialPropertyBlock restored = new MaterialPropertyBlock(); + renderer.GetPropertyBlock(restored, 0); + Assert.That(restored.GetColor(colorProperty), Is.EqualTo(Color.green)); + Assert.That(restored.GetFloat(unrelatedProperty), Is.EqualTo(31f)); + } + finally + { + Object.DestroyImmediate(go); + } + } + + private GamaSpeciesAppearanceContext Context(string modelPath, string experimentName) + { + return new GamaSpeciesAppearanceContext(asset, modelPath, experimentName); + } +} diff --git a/Tests/Editor/GamaSpeciesAppearanceStateStoreTests.cs.meta b/Tests/Editor/GamaSpeciesAppearanceStateStoreTests.cs.meta new file mode 100644 index 0000000..61f605a --- /dev/null +++ b/Tests/Editor/GamaSpeciesAppearanceStateStoreTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 614472cab1d94626ab19eab93c75c5a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/ProjectSimple.GamaUnity.Editor.Tests.asmdef b/Tests/Editor/ProjectSimple.GamaUnity.Editor.Tests.asmdef new file mode 100644 index 0000000..cba7a71 --- /dev/null +++ b/Tests/Editor/ProjectSimple.GamaUnity.Editor.Tests.asmdef @@ -0,0 +1,21 @@ +{ + "name": "ProjectSimple.GamaUnity.Editor.Tests", + "references": [ + "ProjectSimple.GamaUnity", + "ProjectSimple.GamaUnity.Editor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/Tests/Editor/ProjectSimple.GamaUnity.Editor.Tests.asmdef.meta b/Tests/Editor/ProjectSimple.GamaUnity.Editor.Tests.asmdef.meta new file mode 100644 index 0000000..835144c --- /dev/null +++ b/Tests/Editor/ProjectSimple.GamaUnity.Editor.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1909e841c70e42869c85c90def714608 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: