2525import java .util .stream .Collectors ;
2626import java .util .stream .Stream ;
2727import javax .annotation .Nonnull ;
28+ import net .rptools .lib .CodeTimer ;
2829import net .rptools .maptool .client .AppUtil ;
2930import net .rptools .maptool .client .MapTool ;
3031import net .rptools .maptool .client .ui .zone .Illumination .LumensLevel ;
@@ -139,11 +140,57 @@ private void addLightSourceToken(Token token, Set<Player.Role> roles) {
139140 */
140141 private final Map <PlayerView , Illumination > illuminationsPerView = new HashMap <>();
141142
142- /** Map the PlayerView to its exposed area. */
143- private final Map <PlayerView , Area > exposedAreaMap = new HashMap <>();
143+ /**
144+ * Holds the visibility information for a {@link PlayerView}.
145+ *
146+ * <p>The {@link #visibleArea()} and {@link #exposedArea()} are the fundamental areas that
147+ * describe the view's visibility and fog of war. The {@link #clearArea()} and {@link
148+ * #softFogArea()} are derived from these based on the zone's configuration.
149+ *
150+ * <p>When {@link Zone#hasFog()} returns {@code false}, the {@link #exposedArea()} is meaningless.
151+ * In this case, the area will be empty and should not be used. Because {@link #clearArea()} and
152+ * {@link #softFogArea()} derive from {@link #exposedArea()}, these are also meaningless and will
153+ * be set to the empty area.
154+ *
155+ * <p>Because of the way Fog of War has to be rendered, {@link #softFogArea()} may not be very
156+ * intuitive. Keep this rendering logic in mind when thinking about these areas:
157+ *
158+ * <ol>
159+ * <li>Start by assuming everything is covered in hard FoW.
160+ * <li>Use {@link #softFogArea()} to carve out part of the hard FoW.
161+ * <li>Use {@link #clearArea()} to carve out part of the soft FoW and hard Fow.
162+ * </ol>
163+ *
164+ * @param visibleArea The combined visible area of all tokens in the view.
165+ * @param exposedArea The combined exposed area of all tokens in the view.
166+ * @param softFogArea The area that is clear of hard FoW. Typically, the same as {@link
167+ * #exposedArea()}, unless vision is off in which case it is empty.
168+ * @param clearArea The area that is clear of soft FoW and hard Fow. Typically, the intersection
169+ * of {@link #visibleArea()} and {@link #exposedArea()}, unless vision if off in which case it
170+ * is just {@link #exposedArea()}.
171+ */
172+ public record Visibility (
173+ @ Nonnull Area visibleArea ,
174+ @ Nonnull Area exposedArea ,
175+ @ Nonnull Area softFogArea ,
176+ @ Nonnull Area clearArea ) {
177+
178+ /**
179+ * Creates a visibility record for when the zone does not have fog enabled.
180+ *
181+ * <p>The {@link #exposedArea()}, {@link #softFogArea()}, and {@link #clearArea()} will all be
182+ * set to empty areas.
183+ *
184+ * @param visibleArea The visible area for the view.
185+ * @return A visibility record that only contains the visible area.
186+ */
187+ public static @ Nonnull Visibility withoutFog (@ Nonnull Area visibleArea ) {
188+ return new Visibility (visibleArea , new Area (), new Area (), new Area ());
189+ }
190+ }
144191
145- /** Map the PlayerView to its visible area. */
146- private final Map <PlayerView , Area > visibleAreaMap = new HashMap <>();
192+ /** Map the PlayerView to its visible area and exposed area . */
193+ private final Map <PlayerView , Visibility > visibilityMap = new HashMap <>();
147194
148195 // endregion
149196
@@ -169,57 +216,80 @@ public ZoneView(Zone zone) {
169216 new MapToolEventBus ().getMainEventBus ().register (this );
170217 }
171218
172- public Area getExposedArea (PlayerView view ) {
173- Area exposed = exposedAreaMap .get (view );
174-
175- if (exposed == null ) {
176- boolean combinedView =
177- !isUsingVision ()
178- || MapTool .isPersonalServer ()
179- || !MapTool .getServerPolicy ().isUseIndividualFOW ()
180- || view .isGMView ();
181-
182- if (view .isUsingTokenView () || combinedView ) {
183- exposed = zone .getExposedArea (view );
184- } else {
185- // Not a token-specific view, but we are using Individual FoW. So we build up all the owned
186- // tokens' exposed areas to build the soft FoW. Note that not all owned tokens may still
187- // have sight (so weren't included in the PlayerView), but could still have previously
188- // exposed areas.
189- exposed = new Area ();
190- for (Token tok : zone .getTokensForLayers (Zone .Layer ::supportsVision )) {
191- if (!AppUtil .playerOwns (tok )) {
192- continue ;
193- }
194- ExposedAreaMetaData meta = zone .getExposedAreaMetaData (tok .getExposedAreaGUID ());
195- Area exposedArea = meta .getExposedAreaHistory ();
196- exposed .add (new Area (exposedArea ));
219+ private @ Nonnull Area calculateExposedArea (PlayerView view ) {
220+ boolean combinedView =
221+ !isUsingVision ()
222+ || MapTool .isPersonalServer ()
223+ || !MapTool .getServerPolicy ().isUseIndividualFOW ()
224+ || view .isGMView ();
225+
226+ @ Nonnull Area exposed ;
227+ if (view .isUsingTokenView () || combinedView ) {
228+ exposed = zone .getExposedArea (view );
229+ } else {
230+ // Not a token-specific view, but we are using Individual FoW. So we build up all the owned
231+ // tokens' exposed areas to build the soft FoW. Note that not all owned tokens may still
232+ // have sight (so weren't included in the PlayerView), but could still have previously
233+ // exposed areas.
234+ exposed = new Area ();
235+ for (Token tok : zone .getTokensForLayers (Zone .Layer ::supportsVision )) {
236+ if (!AppUtil .playerOwns (tok )) {
237+ continue ;
197238 }
239+ ExposedAreaMetaData meta = zone .getExposedAreaMetaData (tok .getExposedAreaGUID ());
240+ Area exposedArea = meta .getExposedAreaHistory ();
241+ exposed .add (new Area (exposedArea ));
198242 }
199-
200- exposedAreaMap .put (view , exposed );
201243 }
202244 return exposed ;
203245 }
204246
205- /**
206- * Calculate the visible area of the view, cache it in visibleAreaMap, and return it
207- *
208- * <p>The visible area is calculated for each token in the view. The token's visible area is its
209- * vision obstructed by topology and restricted to the illuminated portions of the map.
210- *
211- * @param view the PlayerView
212- * @return the visible area
213- */
214- public @ Nonnull Area getVisibleArea (PlayerView view ) {
215- return visibleAreaMap .computeIfAbsent (
247+ private @ Nonnull Area calculateVisibleArea (PlayerView view ) {
248+ final var visibleArea = new Area ();
249+ getTokensForView (view ).map (token -> this .getVisibleArea (token , view )).forEach (visibleArea ::add );
250+ return visibleArea ;
251+ }
252+
253+ public @ Nonnull Visibility getVisibility (PlayerView view ) {
254+ return visibilityMap .computeIfAbsent (
216255 view ,
217256 view2 -> {
218- final var visibleArea = new Area ();
219- getTokensForView (view2 )
220- .map (token -> this .getVisibleArea (token , view2 ))
221- .forEach (visibleArea ::add );
222- return visibleArea ;
257+ var timer = CodeTimer .get ();
258+ var tokenCount = view .isUsingTokenView () ? view .getTokens ().size () : 0 ;
259+
260+ timer .start ("ZoneView.getVisibility(%d tokens)-getVisibleArea" , tokenCount );
261+ var visibleArea = calculateVisibleArea (view2 );
262+ timer .stop ("ZoneView.getVisibility(%d tokens)-getVisibleArea" , tokenCount );
263+
264+ if (!zone .hasFog ()) {
265+ // Exposed and clear areas are meaningless when not using fog of war.
266+ return Visibility .withoutFog (visibleArea );
267+ }
268+
269+ timer .start ("ZoneView.getVisibility(%d tokens)-getExposedArea" , tokenCount );
270+ var exposedArea = calculateExposedArea (view2 );
271+ timer .stop ("ZoneView.getVisibility(%d tokens)-getExposedArea" , tokenCount );
272+
273+ /*
274+ * Hard FOW is cleared by exposed areas. The exposed area itself has two regions: the
275+ * visible area (rendered clear) and the soft FOW area (rendered translucent). But if
276+ * vision is off, treat the entire exposed area as clear with no soft FOW.
277+ */
278+
279+ timer .start ("ZoneView.getVisibility(%d tokens)-getClearArea" , tokenCount );
280+ Area softFogArea ;
281+ Area clearArea ;
282+ if (isUsingVision ()) {
283+ softFogArea = exposedArea ;
284+ clearArea = new Area (visibleArea );
285+ clearArea .intersect (softFogArea );
286+ } else {
287+ softFogArea = new Area ();
288+ clearArea = exposedArea ;
289+ }
290+ timer .stop ("ZoneView.getVisibility(%d tokens)-getClearArea" , tokenCount );
291+
292+ return new Visibility (visibleArea , exposedArea , softFogArea , clearArea );
223293 });
224294 }
225295
@@ -722,8 +792,8 @@ public Collection<DrawableLight> getDrawableLights(PlayerView view) {
722792 }
723793
724794 /**
725- * Clear the vision caches (@link #tokenVisionCachePerView}, {@link #visibleAreaMap }), fog cache
726- * ({@link #exposedAreaMap}), and illumination caches ({@link #illuminationModels}.
795+ * Clear the vision caches (@link #tokenVisionCachePerView}, {@link #visibilityMap }), and
796+ * illumination caches ({@link #illuminationModels}.
727797 *
728798 * <p>Needs to be called whenever topology changes, fog is edited, or map vision settings are
729799 * changed. These are all external factors that directly affect vision and illumination. In the
@@ -739,14 +809,13 @@ public void flush() {
739809
740810 tokenVisionCachePerView .clear ();
741811 illuminationsPerView .clear ();
742- exposedAreaMap .clear ();
743- visibleAreaMap .clear ();
812+ flushFog ();
744813
745814 flushLights ();
746815 }
747816
748817 public void flushFog () {
749- exposedAreaMap .clear ();
818+ visibilityMap .clear ();
750819 }
751820
752821 private void flushLights () {
@@ -756,8 +825,8 @@ private void flushLights() {
756825
757826 /**
758827 * Flush the ZoneView cache of the token. Remove token from {@link #tokenVisionCachePerView}, and
759- * {@link #illuminationModels}. Can clear {@link #tokenVisionCachePerView}, {@link
760- * #visibleAreaMap}, and {@link #exposedAreaMap } depending on the token.
828+ * {@link #illuminationModels}. Can clear {@link #tokenVisionCachePerView}, and {@link
829+ * #visibilityMap } depending on the token.
761830 *
762831 * @param token the token to flush.
763832 */
@@ -776,14 +845,12 @@ public void flush(Token token) {
776845 contributedPersonalLightsByToken .remove (token .getId ());
777846 tokenVisionCachePerView .clear ();
778847 illuminationsPerView .clear ();
779- exposedAreaMap .clear ();
780- visibleAreaMap .clear ();
848+ flushFog ();
781849 drawableLights .clear ();
782850 } else if (token .getHasSight ()) {
783851 contributedPersonalLightsByToken .remove (token .getId ());
784852 illuminationsPerView .clear ();
785- exposedAreaMap .clear ();
786- visibleAreaMap .clear ();
853+ flushFog ();
787854 drawableLights .clear ();
788855 }
789856
@@ -884,16 +951,15 @@ private void onTokensChanged(TokensChanged event) {
884951
885952 /**
886953 * Update {@link #lightSourceMap} with the light sources of the tokens, and clear {@link
887- * #visibleAreaMap} and {@link #exposedAreaMap } if one of the tokens has sight.
954+ * #visibilityMap } if one of the tokens has sight.
888955 *
889956 * @param tokens the list of tokens
890957 */
891958 private void processTokenAddChangeEvent (List <Token > tokens ) {
892959 updateLightSourcesFromTokens (tokens );
893960
894961 if (tokens .stream ().anyMatch (Token ::getHasSight )) {
895- exposedAreaMap .clear ();
896- visibleAreaMap .clear ();
962+ flushFog ();
897963 }
898964
899965 if (tokens .stream ().anyMatch (Token ::hasAnyMaskTopology )) {
0 commit comments