diff --git a/core/src/mindustry/content/UnitTypes.java b/core/src/mindustry/content/UnitTypes.java index baca774ccc..eb286eec15 100644 --- a/core/src/mindustry/content/UnitTypes.java +++ b/core/src/mindustry/content/UnitTypes.java @@ -482,7 +482,7 @@ public class UnitTypes{ shootSound = Sounds.shootLancer; bullet = new LaserBulletType(){{ - damage = 45f; + damage = 55f; recoil = 0f; sideAngle = 45f; sideWidth = 1f; diff --git a/core/src/mindustry/entities/Damage.java b/core/src/mindustry/entities/Damage.java index 57c3cb5944..adc28ea585 100644 --- a/core/src/mindustry/entities/Damage.java +++ b/core/src/mindustry/entities/Damage.java @@ -10,6 +10,7 @@ import arc.util.*; import arc.util.pooling.*; import mindustry.content.*; import mindustry.core.*; +import mindustry.entities.abilities.*; import mindustry.game.EventType.*; import mindustry.game.*; import mindustry.gen.*; @@ -26,7 +27,7 @@ public class Damage{ private static final UnitDamageEvent bulletDamageEvent = new UnitDamageEvent(); private static final Rect rect = new Rect(); private static final Rect hitrect = new Rect(); - private static final Vec2 vec = new Vec2(), seg1 = new Vec2(), seg2 = new Vec2(); + private static final Vec2 vec = new Vec2(), seg1 = new Vec2(), seg2 = new Vec2(), polyHit = new Vec2(); private static final IntSet collidedBlocks = new IntSet(); private static final IntFloatMap damages = new IntFloatMap(); private static final Seq collided = new Seq<>(), shieldHits = new Seq<>(); @@ -236,6 +237,31 @@ public class Damage{ return findShieldLength(b, result, laser, absorb); } + /** @return the first point where a segment enters a regular polygon, stored in a shared vector, or null if it doesn't. */ + public static @Nullable Vec2 raycastRegularPolygon(int sides, float cx, float cy, float radius, float rotation, float x1, float y1, float x2, float y2){ + if(radius <= 0f) return null; + + if(Intersector.isInRegularPolygon(sides, cx, cy, radius, rotation, x1, y1)){ + return polyHit.set(x1, y1); + } + + float best = Float.MAX_VALUE; + for(int i = 0; i < sides; i++){ + Tmp.v1.trns(rotation + i * 360f / sides, radius).add(cx, cy); + Tmp.v2.trns(rotation + (i + 1) * 360f / sides, radius).add(cx, cy); + + if(Intersector.intersectSegments(x1, y1, x2, y2, Tmp.v1.x, Tmp.v1.y, Tmp.v2.x, Tmp.v2.y, Tmp.v3)){ + float dst = Tmp.v3.dst2(x1, y1); + if(dst < best){ + best = dst; + polyHit.set(Tmp.v3); + } + } + } + + return best == Float.MAX_VALUE ? null : polyHit; + } + /** * Finds the enemy shields hit by a laser, in order, until one absorbs the rest of its damage. * @param absorb whether to actually apply the hits to the shields; otherwise the first shield is assumed to absorb everything. @@ -261,13 +287,31 @@ public class Damage{ } } + var units = Units.enemyShields(b.team, rect.x, rect.y, rect.width, rect.height); + for(int i = 0; i < units.size; i++){ + Unit unit = units.get(i); + if(unit.dead) continue; + + for(Ability ability : unit.abilities){ + if(ability instanceof UnitShieldProvider shield){ + Vec2 hit = shield.intersectLaser(unit, seg1.x, seg1.y, seg2.x, seg2.y, damage); + if(hit != null){ + shieldHits.add(collidePool.obtain().set(hit.x, hit.y, unit, shield)); + } + } + } + } + float result = length, remaining = damage; shieldHits.sort(c -> Mathf.dst2(seg1.x, seg1.y, c.x, c.y)); for(int i = 0; i < shieldHits.size; i++){ Collided c = shieldHits.get(i); - float absorbed = absorb ? ((ShieldProvider)c.target).absorbLaser(c.x, c.y, remaining) : remaining; + float absorbed = remaining; + if(absorb){ + absorbed = c.ability != null ? c.ability.absorbLaser((Unit)c.target, c.x, c.y, remaining) : ((ShieldProvider)c.target).absorbLaser(c.x, c.y, remaining); + } remaining -= absorbed; if(remaining <= 0f){ @@ -739,6 +783,8 @@ public class Damage{ public static class Collided implements Pool.Poolable{ public float x, y; public Teamc target; + /** Set when the target is a unit hit through one of its shield abilities. */ + public @Nullable UnitShieldProvider ability; public Collided set(float x, float y, Teamc target){ this.x = x; @@ -747,9 +793,15 @@ public class Damage{ return this; } + public Collided set(float x, float y, Teamc target, UnitShieldProvider ability){ + this.ability = ability; + return set(x, y, target); + } + @Override public void reset(){ target = null; + ability = null; } } } \ No newline at end of file diff --git a/core/src/mindustry/entities/Units.java b/core/src/mindustry/entities/Units.java index 2ccd5bf36b..df64a0b884 100644 --- a/core/src/mindustry/entities/Units.java +++ b/core/src/mindustry/entities/Units.java @@ -18,6 +18,7 @@ import static mindustry.Vars.*; /** Utility class for unit and team interactions.*/ public class Units{ private static final Rect hitrect = new Rect(); + private static final Seq shieldReturn = new Seq<>(); private static Unit result; private static float cdist, cpriority; private static int intResult; @@ -441,6 +442,20 @@ public class Units{ } } + /** @return all enemy units with shield abilities that may reach into a rectangle (bottom left corner origin). The result is reused between calls. */ + public static Seq enemyShields(Team team, float x, float y, float width, float height){ + shieldReturn.clear(); + Seq data = state.teams.present; + for(int i = 0; i < data.size; i++){ + var tree = data.items[i].unitShieldTree; + if(data.items[i].team != team && tree != null){ + tree.intersect(x, y, width, height, shieldReturn); + } + } + + return shieldReturn; + } + /** Iterates over all units in a circle around this position. */ public static void nearby(@Nullable Team team, float x, float y, float radius, Cons cons){ nearby(team, x - radius, y - radius, radius*2f, radius*2f, unit -> { diff --git a/core/src/mindustry/entities/abilities/ForceFieldAbility.java b/core/src/mindustry/entities/abilities/ForceFieldAbility.java index 6addf39954..06f4ba69ad 100644 --- a/core/src/mindustry/entities/abilities/ForceFieldAbility.java +++ b/core/src/mindustry/entities/abilities/ForceFieldAbility.java @@ -11,13 +11,14 @@ import arc.scene.ui.layout.*; import arc.util.*; import mindustry.*; import mindustry.content.*; +import mindustry.entities.*; import mindustry.gen.*; import mindustry.graphics.*; import mindustry.ui.*; import static mindustry.Vars.*; -public class ForceFieldAbility extends Ability{ +public class ForceFieldAbility extends Ability implements UnitShieldProvider{ /** Shield radius. */ public float radius = 60f; /** Shield regen speed in damage/tick. */ @@ -74,6 +75,27 @@ public class ForceFieldAbility extends Ability{ return max * Vars.state.rules.unitHealth(unit.team); } + @Override + public float shieldBounds(){ + return radius; + } + + @Override + public @Nullable Vec2 intersectLaser(Unit unit, float x1, float y1, float x2, float y2, float damage){ + return unit.shield > 0f ? Damage.raycastRegularPolygon(sides, unit.x, unit.y, radiusScale * radius, rotation, x1, y1, x2, y2) : null; + } + + @Override + public float absorbLaser(Unit unit, float x, float y, float damage){ + float absorbed = Math.min(damage, Math.max(unit.shield, 0f)); + if(absorbed > 0f){ + Fx.absorb.at(x, y); + unit.shield -= damage; + alpha = 1f; + } + return absorbed; + } + @Override public void addStats(Table t){ super.addStats(t); diff --git a/core/src/mindustry/entities/abilities/ShieldArcAbility.java b/core/src/mindustry/entities/abilities/ShieldArcAbility.java index 225b57d165..3d3143bdc6 100644 --- a/core/src/mindustry/entities/abilities/ShieldArcAbility.java +++ b/core/src/mindustry/entities/abilities/ShieldArcAbility.java @@ -15,11 +15,12 @@ import mindustry.gen.*; import mindustry.graphics.*; import mindustry.ui.*; -public class ShieldArcAbility extends Ability{ +public class ShieldArcAbility extends Ability implements UnitShieldProvider{ private static Unit paramUnit; private static ShieldArcAbility paramField; private static Vec2 paramPos = new Vec2(); + private static final Vec2 laserHit = new Vec2(); private static final Cons shieldConsumer = b -> { if(b.team != paramUnit.team && b.type.absorbable && paramField.data > 0 && !(b.within(paramPos, paramField.radius - paramField.width) && paramPos.within(b.x - b.deltaX, b.y - b.deltaY, paramField.radius - paramField.width)) && @@ -169,6 +170,84 @@ public class ShieldArcAbility extends Ability{ return max * Vars.state.rules.unitHealth(unit.team); } + @Override + public float shieldBounds(){ + return Mathf.len(x, y) + radius + width; + } + + @Override + public @Nullable Vec2 intersectLaser(Unit unit, float x1, float y1, float x2, float y2, float damage){ + if(data <= 0f || !(unit.isShooting || !whenShooting)) return null; + + Tmp.v1.set(x, y).rotate(unit.rotation - 90f).add(unit); + float cx = Tmp.v1.x, cy = Tmp.v1.y; + float rot = unit.rotation + angleOffset, half = angle / 2f; + float inner = Math.max(radius - width, 0f), outer = radius + width; + float dx = x2 - x1, dy = y2 - y1, a = dx * dx + dy * dy; + float fx = x1 - cx, fy = y1 - cy, start = fx * fx + fy * fy; + + //starts inside the band + if(start >= inner * inner && start <= outer * outer && inSpan(cx, cy, x1, y1, rot, half)){ + return laserHit.set(x1, y1); + } + + float best = Float.MAX_VALUE; + + //crossings of the outer and inner arcs, as fractions along the segment + if(a > 0f){ + float b = 2f * (fx * dx + fy * dy); + for(int i = 0; i < 2; i++){ + float r = i == 0 ? outer : inner; + float disc = b * b - 4f * a * (start - r * r); + if(disc < 0f) continue; + + float sqrt = Mathf.sqrt(disc); + for(int s : Mathf.signs){ + float t = (-b + sqrt * s) / (2f * a); + if(t >= 0f && t <= 1f && t < best && inSpan(cx, cy, x1 + dx * t, y1 + dy * t, rot, half)){ + best = t; + } + } + } + } + + //crossings of the radial end edges + if(angle < 360f){ + for(int s : Mathf.signs){ + float ex = Angles.trnsx(rot + half * s, 1f), ey = Angles.trnsy(rot + half * s, 1f); + if(Intersector.intersectSegments(x1, y1, x2, y2, cx + ex * inner, cy + ey * inner, cx + ex * outer, cy + ey * outer, Tmp.v2)){ + best = Math.min(best, Tmp.v2.dst(x1, y1) / Mathf.sqrt(a)); + } + } + } + + return best == Float.MAX_VALUE ? null : laserHit.set(x1 + dx * best, y1 + dy * best); + } + + @Override + public float absorbLaser(Unit unit, float lx, float ly, float damage){ + float absorbed = Math.min(damage, Math.max(data, 0f)); + if(absorbed > 0f){ + Fx.absorb.at(lx, ly); + + if(data <= damage){ + Tmp.v1.set(x, y).rotate(unit.rotation - 90f).add(unit); + data -= cooldown * regen; + + Fx.arcShieldBreak.at(Tmp.v1.x, Tmp.v1.y, 0, color == null ? unit.type.shieldColor(unit) : color, unit); + breakSound.at(Tmp.v1.x, Tmp.v1.y); + } + + data -= damage; + alpha = 1f; + } + return absorbed; + } + + protected boolean inSpan(float cx, float cy, float px, float py, float rotation, float half){ + return angle >= 360f || Angles.within(Angles.angle(cx, cy, px, py), rotation, half); + } + @Override public void addStats(Table t){ super.addStats(t); diff --git a/core/src/mindustry/entities/abilities/UnitShieldProvider.java b/core/src/mindustry/entities/abilities/UnitShieldProvider.java new file mode 100644 index 0000000000..9d213041ba --- /dev/null +++ b/core/src/mindustry/entities/abilities/UnitShieldProvider.java @@ -0,0 +1,17 @@ +package mindustry.entities.abilities; + +import arc.math.geom.*; +import arc.util.*; +import mindustry.gen.*; + +/** Implemented by abilities that shield their unit from lasers. Ability instances are per-unit copies, so the owner is passed in. */ +public interface UnitShieldProvider{ + /** @return maximum distance the shield can reach from the unit's center. */ + float shieldBounds(); + + /** @return the first point where the segment hits this shield, stored in a shared vector (copy it), or null if it doesn't. */ + @Nullable Vec2 intersectLaser(Unit unit, float x1, float y1, float x2, float y2, float damage); + + /** Applies a laser hit at x, y. @return how much of the damage was absorbed; anything less than the full damage means the laser continues through. */ + float absorbLaser(Unit unit, float x, float y, float damage); +} diff --git a/core/src/mindustry/game/Teams.java b/core/src/mindustry/game/Teams.java index f488bc59b7..69460afc67 100644 --- a/core/src/mindustry/game/Teams.java +++ b/core/src/mindustry/game/Teams.java @@ -195,6 +195,9 @@ public class Teams{ if(data.unitTree != null){ data.unitTree.clear(); } + if(data.unitShieldTree != null){ + data.unitShieldTree.clear(); + } if(data.typeCounts != null){ Arrays.fill(data.typeCounts, 0); @@ -215,6 +218,9 @@ public class Teams{ if(unit.type == null) continue; TeamData data = unit.team.data(); data.tree().insert(unit); + if(unit.type.shieldBounds > 0f && !unit.dead){ + data.unitShieldTree().insert(unit); + } data.units.add(unit); data.presentFlag = true; @@ -299,6 +305,8 @@ public class Teams{ public @Nullable QuadTree shieldTree; /** Quadtree for units of this team. Do not access directly. */ public @Nullable QuadTree unitTree; + /** Quadtree for units of this team that have shield abilities. Rebuilt each frame; do not access directly. */ + public @Nullable QuadTree unitShieldTree; /** Current unit cap. Do not modify externally. */ public int unitCap; /** Total unit count. */ @@ -446,6 +454,11 @@ public class Teams{ return unitTree; } + public QuadTree unitShieldTree(){ + if(unitShieldTree == null) unitShieldTree = new UnitShieldQuadtree(Vars.world.getQuadBounds(new Rect())); + return unitShieldTree; + } + public int countType(UnitType type){ return typeCounts == null || typeCounts.length <= type.id ? 0 : typeCounts[type.id]; } @@ -514,6 +527,24 @@ public class Teams{ } } + static class UnitShieldQuadtree extends QuadTree{ + + public UnitShieldQuadtree(Rect bounds){ + super(bounds); + } + + @Override + public void hitbox(Unit unit){ + //padded by speed, as the tree is built before units move each frame + tmp.setCentered(unit.x, unit.y, (unit.type.shieldBounds + unit.type.speed * 2f) * 2f); + } + + @Override + protected QuadTree newChild(Rect rect){ + return new UnitShieldQuadtree(rect); + } + } + /** Represents a block made by this team that was destroyed somewhere on the map. * This does not include deconstructed blocks.*/ public static class BlockPlan{ diff --git a/core/src/mindustry/type/UnitType.java b/core/src/mindustry/type/UnitType.java index 23a38e9865..da096fa654 100644 --- a/core/src/mindustry/type/UnitType.java +++ b/core/src/mindustry/type/UnitType.java @@ -285,6 +285,8 @@ public class UnitType extends UnlockableContent implements Senseable{ /** list of "abilities", which are various behaviors that update each frame */ public Seq abilities = new Seq<>(); + /** Maximum reach of shield abilities from the unit's center; 0 if there are none. Set in init(). */ + public float shieldBounds; /** All weapons that this unit will shoot with. */ public Seq weapons = new Seq<>(); /** None of the status effects in this set can be applied to this unit. */ @@ -1033,6 +1035,8 @@ public class UnitType extends UnlockableContent implements Senseable{ ab.init(this); } + updateShieldBounds(); + //add mirrored weapon variants Seq mapped = new Seq<>(); for(Weapon w : weapons){ @@ -1303,6 +1307,16 @@ public class UnitType extends UnlockableContent implements Senseable{ pathCost = null; pathCostId = -1; initPathType(); + updateShieldBounds(); + } + + public void updateShieldBounds(){ + shieldBounds = 0f; + for(Ability ab : abilities){ + if(ab instanceof UnitShieldProvider shield){ + shieldBounds = Math.max(shieldBounds, shield.shieldBounds()); + } + } } public void beforeParse(){ diff --git a/core/src/mindustry/ui/dialogs/SettingsMenuDialog.java b/core/src/mindustry/ui/dialogs/SettingsMenuDialog.java index 065ab42c0e..44417a6752 100644 --- a/core/src/mindustry/ui/dialogs/SettingsMenuDialog.java +++ b/core/src/mindustry/ui/dialogs/SettingsMenuDialog.java @@ -520,7 +520,7 @@ public class SettingsMenuDialog extends BaseDialog{ graphics.checkPref("mouseposition", false); } graphics.checkPref("fps", false); - graphics.checkPref("playerindicators", true); + graphics.checkPref("playerindicators", false); graphics.checkPref("showpings", true); graphics.checkPref("showotherbuildplans", true); graphics.checkPref("indicators", true); diff --git a/core/src/mindustry/world/blocks/defense/ForceProjector.java b/core/src/mindustry/world/blocks/defense/ForceProjector.java index cb11484470..6d06040172 100644 --- a/core/src/mindustry/world/blocks/defense/ForceProjector.java +++ b/core/src/mindustry/world/blocks/defense/ForceProjector.java @@ -57,7 +57,6 @@ public class ForceProjector extends Block{ //lambdas need to be static to prevent GC protected static ForceProjector paramBlock; protected static ForceBuild paramEntity; - protected static final Vec2 laserHit = new Vec2(); protected static final Cons shieldConsumer = bullet -> { if(bullet.team != paramEntity.team && bullet.type.absorbable && !bullet.absorbed && Intersector.isInRegularPolygon(paramBlock.sides, paramEntity.x, paramEntity.y, paramEntity.realRadius(), paramBlock.shieldRotation, bullet.x, bullet.y)){ @@ -280,28 +279,7 @@ public class ForceProjector extends Block{ @Override public @Nullable Vec2 intersectLaser(float x1, float y1, float x2, float y2, float damage){ - float radius = realRadius(); - if(broken || radius <= 0f) return null; - - if(Intersector.isInRegularPolygon(sides, x, y, radius, shieldRotation, x1, y1)){ - return laserHit.set(x1, y1); - } - - float best = Float.MAX_VALUE; - for(int i = 0; i < sides; i++){ - Tmp.v1.trns(shieldRotation + i * 360f / sides, radius).add(x, y); - Tmp.v2.trns(shieldRotation + (i + 1) * 360f / sides, radius).add(x, y); - - if(Intersector.intersectSegments(x1, y1, x2, y2, Tmp.v1.x, Tmp.v1.y, Tmp.v2.x, Tmp.v2.y, Tmp.v3)){ - float dst = Tmp.v3.dst2(x1, y1); - if(dst < best){ - best = dst; - laserHit.set(Tmp.v3); - } - } - } - - return best == Float.MAX_VALUE ? null : laserHit; + return broken ? null : Damage.raycastRegularPolygon(sides, x, y, realRadius(), shieldRotation, x1, y1, x2, y2); } @Override