using System.Collections.Generic; using System.Linq; using Ashfall.Core; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.Rendering.Universal; namespace Ashfall.Runtime { public sealed class WorldView : MonoBehaviour { private GameSession session; private SpriteLibrary art; private Camera cameraView; private Light2D ambient, playerLight; private Transform root; private readonly Dictionary renderers = new Dictionary(); private readonly HashSet visible = new HashSet(); private readonly Dictionary displayPositions = new Dictionary(); private FloorPhysics floors; private string previousSpace = ""; private int previousFloor = -1; private float zoom = 12; public bool Night { get; set; } public string ViewedSpace { get; private set; } = "world"; public int ViewedFloor { get; private set; } public V2 MouseGround { get; private set; } public Camera Camera => cameraView; public VehicleState ControlledVehicle => session.LocalPlayer == null ? null : !session.IsAuthority && session.PredictedVehicle != null ? session.PredictedVehicle : session.Simulation?.StationVehicle(session.LocalPlayer.stationId); public void Initialize(GameSession value) { session = value; art = new SpriteLibrary(); root = new GameObject("Visible space sprites").transform; root.SetParent(transform); cameraView = Camera.main; if (cameraView == null) { var obj = new GameObject("Main Camera"); obj.tag = "MainCamera"; cameraView = obj.AddComponent(); obj.AddComponent(); } cameraView.orthographic = true; cameraView.orthographicSize = zoom; cameraView.transform.position = new Vector3(0, 0, -20); cameraView.clearFlags = CameraClearFlags.SolidColor; cameraView.backgroundColor = new Color(.075f, .085f, .075f); cameraView.nearClipPlane = .1f; cameraView.farClipPlane = 100; ambient = new GameObject("Environment light").AddComponent(); ambient.transform.SetParent(transform); ambient.lightType = Light2D.LightType.Global; ambient.intensity = .92f; playerLight = new GameObject("Player worklight").AddComponent(); playerLight.transform.SetParent(transform); playerLight.lightType = Light2D.LightType.Point; playerLight.pointLightOuterRadius = 10; playerLight.pointLightInnerRadius = 2; playerLight.intensity = .3f; playerLight.color = new Color(.77f, .88f, .95f); floors = new FloorPhysics(); } private void Update() { if (cameraView == null) return; var mouse = Mouse.current; if (mouse != null) { var screen = cameraView.ScreenToWorldPoint(mouse.position.ReadValue()); MouseGround = Projection.ToGround(new V2(screen.x, screen.y)); if (session.Running && GetComponent()?.CapturesPointer != true) zoom = Mathf.Clamp(zoom - mouse.scroll.ReadValue().y * .006f, 7, 28); } } private void LateUpdate() { if (art == null) return; visible.Clear(); var world = session.World; var player = session.LocalPlayer; if (world == null || player == null || !session.Running) { HideUnused(); return; } var v = ControlledVehicle; ViewedSpace = v == null ? player.location.spaceId : "world"; ViewedFloor = v == null ? player.location.floor : 0; var space = world.Space(ViewedSpace); if (space == null) return; bool changed = previousSpace != ViewedSpace || previousFloor != ViewedFloor; if (changed) displayPositions.Clear(); previousSpace = ViewedSpace; previousFloor = ViewedFloor; var predicted = !session.IsAuthority && session.PredictedPlayer != null ? session.PredictedPlayer : player; V2 focus = v == null ? predicted.location.position : v.position; V2 projected = Projection.ToScreen(focus, v == null ? predicted.location.height : v.Altitude); if (v == null && ViewedSpace != "world") { var f = world.Floor(player.location); projected.x = Mathf.Clamp(projected.x, f.bounds.center.x - 3, f.bounds.center.x + 3); projected.y = Mathf.Clamp(projected.y, f.bounds.center.y - 2, f.bounds.center.y + 2); } else if (v == null) projected += (Projection.ToScreen(MouseGround) - projected) * .1f; var target = new Vector3(projected.x, projected.y, -20); cameraView.transform.position = changed ? target : Vector3.Lerp(cameraView.transform.position, target, 1 - Mathf.Exp(-Time.unscaledDeltaTime * 8)); float targetZoom = v == null ? ViewedSpace == "world" ? zoom : Mathf.Min(zoom, 7.5f) : Mathf.Max(zoom, v.flying ? 22 : 16); cameraView.orthographicSize = Mathf.Lerp(cameraView.orthographicSize, targetZoom, 1 - Mathf.Exp(-Time.unscaledDeltaTime * 6)); ambient.intensity = Night ? .27f : ViewedSpace == "world" ? .92f : .62f; ambient.color = Night ? new Color(.57f, .67f, .83f) : new Color(.96f, .94f, .86f); playerLight.transform.position = new Vector3(projected.x, projected.y, -1); playerLight.intensity = Night || ViewedSpace != "world" ? 1.05f : .2f; if (ViewedSpace == "world") DrawExterior(world, focus); else DrawInterior(world, space, ViewedFloor); DrawEntities(world, changed); DrawBuildPreview(); HideUnused(); floors.Synchronize(world, session.Revision); floors.Simulate(Time.unscaledDeltaTime); } private SpriteRenderer Put(string id, Sprite sprite, V2 position, V2 size, Color color, int order, bool unlit = false) { visible.Add(id); if (!renderers.TryGetValue(id, out var renderer)) { var obj = new GameObject(id); obj.transform.SetParent(root); renderer = obj.AddComponent(); renderers[id] = renderer; } renderer.gameObject.SetActive(true); renderer.sprite = sprite; renderer.sharedMaterial = unlit ? art.Unlit : art.Lit; renderer.color = color; renderer.sortingOrder = Mathf.Clamp(order, -32000, 32000); renderer.transform.position = new Vector3(position.x, position.y, 0); renderer.transform.rotation = Quaternion.identity; renderer.transform.localScale = new Vector3(size.x / Mathf.Max(.001f, sprite.bounds.size.x), size.y / Mathf.Max(.001f, sprite.bounds.size.y), 1); return renderer; } private V2 Interpolate(string key, V2 target, bool immediate = false) { if (session.IsAuthority || immediate || !displayPositions.TryGetValue(key, out var previous) || V2.Distance(previous, target) > 8) return displayPositions[key] = target; return displayPositions[key] = V2.Lerp(previous, target, 1 - Mathf.Exp(-Time.unscaledDeltaTime * 15)); } private void Rect(string id, V2 at, V2 size, Color color, int order, bool unlit = false) => Put(id, art.Get("white"), Projection.ToScreen(at), new V2(size.x, size.y * Projection.GroundScale), color, order, unlit); private void Tile(string id, V2 at, V2 size, Color color, int order, bool metal) => Put(id, art.Get(metal ? "metal" : "soil"), Projection.ToScreen(at), new V2(size.x, size.y * Projection.GroundScale), color, order); private static int Depth(V2 ground) => 10000 - Mathf.RoundToInt(ground.y * 30); private void Shadow(string id, V2 at, V2 size, float alpha = .8f) => Put(id, art.Get("shadow"), Projection.ToScreen(at), new V2(size.x, size.y * Projection.GroundScale), new Color(1, 1, 1, alpha), -1000, true); private void DrawExterior(WorldState world, V2 focus) { int cx = Mathf.RoundToInt(focus.x / 4), cy = Mathf.RoundToInt(focus.y / 4); for (int x = cx - 15; x <= cx + 15; x++) for (int y = cy - 12; y <= cy + 12; y++) { float variation = Mathf.PerlinNoise((x + 121) * .13f, (y + 77) * .13f); Tile("ground." + x + "." + y, new V2(x * 4, y * 4), new V2(4.02f, 4.02f), Color.Lerp(new Color(.19f, .205f, .17f), new Color(.32f, .29f, .23f), variation), -30000, false); } foreach (var s in world.spaces.Where(s => s.kind == SpaceKind.Building)) { bool built = s.id == "base"; V2 size = built ? new V2(18, 18) : new V2(16, 14); V2 center = s.worldAnchor; Shadow(s.id + ".shadow", center + new V2(2, -1.5f), size + new V2(3, 3)); Tile(s.id + ".roof", center, size, built ? new Color(.32f, .36f, .35f) : new Color(.43f, .45f, .4f), Depth(center), true); Rect(s.id + ".facade", center + new V2(0, -size.y / 2), new V2(size.x, 1.5f), new Color(.12f, .16f, .17f), Depth(center) + 1); Rect(s.id + ".door", center + new V2(0, -size.y / 2), new V2(2, 1.6f), new Color(.07f, .09f, .09f), Depth(center) + 2); Rect(s.id + ".light", center + new V2(0, -size.y / 2 + 1), new V2(2.1f, .12f), new Color(.42f, .73f, .69f), Depth(center) + 3, true); for (int i = -1; i <= 1; i++) Rect(s.id + ".vent" + i, center + new V2(i * 4, 2), new V2(2, 4), new Color(.23f, .27f, .27f), Depth(center) + 2); } foreach (var authority in world.vehicles.Where(v => v.dockedTo == "")) { var v = !session.IsAuthority && session.PredictedVehicle?.id == authority.id ? session.PredictedVehicle : authority; V2 at = Interpolate("vehicle." + v.id, v.position, session.PredictedVehicle?.id == v.id); string asset = v.flying ? "ark" : v.family == Family.Mechanical ? "crawler" : "beast"; float altitude = v.Altitude; Shadow(v.id + ".shadow", at + new V2(altitude * .28f, -altitude * .2f), new V2(v.width * 1.2f, v.length * 1.1f), Mathf.Clamp01(.9f - altitude * .012f)); int direction = Mathf.RoundToInt(Mathf.Repeat(-v.heading, 360) / 90) % 4; bool side = direction % 2 == 1; Put(v.id, art.Direction(asset, v.heading), Projection.ToScreen(at, altitude), new V2(side ? v.length * 1.25f : v.width * 1.45f, side ? v.width * 1.45f : v.length * 1.04f), Color.white, Depth(v.position) + (altitude > 0 ? 5000 : 0)); foreach (var hp in v.hardpoints) { V2 mount = at + new V2(hp.group == 0 ? -v.width * .18f : v.width * .18f, v.length * .12f).Rotate(v.heading); var turret = Put(v.id + "." + hp.id, art.Get("prop0"), Projection.ToScreen(mount, altitude + 1), new V2(2.2f, 2.2f), v.family == Family.Mechanical ? Color.white : new Color(.68f, .77f, .51f), Depth(v.position) + 2 + (altitude > 0 ? 5000 : 0)); turret.transform.rotation = Quaternion.Euler(0, 0, hp.aimHeading); } if (v.doorOpen) Rect(v.id + ".entry", v.DoorPosition, new V2(2, .4f), new Color(.33f, .82f, .72f, .85f), 18000, true); } foreach (var wall in world.Space("world").floors[0].walls) { Shadow(wall.id + ".shadow", wall.bounds.center + new V2(.8f, -.5f), wall.bounds.size + new V2(1, 1)); Put(wall.id, art.Get("extra3"), Projection.ToScreen(wall.bounds.center), new V2(wall.bounds.size.x * 1.4f, wall.bounds.size.y * Projection.GroundScale), new Color(.72f, .76f, .73f), Depth(wall.bounds.center)); } } private void DrawInterior(WorldState world, SpaceState space, int floorIndex) { var floor = space.floors.Find(f => f.index == floorIndex); if (floor == null) return; int sx = Mathf.FloorToInt(floor.bounds.size.x / 2), sy = Mathf.FloorToInt(floor.bounds.size.y / 2); for (int x = -sx; x <= sx; x++) for (int y = -sy; y <= sy; y++) { if (floor.playerBuilt && !world.pieces.Any(b => b.kind == PieceKind.Floor && b.location.spaceId == space.id && b.location.floor == floorIndex && Mathf.Abs(b.location.position.x - x) < .1f && Mathf.Abs(b.location.position.y - y) < .1f)) continue; bool bio = space.family == Family.Biological; Tile("floor." + x + "." + y, new V2(x, y), new V2(1, 1), bio ? new Color(.32f, .27f, .22f) : new Color(.36f, .4f, .4f), -20000, !bio); } Rect("wall.back", new V2(0, sy), new V2(sx * 2 + 1, 1.3f), new Color(.18f, .23f, .25f), 9000); Rect("wall.left", new V2(-sx, 0), new V2(.35f, sy * 2), new Color(.17f, .21f, .22f), 9000); Rect("wall.right", new V2(sx, 0), new V2(.35f, sy * 2), new Color(.17f, .21f, .22f), 9000); // Foreground wall is drawn at cutaway height only; no other floor is disabled globally. Rect("wall.front", new V2(0, -sy), new V2(sx * 2, .28f), new Color(.27f, .36f, .36f, .5f), 16000); foreach (var f in world.facilities.Where(f => f.location.spaceId == space.id && f.location.floor == floorIndex)) { string key = f.kind == FacilityKind.Storage ? "prop2" : f.kind == FacilityKind.Generator ? "extra1" : f.family == Family.Biological ? "extra0" : "prop1"; Shadow(f.id + ".shadow", f.location.position, new V2(2.7f, 1.8f)); Put(f.id, art.Get(key), Projection.ToScreen(f.location.position) + new V2(0, .5f), new V2(2.4f, 2.4f), f.powered ? Color.white : new Color(.5f, .5f, .5f), Depth(f.location.position)); Rect(f.id + ".state", f.location.position + new V2(0, -.8f), new V2(.9f, .08f), f.powered ? new Color(.3f, .8f, .65f) : new Color(.9f, .37f, .17f), 17000, true); } foreach (var vehicle in world.vehicles) foreach (var station in vehicle.stations.Where(s => s.location.spaceId == space.id && s.location.floor == floorIndex)) { Put(station.id, art.Get("prop1"), Projection.ToScreen(station.location.position) + new V2(0, .55f), new V2(2.2f, 2.2f), new Color(.76f, .92f, .98f), Depth(station.location.position)); Rect(station.id + ".state", station.location.position + new V2(0, -.9f), new V2(1.2f, .13f), station.playerId == "" ? new Color(.28f, .71f, .8f) : new Color(.97f, .74f, .3f), 17000, true); } foreach (var portal in world.portals.Where(p => p.from.spaceId == space.id && p.from.floor == floorIndex)) { if (portal.stairs) Put(portal.id, art.Get("extra2"), Projection.ToScreen(portal.from.position) + new V2(0, .5f), new V2(2.1f, 3), new Color(.9f, .93f, .9f), 9500); Rect(portal.id + ".entry", portal.from.position, new V2(1.4f, .32f), new Color(.33f, .76f, .74f, .8f), 16000, true); } foreach (var b in world.pieces.Where(b => b.location.spaceId == space.id && b.location.floor == floorIndex)) { if (b.kind == PieceKind.Wall) Rect(b.id, b.location.position, b.rotation % 2 == 0 ? new V2(1, .4f) : new V2(.4f, 1), new Color(.21f, .27f, .29f), Depth(b.location.position)); else if (b.kind == PieceKind.Door) Rect(b.id, b.location.position, new V2(.8f, .2f), new Color(.38f, .65f, .62f), Depth(b.location.position)); else if (b.kind == PieceKind.Turret) Put(b.id, art.Get("prop0"), Projection.ToScreen(b.location.position), new V2(2, 2), Color.white, Depth(b.location.position)); } } private void DrawEntities(WorldState world, bool changed) { foreach (var node in world.resources.Where(x => x.location.spaceId == ViewedSpace && x.location.floor == ViewedFloor)) { Put(node.id, art.Get(node.itemId == "biomass" ? "prop3" : "prop2"), Projection.ToScreen(node.location.position), new V2(1.2f, 1.2f), node.itemId == "sample" ? new Color(.45f, .85f, .97f) : Color.white, Depth(node.location.position)); } foreach (var e in world.enemies.Where(x => x.location.spaceId == ViewedSpace && x.location.floor == ViewedFloor)) { float size = e.boss ? 4 : 1.8f; V2 at = Interpolate(e.id, e.location.position); Shadow(e.id + ".shadow", at, new V2(size, size * .5f)); Put(e.id, art.Get("worm2"), Projection.ToScreen(at), new V2(size, size), e.boss ? new Color(.93f, .61f, .41f) : new Color(.85f, .8f, .67f), Depth(at)); Rect(e.id + ".health", e.location.position + new V2(0, size * .6f), new V2(size * e.health / e.maxHealth, .08f), new Color(.8f, .28f, .16f), 21000, true); } foreach (var authority in world.players.Where(x => x.connected && x.location.spaceId == ViewedSpace && x.location.floor == ViewedFloor)) { var p = authority.id == session.PlayerId && !session.IsAuthority && session.PredictedPlayer != null ? session.PredictedPlayer : authority; bool worm = p.IsWorm; string key = worm ? "worm" : p.species == Species.Human ? "human" : "adult"; bool underground = p.burrowUntil > world.time; V2 at = Interpolate(p.id, p.location.position, p.id == session.PlayerId); Shadow(p.id + ".shadow", at, new V2(worm ? 1.2f : 1.4f, .85f)); float bob = worm ? Mathf.Sin(Time.time * 8 + p.location.position.x) * .04f : 0; V2 pos = Projection.ToScreen(at, p.location.height) + new V2(0, bob); Put(p.id, art.Direction(key, p.heading), pos, worm ? new V2(1.4f, 1.4f) : new V2(2.35f, 2.35f), underground ? new Color(.55f, .63f, .48f, .28f) : Color.white, Depth(p.location.position) + 1); if (p.id == session.PlayerId) Rect(p.id + ".marker", p.location.position + new V2(0, -.4f), new V2(.75f, .06f), new Color(.35f, .91f, .81f), 17000, true); } foreach (var shot in world.shots.Where(x => x.spaceId == ViewedSpace && x.floor == ViewedFloor)) { V2 a = Projection.ToScreen(shot.from, shot.height), b = Projection.ToScreen(shot.to), delta = b - a; var r = Put("shot." + shot.id, art.Get("white"), (a + b) / 2, new V2(delta.Length, .045f), shot.family == Family.Mechanical ? new Color(1, .83f, .48f, .85f) : new Color(.65f, .95f, .39f, .85f), 22000, true); r.transform.rotation = Quaternion.Euler(0, 0, Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg); } } private void DrawBuildPreview() { var hud = GetComponent(); if (hud == null || !hud.BuildMode || ViewedSpace != "base") return; V2 position = new V2(Mathf.Round(MouseGround.x), Mathf.Round(MouseGround.y)); Rect("build.preview", position, new V2(1, 1), new Color(.35f, .84f, .85f, .38f), 25000, true); } private void HideUnused() { foreach (var pair in renderers) if (!visible.Contains(pair.Key)) pair.Value.gameObject.SetActive(false); if (renderers.Count < 2400) return; foreach (var id in renderers.Keys.Where(k => !visible.Contains(k)).Take(800).ToArray()) { Destroy(renderers[id].gameObject); renderers.Remove(id); } } public Vector2 GuiPosition(Location l) { V2 p = Projection.ToScreen(l.position, l.height); var screen = cameraView.WorldToScreenPoint(new Vector3(p.x, p.y, 0)); return new Vector2(screen.x, Screen.height - screen.y); } public void ReleasePhysics() { floors?.Dispose(); } private void OnDestroy() { floors?.Dispose(); art?.Dispose(); } } }