From 07c4d70de5d62e8c060cb9e7f81b84a0b2f6d383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Thu, 15 Oct 2026 16:53:21 +0200 Subject: [PATCH] Show discovered mission markers in the handheld atlas --- .../ERR/00_Core/ERR_NativeObjectiveMapProbe.c | 22 +++++++++-- .../Game/ERR/30_Map/ERRM_AtlasPresentation.c | 11 +++++- .../ERRMap/SharedMapRenderTarget.layout | 10 ++--- docs/player-route.md | 2 +- tests/test_atlas_markers.py | 39 +++++++++++++++++++ tests/test_native_objective_map_probe.py | 12 +++--- 6 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 tests/test_atlas_markers.py diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeObjectiveMapProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeObjectiveMapProbe.c index 66d6aab..1947f6d 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeObjectiveMapProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeObjectiveMapProbe.c @@ -27,6 +27,7 @@ class ERR_NativeObjectiveMapProbe protected ref ERR_ObjectiveMapPresentation m_Presentation; protected SCR_MapMenuUI m_Menu; protected Widget m_SharedRoot, m_ReusedRoot, m_ExpectedRoot, m_PreviousWidget; + protected ref ERRM_AtlasPresentation m_Atlas; protected ref MapConfiguration m_Config; protected MapConfiguration m_Ready; protected vector m_Point; @@ -94,7 +95,7 @@ class ERR_NativeObjectiveMapProbe m_CloseObserved = false; return 1; } - protected Widget CreateSharedRoot() + protected Widget CreateSharedRoot(int page = ERRM_PaperPages.OVERVIEW) { Widget root = GetGame().GetWorkspace().CreateWidgets("{255E1265F702B4AE}UI/layouts/ERRMap/SharedMapRenderTarget.layout"); if (!root) return null; @@ -104,6 +105,12 @@ class ERR_NativeObjectiveMapProbe // is bound, so these observations cannot qualify the physical held texture. target.SetEnabled(false); root.SetVisible(true); + // Exercise the production atlas gutter, index grid and objective visibility. + m_Atlas = new ERRM_AtlasPresentation(root); + float zoom; int columns, rows; + if (!ERRM_PaperPages.Layout(m_Map.Size(), 0.5, zoom, columns, rows)) return null; + m_Atlas.Prepare(page, m_Map.Size(), zoom, columns, rows); + m_Atlas.Draw(page, zoom, columns, rows, m_Map.Size()); return root; } protected bool OpenShared(Widget root) @@ -122,7 +129,9 @@ class ERR_NativeObjectiveMapProbe protected void Center(float zoom) { int position[2]; m_Marker.GetWorldPos(position); - m_Map.ZoomPanSmooth(zoom, position[0] + 100.0 / zoom, position[1], 0.1); + float width, height; m_Map.GetMapWidget().GetScreenSize(width, height); + float offset = Math.Min(100, width * 0.25); + m_Map.ZoomPanSmooth(zoom, position[0] + offset / zoom, position[1], 0.1); } protected bool Inspect(string label, bool rough) { @@ -133,6 +142,13 @@ class ERR_NativeObjectiveMapProbe Widget nativeIcons = m_ExpectedRoot.FindAnyWidget("UIIconsContainer"); if (!container && nativeIcons) container = nativeIcons.GetParent(); if (!Check(root && element && container && container != m_Map.GetMapWidget() && root.GetParent() == container && m_Presentation.ERR_ProbeContainer() == container && m_Presentation.ERR_ProbeCanvas() == m_Map.GetMapWidget(), "actual authored layout handler belongs to the exact supported objective container and native canvas")) return false; + if (m_ExpectedRoot.FindAnyWidget("ERR_ObjectiveOverlay")) + { + vector mapPos = FrameSlot.GetPos(m_Map.GetMapWidget()), overlayPos = FrameSlot.GetPos(container); + float mapW, mapH, overlayW, overlayH; + m_Map.GetMapWidget().GetScreenSize(mapW, mapH); container.GetScreenSize(overlayW, overlayH); + if (!Check(container.IsVisible() && container.GetZOrder() > 10 && Math.AbsFloat(mapPos[0] - overlayPos[0]) < 0.1 && Math.AbsFloat(mapPos[1] - overlayPos[1]) < 0.1 && Math.AbsFloat(mapW - overlayW) < 1 && Math.AbsFloat(mapH - overlayH) < 1, "atlas index/regional overlay is visible above print and matches the map rectangle")) return false; + } TextWidget text = TextWidget.Cast(root.FindAnyWidget("ObjectiveText")); ImageWidget image = ImageWidget.Cast(root.FindAnyWidget("Image")); if (!Check(text && image && text.GetText() == label && root.IsVisible(), "actual visible widget contains the current objective label and icon widget")) return false; @@ -285,7 +301,7 @@ class ERR_NativeObjectiveMapProbe MapConfiguration previousConfig = m_Config; m_Map.CloseMap(); if (!Check(!m_Presentation.ERR_ProbeRoot(), "closing shared root clears only owned objective widget")) return -1; - m_ReusedRoot = CreateSharedRoot(); + m_ReusedRoot = CreateSharedRoot(ERRM_PaperPages.REGION); if (!OpenShared(m_ReusedRoot)) return -1; if (!Check(m_Config == previousConfig && m_ReusedRoot != m_SharedRoot, "native plain config is reused with a distinct root")) return -1; m_Stage = 11; m_Wait = now + 1000; return 0; diff --git a/addons/core/Scripts/Game/ERR/30_Map/ERRM_AtlasPresentation.c b/addons/core/Scripts/Game/ERR/30_Map/ERRM_AtlasPresentation.c index b4f53d5..a4c9754 100644 --- a/addons/core/Scripts/Game/ERR/30_Map/ERRM_AtlasPresentation.c +++ b/addons/core/Scripts/Game/ERR/30_Map/ERRM_AtlasPresentation.c @@ -46,9 +46,16 @@ class ERRM_AtlasPresentation Widget mapWidget = m_Root.FindAnyWidget("MapWidget"); FrameSlot.SetPos(mapWidget, m_X, m_Y); FrameSlot.SetSize(mapWidget, m_Width, m_Height); - // Objective pins belong on detail pages, not on the atlas jacket/index. + // Every geographical sheet carries the same disclosed objectives as the + // interactive map. Clip pins/labels to cartography, above the printed grid. Widget objectives = m_Root.FindAnyWidget("ERR_ObjectiveOverlay"); - if (objectives) objectives.SetVisible(page < ERRM_PaperPages.OVERVIEW); + if (objectives) + { + FrameSlot.SetPos(objectives, m_X, m_Y); + FrameSlot.SetSize(objectives, m_Width, m_Height); + objectives.SetZOrder(20); + objectives.SetVisible(page != ERRM_PaperPages.COVER); + } } void HideNames(MapLayer layer, int faction) { diff --git a/addons/core/UI/layouts/ERRMap/SharedMapRenderTarget.layout b/addons/core/UI/layouts/ERRMap/SharedMapRenderTarget.layout index 930b6bf..dce2e44 100644 --- a/addons/core/UI/layouts/ERRMap/SharedMapRenderTarget.layout +++ b/addons/core/UI/layouts/ERRMap/SharedMapRenderTarget.layout @@ -29,11 +29,11 @@ FrameWidgetClass { FrameWidgetClass { Name "ERR_ObjectiveOverlay" Slot FrameWidgetSlot { - Anchor 0 0 1 1 - SizeX 0 - OffsetRight 0 - SizeY 0 - OffsetBottom 0 + Anchor 0 0 0 0 + SizeX 744 + OffsetRight -744 + SizeY 1024 + OffsetBottom -1024 } Clipping True "Ignore Cursor" 1 diff --git a/docs/player-route.md b/docs/player-route.md index 04c8731..83814d9 100644 --- a/docs/player-route.md +++ b/docs/player-route.md @@ -4,7 +4,7 @@ This describes the intended current Everon route from source. **It is a playtest 1. **Leave prison.** Wait for mission preparation and your prisoner spawn. Search the Kolobok **backpack inside the prison** for the shared finite stock: five PM pistols, eight pistol magazines and one field map. Prisoners begin with basic medical supplies, not an issued rifle. At the gate, select **“Pick prison lock”** and hold your interaction control for four seconds, then walk out. If the selected prompt is disabled **“Open”**, switch actions at the handle until **“Pick prison lock”** is selected. Confirm the objective changes to locating the radio. A destroyed gate also has an observed physical-escape path; simply losing the gate must not immediately advance the mission. 2. **Recover physical clues.** After escape, the map identifies the **Southern supply cache** and **Field hospital 1**. Each holds two objective folders targeting the radio. The southern cache also has weapons, ammunition, a replacement map, one emergency repair kit (20 finite supplies) and two fuel cans (five litres each); the hospital has medical supplies. For vehicle maintenance, carry and equip the item, park the vehicle, then use the native repair action at an eligible damaged part or the refuel action at its fuel cap. Supplies are finite and do not respawn. Transfer folders into your own inventory; looking inside a container alone is insufficient. The first folder may identify the radio immediately (15% with fresh default settings), otherwise it reveals a rough search area. In that case, take a different folder to pinpoint the station. The shared objective acknowledges the rough clue with **“Intel recovered”**, and the map shows a question mark at the centre of a 500 m grid cell. An already identified radio takes precedence over rough clues from other sites. Dropping or handing around a recovered folder cannot grant another clue. If one source is lost, try the other. Other surviving field hospitals also carry radio documents, but are not marked automatically. If all documents are lost, approaching within eight metres with a clear view can identify the station itself. -3. **Read the map and objective.** Use your bound map control with the carried map. Rough discovery shows an approximate search area; identification gives the station's position. Confirm the label and task text update. The backpack **OCRAM OLOP Autoatlas** stays in your hands. Its cover, terrain name, overview grid and numbered overlapping sheets are generated from the active terrain, with no player marker or automatic centering. Enter or right opens the index from the cover and the first sheet from the index; on detail sheets, arrows move west/east/north/south. Type a page number on the numpad and press either Enter key to jump directly; 0 + Enter returns to the index, and left from the index returns to the cover. Quickly press the bound map key twice (M by default) to open the interactive map; a single press puts the held paper away. Larger terrains have more sheets at the same readable scale, coarsened only if needed to keep the atlas at most 400 detail sheets. An ordinary stolen map opens the usual full-screen view; equip the desired item if carrying both. Shared-map visibility to other real players still needs multiplayer testing. The prison, radio, hospitals and field camps use generated positions, so follow this round's discoveries rather than coordinates from a previous run. Other field camps and hospitals may be omitted when no safe placement is found; omitted sites have no marker or intel target. Field hospital 1 is required as the second initially marked clue source: if it cannot be placed, preparation fails instead of admitting players to an incomplete route. **The western cache and its vehicle belong to historical test fixtures and are absent from the normal mission.** The Southern cache now attempts one locked UAZ. If safe placement or vehicle preparation fails, it is omitted and the walking route remains available. If present, approach its driver-door lock and hold **“Force vehicle lock”** for four seconds before boarding. One native startup confirms Southern parking and the locked state; ordinary driving and remote lock interactions still need playtesting. An eight-second horn alarm is now implemented; audible output and remote behavior still need playtesting. +3. **Read the map and objective.** Use your bound map control with the carried map. Rough discovery shows an approximate search area; identification gives the station's position. Confirm the label and task text update. The backpack **OCRAM OLOP Autoatlas** stays in your hands. Its cover, terrain name, overview grid and numbered overlapping sheets are generated from the active terrain, with no player marker or automatic centering. Enter or right opens the index from the cover and the first sheet from the index; on detail sheets, arrows move west/east/north/south. Type a page number on the numpad and press either Enter key to jump directly; 0 + Enter returns to the index, and left from the index returns to the cover. Quickly press the bound map key twice (M by default) to open the interactive map; a single press puts the held paper away. Larger terrains have more sheets at the same readable scale, coarsened only if needed to keep the atlas at most 400 detail sheets. Discovered mission markers and mine warnings also appear on the atlas island index, regional pages and relevant detail sheets, using the same labels and approximate or exact positions as the interactive map. The cover has no markers. An ordinary stolen map opens the usual full-screen view; equip the desired item if carrying both. Shared-map visibility to other real players still needs multiplayer testing. The prison, radio, hospitals and field camps use generated positions, so follow this round's discoveries rather than coordinates from a previous run. Other field camps and hospitals may be omitted when no safe placement is found; omitted sites have no marker or intel target. Field hospital 1 is required as the second initially marked clue source: if it cannot be placed, preparation fails instead of admitting players to an incomplete route. **The western cache and its vehicle belong to historical test fixtures and are absent from the normal mission.** The Southern cache now attempts one locked UAZ. If safe placement or vehicle preparation fails, it is omitted and the walking route remains available. If present, approach its driver-door lock and hold **“Force vehicle lock”** for four seconds before boarding. One native startup confirms Southern parking and the locked state; ordinary driving and remote lock interactions still need playtesting. An eight-second horn alarm is now implemented; audible output and remote behavior still need playtesting. 4. **Call for extraction.** Once identified, approach the physical radio and complete **“Contact extraction HQ”** (eight seconds). The interaction is close-range; standing elsewhere inside the defence area does not start it. Confirm the objective changes to defending the transmission. 5. **Keep a defender present.** Maintain at least one living, conscious prisoner strictly inside 60 metres of the radio for the configured duration—180 seconds by default. Players can hand over defence while coverage remains continuous. If everyone leaves, becomes unconscious/dies, or disconnects, progress resets. Check the task's interruption message and remaining time; returning resumes a fresh interval. 6. **Signal the pickup.** Keep your smoke grenades ready. When preparations finish, the map marks the exact pickup point. Gather your team nearby and throw a smoke grenade within 25 metres of it. Wait for the smoke-confirmation announcement and keep the landing point clear. One armed transport and two armed escorts approach. diff --git a/tests/test_atlas_markers.py b/tests/test_atlas_markers.py new file mode 100644 index 0000000..f658c75 --- /dev/null +++ b/tests/test_atlas_markers.py @@ -0,0 +1,39 @@ +"""Execute the actual atlas page preparation; widget operations are doubled.""" +from policy_harness import ROOT, body, run +p=ROOT/'addons/core/Scripts/Game/ERR/30_Map/ERRM_AtlasPresentation.c' +prepare=body(p,'void Prepare(').replace('Vector(', 'vector(').replace('Widget mapWidget','Widget* mapWidget').replace('Widget objectives','Widget* objectives') +for name in ['ERRM_PaperPages','Math','FrameSlot']:prepare=prepare.replace(name+'.',name+'::') +for name in ['m_Root','objectives']:prepare=prepare.replace(name+'.',name+'->') +run(r''' +#include +#include +#include +struct vector{float v[3]={0,0,0};vector(){}vector(float x,float y,float z):v{x,y,z}{}float&operator[](int i){return v[i];}}; +struct Widget{float x=0,y=0,w=744,h=1024;int z=0;bool visible=true;Widget*map=nullptr,*overlay=nullptr;Widget*FindAnyWidget(std::string n){return n=="MapWidget"?map:overlay;}void SetVisible(bool b){visible=b;}void SetZOrder(int n){z=n;}}; +struct FrameSlot{static void SetPos(Widget*w,float x,float y){w->x=x;w->y=y;}static void SetSize(Widget*w,float x,float y){w->w=x;w->h=y;}}; +struct Math{static float Min(float a,float b){return std::min(a,b);}static float Max(float a,float b){return std::max(a,b);}}; +struct ERRM_PaperPages{static constexpr int COVER=16777215,OVERVIEW=16777214,REGION=16777216;static bool IsRegion(int page){return page>=REGION;} + // Page geometry is independently implemented by PaperPages; this test targets + // marker visibility and alignment, including non-square regional bounds. + static void RegionBounds(int,int,int,vector,float,vector&lo,vector&hi){lo=vector(.2,0,.3);hi=vector(.7,0,.9);} +}; +struct Atlas{Widget*m_Root;float m_X=0,m_Y=0,m_Width=0,m_Height=0; +'''+prepare+r''' +}; +int main(){ + Widget root,map,overlay;root.map=↦root.overlay=&overlay;Atlas a{&root}; + for(int page:{ERRM_PaperPages::COVER,ERRM_PaperPages::OVERVIEW,ERRM_PaperPages::REGION,ERRM_PaperPages::REGION+4,0,4097}){ + for(vector size:{vector(12800,0,12800),vector(24000,0,12000),vector(8000,0,16000)}){ + a.Prepare(page,size,.5,8,6); + assert(overlay.visible==(page!=ERRM_PaperPages::COVER));assert(overlay.z>10); + assert(overlay.x==map.x&&overlay.y==map.y&&overlay.w==map.w&&overlay.h==map.h); + assert(overlay.x>=0&&overlay.y>=0&&overlay.x+overlay.w<=744&&overlay.y+overlay.h<=1024); + } + } + root.overlay=nullptr;a.Prepare(0,vector(12800,0,12800),.5,8,6); +} +''') +layout=ROOT/'addons/core/UI/layouts/ERRMap/SharedMapRenderTarget.layout' +assert 'Anchor 0 0 0 0' in body(layout,'Name "ERR_ObjectiveOverlay"') +assert 'Clipping True' in layout.read_text() +print('PASS: actual atlas preparation shows disclosed markers on overview, regional and detail sheets; cover hidden; overlay matches map rectangle above print for square/wide/tall terrains') diff --git a/tests/test_native_objective_map_probe.py b/tests/test_native_objective_map_probe.py index 0091c93..fd91dae 100644 --- a/tests/test_native_objective_map_probe.py +++ b/tests/test_native_objective_map_probe.py @@ -92,7 +92,7 @@ run(r''' #include #include using string=std::string;using vector=std::array; -struct Widget{Widget*parent=nullptr,*container=nullptr,*overlay=nullptr,*text=nullptr,*image=nullptr;vector placed={0,0,0};float sx=0,sy=0,width=36,height=36;bool visible=true;virtual ~Widget()=default;Widget*GetParent(){return parent;}Widget*FindAnyWidget(string n){if(n=="UIIconsContainer")return container;if(n=="ERR_ObjectiveOverlay")return overlay;if(n=="ObjectiveText")return text;if(n=="Image")return image;return nullptr;}bool IsVisible(){return visible;}void GetScreenPos(float&x,float&y){x=sx;y=sy;}void GetScreenSize(float&w,float&h){w=width;h=height;}}; +struct Widget{Widget*parent=nullptr,*container=nullptr,*overlay=nullptr,*text=nullptr,*image=nullptr;vector placed={0,0,0};float sx=0,sy=0,width=36,height=36;bool visible=true;virtual ~Widget()=default;Widget*GetParent(){return parent;}Widget*FindAnyWidget(string n){if(n=="UIIconsContainer")return container;if(n=="ERR_ObjectiveOverlay")return overlay;if(n=="ObjectiveText")return text;if(n=="Image")return image;return nullptr;}int GetZOrder(){return 20;}bool IsVisible(){return visible;}void GetScreenPos(float&x,float&y){x=sx;y=sy;}void GetScreenSize(float&w,float&h){w=width;h=height;}}; struct TextWidget:Widget{string label;static TextWidget*Cast(Widget*w){return dynamic_cast(w);}string GetText(){return label;}}; struct ImageWidget:Widget{static ImageWidget*Cast(Widget*w){return dynamic_cast(w);}void GetImageSize(int,int&w,int&h){w=64;h=64;}}; struct ERR_ObjectiveMapElement{vector pos;vector GetPos(){return pos;}}; @@ -163,16 +163,18 @@ assert 'GetOrigin() + "1037.25 0 1063.75"' in s print('PASS: actual long-warning phases retain visible label through1500ms checkpoint then restore radio before close; synthetic marker separated from unmoved player') # Actual Center method offsets the pan target, not the disclosed data or input. -center=body(p,'protected void Center(').replace('protected ','').replace('m_Marker.','m_Marker->').replace('m_Map.','m_Map->') +center=body(p,'protected void Center(').replace('Math.Min','std::min').replace('GetMapWidget().','GetMapWidget()->').replace('Math.Min','std::min').replace('std::min(100, width * 0.25)', 'std::min(100.f, width * 0.25f)').replace('protected ','').replace('m_Marker.','m_Marker->').replace('m_Map.','m_Map->') run(r''' #include #include #include +#include +struct Canvas{float width=500;void GetScreenSize(float&w,float&h){w=width;h=500;}}; struct Marker{int x=6250,y=7250;void GetWorldPos(int p[2]){p[0]=x;p[1]=y;}}; -struct Map{float zoom=0,x=0,y=0,duration=0;int calls=0;void ZoomPanSmooth(float z,float px,float py,float t){zoom=z;x=px;y=py;duration=t;calls++;}}; +struct Map{Canvas canvas;Canvas*GetMapWidget(){return &canvas;}float zoom=0,x=0,y=0,duration=0;int calls=0;void ZoomPanSmooth(float z,float px,float py,float t){zoom=z;x=px;y=py;duration=t;calls++;}}; struct Probe{Marker*m_Marker;Map*m_Map; '''+center+r''' }; -int main(){Marker marker;Map map;Probe p{&marker,&map};for(float zoom:{0.3f,0.6f}){p.Center(zoom);assert(map.zoom==zoom&&map.y==7250&&std::abs(map.duration-0.1f)<0.001f);assert(std::abs((marker.x-map.x)*zoom+100.0f)<0.01f);assert(marker.x==6250&&marker.y==7250);}assert(map.calls==2);} +int main(){Marker marker;Map map;Probe p{&marker,&map};for(float zoom:{0.3f,0.6f}){p.Center(zoom);assert(map.zoom==zoom&&map.y==7250&&std::abs(map.duration-0.1f)<0.001f);assert(std::abs((marker.x-map.x)*zoom+100.0f)<0.01f);assert(marker.x==6250&&marker.y==7250);}assert(map.calls==2);map.canvas.width=124;p.Center(.3f);assert(std::abs((map.x-marker.x)*.3f-31)<.01f);} ''') -print('PASS: actual Center places marker100screenpx left of pan center at both fixture zooms without altering disclosed coordinates') +print('PASS: actual Center keeps marker offset within large and small native viewports at both fixture zooms without altering disclosed coordinates')