From 6b53e86a57ce57a118db630cf87da60f0f811531 Mon Sep 17 00:00:00 2001
From: Pan
Date: Sat, 18 Jul 2026 16:45:05 +1200
Subject: [PATCH 1/4] Add procedural audio, dynamic blast lighting,
camera/cursor overhaul, and migrate highscores off MySQL
- New procedural sound engine (audio/SoundSynth, audio/SoundEngine): every SFX (movement,
attack, harvest, weapon fire, explosions, building/vehicle deploy, credit earn/spend
jingles, victory/defeat) synthesized in code with zero external audio assets.
- Dynamic point-light illumination on 3D models from nearby explosions (polygon3D,
solidObject, rasterizer), using face-normal-only lighting and a single strongest
light source per model, deliberately kept simple per design constraints. Wired into
all combat-capable entities plus gold mines, palm trees, and light poles.
- Right-click-drag camera panning reworked from a one-shot grab-and-drag into a
continuous hold-and-scroll model with a tunable speed curve (camera.java,
inputHandler.java), plus a directional cursor icon and deploy-vehicle interaction
(gameCursor.java, playerCommander.java) with new thin arrow icon assets.
- Unit veterancy stars now show on every visible unit regardless of selection
(AssetManager.java, postProcessingThread.java), while health bars/group info stay
selection-gated as before.
- Migrated the highscore backend from a raw JDBC/MySQL connection (embedded, insecure
credentials) to Firebase Firestore's REST API (highscoreManager.java): public reads,
anonymous-auth-gated writes, a per-identity submission cooldown, and plausibility
bounds on submitted times to prevent the score spamming the old backend suffered from.
Removed the now-unused mysql-connector-java dependency entirely.
- Various fixes: refinery ore-pit rise-animation visibility, run.bat launching the game
as a properly detached process instead of blocking the script, a Swing focus-request
race that could leave hotkeys dead on load, and misc rendering/rasterizer cleanups.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01DSMrD2nQJvvAKUCmw68uWX
---
.classpath | 1 -
.gitignore | 1 +
audio/.gitignore | 4 +
audio/SoundEngine.java | 114 ++++++++++
audio/SoundSynth.java | 390 ++++++++++++++++++++++++++++++++
core/AssetManager.java | 125 +++++++---
core/camera.java | 51 ++++-
core/gameData.java | 2 +-
core/highscoreManager.java | 329 ++++++++++++++++-----------
core/mainThread.java | 92 ++++++--
core/playerCommander.java | 158 ++++++++++---
core/polygon3D.java | 35 ++-
core/postProcessingThread.java | 207 +++++++++--------
core/rasterizer.java | 117 +++++-----
core/sideBarManager.java | 7 +-
entity/constructionVehicle.java | 4 +
entity/constructionYard.java | 14 +-
entity/factory.java | 1 +
entity/goldMine.java | 1 +
entity/gunTurret.java | 4 +-
entity/harvester.java | 6 +-
entity/heavyTank.java | 2 +
entity/lightPole.java | 14 +-
entity/lightTank.java | 3 +-
entity/palmTree.java | 8 +-
entity/powerPlant.java | 1 +
entity/refinery.java | 14 +-
entity/solidObject.java | 55 ++++-
entity/stealthTank.java | 5 +-
gui/SideBar.java | 2 +-
gui/gameCursor.java | 99 +++++++-
gui/gameMenu.java | 36 ++-
gui/inputHandler.java | 84 ++++++-
gui/textRenderer.java | 8 +-
images/smallArrowDown.png | Bin 0 -> 199 bytes
images/smallArrowLeft.png | Bin 0 -> 213 bytes
images/smallArrowRight.png | Bin 0 -> 220 bytes
images/smallArrowUp.png | Bin 0 -> 200 bytes
main.java | 1 +
mysql-connector-java-5.1.47.jar | Bin 1007502 -> 0 bytes
particles/explosion.java | 22 +-
run.bat | 20 ++
42 files changed, 1592 insertions(+), 445 deletions(-)
create mode 100644 audio/.gitignore
create mode 100644 audio/SoundEngine.java
create mode 100644 audio/SoundSynth.java
create mode 100644 images/smallArrowDown.png
create mode 100644 images/smallArrowLeft.png
create mode 100644 images/smallArrowRight.png
create mode 100644 images/smallArrowUp.png
delete mode 100644 mysql-connector-java-5.1.47.jar
create mode 100644 run.bat
diff --git a/.classpath b/.classpath
index ccd4c4c..07661e7 100644
--- a/.classpath
+++ b/.classpath
@@ -2,6 +2,5 @@
-
diff --git a/.gitignore b/.gitignore
index 9bf81e5..52007bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
/main.class
+/dist/
diff --git a/audio/.gitignore b/audio/.gitignore
new file mode 100644
index 0000000..165993f
--- /dev/null
+++ b/audio/.gitignore
@@ -0,0 +1,4 @@
+/SoundEngine.class
+/SoundEngine$Sfx.class
+/SoundSynth.class
+/SoundSynth$Waveform.class
diff --git a/audio/SoundEngine.java b/audio/SoundEngine.java
new file mode 100644
index 0000000..8f11581
--- /dev/null
+++ b/audio/SoundEngine.java
@@ -0,0 +1,114 @@
+package audio;
+
+import javax.sound.sampled.AudioFormat;
+import javax.sound.sampled.AudioSystem;
+import javax.sound.sampled.Clip;
+import javax.sound.sampled.FloatControl;
+import javax.sound.sampled.LineUnavailableException;
+
+//Small non-blocking sound engine on top of javax.sound.sampled. Every effect keeps a
+//pool of Clips so overlapping triggers (e.g. several units firing the same frame)
+//don't cut each other off. play() never blocks the caller - Clip playback runs on the
+//Java Sound mixer thread.
+public class SoundEngine {
+
+ public enum Sfx {
+ CLICK, SELECT, MOVE, ATTACK, HARVEST,
+ SHOOT_CANNON, SHOOT_CANNON_HEAVY, SHOOT_AUTOCANNON,
+ SHOOT_ROCKET, SHOOT_MISSILE, SHOOT_RAILGUN,
+ EXPLOSION_SMALL, EXPLOSION_LARGE,
+ DEPLOY_BUILDING, DEPLOY_VEHICLE,
+ VICTORY, DEFEAT,
+ CREDIT_EARN, CREDIT_SPEND
+ }
+
+ public static volatile boolean enabled = true;
+
+ private static final int POOL_SIZE = 4;
+ private static final AudioFormat FORMAT = new AudioFormat(SoundSynth.SAMPLE_RATE, 16, 1, true, false);
+
+ private static final Clip[][] pools = new Clip[Sfx.values().length][];
+ private static final int[] nextSlot = new int[Sfx.values().length];
+ private static boolean initialized;
+
+ public static synchronized void init() {
+ if (initialized)
+ return;
+ initialized = true;
+
+ load(Sfx.CLICK, SoundSynth.click());
+ load(Sfx.SELECT, SoundSynth.select());
+ load(Sfx.MOVE, SoundSynth.move());
+ load(Sfx.ATTACK, SoundSynth.attack());
+ load(Sfx.HARVEST, SoundSynth.harvest());
+ load(Sfx.SHOOT_CANNON, SoundSynth.shootCannon());
+ load(Sfx.SHOOT_CANNON_HEAVY, SoundSynth.shootCannonHeavy());
+ load(Sfx.SHOOT_AUTOCANNON, SoundSynth.shootAutocannon());
+ load(Sfx.SHOOT_ROCKET, SoundSynth.shootRocket());
+ load(Sfx.SHOOT_MISSILE, SoundSynth.shootMissile());
+ load(Sfx.SHOOT_RAILGUN, SoundSynth.shootRailgun());
+ load(Sfx.EXPLOSION_SMALL, SoundSynth.explosionSmall());
+ load(Sfx.EXPLOSION_LARGE, SoundSynth.explosionLarge());
+ load(Sfx.DEPLOY_BUILDING, SoundSynth.deployBuilding());
+ load(Sfx.DEPLOY_VEHICLE, SoundSynth.deployVehicle());
+ load(Sfx.VICTORY, SoundSynth.victory());
+ load(Sfx.DEFEAT, SoundSynth.defeat());
+ load(Sfx.CREDIT_EARN, SoundSynth.creditEarn());
+ load(Sfx.CREDIT_SPEND, SoundSynth.creditSpend());
+ }
+
+ private static void load(Sfx sfx, byte[] pcm) {
+ Clip[] clips = new Clip[POOL_SIZE];
+ for (int i = 0; i < POOL_SIZE; i++) {
+ try {
+ Clip clip = AudioSystem.getClip();
+ clip.open(FORMAT, pcm, 0, pcm.length);
+ clips[i] = clip;
+ } catch (LineUnavailableException e) {
+ clips[i] = null;
+ }
+ }
+ pools[sfx.ordinal()] = clips;
+ }
+
+ public static void play(Sfx sfx) {
+ play(sfx, 1f);
+ }
+
+ public static void play(Sfx sfx, float volume) {
+ if (!enabled || !initialized)
+ return;
+
+ Clip[] clips = pools[sfx.ordinal()];
+ if (clips == null)
+ return;
+
+ int slot;
+ synchronized (nextSlot) {
+ slot = nextSlot[sfx.ordinal()];
+ nextSlot[sfx.ordinal()] = (slot + 1) % clips.length;
+ }
+
+ Clip clip = clips[slot];
+ if (clip == null)
+ return;
+
+ synchronized (clip) {
+ if (clip.isRunning())
+ clip.stop();
+ clip.setFramePosition(0);
+ applyVolume(clip, volume);
+ clip.start();
+ }
+ }
+
+ private static void applyVolume(Clip clip, float volume) {
+ if (!clip.isControlSupported(FloatControl.Type.MASTER_GAIN))
+ return;
+ FloatControl gainControl = (FloatControl) clip.getControl(FloatControl.Type.MASTER_GAIN);
+ float clamped = Math.max(0.0001f, Math.min(1f, volume));
+ float dB = (float) (Math.log10(clamped) * 20.0);
+ dB = Math.max(gainControl.getMinimum(), Math.min(gainControl.getMaximum(), dB));
+ gainControl.setValue(dB);
+ }
+}
diff --git a/audio/SoundSynth.java b/audio/SoundSynth.java
new file mode 100644
index 0000000..2ff923b
--- /dev/null
+++ b/audio/SoundSynth.java
@@ -0,0 +1,390 @@
+package audio;
+
+import java.util.Random;
+
+//Generates every sound effect in code as 16-bit mono PCM, so the game ships with
+//zero audio assets and zero licensing to worry about.
+public class SoundSynth {
+
+ public static final float SAMPLE_RATE = 22050f;
+
+ private static final Random RNG = new Random();
+
+ private interface Waveform {
+ double sample(double phase);
+ }
+
+ private static final Waveform SINE = Math::sin;
+ private static final Waveform TRIANGLE = phase -> {
+ double x = ((phase / (2 * Math.PI)) % 1 + 1) % 1;
+ return 4 * Math.abs(x - 0.5) - 1;
+ };
+ private static final Waveform SQUARE = phase -> Math.sin(phase) >= 0 ? 1 : -1;
+ private static final Waveform SAWTOOTH = phase -> {
+ double x = ((phase / (2 * Math.PI)) % 1 + 1) % 1;
+ return 2 * x - 1;
+ };
+
+ private static int ms(int milliseconds) {
+ return (int) (SAMPLE_RATE * milliseconds / 1000.0);
+ }
+
+ //linear frequency sweep from freqStart to freqEnd over n samples
+ private static double[] tone(double freqStart, double freqEnd, int n, Waveform wf) {
+ double[] buf = new double[n];
+ double phase = 0;
+ for (int i = 0; i < n; i++) {
+ double frac = n <= 1 ? 0 : i / (double) (n - 1);
+ double freq = freqStart + (freqEnd - freqStart) * frac;
+ phase += 2 * Math.PI * freq / SAMPLE_RATE;
+ buf[i] = wf.sample(phase);
+ }
+ return buf;
+ }
+
+ //one-pole lowpass filtered white noise; smaller alpha = duller/bassier
+ private static double[] filteredNoise(int n, double alpha) {
+ double[] buf = new double[n];
+ double y = 0;
+ for (int i = 0; i < n; i++) {
+ double x = RNG.nextDouble() * 2 - 1;
+ y += alpha * (x - y);
+ buf[i] = y;
+ }
+ return buf;
+ }
+
+ //filtered noise whose cutoff opens up over time, for a rocket "whoosh"
+ private static double[] sweepingNoise(int n, double alphaStart, double alphaEnd) {
+ double[] buf = new double[n];
+ double y = 0;
+ for (int i = 0; i < n; i++) {
+ double frac = i / (double) n;
+ double alpha = alphaStart + (alphaEnd - alphaStart) * frac;
+ double x = RNG.nextDouble() * 2 - 1;
+ y += alpha * (x - y);
+ buf[i] = y;
+ }
+ return buf;
+ }
+
+ private static double expDecay(int i, int n, double rate) {
+ return Math.exp(-rate * i / n);
+ }
+
+ //envelope that rises then falls, peaking at peakFrac through the buffer
+ private static double bell(int i, int n, double peakFrac) {
+ double frac = i / (double) n;
+ if (frac < peakFrac)
+ return frac / peakFrac;
+ return Math.max(0, 1 - (frac - peakFrac) / (1 - peakFrac));
+ }
+
+ private static double[] envelope(double[] buf, java.util.function.IntToDoubleFunction env) {
+ double[] out = new double[buf.length];
+ for (int i = 0; i < buf.length; i++)
+ out[i] = buf[i] * env.applyAsDouble(i);
+ return out;
+ }
+
+ private static double[] gain(double[] buf, double g) {
+ double[] out = new double[buf.length];
+ for (int i = 0; i < buf.length; i++)
+ out[i] = buf[i] * g;
+ return out;
+ }
+
+ //soft-clip saturation for punch/grit without harsh digital clipping; drive > 1 pushes harder
+ private static double[] saturate(double[] buf, double drive) {
+ double[] out = new double[buf.length];
+ for (int i = 0; i < buf.length; i++)
+ out[i] = Math.tanh(buf[i] * drive);
+ return out;
+ }
+
+ private static double[] padStart(double[] buf, int totalLength) {
+ double[] out = new double[totalLength];
+ System.arraycopy(buf, 0, out, 0, Math.min(buf.length, totalLength));
+ return out;
+ }
+
+ private static double[] mix(double[]... parts) {
+ int n = 0;
+ for (double[] p : parts)
+ n = Math.max(n, p.length);
+ double[] out = new double[n];
+ for (double[] p : parts)
+ for (int i = 0; i < p.length; i++)
+ out[i] += p[i];
+ return out;
+ }
+
+ private static double[] concat(double[]... parts) {
+ int total = 0;
+ for (double[] p : parts)
+ total += p.length;
+ double[] out = new double[total];
+ int pos = 0;
+ for (double[] p : parts) {
+ System.arraycopy(p, 0, out, pos, p.length);
+ pos += p.length;
+ }
+ return out;
+ }
+
+ private static byte[] toPcm(double[] buf, double masterGain) {
+ byte[] out = new byte[buf.length * 2];
+ for (int i = 0; i < buf.length; i++) {
+ double v = buf[i] * masterGain;
+ if (v > 1)
+ v = 1;
+ if (v < -1)
+ v = -1;
+ short s = (short) Math.round(v * Short.MAX_VALUE);
+ out[i * 2] = (byte) (s & 0xff);
+ out[i * 2 + 1] = (byte) ((s >> 8) & 0xff);
+ }
+ return out;
+ }
+
+ //a dry mechanical switch/relay tick, not a musical blip
+ public static byte[] click() {
+ int n = ms(16);
+ double[] tick = envelope(filteredNoise(n, 0.7), i -> expDecay(i, n, 11));
+ double[] thump = envelope(tone(190, 120, n, SINE), i -> expDecay(i, n, 13));
+ return toPcm(saturate(mix(gain(tick, 0.85), gain(thump, 0.55)), 1.3), 0.32);
+ }
+
+ //a single "ding": the exact same tone+noise click structure as creditSpend() below, just pitched up
+ private static double[] ding(int n, double startPitch, double endPitch) {
+ return envelope(mix(tone(startPitch, endPitch, n, SINE), gain(filteredNoise(n, 0.6), 0.4)), i -> expDecay(i, n, 14));
+ }
+
+ //a longer "ding-ding-ding-ding..." for a credit increase (harvester delivering ore, a cancelled
+ //building refunding): the same simple click creditSpend() uses, just pitched higher and repeated
+ //several times, rather than trying to synthesize a more elaborate/realistic coin sound
+ public static byte[] creditEarn() {
+ int dingLen = ms(50);
+ int gapLen = ms(20);
+ int dingCount = 12;
+
+ double[] silence = new double[gapLen];
+ double[] result = new double[0];
+ for (int i = 0; i < dingCount; i++) {
+ double pitch = 3200 + RNG.nextDouble() * 400;
+ double[] d = ding(dingLen, pitch, pitch * 0.65);
+ result = i == 0 ? d : concat(result, silence, d);
+ }
+ return toPcm(result, 0.22);
+ }
+
+ //a quiet metallic coin-click for a credit decrease (construction, repair, research draining the
+ //treasury) - kept short and quiet since it can fire repeatedly during a fast trickle
+ public static byte[] creditSpend() {
+ int n = ms(35);
+ double[] click = envelope(mix(tone(950, 550, n, SINE), gain(filteredNoise(n, 0.6), 0.4)), i -> expDecay(i, n, 14));
+ return toPcm(click, 0.12);
+ }
+
+ //unit selection blip: kept soft/melodic, matches the softer command-confirmation style
+ public static byte[] select() {
+ int n1 = ms(70), gap = ms(15), n2 = ms(70);
+ double[] blip1 = envelope(tone(880, 880, n1, SINE), i -> expDecay(i, n1, 5));
+ double[] silence = new double[gap];
+ double[] blip2 = envelope(tone(1320, 1320, n2, SINE), i -> expDecay(i, n2, 5));
+ return toPcm(concat(blip1, silence, blip2), 0.32);
+ }
+
+ //command-confirmation chirp: kept soft/melodic rather than industrial, reads better as a quick acknowledgement
+ public static byte[] move() {
+ int n = ms(110);
+ double[] buf = envelope(tone(700, 450, n, TRIANGLE), i -> expDecay(i, n, 4));
+ return toPcm(buf, 0.32);
+ }
+
+ public static byte[] attack() {
+ int n = ms(140);
+ double[] toneBuf = envelope(tone(320, 220, n, SQUARE), i -> expDecay(i, n, 3.5));
+ double[] grit = envelope(filteredNoise(n, 0.6), i -> expDecay(i, n, 6));
+ return toPcm(mix(toneBuf, gain(grit, 0.25)), 0.3);
+ }
+
+ //a mechanical valve-clunk, not a soft chirp
+ public static byte[] harvest() {
+ int n = ms(85);
+ double[] clunk = envelope(tone(170, 105, n, SQUARE), i -> expDecay(i, n, 7));
+ double[] grit = envelope(filteredNoise(n, 0.45), i -> expDecay(i, n, 8));
+ return toPcm(saturate(mix(clunk, gain(grit, 0.4)), 1.25), 0.3);
+ }
+
+ public static byte[] shootCannon() {
+ int n = ms(140);
+ double[] thump = envelope(tone(95, 60, n, SINE), i -> expDecay(i, n, 7));
+ double[] crack = envelope(filteredNoise(n, 0.5), i -> expDecay(i, n, 9));
+ return toPcm(saturate(mix(thump, gain(crack, 0.65)), 1.3), 0.58);
+ }
+
+ public static byte[] shootRocket() {
+ int n = ms(230);
+ double[] whoosh = envelope(sweepingNoise(n, 0.08, 0.45), i -> bell(i, n, 0.35));
+ double[] sweep = envelope(tone(150, 550, n, SINE), i -> bell(i, n, 0.35));
+ return toPcm(saturate(mix(gain(whoosh, 0.8), gain(sweep, 0.35)), 1.15), 0.52);
+ }
+
+ //heavy tank's main gun: a big, saturated boom with a sub-bass layer and a breech clank tail
+ public static byte[] shootCannonHeavy() {
+ int n = ms(260);
+ double[] sub = envelope(tone(38, 20, n, SINE), i -> expDecay(i, n, 3.6));
+ double[] thump = envelope(tone(70, 38, n, SINE), i -> expDecay(i, n, 4.8));
+ double[] crack = envelope(filteredNoise(n, 0.32), i -> expDecay(i, n, 6));
+
+ int clankLen = ms(50);
+ double[] clank = envelope(tone(190, 130, clankLen, TRIANGLE), i -> expDecay(i, clankLen, 6));
+ double[] clankPadded = padStart(shiftRight(clank, ms(95)), n);
+
+ double[] mixed = mix(gain(sub, 0.8), thump, gain(crack, 0.85), gain(clankPadded, 0.35));
+ return toPcm(saturate(mixed, 1.5), 0.68);
+ }
+
+ //gun turret's rapid-fire burst: three metallic clacks, not three cute beeps
+ public static byte[] shootAutocannon() {
+ int tapLen = ms(28), gap = ms(24);
+ double[] tap = envelope(mix(gain(filteredNoise(tapLen, 0.6), 0.9), tone(240, 150, tapLen, SQUARE)),
+ i -> expDecay(i, tapLen, 11));
+ double[] tapSat = saturate(tap, 1.4);
+ double[] silence = new double[gap];
+ return toPcm(concat(tapSat, silence, tapSat, silence, tapSat), 0.42);
+ }
+
+ //missile turret's warhead: bigger and bassier than the tank rocket
+ public static byte[] shootMissile() {
+ int n = ms(320);
+ double[] whoosh = envelope(sweepingNoise(n, 0.05, 0.3), i -> bell(i, n, 0.3));
+ double[] sweep = envelope(tone(90, 320, n, SINE), i -> bell(i, n, 0.3));
+ double[] thump = envelope(tone(60, 40, ms(120), SINE), i -> expDecay(i, ms(120), 5));
+ return toPcm(saturate(mix(gain(whoosh, 0.85), gain(sweep, 0.3), gain(thump, 0.5)), 1.2), 0.62);
+ }
+
+ //stealth tank's railgun: a sci-fi energy zap, unlike anything mechanical
+ public static byte[] shootRailgun() {
+ int n = ms(160);
+ double[] zap = envelope(tone(2400, 300, n, SAWTOOTH), i -> expDecay(i, n, 5));
+ int ringLen = ms(60);
+ double[] ring = new double[n];
+ double[] ringTone = envelope(tone(900, 900, ringLen, SINE), i -> expDecay(i, ringLen, 6));
+ System.arraycopy(ringTone, 0, ring, ms(20), Math.min(ringLen, n - ms(20)));
+ return toPcm(mix(zap, gain(ring, 0.4)), 0.4);
+ }
+
+ //a building locking into place on the deployment grid: two heavy metal thunks with a bit of clang
+ public static byte[] deployBuilding() {
+ int thunkLen = ms(75), gap = ms(35);
+ double[] thunk1 = envelope(mix(tone(130, 70, thunkLen, SINE), gain(filteredNoise(thunkLen, 0.3), 0.55)),
+ i -> expDecay(i, thunkLen, 5.5));
+ double[] thunk2 = envelope(mix(tone(100, 55, thunkLen, SINE), gain(filteredNoise(thunkLen, 0.3), 0.55)),
+ i -> expDecay(i, thunkLen, 5.5));
+ double[] clank = envelope(tone(1400, 900, ms(15), SINE), i -> expDecay(i, ms(15), 6));
+ double[] silence = new double[gap];
+ double[] mixed = mix(concat(thunk1, silence, thunk2), gain(shiftRight(clank, 2 * thunkLen + gap + ms(15)), 0.45));
+ return toPcm(saturate(mixed, 1.3), 0.55);
+ }
+
+ //the MCV unfolding into a construction yard: unlock click, dull mechanical grind, panel clunks, final thuds
+ public static byte[] deployVehicle() {
+ int n = ms(650);
+
+ //every tonal whine tried here (sawtooth, square, pulsed) has read as electronic/balloon-like -
+ //drop the tone entirely and use only a dull, heavily-filtered noise swell for the extension
+ //movement, which reads as mechanical grinding rather than a synth whistle
+ int unlockLen = ms(25);
+ double[] unlock = envelope(mix(filteredNoise(unlockLen, 0.8), gain(tone(600, 300, unlockLen, SINE), 0.5)),
+ i -> expDecay(i, unlockLen, 10));
+
+ int grindLen = ms(420);
+ double[] grind = envelope(filteredNoise(grindLen, 0.18), i -> bell(i, grindLen, 0.5));
+
+ //three heavy metallic clunks as panels unfold, spread across the sequence
+ int clunkLen = ms(60);
+ double[] clunk1 = envelope(mix(tone(150, 90, clunkLen, SINE), gain(filteredNoise(clunkLen, 0.35), 0.6)),
+ i -> expDecay(i, clunkLen, 6));
+ double[] clunk2 = envelope(mix(tone(140, 85, clunkLen, SINE), gain(filteredNoise(clunkLen, 0.35), 0.6)),
+ i -> expDecay(i, clunkLen, 6));
+ double[] clunk3 = envelope(mix(tone(130, 80, clunkLen, SINE), gain(filteredNoise(clunkLen, 0.35), 0.6)),
+ i -> expDecay(i, clunkLen, 6));
+
+ //final heavy double-thud as the whole assembly locks into place
+ int thudLen = ms(90);
+ double[] thud1 = envelope(mix(tone(65, 32, thudLen, SINE), gain(filteredNoise(thudLen, 0.3), 0.6)),
+ i -> expDecay(i, thudLen, 5));
+ double[] thud2 = envelope(mix(tone(58, 28, thudLen, SINE), gain(filteredNoise(thudLen, 0.3), 0.6)),
+ i -> expDecay(i, thudLen, 5));
+
+ double[] mixed = mix(
+ padStart(unlock, n),
+ gain(padStart(shiftRight(grind, ms(40)), n), 0.55),
+ padStart(shiftRight(clunk1, ms(150)), n),
+ padStart(shiftRight(clunk2, ms(320)), n),
+ padStart(shiftRight(clunk3, ms(470)), n),
+ padStart(shiftRight(thud1, n - thudLen * 2), n),
+ padStart(shiftRight(thud2, n - thudLen), n));
+ return toPcm(saturate(mixed, 1.15), 0.55);
+ }
+
+ private static double[] shiftRight(double[] buf, int offset) {
+ double[] out = new double[offset + buf.length];
+ System.arraycopy(buf, 0, out, offset, buf.length);
+ return out;
+ }
+
+ public static byte[] explosionSmall() {
+ int n = ms(320);
+ int crackLen = ms(12);
+ double[] crack = padStart(envelope(filteredNoise(crackLen, 0.85), i -> expDecay(i, crackLen, 7)), n);
+ double[] boom = envelope(filteredNoise(n, 0.14), i -> expDecay(i, n, 3.6));
+ double[] rumble = envelope(tone(60, 32, n, SINE), i -> expDecay(i, n, 4.2));
+ double[] mixed = mix(gain(crack, 0.9), boom, gain(rumble, 0.65));
+ return toPcm(saturate(mixed, 1.35), 0.65);
+ }
+
+ public static byte[] explosionLarge() {
+ int n = ms(650);
+ int crackLen = ms(20);
+ double[] crack = padStart(envelope(filteredNoise(crackLen, 0.9), i -> expDecay(i, crackLen, 6)), n);
+ double[] boom = envelope(filteredNoise(n, 0.075), i -> expDecay(i, n, 2.1));
+ double[] rumble = envelope(tone(35, 18, n, SINE), i -> expDecay(i, n, 2.5));
+
+ int crackleStart = ms(110);
+ int crackleLen = n - crackleStart;
+ double[] crackle = new double[n];
+ double[] crackleNoise = envelope(filteredNoise(crackleLen, 0.35), i -> expDecay(i, crackleLen, 5));
+ System.arraycopy(crackleNoise, 0, crackle, crackleStart, crackleLen);
+
+ double[] mixed = mix(gain(crack, 0.8), boom, gain(rumble, 0.75), gain(crackle, 0.45));
+ return toPcm(saturate(mixed, 1.4), 0.85);
+ }
+
+ //a distorted sawtooth "horn" note, for an industrial fanfare instead of a chiptune melody
+ private static double[] hornNote(double freq, int n, double rate) {
+ double[] toneBuf = envelope(tone(freq, freq, n, SAWTOOTH), i -> expDecay(i, n, rate));
+ double[] grit = envelope(filteredNoise(n, 0.4), i -> expDecay(i, n, rate + 2));
+ return saturate(mix(toneBuf, gain(grit, 0.2)), 1.8);
+ }
+
+ public static byte[] victory() {
+ int noteLen = ms(150);
+ double[] c4 = hornNote(262, noteLen, 2.5);
+ double[] e4 = hornNote(330, noteLen, 2.5);
+ double[] g4 = hornNote(392, noteLen, 2.5);
+ double[] c5 = hornNote(523, noteLen * 2, 1.8);
+ return toPcm(concat(c4, e4, g4, c5), 0.42);
+ }
+
+ public static byte[] defeat() {
+ int noteLen = ms(190);
+ double[] a3 = hornNote(220, noteLen, 2.5);
+ double[] f3 = hornNote(175, noteLen, 2.5);
+ double[] d3 = hornNote(147, noteLen, 2.5);
+ double[] a2 = hornNote(110, noteLen * 2, 1.8);
+ return toPcm(concat(a3, f3, d3, a2), 0.42);
+ }
+}
diff --git a/core/AssetManager.java b/core/AssetManager.java
index 01bb244..3ac9449 100644
--- a/core/AssetManager.java
+++ b/core/AssetManager.java
@@ -4,6 +4,8 @@
import entity.*;
import gui.*;
import particles.*;
+import audio.SoundEngine;
+import audio.SoundEngine.Sfx;
//This class stores and maintains all the entities created in the game
public class AssetManager {
@@ -13,8 +15,10 @@ public class AssetManager {
public int visibleUnitCount;
public solidObject[] visibleUnit;
- public int[][] selectedUnitsInfo;
- public int[][] selectedUnitsInfo2;
+ //per-frame health bar/group/veterancy display info for every currently-visible unit (not just
+ //selected ones), sourced from visibleUnit[] so health bars show by default regardless of selection
+ public int[][] allUnitsInfo;
+ public int[][] allUnitsInfo2;
public float[][] visionPolygonInfo;
public float[][] visionPolygonInfo2;
@@ -34,6 +38,9 @@ public class AssetManager {
public float[][] explosionInfo;
public float[][] explosionInfo2;
public int explosionCount;;
+
+ //dynamic point lights spawned by explosions, so nearby models light up instead of just the ground
+ public float[][] blastLights;
public float[][] helixInfo;
public float[][] helixInfo2;
@@ -139,7 +146,10 @@ public void init(){
}
visibleUnit = new solidObject[400];
-
+
+ //x, y, z, remainingFrames, peakIntensity - a slot with remainingFrames<=0 is unused
+ blastLights = new float[24][5];
+
mainThread.pc = new playerCommander();
mainThread.ec = new enemyCommander();
@@ -154,8 +164,8 @@ public void prepareAssetForNewGame(){
camera.XZ_angle = 0;
- selectedUnitsInfo = new int[100][6];
- selectedUnitsInfo2 = new int[100][6];
+ allUnitsInfo = new int[400][6];
+ allUnitsInfo2 = new int[400][6];
visionPolygonInfo = new float[400][5];
visionPolygonInfo2 = new float[400][5];
@@ -226,10 +236,10 @@ public void prepareAssetForNewGame(){
communicationCenter.resetResearchStatus();
- addConstructionVehicle(new constructionVehicle(new vector(3.125f,-0.3f, 2.125f), 90, 0));
- addConstructionVehicle(new constructionVehicle(new vector(29.625f,-0.3f, 28.875f), 90, 1));
+ addConstructionVehicle(new constructionVehicle(new vector(3.125f,-0.3f, 2.125f), 90, 0));
+ addConstructionVehicle(new constructionVehicle(new vector(29.625f,-0.3f, 28.875f), 90, 1));
constructionVehicles[1].expand();
-
+
numberOfPlayerBuildings = 1;
numberOfAIBuildings = 1;
@@ -279,8 +289,8 @@ public void destoryAsset() {
camera.frameIndex = 0;
camera.XZ_angle = 0;
- selectedUnitsInfo = null;
- selectedUnitsInfo2 = null;
+ allUnitsInfo = null;
+ allUnitsInfo2 = null;
visionPolygonInfo = null;
visionPolygonInfo2 = null;
@@ -298,6 +308,10 @@ public void destoryAsset() {
explosionInfo = null;
explosionInfo2 = null;
+ explosionCount = 0; //the blast-light harvesting loop at the top of updateAndDraw() reads
+ //explosionInfo unconditionally every frame, before explosionCount gets reset for the frame -
+ //without this, a stale nonzero count left over from the instant the game ended indexes into
+ //the now-null array on the very next frame
helixInfo = null;
helixInfo2 = null;
@@ -523,10 +537,12 @@ public void updateAndDraw(){
if(numberOfAIBuildings == 0) {
mainThread.playerVictory = true;
mainThread.gamePaused = true;
+ SoundEngine.play(Sfx.VICTORY);
destoryAllUnit(1);
}else if(numberOfPlayerBuildings == 0) {
mainThread.AIVictory = true;
mainThread.gamePaused = true;
+ SoundEngine.play(Sfx.DEFEAT);
destoryAllUnit(0);
}
@@ -538,6 +554,12 @@ public void updateAndDraw(){
}
+ //turn last frame's freshly spawned explosions into blast lights that nearby models can react to
+ for(int i = 0; i < explosionCount; i++){
+ registerBlastLight(explosionInfo[i][0], explosionInfo[i][1], explosionInfo[i][2], explosionInfo[i][3]);
+ }
+ decayBlastLights();
+
polygonCount = 0;
visibleUnitCount = 0;
visionPolygonCount = 0;
@@ -810,25 +832,28 @@ public void updateAndDraw(){
}
- //prepare selected unit list
- for(int i = 0; i < 99; i++){
- if(mainThread.pc.selectedUnits[i] != null && mainThread.pc.selectedUnits[i].isSelectable){
- selectedUnitsInfo[i][0] = mainThread.pc.selectedUnits[i].level << 16 | mainThread.pc.selectedUnits[i].groupNo << 8 | mainThread.pc.selectedUnits[i].type;
- selectedUnitsInfo[i][1] = (int)mainThread.pc.selectedUnits[i].tempCentre.screenX;
- selectedUnitsInfo[i][2] = (int)mainThread.pc.selectedUnits[i].tempCentre.screenY;
- if(mainThread.pc.selectedUnits[i].type == 199){
- selectedUnitsInfo[i][1] = (int)mainThread.pc.selectedUnits[i].screenX_gui;
- selectedUnitsInfo[i][2] = (int)mainThread.pc.selectedUnits[i].screenY_gui;
+ //prepare per-unit display info for every visible unit, not just selected ones - the
+ //veterancy star reads directly off this for every unit, while the health bar/group number/
+ //progress bar are only drawn for selected units (the isSelected flag packed into bit 24
+ //of [0] is what the draw loop gates on)
+ for(int i = 0; i < allUnitsInfo.length; i++){
+ if(i < visibleUnitCount && visibleUnit[i] != null && visibleUnit[i].isSelectable){
+ allUnitsInfo[i][0] = (visibleUnit[i].isSelected ? 1<<24 : 0) | visibleUnit[i].level << 16 | visibleUnit[i].groupNo << 8 | visibleUnit[i].type;
+ allUnitsInfo[i][1] = (int)visibleUnit[i].tempCentre.screenX;
+ allUnitsInfo[i][2] = (int)visibleUnit[i].tempCentre.screenY;
+ if(visibleUnit[i].type == 199){
+ allUnitsInfo[i][1] = (int)visibleUnit[i].screenX_gui;
+ allUnitsInfo[i][2] = (int)visibleUnit[i].screenY_gui;
}
-
- selectedUnitsInfo[i][3] = (int)mainThread.pc.selectedUnits[i].type;
- selectedUnitsInfo[i][4] = mainThread.pc.selectedUnits[i].currentHP;
- selectedUnitsInfo[i][5] = mainThread.pc.selectedUnits[i].progressStatus;
+
+ allUnitsInfo[i][3] = (int)visibleUnit[i].type;
+ allUnitsInfo[i][4] = visibleUnit[i].currentHP;
+ allUnitsInfo[i][5] = visibleUnit[i].progressStatus;
}else{
- selectedUnitsInfo[i][0] = -1;
+ allUnitsInfo[i][0] = -1;
}
}
-
+
}
}
@@ -837,9 +862,9 @@ public void updateAndDraw(){
//swap the resources that are held by the main thread and the post processing thread
public void swapResources(){
int[][] list;
- list = selectedUnitsInfo;
- selectedUnitsInfo = selectedUnitsInfo2;
- selectedUnitsInfo2 = list;
+ list = allUnitsInfo;
+ allUnitsInfo = allUnitsInfo2;
+ allUnitsInfo2 = list;
float[][] floatList;
floatList = visionPolygonInfo;
@@ -872,20 +897,62 @@ public void swapResources(){
confirmationIconInfo2 = iconInfo;
}
+ //register a dynamic light at an explosion's location; reuses the weakest/expired slot once full
+ public void registerBlastLight(float x, float y, float z, float size){
+ int weakestIndex = 0;
+ float weakestScore = Float.MAX_VALUE;
+
+ for(int i = 0; i < blastLights.length; i++){
+ if(blastLights[i][3] <= 0){
+ weakestIndex = i;
+ break;
+ }
+ if(blastLights[i][3] < weakestScore){
+ weakestScore = blastLights[i][3];
+ weakestIndex = i;
+ }
+ }
+
+ float[] light = blastLights[weakestIndex];
+ light[0] = x;
+ light[1] = y;
+ light[2] = z;
+ light[3] = 11; //shorter lifetime so the light fades out quickly instead of lingering
+ light[4] = size;
+ }
+
+ //ages every active blast light by one frame; called once per frame
+ public void decayBlastLights(){
+ for(int i = 0; i < blastLights.length; i++){
+ if(blastLights[i][3] > 0)
+ blastLights[i][3]--;
+ }
+ }
+
//spawn a bullet
public void spawnBullet(int angle, int damage, solidObject target, vector centre, solidObject attacker){
for(int i = 0; i < 200; i ++)
if(!bullets[i].isInAction){
bullets[i].setActive(angle, damage, target, centre, attacker);
+ if(attacker.type == 7)
+ SoundEngine.play(Sfx.SHOOT_CANNON_HEAVY);
+ else if(attacker.type == 200)
+ SoundEngine.play(Sfx.SHOOT_AUTOCANNON);
+ else
+ SoundEngine.play(Sfx.SHOOT_CANNON);
break;
}
}
-
+
//spawn a rocket
public void spawnRocket(int angle, int damage, solidObject target, vector centre, solidObject attacker){
for(int i = 0; i < 200; i ++)
if(!rockets[i].isInAction){
rockets[i].setActive(angle, damage, target, centre, attacker);
+ if(attacker.type == 199)
+ SoundEngine.play(Sfx.SHOOT_MISSILE);
+ else
+ SoundEngine.play(Sfx.SHOOT_ROCKET);
break;
}
}
diff --git a/core/camera.java b/core/camera.java
index 9ad7a70..7f592b8 100644
--- a/core/camera.java
+++ b/core/camera.java
@@ -131,21 +131,56 @@ public void update(){
cosXZ_angle = gameData.cos[XZ_angle];
sinYZ_angle = gameData.sin[YZ_angle];
cosYZ_angle = gameData.cos[YZ_angle];
-
-
-
+
+
+
view_Direction.set(viewDirection);
view_Direction.rotate_YZ(YZ_angle);
view_Direction.rotate_XZ(XZ_angle);
view_Direction.y*=-1;
view_Direction.x*=-1;
view_Direction.unit();
-
+
position.add(-view_Direction.x*3, 0 , -view_Direction.z*3);
-
-
-
-
+
+
+
+
+ }
+
+ //pan the camera every frame while the right button is held, towards wherever the cursor has
+ //been dragged relative to the fixed anchor point where the drag started; the actual world-bounds
+ //clamp happens right after in update()
+ public static void panByHeldOffset(int offsetXPixels, int offsetYPixels){
+ float dx = holdSpeed(offsetXPixels);
+ float dy = holdSpeed(offsetYPixels);
+
+ vector leftVec = new vector(0,0,0);
+ leftVec.cross(view_Direction, left_);
+ leftVec.unit();
+ position.add(leftVec, dx);
+
+ vector forwardVec = new vector(view_Direction.x, 0, view_Direction.z);
+ forwardVec.unit();
+ position.add(forwardVec, -dy);
+ }
+
+ //converts how far (in pixels) the cursor has been dragged away from the anchor into a per-frame
+ //scroll speed: a small deadzone near the anchor to absorb hand tremor, then an immediate speed
+ //floor right past that (so even a barely-past-deadzone drag scrolls at a usable pace instead of
+ //crawling), ramping the rest of the way up to max speed as the drag gets larger
+ private static float holdSpeed(int pixels){
+ float deadZone = 8f;
+ float maxPixels = 220f;
+ float maxSpeed = 0.4f;
+ float minFraction = 0.18f; //speed as soon as the drag clears the deadzone, as a fraction of maxSpeed
+
+ float mag = Math.abs(pixels);
+ if(mag <= deadZone)
+ return 0;
+ float t = Math.min((mag - deadZone) / (maxPixels - deadZone), 1f);
+ float speed = (minFraction + (1f - minFraction) * t) * maxSpeed;
+ return pixels < 0 ? -speed : speed;
}
}
\ No newline at end of file
diff --git a/core/gameData.java b/core/gameData.java
index 3820d33..eb0e8a5 100644
--- a/core/gameData.java
+++ b/core/gameData.java
@@ -17,7 +17,7 @@ public class gameData {
public static int[][] size;
public static byte[][] cloakTextures;
- public static String imageFolder = "../images/";
+ public static String imageFolder = "/images/";
diff --git a/core/highscoreManager.java b/core/highscoreManager.java
index 9db6049..15ed01c 100644
--- a/core/highscoreManager.java
+++ b/core/highscoreManager.java
@@ -1,27 +1,57 @@
package core;
-import java.sql.*;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
public class highscoreManager implements Runnable{
- public Connection connect;
+
+ //Firebase project details (Project settings > General page has the Project ID and Web API Key -
+ //no service account or Auth needed). The API key is safe to ship inside the game jar: Firestore
+ //Security Rules on the "highscores" collection (see the setup notes) restrict it to creating one
+ //valid score document and reading scores - nothing else, unlike the old raw JDBC connection
+ //string which gave anyone full read/write/drop access.
+ private static final String FIREBASE_PROJECT_ID = "battle-tank-3";
+ private static final String FIREBASE_API_KEY = "AIzaSyCbKB324xgpJkY2Ei4bRoy_6dutUZ-bDMc";
+
+ private static final String BASE_URL = "https://firestore.googleapis.com/v1/projects/"
+ + FIREBASE_PROJECT_ID + "/databases/(default)/documents";
+
+ private static final HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+
+ //tolerant of the whitespace Firestore's pretty-printed REST responses put around colons/braces
+ //(e.g. "player_name": { "stringValue": ... } rather than the compact form) - see fetchSkillLevel
+ //for how these two are paired up per-document
+ private static final Pattern NAME_PATTERN = Pattern.compile("\"player_name\"\\s*:\\s*\\{\\s*\"stringValue\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");
+ private static final Pattern TIME_PATTERN = Pattern.compile("\"finishing_time\"\\s*:\\s*\\{\\s*\"integerValue\"\\s*:\\s*\"(-?\\d+)\"");
+ private static final Pattern ID_TOKEN_PATTERN = Pattern.compile("\"idToken\":\\s*\"([^\"]+)\"");
+ private static final Pattern LOCAL_ID_PATTERN = Pattern.compile("\"localId\":\\s*\"([^\"]+)\"");
+
public int counter;
-
+
public int status;
public static final int idle = 0;
public static final int processing = 1;
public static final int error = 2;
-
+
public int task;
public static final int none = 0;
public static final int loadHighscores = 1;
public static final int uploadScore = 2;
-
+
public boolean isSleeping;
-
+
public String playerName;
-
+
public String[][] result;
-
+
public highscoreManager(){
status = processing;
playerName = "";
@@ -30,180 +60,207 @@ public highscoreManager(){
@Override
public void run() {
- // TODO Auto-generated method stub
while(true) {
if(counter == 0) {
status = idle;
}
-
+
if(status == idle) {
-
- if(task != none) {
+
+ if(task != none) {
status = processing;
- Statement stmt = null;
- ResultSet rs = null;
-
- try {
-
- connect = DriverManager.getConnection("jdbc:mysql://db4free.net:3306/javarts", "javarts", "kgiFO3nGzT");
-
-
- } catch (Exception e) {
- // TODO Auto-generated catch block
- e.printStackTrace();
- status = error;
- }
-
-
-
+
if(task == loadHighscores) {
- //get high scores from remote database
-
+ //get high scores from Firestore, one skill level at a time
try {
String[][] myResult = new String[30][2];
- int numOfRows = 0;
-
- stmt = connect.createStatement();
- rs=stmt.executeQuery("select * from highscore where skillLevel = 0 order by finishingTime");
- while(rs.next()) {
- playerName = rs.getString(1);
- if(!hasDuplicateName(0, numOfRows, myResult, playerName)) {
-
- myResult[numOfRows][0] = playerName;
- myResult[numOfRows][1] = secondsToString(rs.getInt(2));
-
- numOfRows++;
- if(numOfRows == 10)
- break;
- }
- }
- rs.close();
-
- numOfRows = 10;
- rs=stmt.executeQuery("select * from highscore where skillLevel = 1 order by finishingTime");
- while(rs.next()) {
- playerName = rs.getString(1);
- if(!hasDuplicateName(10, numOfRows, myResult, playerName)) {
- myResult[numOfRows][0] = rs.getString(1);
- myResult[numOfRows][1] = secondsToString(rs.getInt(2));
-
- numOfRows++;
- if(numOfRows == 20)
- break;
- }
- }
- rs.close();
-
- numOfRows = 20;
- rs=stmt.executeQuery("select * from highscore where skillLevel = 2 order by finishingTime");
- while(rs.next()) {
- playerName = rs.getString(1);
- if(!hasDuplicateName(20, numOfRows, myResult, playerName)) {
- myResult[numOfRows][0] = rs.getString(1);
- myResult[numOfRows][1] = secondsToString(rs.getInt(2));
-
- numOfRows++;
- if(numOfRows == 30)
- break;
- }
- }
-
+
+ fetchSkillLevel(0, myResult, 0);
+ fetchSkillLevel(1, myResult, 10);
+ fetchSkillLevel(2, myResult, 20);
+
result = myResult;
- playerName ="";
-
+ playerName = "";
+
} catch (Exception e) {
- // TODO Auto-generated catch block
e.printStackTrace();
status = error;
result = null;
playerName = "";
- }finally {
- if (rs != null) {
- try {
- rs.close();
- } catch (SQLException e) { /* ignored */}
- }
- if (stmt != null) {
- try {
- stmt.close();
- } catch (SQLException e) { /* ignored */}
- }
- if (connect != null) {
- try {
- connect.close();
- } catch (SQLException e) { /* ignored */}
- }
}
-
-
+
}else if(task == uploadScore) {
- PreparedStatement preparedStmt = null;
try {
-
- // the mysql insert statement
- String query = " insert into highscore" + " values (?, ?, ?)";
- preparedStmt = connect.prepareStatement(query);
-
- preparedStmt.setString (1, playerName);
- preparedStmt.setInt (2, (int)(mainThread.gameFrame*0.025));
- preparedStmt.setInt (3, mainThread.ec.difficulty);
- preparedStmt.execute();
-
-
+ submitScoreWithRateLimit(playerName, (int)(mainThread.matchElapsedMs/1000), mainThread.ec.difficulty);
+ //invalidate the cached leaderboard so the next time it's viewed, it re-fetches
+ //and shows the score that was just submitted, instead of stale cached data
+ //from whenever the board was first opened this session
+ result = null;
+
}catch (Exception e) {
- // TODO Auto-generated catch block
e.printStackTrace();
status = error;
playerName = "";
- }finally {
- if (rs != null) {
- try {
- rs.close();
- } catch (SQLException e) { /* ignored */}
- }
- if (preparedStmt != null) {
- try {
- preparedStmt.close();
- } catch (SQLException e) { /* ignored */}
- }
- if (connect != null) {
- try {
- connect.close();
- } catch (SQLException e) { /* ignored */}
- }
- }
+ }
}
-
+
if(status != error)
status = idle;
task = none;
}
}
-
+
isSleeping = true;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
- // TODO Auto-generated catch block
e.printStackTrace();
}
isSleeping = false;
-
+
counter++;
}
-
+
+ }
+
+ //fetches up to 10 unique-named top scores for one skill level, writing them into myResult
+ //starting at index "start" - mirrors the original JDBC version's per-level dedup/limit behaviour
+ private void fetchSkillLevel(int skillLevel, String[][] myResult, int start) throws Exception {
+ //ask for a generous buffer since duplicate names get filtered out client-side below
+ String query = "{"
+ + "\"structuredQuery\":{"
+ + "\"from\":[{\"collectionId\":\"highscores\"}],"
+ + "\"where\":{\"fieldFilter\":{\"field\":{\"fieldPath\":\"skill_level\"},\"op\":\"EQUAL\",\"value\":{\"integerValue\":\"" + skillLevel + "\"}}},"
+ + "\"orderBy\":[{\"field\":{\"fieldPath\":\"finishing_time\"},\"direction\":\"ASCENDING\"}],"
+ + "\"limit\":50"
+ + "}}";
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(BASE_URL + ":runQuery?key=" + FIREBASE_API_KEY))
+ .header("Content-Type", "application/json")
+ .timeout(Duration.ofSeconds(10))
+ .POST(HttpRequest.BodyPublishers.ofString(query))
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if(response.statusCode() != 200)
+ throw new RuntimeException("Firestore query failed: " + response.statusCode() + " " + response.body());
+
+ //each result document contributes exactly one player_name and one finishing_time, appearing
+ //in the same relative order (name before time) with no interleaving between documents - so
+ //advancing both matchers in lockstep pairs them correctly without needing to first isolate
+ //each document's own "fields" block (which isn't actually flat - every value is wrapped in
+ //its own type-descriptor object, e.g. "player_name": {"stringValue": ...}, so a naive
+ //first-brace-to-first-brace scan was cutting off after only the first nested field)
+ int numOfRows = start;
+ Matcher nameM = NAME_PATTERN.matcher(response.body());
+ Matcher timeM = TIME_PATTERN.matcher(response.body());
+ while(nameM.find() && timeM.find() && numOfRows < start + 10){
+ String name = unescapeJson(nameM.group(1));
+ int seconds = Integer.parseInt(timeM.group(1));
+
+ if(!hasDuplicateName(start, numOfRows, myResult, name)){
+ myResult[numOfRows][0] = name;
+ myResult[numOfRows][1] = secondsToString(seconds);
+ numOfRows++;
+ }
+ }
}
-
+
+ //signs in anonymously (giving this submission a verifiable identity the security rules can key a
+ //cooldown off of), submits the score, then marks the identity's last-submission time so the next
+ //attempt gets throttled. A fresh anonymous identity is created per submission rather than reused -
+ //it doesn't need to persist across game sessions, since the cooldown only needs to catch someone
+ //hammering the same script/loop, not fingerprint a specific player across launches.
+ private void submitScoreWithRateLimit(String name, int finishingTimeSeconds, int skillLevel) throws Exception {
+ String[] auth = signInAnonymously();
+ String idToken = auth[0];
+ String uid = auth[1];
+
+ //submit first, against whatever the rate-limit doc currently says (or its absence) - touching
+ //the rate limit only AFTER a successful submission, so this attempt isn't blocked by its own update
+ uploadScore(idToken, name, finishingTimeSeconds, skillLevel);
+ touchRateLimit(idToken, uid);
+ }
+
+ private String[] signInAnonymously() throws Exception {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create("https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=" + FIREBASE_API_KEY))
+ .header("Content-Type", "application/json")
+ .timeout(Duration.ofSeconds(10))
+ .POST(HttpRequest.BodyPublishers.ofString("{\"returnSecureToken\":true}"))
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if(response.statusCode() != 200)
+ throw new RuntimeException("Anonymous sign-in failed: " + response.statusCode() + " " + response.body());
+
+ Matcher tokenM = ID_TOKEN_PATTERN.matcher(response.body());
+ Matcher uidM = LOCAL_ID_PATTERN.matcher(response.body());
+ if(!tokenM.find() || !uidM.find())
+ throw new RuntimeException("Anonymous sign-in response missing idToken/localId: " + response.body());
+
+ return new String[]{tokenM.group(1), uidM.group(1)};
+ }
+
+ private void uploadScore(String idToken, String name, int finishingTimeSeconds, int skillLevel) throws Exception {
+ String body = "{\"fields\":{"
+ + "\"player_name\":{\"stringValue\":\"" + escapeJson(name) + "\"},"
+ + "\"finishing_time\":{\"integerValue\":\"" + finishingTimeSeconds + "\"},"
+ + "\"skill_level\":{\"integerValue\":\"" + skillLevel + "\"}"
+ + "}}";
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(BASE_URL + "/highscores?key=" + FIREBASE_API_KEY))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer " + idToken)
+ .timeout(Duration.ofSeconds(10))
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if(response.statusCode() / 100 != 2)
+ throw new RuntimeException("Firestore insert failed: " + response.statusCode() + " " + response.body());
+ }
+
+ //records "now" (by this client's clock) against this anonymous identity so the next submission can
+ //be throttled by the security rules; those rules cross-check this value against Firestore's own
+ //server-perceived request time within a tolerance, so a client can't just backdate it to permanently
+ //bypass its own cooldown
+ private void touchRateLimit(String idToken, String uid) throws Exception {
+ String body = "{\"fields\":{\"lastSubmission\":{\"timestampValue\":\"" + Instant.now().toString() + "\"}}}";
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(BASE_URL + "/rateLimits/" + uid + "?key=" + FIREBASE_API_KEY + "&updateMask.fieldPaths=lastSubmission"))
+ .header("Content-Type", "application/json")
+ .header("Authorization", "Bearer " + idToken)
+ .timeout(Duration.ofSeconds(10))
+ .method("PATCH", HttpRequest.BodyPublishers.ofString(body))
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ if(response.statusCode() / 100 != 2)
+ throw new RuntimeException("Rate-limit update failed: " + response.statusCode() + " " + response.body());
+ }
+
+ private static String escapeJson(String s) {
+ return s.replace("\\", "\\\\").replace("\"", "\\\"");
+ }
+
+ private static String unescapeJson(String s) {
+ return s.replace("\\\"", "\"").replace("\\\\", "\\");
+ }
+
public boolean hasDuplicateName(int start, int current, String[][] myResult, String name) {
for(int i = start; i < current; i++) {
if(myResult[i][0].toLowerCase().equals(name.toLowerCase())) {
return true;
}
}
-
+
return false;
}
-
+
public String secondsToString(int pTime) {
int min = pTime/60;
int sec = pTime-(min*60);
diff --git a/core/mainThread.java b/core/mainThread.java
index 0858d1e..d95f163 100644
--- a/core/mainThread.java
+++ b/core/mainThread.java
@@ -12,6 +12,7 @@
import enemyAI.*;
import gui.*;
+import audio.SoundEngine;
public class mainThread extends JFrame implements KeyListener, ActionListener, MouseMotionListener, MouseListener, FocusListener{
@@ -31,6 +32,12 @@ public class mainThread extends JFrame implements KeyListener, ActionListener, M
public static long sleepTime;
public static int framePerSecond, cpuUsage;
public static double thisTime, lastTime;
+ public static long fpsWindowStart;
+ public static int frameCountThisSecond;
+ public static boolean hasWaterDistortion;
+ public static long matchElapsedMs;
+ public static long lastTickRealTime;
+ public static boolean wasPausedLastFrame = true;
public static boolean JavaRTSLoaded;
public static boolean gamePaused, gameStarted, playerVictory, AIVictory, afterMatch;
public static texture[] textures;
@@ -62,8 +69,8 @@ public class mainThread extends JFrame implements KeyListener, ActionListener, M
public static final int optionMenu = 4;
public static final int highscoreMenu = 5;
- public static final int screen_width = 1024;
- public static final int screen_height = 682;
+ public static final int screen_width = (int)(1024);
+ public static final int screen_height = (int)(682);
public static final int screen_size= screen_width*screen_height;
public static final int shadowmap_width = 2048;
@@ -102,7 +109,7 @@ public mainThread(){
screen = ((DataBufferInt)dest).getData();
bufferScreen = screen;
- doubleBuffer2 = new BufferedImage(screen_width, screen_width, BufferedImage.TYPE_INT_RGB);
+ doubleBuffer2 = new BufferedImage(screen_width, screen_height, BufferedImage.TYPE_INT_RGB);
DataBuffer dest2 = doubleBuffer2.getRaster().getDataBuffer();
screen2 = ((DataBufferInt)dest2).getData();
buffer2Screen = screen2;
@@ -129,8 +136,9 @@ public mainThread(){
}
frameIndex = 0;
- frameInterval = 25;
+ frameInterval = 22; //~45fps target (1000/45 = 22.2ms, rounded down to the nearest whole ms)
lastDraw = 0;
+ fpsWindowStart = System.currentTimeMillis();
try {
myRobot = new Robot();
} catch (AWTException e) {
@@ -179,7 +187,7 @@ public void actionPerformed(ActionEvent e){
panel.addMouseMotionListener(this);
panel.addMouseListener(this);
panel.addFocusListener(this);
- panel.requestFocus();
+ panel.requestFocusInWindow();
//create camera
Camera = new camera(new vector(3,2f,-1.25f), 0, 300);
@@ -207,6 +215,8 @@ public void actionPerformed(ActionEvent e){
theGameCursor = new gameCursor();
theGameCursor.init();
+
+ SoundEngine.init();
currentMouseX = getLocationOnScreen().x + screen_width/2;
currentMouseY = getLocationOnScreen().y + screen_height/2;
@@ -231,7 +241,13 @@ public void actionPerformed(ActionEvent e){
}
- frameIndex++;
+ frameIndex++;
+
+ //the very first requestFocusInWindow() call can silently fail if the window hasn't actually
+ //been realized on screen yet, which would leave keyboard hotkeys dead until the player clicks
+ //into the window - keep retrying for the first couple of seconds until focus actually lands
+ if(frameIndex < 90 && !panel.isFocusOwner())
+ panel.requestFocusInWindow();
if(capturedMouse && !mouseLeftScreen && !focusLost) {
currentMouseX = MouseInfo.getPointerInfo().getLocation().x;
@@ -265,9 +281,25 @@ public void actionPerformed(ActionEvent e){
if(!gamePaused) {
if(gameStarted)
gameFrame++;
-
- timeString = secondsToString((int)(gameFrame*0.025));
-
+
+ //the displayed/saved match time is measured from real elapsed wall-clock time (excluding
+ //time spent paused), not derived from gameFrame count, so it stays accurate regardless of
+ //the actual frame rate achieved
+ long now = System.currentTimeMillis();
+ if(gameStarted){
+ if(gameFrame == 1){
+ matchElapsedMs = 0;
+ lastTickRealTime = now;
+ }else if(wasPausedLastFrame){
+ lastTickRealTime = now;
+ }
+ matchElapsedMs += now - lastTickRealTime;
+ lastTickRealTime = now;
+ }
+ wasPausedLastFrame = false;
+
+ timeString = secondsToString((int)(matchElapsedMs/1000));
+
//handle user's interaction with game GUI
if(gameFrame == 1 && gameStarted){
theAssetManager.prepareAssetForNewGame();
@@ -277,7 +309,10 @@ public void actionPerformed(ActionEvent e){
//Clears the z-buffer. All depth values are set to 0.
clearDepthBuffer();
-
+
+ //reset once per frame; the rasterizer sets this back to true if any water polygon is actually drawn
+ hasWaterDistortion = false;
+
//update camera
Camera.update();
@@ -292,11 +327,10 @@ public void actionPerformed(ActionEvent e){
ec.update();
}
}else {
-
-
+ wasPausedLastFrame = true;
}
-
- //show unpassable obstacle
+
+ //show unpassable obstacle
//gridMap.draw();
if(this.getGraphics() != null && PPT!= null){
@@ -443,32 +477,34 @@ public void mouseExited(MouseEvent arg0) {
public void mousePressed(MouseEvent e) {
if(e.getButton() == 1){
inputHandler.leftMouseButtonPressed = true;
-
+
}
-
+
if(e.getButton() == 3){
inputHandler.rightMouseButtonPressed = true;
+ inputHandler.rightMouseButtonHeld = true;
}
-
+
}
@Override
public void mouseReleased(MouseEvent e) {
if(e.getButton() == 1){
-
+
inputHandler.leftMouseButtonReleased = true;
}
-
+
if(e.getButton() == 3){
inputHandler.rightMouseButtonReleased = true;
+ inputHandler.rightMouseButtonHeld = false;
}
-
+
}
public void loadTexture(){
textures = new texture[73];
- String imageFolder = "../images/";
+ String imageFolder = "/images/";
try{
textures[0] = new texture("basic", ImageIO.read(getClass().getResource(imageFolder + "1.jpg")), 9, 9);
textures[1] = new texture("explosion aura", ImageIO.read(getClass().getResource(imageFolder + "2.jpg")), 7, 7);
@@ -664,10 +700,20 @@ public void regulateFramerate(){
// TODO Auto-generated catch block
e1.printStackTrace();
}
-
+
lastDraw=System.currentTimeMillis();
+
+ frameCountThisSecond++;
+ long elapsedSinceWindowStart = lastDraw - fpsWindowStart;
+ if(elapsedSinceWindowStart >= 1000){
+ //use the actual elapsed wall-clock time rather than assuming exactly 1000ms passed,
+ //since the window can close a few ms late depending on when frames land
+ framePerSecond = (int)Math.round(frameCountThisSecond * 1000.0 / elapsedSinceWindowStart);
+ frameCountThisSecond = 0;
+ fpsWindowStart = lastDraw;
+ }
}
-
+
public static String secondsToString(int pTime) {
int min = pTime/60;
int sec = pTime-(min*60);
diff --git a/core/playerCommander.java b/core/playerCommander.java
index 3be1ce0..f6d7e6e 100644
--- a/core/playerCommander.java
+++ b/core/playerCommander.java
@@ -2,10 +2,13 @@
import java.awt.Rectangle;
+import entity.constructionVehicle;
import entity.constructionYard;
import entity.factory;
import entity.solidObject;
import gui.inputHandler;
+import audio.SoundEngine;
+import audio.SoundEngine.Sfx;
//this class interprets player's inputs and turns them into commands that can be issued to game units
public class playerCommander {
@@ -14,7 +17,7 @@ public class playerCommander {
public solidObject[][] groups;
- public boolean leftMouseButtonPressed, rightMouseButtonPressed, leftMouseButtonReleased, rightMouseButtonReleased, attackKeyPressed, toggleConyard, toggleFactory, holdKeyPressed, controlKeyPressed;
+ public boolean leftMouseButtonPressed, rightMouseButtonPressed, leftMouseButtonReleased, rightMouseButtonReleased, attackKeyPressed, toggleConyard, toggleFactory, holdKeyPressed, controlKeyPressed, deployKeyPressed;
public int numberTyped;
@@ -46,11 +49,24 @@ public class playerCommander {
public constructionYard selectedConstructionYard;
public baseInfo theBaseInfo;
+
+ //tracks the previous frame's credit total so a change (from any of the many scattered
+ //currentCredit++/-- sites across buildings/units) can be detected in one place and turned into a
+ //classic RTS money-counter tick, throttled so a fast trickle (e.g. construction cost draining
+ //every frame) doesn't spam a sound 45 times a second. earn and spend use separate cooldowns
+ //(rather than one shared one) so an earn tick is never suppressed just because a spend tick
+ //happened to fire a moment earlier - earning always wins: spend is muted for the whole duration
+ //of an earn sound instead of just being throttled like it throttles itself
+ private int previousCredit = -1;
+ private int creditEarnCooldown;
+ private int creditSpendCooldown;
+ private int earnSoundBusyFrames;
public boolean mouseOverSelectableUnit;
public int mouseOverUnitType;
public int mouseOverUnitTeam;
public boolean mouseOverUnitIsSelected;
+ public boolean mouseOverUnitCanDeploy;
public boolean hasConVehicleSelected;
public boolean hasHarvesterSelected;
public boolean hasTroopsSelected;
@@ -82,7 +98,31 @@ public void init(){
public void update(){
theBaseInfo.update();
-
+
+ if(creditEarnCooldown > 0)
+ creditEarnCooldown--;
+ if(creditSpendCooldown > 0)
+ creditSpendCooldown--;
+ if(earnSoundBusyFrames > 0)
+ earnSoundBusyFrames--;
+
+ if(previousCredit == -1){
+ previousCredit = theBaseInfo.currentCredit;
+ }else if(theBaseInfo.currentCredit != previousCredit){
+ if(theBaseInfo.currentCredit > previousCredit){
+ if(creditEarnCooldown == 0){
+ SoundEngine.play(Sfx.CREDIT_EARN);
+ creditEarnCooldown = 3;
+ earnSoundBusyFrames = 38; //~820ms at 45fps, matches the earn clip's length so spend stays muted while it rings out
+ }
+ }else if(creditSpendCooldown == 0 && earnSoundBusyFrames == 0){
+ SoundEngine.play(Sfx.CREDIT_SPEND);
+ creditSpendCooldown = 3;
+ }
+ previousCredit = theBaseInfo.currentCredit;
+ }
+
+
if(isDeployingBuilding){
if(leftMouseButtonPressed && !cursorIsInMiniMap() && !cursorIsInSideBar() && selectedConstructionYard.dg.canBeDeployed){
@@ -95,15 +135,16 @@ public void update(){
leftMouseButtonPressed = false;
}
- if(rightMouseButtonPressed){
+ if(rightMouseButtonReleased && !inputHandler.isDraggingCamera){
isDeployingBuilding = false;
selectedConstructionYard.needToDrawDeploymentGrid = false;
selectedConstructionYard = null;
- rightMouseButtonPressed = false;
+ rightMouseButtonReleased = false;
}else{
if(!cursorIsInMiniMap()){
theSideBarManager.update();
leftMouseButtonPressed = false;
+ rightMouseButtonReleased = false;
isMovingViewWindow = false;
return;
}
@@ -377,9 +418,9 @@ public void update(){
}
- if(rightMouseButtonPressed){
+ if(rightMouseButtonReleased && !inputHandler.isDraggingCamera){
attackKeyPressed = false;
-
+
if(cursorIsInMiniMap()){
clickPoint.set(0.25f*(inputHandler.mouse_x-3), 0, 0.25f*(127-(inputHandler.mouse_y-(screen_height-131))));
}else{
@@ -475,7 +516,7 @@ public void update(){
if(selectedConyardID != -1) {
for(int i = 0; i < factories.length; i++) {
- if(factories[i].ID == selectedConyardID) {
+ if(factories[i] != null && factories[i].ID == selectedConyardID) {
factoryIndex = i;
break;
}
@@ -502,7 +543,41 @@ public void update(){
toggleFactory = false;
}
-
+
+ if(deployKeyPressed) {
+ int numOfSelectedConstructionYard = 0;
+ constructionYard selectedConyard = null;
+ for(int i = 0; i < selectedUnits.length; i++) {
+ if(selectedUnits[i] != null && selectedUnits[i].teamNo == 0 && selectedUnits[i].currentHP > 0 && selectedUnits[i].type == 104) {
+ numOfSelectedConstructionYard++;
+ selectedConyard = (constructionYard)selectedUnits[i];
+ }
+ }
+
+ if(numOfSelectedConstructionYard == 1) {
+ constructionYard cy = selectedConyard;
+ if(cy.powerPlantProgress == 240 || cy.refineryProgress == 240 || cy.factoryProgress == 240
+ || cy.communicationCenterProgress == 240 || cy.gunTurretProgress == 240
+ || cy.missileTurretProgress == 240 || cy.techCenterProgress == 240) {
+ cy.needToDrawDeploymentGrid = true;
+ isDeployingBuilding = true;
+ selectedConstructionYard = cy;
+ }
+ }
+
+ //deploy any selected construction vehicle(s) that are ready to expand into a construction yard
+ for(int i = 0; i < selectedUnits.length; i++) {
+ if(selectedUnits[i] != null && selectedUnits[i].teamNo == 0 && selectedUnits[i].currentHP > 0 && selectedUnits[i].type == 3) {
+ constructionVehicle cv = (constructionVehicle)selectedUnits[i];
+ if(cv.canBeDeployed()) {
+ cv.expand();
+ }
+ }
+ }
+
+ deployKeyPressed = false;
+ }
+
//display health bar when mouse cursor hover over a unit
if(!isSelectingUnit){
@@ -535,7 +610,12 @@ public void update(){
}else if(selectedUnits[i].type == 2) {
hasHarvesterSelected = true;
}else if(selectedUnits[i].type == 3) {
- hasConVehicleSelected = true;
+ //don't show the move cursor for a construction vehicle that's already mid-deploy -
+ //it's no longer something you'd issue a move order to
+ constructionVehicle cv = (constructionVehicle)selectedUnits[i];
+ if(cv.jobStatus != cv.deploying){
+ hasConVehicleSelected = true;
+ }
}else if(selectedUnits[i].type == 200 || selectedUnits[i].type == 199) {
hasTowerSelected = true;
}
@@ -654,6 +734,7 @@ public void moveSelectedUnit(float x, float y){
theAssetManager.confirmationIconInfo[1] = x;
theAssetManager.confirmationIconInfo[2] = y;
theAssetManager.confirmationIconInfo[3] = 0xbb22;
+ SoundEngine.play(Sfx.MOVE);
}
}
@@ -716,53 +797,52 @@ public void attackMoveSelectUnit(float x, float y){
theAssetManager.confirmationIconInfo[1] = x;
theAssetManager.confirmationIconInfo[2] = y;
theAssetManager.confirmationIconInfo[3] = 0xcc2222;
+ SoundEngine.play(Sfx.ATTACK);
}
}
public void addMouseHoverUnitToDisplayInfo(Rectangle unitArea, Rectangle unitAreaSmall){
solidObject theSelected = null;
+ int theSelectedIndex = -1;
mouseOverSelectableUnit = false;
mouseOverUnitIsSelected = false;
+ mouseOverUnitCanDeploy = false;
for(int i = 0; i < theAssetManager.visibleUnitCount; i++){
if(unitArea.contains(theAssetManager.visibleUnit[i].tempCentre.screenX, theAssetManager.visibleUnit[i].tempCentre.screenY)){
if((theAssetManager.visibleUnit[i].type < 100 || theAssetManager.visibleUnit[i].type >= 199) && !unitAreaSmall.contains(theAssetManager.visibleUnit[i].tempCentre.screenX, theAssetManager.visibleUnit[i].tempCentre.screenY))
continue;
-
+
if(theAssetManager.visibleUnit[i].type < 100 || theAssetManager.visibleUnit[i].type >= 199){
theSelected = theAssetManager.visibleUnit[i];
+ theSelectedIndex = i;
break;
}
-
- theSelected = theAssetManager.visibleUnit[i];
+
+ theSelected = theAssetManager.visibleUnit[i];
+ theSelectedIndex = i;
}
}
-
+
if(theSelected != null && theSelected.isSelectable && !cursorIsInMiniMap() && !cursorIsInSideBar()) {
mouseOverSelectableUnit = true;
mouseOverUnitType = theSelected.type;
mouseOverUnitTeam = theSelected.teamNo;
if(theSelected.isSelected) {
mouseOverUnitIsSelected = true;
+ if(theSelected.type == 3 && ((constructionVehicle)theSelected).canBeDeployed()){
+ mouseOverUnitCanDeploy = true;
+ }
}
-
- }
-
- if(theSelected != null && !theSelected.isSelected && theSelected.isSelectable && !cursorIsInMiniMap() && !cursorIsInSideBar()){
- mainThread.theAssetManager.selectedUnitsInfo[99][0] = theSelected.level << 16 | theSelected.groupNo << 8 | theSelected.type;
- mainThread.theAssetManager.selectedUnitsInfo[99][1] = (int)theSelected.tempCentre.screenX;
- mainThread.theAssetManager.selectedUnitsInfo[99][2] = (int)theSelected.tempCentre.screenY;
- if(theSelected.type == 199){
- mainThread.theAssetManager.selectedUnitsInfo[99][1] = (int)theSelected.screenX_gui;
- mainThread.theAssetManager.selectedUnitsInfo[99][2] = (int)theSelected.screenY_gui;
+
+ //show the health bar/group/progress block for the hovered unit too, even if it isn't
+ //selected - reuses the same "treat as selected for display" bit the draw loop checks,
+ //restoring the original hover-preview behaviour within the new all-visible-units data flow
+ if(!theSelected.isSelected && theSelectedIndex >= 0 && theSelectedIndex < theAssetManager.allUnitsInfo.length){
+ theAssetManager.allUnitsInfo[theSelectedIndex][0] |= (1<<24);
}
-
- mainThread.theAssetManager.selectedUnitsInfo[99][3] = (int)theSelected.type;
- mainThread.theAssetManager.selectedUnitsInfo[99][4] = theSelected.currentHP;
- mainThread.theAssetManager.selectedUnitsInfo[99][5] = theSelected.progressStatus;
- }else{
- mainThread.theAssetManager.selectedUnitsInfo[99][0] = -1;
}
+
}
public void selectUnit(Rectangle unitArea, Rectangle unitAreaSmall){
@@ -785,7 +865,14 @@ public void selectUnit(Rectangle unitArea, Rectangle unitAreaSmall){
}
if(theSelected != null){
-
+
+ //clicking your own already-selected, ready-to-deploy construction vehicle deploys it
+ //into a construction yard instead of just re-selecting it
+ if(theSelected.type == 3 && theSelected.isSelected && ((constructionVehicle)theSelected).canBeDeployed()){
+ ((constructionVehicle)theSelected).expand();
+ return;
+ }
+
if(!controlKeyPressed)
deSelectAll();
@@ -796,7 +883,8 @@ public void selectUnit(Rectangle unitArea, Rectangle unitAreaSmall){
addToSelection(theSelected);
theSelected.isSelected = true;
-
+ SoundEngine.play(Sfx.SELECT);
+
if(doubleClicked){
int type = theSelected.type;
for(int j = 0; j < theAssetManager.visibleUnitCount; j++){
@@ -831,8 +919,10 @@ public void selectMultipleUnits(Rectangle area){
theAssetManager.visibleUnit[i].isSelected = true;
}
}
+ if(unitIsSelected)
+ SoundEngine.play(Sfx.SELECT);
}
-
+
public void addToSelection(solidObject o){
//dont add gold mine to select units
//if(o.type == 103)
@@ -959,6 +1049,7 @@ public void attackUnit(solidObject o){
theAssetManager.confirmationIconInfo[1] = o.centre.x;
theAssetManager.confirmationIconInfo[2] = o.centre.z;
theAssetManager.confirmationIconInfo[3] = 0xcc2222;
+ SoundEngine.play(Sfx.ATTACK);
}
}
@@ -972,7 +1063,7 @@ public void harvestMine(solidObject o){
theAssetManager.confirmationIconInfo[1] = o.centre.x;
theAssetManager.confirmationIconInfo[2] = o.centre.z;
theAssetManager.confirmationIconInfo[3] = 0xbbbb00;
-
+ SoundEngine.play(Sfx.HARVEST);
}
}
}
@@ -987,6 +1078,7 @@ public void returnToRefinery(solidObject o){
theAssetManager.confirmationIconInfo[1] = o.centre.x;
theAssetManager.confirmationIconInfo[2] = o.centre.z;
theAssetManager.confirmationIconInfo[3] = 0xbbbb00;
+ SoundEngine.play(Sfx.HARVEST);
}
}
}
diff --git a/core/polygon3D.java b/core/polygon3D.java
index e60c44a..89fa680 100644
--- a/core/polygon3D.java
+++ b/core/polygon3D.java
@@ -49,13 +49,14 @@ public class polygon3D {
public solidObject parentObject;
//A pool of vectors which will be used for vector arithmetic
- public static vector
+ public static vector
tempVector1 = new vector(0,0,0),
tempVector2 = new vector(0,0,0),
tempVector3 = new vector(0,0,0),
tempVector4 = new vector(0,0,0),
tempVector5 = new vector(0,0,0),
- tempVector6 = new vector(0,0,0);
+ tempVector6 = new vector(0,0,0),
+ tempVector7 = new vector(0,0,0);
//whether the polygon is visible
@@ -77,7 +78,11 @@ public class polygon3D {
public int diffuse_I;
public int Ambient_I = 16; //the default ambient intensity is 16
public int reflectance = 96;
-
+
+ //additional brightness from a nearby explosion this frame, added on top of diffuse_I at render time;
+ //recomputed fresh every frame in update(), never accumulates
+ public int blastLightBoost;
+
//diffuse value at vertex (only for polygons with 3 vertex)
public byte[] diffuse = new byte[3];
@@ -225,10 +230,30 @@ public void update(){
tempVector1.subtract(vertex3D[0]);
if(tempVector1.dot(normal) <= 0){
visible = false;
-
+
return;
}
-
+
+ //add this frame's explosion light contribution, if this face actually points toward it
+ blastLightBoost = 0;
+ if(parentObject != null && parentObject.activeBlastLightIntensity > 0){
+ tempVector7.set(parentObject.activeBlastLightPos);
+ tempVector7.subtract(vertex3D[0]);
+ tempVector7.unit();
+ double facing = normal.dot(tempVector7);
+ //each face is lit purely by its own flat normal, with nothing shared across edges. on
+ //thin/angled geometry (e.g. a crane arm) two adjacent faces can sit near opposite
+ //extremes of facing - one nearly toward the blast, one nearly away - so a purely
+ //directional boost makes one face jump to full brightness right next to a neighbor at
+ //zero, reading as a hard dark seam along their shared edge. flooring the boost at a
+ //fraction of peak (instead of letting it hit zero) keeps that face-to-face gap bounded
+ //while still favoring the side that actually faces the explosion
+ double normalizedFacing = (facing + 1) / 2; //-1..1 -> 0..1
+ double factor = 0.6 + 0.4 * normalizedFacing;
+ blastLightBoost = (int)(factor * parentObject.activeBlastLightIntensity * 90);
+ }
+
+
//translate vertex from world space to camera space
float x = 0,y = 0, z = 0,
camX = camera.position.x, camY = camera.position.y, camZ = camera.position.z,
diff --git a/core/postProcessingThread.java b/core/postProcessingThread.java
index f7820a6..e48f7e8 100644
--- a/core/postProcessingThread.java
+++ b/core/postProcessingThread.java
@@ -10,13 +10,15 @@
import particles.explosion;
import particles.helix;
import particles.smokeParticle;
+import audio.SoundEngine;
+import audio.SoundEngine.Sfx;
//this class handles all the post processing effect
public class postProcessingThread implements Runnable{
public static int[] currentScreen;
public static int[] currentZbuffer;
- public static int[][] currentSelectedUnitsInfo;
+ public static int[][] currentAllUnitsInfo;
public static int[][] unitInfoTable;
public static float[][] visionPolygonInfo;
public static float[][] explosionInfo;
@@ -47,6 +49,7 @@ public class postProcessingThread implements Runnable{
public static byte[] shadowBitmap;
public static byte[] smoothedShadowBitmap;
public static short[] displacementBuffer;
+ public static boolean hasWaterDistortion;
public static int[] offScreen;
@@ -268,7 +271,8 @@ public void doPostProcesssing(){
if(!explosions[i].isInAction){
if(j < explosionCount){
tempFloat = explosionInfo[j];
- explosions[i].setActive(tempFloat[0], tempFloat[1], tempFloat[2], tempFloat[3], (int)tempFloat[4], (int)tempFloat[5], (int)tempFloat[6], tempFloat[7]);
+ explosions[i].setActive(tempFloat[0], tempFloat[1], tempFloat[2], tempFloat[3], (int)tempFloat[4], (int)tempFloat[5], (int)tempFloat[6], tempFloat[7]);
+ SoundEngine.play(tempFloat[3] >= 2.5f ? Sfx.EXPLOSION_LARGE : Sfx.EXPLOSION_SMALL);
j++;
}else {
break;
@@ -303,21 +307,23 @@ public void doPostProcesssing(){
blendShadow();
- //apply distortion map
+ //apply distortion map (only water polygons write non-31 values here, so skip both passes entirely when none are on screen)
int xMap;
int yMap;
int distortionIndex;
-
- for(int i = 0; i < screen_size; i++){
- if(displacementBuffer[i] != 31){
- xMap = ((displacementBuffer[i]&992) >> 5) - 16;
- yMap = (displacementBuffer[i]&31) - 16;
- distortionIndex = i + xMap + yMap*screen_width;
- if(distortionIndex > 0 && distortionIndex < screen_size ){
- if(currentZbuffer[i] - currentZbuffer[distortionIndex] > -1000000)
- offScreen[i] = currentScreen[distortionIndex];
- else
- offScreen[i] = currentScreen[i];
+
+ if(hasWaterDistortion){
+ for(int i = 0; i < screen_size; i++){
+ if(displacementBuffer[i] != 31){
+ xMap = ((displacementBuffer[i]&992) >> 5) - 16;
+ yMap = (displacementBuffer[i]&31) - 16;
+ distortionIndex = i + xMap + yMap*screen_width;
+ if(distortionIndex > 0 && distortionIndex < screen_size ){
+ if(currentZbuffer[i] - currentZbuffer[distortionIndex] > -1000000)
+ offScreen[i] = currentScreen[distortionIndex];
+ else
+ offScreen[i] = currentScreen[i];
+ }
}
}
}
@@ -349,60 +355,61 @@ public void doPostProcesssing(){
int z = vector.Z_length;
- for(int i = screen_width; i < screen_size-screen_width; i++){
- if(displacementBuffer[i] != 31){
-
- r1 = (offScreen[i + 1]&0xff0000) >> 16;
- r2 = (offScreen[i - w_]&0xff0000) >> 16;
- r3 = (offScreen[i - screen_width]&0xff0000) >> 16;
- r4 = (offScreen[i - 1]&0xff0000) >> 16;
-
-
- g1 = (offScreen[i + 1]&0xff00) >> 8;
- g2 = (offScreen[i - w_]&0xff00) >> 8;
- g3 = (offScreen[i - screen_width]&0xff00) >> 8;
- g4 = (offScreen[i]&0xff00) >> 8;
-
-
- b1 = (offScreen[i + 1]&0xff);
- b2 = (offScreen[i - w_]&0xff);
- b3 = (offScreen[i - screen_width]&0xff);
- b4 = (offScreen[i]&0xff);
-
-
-
-
- currentScreen[i] = (((r1 + r2 + r3 + r4)>>3) << 16 | ((g1 + g2 + g3 + g4)>>3) << 8 | ((b1 + b2 + b3 + b4)>>3 )) + c;
-
- yMap = (displacementBuffer[i]&64512) >> 10;
-
-
- if(yMap > 0){
- eyeDirection.set(-i%screen_width+w_half, -h_half + i/screen_width, -z);
- eyeDirection.unit();
- float I = eyeDirection.dot(lightReflect);
-
- if(I > 0.985){
- int I_ = (int)((I-0.985) *24000);
-
- yMap = yMap * I_ /90;
-
- SpriteValue = 0x010101*yMap;
-
- pixel=(SpriteValue&MASK7Bit)+(currentScreen[i]&MASK7Bit);
- overflow=pixel&0x1010100;
- overflow=overflow-(overflow>>8);
- currentScreen[i] = overflow|pixel;
+ if(hasWaterDistortion){
+ for(int i = screen_width; i < screen_size-screen_width; i++){
+ if(displacementBuffer[i] != 31){
+
+ r1 = (offScreen[i + 1]&0xff0000) >> 16;
+ r2 = (offScreen[i - w_]&0xff0000) >> 16;
+ r3 = (offScreen[i - screen_width]&0xff0000) >> 16;
+ r4 = (offScreen[i - 1]&0xff0000) >> 16;
+
+
+ g1 = (offScreen[i + 1]&0xff00) >> 8;
+ g2 = (offScreen[i - w_]&0xff00) >> 8;
+ g3 = (offScreen[i - screen_width]&0xff00) >> 8;
+ g4 = (offScreen[i]&0xff00) >> 8;
+
+
+ b1 = (offScreen[i + 1]&0xff);
+ b2 = (offScreen[i - w_]&0xff);
+ b3 = (offScreen[i - screen_width]&0xff);
+ b4 = (offScreen[i]&0xff);
+
+
+
+ currentScreen[i] = (((r1 + r2 + r3 + r4)>>3) << 16 | ((g1 + g2 + g3 + g4)>>3) << 8 | ((b1 + b2 + b3 + b4)>>3 )) + c;
+
+ yMap = (displacementBuffer[i]&64512) >> 10;
+
+
+ if(yMap > 0){
+ eyeDirection.set(-i%screen_width+w_half, -h_half + i/screen_width, -z);
+ eyeDirection.unit();
+ float I = eyeDirection.dot(lightReflect);
+
+ if(I > 0.985){
+ int I_ = (int)((I-0.985) *24000);
+
+ yMap = yMap * I_ /90;
+
+ SpriteValue = 0x010101*yMap;
+
+ pixel=(SpriteValue&MASK7Bit)+(currentScreen[i]&MASK7Bit);
+ overflow=pixel&0x1010100;
+ overflow=overflow-(overflow>>8);
+ currentScreen[i] = overflow|pixel;
+ }
+
}
-
+
+ displacementBuffer[i] = 31;
+ }else{
+
+
}
-
- displacementBuffer[i] = 31;
- }else{
-
-
+
}
-
}
if(gameStarted) {
@@ -445,27 +452,36 @@ public void doPostProcesssing(){
- //draw health bar/Group info/unit level for every selected unit
- for(int i = 0; i < 100; i++){
+ //draw health bar/Group info/unit level for every visible unit (shown by default now,
+ //not just when selected)
+ for(int i = 0; i < currentAllUnitsInfo.length; i++){
- if(currentSelectedUnitsInfo[i][0] != -1){
- ObjectType = (currentSelectedUnitsInfo[i][0] & 0xff);
- groupNo = ((currentSelectedUnitsInfo[i][0] & 0xff00) >> 8);
- level = ((currentSelectedUnitsInfo[i][0] & 0xff0000) >> 16);
+ if(currentAllUnitsInfo[i][0] != -1){
+ ObjectType = (currentAllUnitsInfo[i][0] & 0xff);
+ groupNo = ((currentAllUnitsInfo[i][0] & 0xff00) >> 8);
+ level = ((currentAllUnitsInfo[i][0] & 0xff0000) >> 16);
+ boolean isSelected = (currentAllUnitsInfo[i][0] & (1<<24)) != 0;
maxHealth = unitInfoTable[ObjectType][0];
healthBarLength = unitInfoTable[ObjectType][1];
- xPos = currentSelectedUnitsInfo[i][1] + unitInfoTable[ObjectType][2];
- yPos = currentSelectedUnitsInfo[i][2] + unitInfoTable[ObjectType][3];
- remainingHealth = healthBarLength * currentSelectedUnitsInfo[i][4] / maxHealth;
-
+ xPos = currentAllUnitsInfo[i][1] + unitInfoTable[ObjectType][2];
+ yPos = currentAllUnitsInfo[i][2] + unitInfoTable[ObjectType][3];
+
+ //veterancy star shows on every visible unit regardless of selection, so ranked
+ //units are easy to spot at a glance - everything else below stays selection-only
+ if(level != 0){
+ theTextRenderer.drawStarCharacter(xPos + healthBarLength - 13, yPos + 5, level, currentScreen, 0xffff33, 0);
+ }
+
+ if(!isSelected)
+ continue;
+
+ remainingHealth = healthBarLength * currentAllUnitsInfo[i][4] / maxHealth;
+
//draw group info
if(groupNo != 255){
theTextRenderer.drawText_outline(xPos, yPos + 3, String.valueOf(groupNo+1), currentScreen, 0xffffff, 0);
}
- if(level != 0){
- theTextRenderer.drawStarCharacter(xPos + healthBarLength - 13, yPos + 5, level, currentScreen, 0xffff33, 0);
- }
-
+
if(remainingHealth <= 2 && remainingHealth != 0)
remainingHealth = 2;
@@ -526,8 +542,8 @@ else if((float)remainingHealth / healthBarLength > 0.25)
}
//draw progress bar if appliable
- if(currentSelectedUnitsInfo[i][5] >=0){
- int progress = healthBarLength * currentSelectedUnitsInfo[i][5] / 100;
+ if(currentAllUnitsInfo[i][5] >=0){
+ int progress = healthBarLength * currentAllUnitsInfo[i][5] / 100;
if(yPos > 0 && yPos < screen_height){
if(xPos >= 0 && xPos < screen_width)
@@ -886,12 +902,18 @@ public static void blurShadow(){
smoothedShadowBitmap[index] = 32;
else
smoothedShadowBitmap[index]= (byte)(shadowBitmap[index] + 127);
-
+
+ }else if(shadowBitmap[index] >= 32){
+ //this pixel is already fully lit - don't drag it down just because a
+ //shadowed neighbor (e.g. a building's own shadow right at its silhouette)
+ //happens to sit next to it in screen space; only soften pixels that are
+ //themselves at least partly shadowed, which is the blur's actual purpose
+ smoothedShadowBitmap[index] = shadowBitmap[index];
}else{
smoothedShadowBitmap[index] = (byte)((shadowBitmap[index] + shadowBitmap[index - 1] + shadowBitmap[index + screen_width] + shadowBitmap[index + w_]) >> 2);
//smoothedShadowBitmap[index] = (byte)((shadowBitmap[index+769] + shadowBitmap[index+767] + shadowBitmap[index-769] + shadowBitmap[index-767] + shadowBitmap[index-768] + shadowBitmap[index] + shadowBitmap[index - 1] + shadowBitmap[index + 768] + shadowBitmap[index + 1])>>3);
-
+
}
}
}
@@ -911,10 +933,12 @@ public static void blurShadow(){
smoothedShadowBitmap[index] = 32;
else
smoothedShadowBitmap[index]= (byte)(shadowBitmap[index] + 127);
+ }else if(shadowBitmap[index] >= 32){
+ smoothedShadowBitmap[index] = shadowBitmap[index];
}else{
smoothedShadowBitmap[index] = (byte)((shadowBitmap[index] + shadowBitmap[index - 1] + shadowBitmap[index - screen_width] + shadowBitmap[index - screen_width - 1]) >> 2);
-
-
+
+
}
}
}
@@ -935,10 +959,12 @@ public static void blurShadow(){
smoothedShadowBitmap[index] = 32;
else
smoothedShadowBitmap[index]= (byte)(shadowBitmap[index] + 127);
+ }else if(shadowBitmap[index] >= 32){
+ smoothedShadowBitmap[index] = shadowBitmap[index];
}else{
smoothedShadowBitmap[index] = (byte)((shadowBitmap[index] + shadowBitmap[index + 1] + shadowBitmap[index - screen_width] + shadowBitmap[index - w_]) >> 2);
-
-
+
+
}
}
}
@@ -957,9 +983,11 @@ public static void blurShadow(){
smoothedShadowBitmap[index] = 32;
else
smoothedShadowBitmap[index]= (byte)(shadowBitmap[index] + 127);
+ }else if(shadowBitmap[index] >= 32){
+ smoothedShadowBitmap[index] = shadowBitmap[index];
}else{
smoothedShadowBitmap[index] = (byte)((shadowBitmap[index] + shadowBitmap[index + 1] + shadowBitmap[index + screen_width] + shadowBitmap[index + screen_width + 1]) >> 2);
-
+
}
}
}
@@ -1011,8 +1039,9 @@ public static void prepareResources(){
currentScreen = mainThread.screen;
currentZbuffer = mainThread.zBuffer;
displacementBuffer = mainThread.displacementBuffer;
+ hasWaterDistortion = mainThread.hasWaterDistortion;
shadowBitmap = mainThread.shadowBitmap;
- currentSelectedUnitsInfo = mainThread.theAssetManager.selectedUnitsInfo;
+ currentAllUnitsInfo = mainThread.theAssetManager.allUnitsInfo;
visionPolygonInfo = mainThread.theAssetManager.visionPolygonInfo;
visionPolygonCount = mainThread.theAssetManager.visionPolygonCount;
unitsForMiniMap = mainThread.theAssetManager.unitsForMiniMap;
diff --git a/core/rasterizer.java b/core/rasterizer.java
index 8da4462..51f6377 100644
--- a/core/rasterizer.java
+++ b/core/rasterizer.java
@@ -41,7 +41,7 @@ public class rasterizer {
public static int[] xLeft = new int[screen_height], xRight = new int[screen_height];
//2 arrays that define the z depth across the polygon
- public static int[] zLeft = new int[screen_height], zRight = new int[screen_height];
+ //public static int[] zLeft = new int[screen_height], zRight = new int[screen_height];
//2 arrays that define the reflections across the polygon
public static vector[] RLeft = new vector[screen_height], RRight = new vector[screen_height];
@@ -184,6 +184,7 @@ public static void rasterize(polygon3D polygon){
}else if(polygon.type == 6){
scanPolygon();
findVectorOUV();
+ mainThread.hasWaterDistortion = true;
renderWaterPolygon();
}else if(polygon.type == 7){
scanPolygon();
@@ -687,7 +688,7 @@ public static void renderCloakedShadow(polygon3D polygon){
//render basic polygon that can't be shadowed (e.g polygon which back facing the light source)
public static void renderBasicPolygon(){
short[] texture = poly.myTexture.pixelData;
- diffuse_I = poly.diffuse_I&127;
+ diffuse_I = Math.min(poly.diffuse_I + poly.blastLightBoost, 127);
int[]colorTable = gameData.colorTable[diffuse_I];
int index;
@@ -766,26 +767,26 @@ public static void renderBasicPolygon(){
cDotWInverse = 1/cDotW;
X1 = (int)(aDotW*cDotWInverse);
Y1 = (int)(bDotW*cDotWInverse);
- dx = X1 - X;
- dy = Y1 - Y;
-
+ dx = ((X1 - X) << 8)/offset;
+ dy = ((Y1 - Y) << 8)/offset;
+
for( k = offset, d_x = 0, d_y = 0; k >0; k--, d_x+=dx, d_y+=dy, index++, z_left+=dz){
-
+
if(zBuffer[index] < z_left){
zBuffer[index] = z_left;
- textureIndex = (((d_x/offset) + X)&widthMask) + ((((d_y/offset) + Y)&heightMask)<0; k--, d_x+=dx, d_y+=dy, index++, z_left+=dz){
- if(zBuffer[index] < z_left){
+ if(zBuffer[index] < depth){
zBuffer[index] = depth + 20;
xPos = (d_x>>4) + X;
yPos = (d_y>>4) + Y;
@@ -955,15 +956,15 @@ public static void renderUnderGroundPolygon(){
cDotWInverse = 1/cDotW;
X1 = (int)(aDotW*cDotWInverse);
Y1 = (int)(bDotW*cDotWInverse);
- dx = X1 - X;
- dy = Y1 - Y;
-
+ dx = ((X1 - X) << 8)/offset;
+ dy = ((Y1 - Y) << 8)/offset;
+
for( k = offset, d_x = 0, d_y = 0; k >0; k--, d_x+=dx, d_y+=dy, index++, z_left+=dz){
-
- if(zBuffer[index] < z_left){
+
+ if(zBuffer[index] < depth){
zBuffer[index] = depth + 20;
- xPos = (d_x/offset) + X;
- yPos = (d_y/offset) + Y;
+ xPos = (d_x/256) + X;
+ yPos = (d_y/256) + Y;
textureIndex = (xPos&widthMask) + ((yPos&heightMask)<> 16;
@@ -992,7 +993,7 @@ public static void renderUnderGroundPolygon(){
//redner basic texture mapped polygon
public static void renderShadowedPolygon(){
short[] texture = poly.myTexture.pixelData;
- diffuse_I = poly.diffuse_I&127;
+ diffuse_I = Math.min(poly.diffuse_I + poly.blastLightBoost, 127);
int[] colorTable = gameData.colorTable[diffuse_I];
int index, z_lightspace, screenX_lightspace, screenY_lightspace, xPos, yPos;
@@ -1153,15 +1154,15 @@ public static void renderShadowedPolygon(){
cDotWInverse = 1/cDotW;
X1 = (int)(aDotW*cDotWInverse);
Y1 = (int)(bDotW*cDotWInverse);
- dx = X1 - X;
- dy = Y1 - Y;
-
+ dx = ((X1 - X) << 8)/offset;
+ dy = ((Y1 - Y) << 8)/offset;
+
for( k = offset, d_x = 0, d_y = 0; k >0; k--, d_x+=dx, d_y+=dy, index++, z_left+=dz){
-
+
if(zBuffer[index] < z_left){
zBuffer[index] = z_left;
- xPos = (d_x/offset) + X;
- yPos = (d_y/offset) + Y;
+ xPos = (d_x/256) + X;
+ yPos = (d_y/256) + Y;
textureIndex = (xPos&widthMask) + ((yPos&heightMask)<> 16;
@@ -1186,7 +1187,7 @@ public static void renderShadowedPolygon(){
public static void renderShadowedPolygon_Gouraud(){
short[] texture = poly.myTexture.pixelData;
- diffuse_I = poly.diffuse_I&127;
+ diffuse_I = Math.min(poly.diffuse_I + poly.blastLightBoost, 127);
int[] colorTable = gameData.colorTable[diffuse_I];
int index, z_lightspace, screenX_lightspace, screenY_lightspace, xPos, yPos;
@@ -1332,7 +1333,7 @@ public static void renderShadowedPolygon_Gouraud(){
if(z_lightspace - shadowBuffer[size] < shadowBias){
shadowBitmap[index] = 32;
- screen[index] = gameData.colorTable[diffuseStart >> 11][texture[textureIndex]];
+ screen[index] = gameData.colorTable[Math.min((diffuseStart >> 11) + poly.blastLightBoost, 127)][texture[textureIndex]];
}else{
shadowBitmap[index] = shadowLevel;
screen[index] = colorTable[texture[textureIndex]];
@@ -1355,15 +1356,15 @@ public static void renderShadowedPolygon_Gouraud(){
cDotWInverse = 1/cDotW;
X1 = (int)(aDotW*cDotWInverse);
Y1 = (int)(bDotW*cDotWInverse);
- dx = X1 - X;
- dy = Y1 - Y;
-
+ dx = ((X1 - X) << 8)/offset;
+ dy = ((Y1 - Y) << 8)/offset;
+
for( k = offset, d_x = 0, d_y = 0; k >0; k--, d_x+=dx, d_y+=dy, index++, z_left+=dz, diffuseStart+=diffuseGradient){
-
+
if(zBuffer[index] < z_left){
zBuffer[index] = z_left;
- xPos = (d_x/offset) + X;
- yPos = (d_y/offset) + Y;
+ xPos = (d_x/256) + X;
+ yPos = (d_y/256) + Y;
textureIndex = (xPos&widthMask) + ((yPos&heightMask)<> 16;
@@ -1373,17 +1374,17 @@ public static void renderShadowedPolygon_Gouraud(){
if(z_lightspace - shadowBuffer[size] < shadowBias){
shadowBitmap[index] = 32;
- screen[index] = gameData.colorTable[diffuseStart >> 11][texture[textureIndex]];
-
+ screen[index] = gameData.colorTable[Math.min((diffuseStart >> 11) + poly.blastLightBoost, 127)][texture[textureIndex]];
+
}else{
shadowBitmap[index] = shadowLevel;
screen[index] = colorTable[texture[textureIndex]];
}
-
-
+
+
}
}
-
+
break;
}
}
@@ -1391,7 +1392,7 @@ public static void renderShadowedPolygon_Gouraud(){
public static void renderShadowedPolygon_smooth(){
short[] texture = poly.myTexture.pixelData;
- diffuse_I = poly.diffuse_I&127;
+ diffuse_I = Math.min(poly.diffuse_I + poly.blastLightBoost, 127);
int[] colorTable = gameData.colorTable[diffuse_I];
int index, z_lightspace, screenX_lightspace, screenY_lightspace, xPos, yPos;
@@ -1536,6 +1537,7 @@ public static void renderShadowedPolygon_smooth(){
int lit = (int)(I_left + I_difference * (xPos%textureScaledWidth));
if(lit < 0)
lit = 0;
+ lit = Math.min(lit + poly.blastLightBoost, 127);
screen[index] = gameData.colorTable[lit][texture[textureIndex]];
}else{
shadowBitmap[index] = shadowLevel;
@@ -1559,15 +1561,15 @@ public static void renderShadowedPolygon_smooth(){
cDotWInverse = 1/cDotW;
X1 = (int)(aDotW*cDotWInverse);
Y1 = (int)(bDotW*cDotWInverse);
- dx = X1 - X;
- dy = Y1 - Y;
-
+ dx = ((X1 - X) << 8)/offset;
+ dy = ((Y1 - Y) << 8)/offset;
+
for( k = offset, d_x = 0, d_y = 0; k >0; k--, d_x+=dx, d_y+=dy, index++, z_left+=dz){
-
+
if(zBuffer[index] < z_left){
zBuffer[index] = z_left;
- xPos = (d_x/offset) + X;
- yPos = (d_y/offset) + Y;
+ xPos = (d_x/256) + X;
+ yPos = (d_y/256) + Y;
textureIndex = (xPos&widthMask) + ((yPos&heightMask)<> 16;
@@ -1581,6 +1583,7 @@ public static void renderShadowedPolygon_smooth(){
int lit = (int)(I_left + I_difference * (xPos%textureScaledWidth));
if(lit < 0)
lit = 0;
+ lit = Math.min(lit + poly.blastLightBoost, 127);
screen[index] = gameData.colorTable[lit][texture[textureIndex]];
}else{
shadowBitmap[index] = shadowLevel;
@@ -2093,7 +2096,7 @@ public static void renderZbufferRemoverPolygon(){
public static void renderCloakedPolygon(){
short[] texture = poly.myTexture.pixelData;
- diffuse_I = poly.diffuse_I&127;
+ diffuse_I = Math.min(poly.diffuse_I + poly.blastLightBoost, 127);
int[] colorTable = gameData.colorTable[diffuse_I];
int index, z_lightspace, screenX_lightspace, screenY_lightspace, xPos, yPos;
@@ -2265,9 +2268,9 @@ public static void renderCloakedPolygon(){
cDotWInverse = 1/cDotW;
X1 = (int)(aDotW*cDotWInverse);
Y1 = (int)(bDotW*cDotWInverse);
- dx = X1 - X;
- dy = Y1 - Y;
-
+ dx = ((X1 - X) << 8)/offset;
+ dy = ((Y1 - Y) << 8)/offset;
+
for( k = 0, d_x = 0, d_y = 0; k < offset; k++, d_x+=dx, d_y+=dy, index++, z_left+=dz){
if(zBuffer[index] < z_left){
@@ -2284,30 +2287,30 @@ public static void renderCloakedPolygon(){
}
zBuffer[index] = z_left;
- xPos = (d_x/offset) + X;
- yPos = (d_y/offset) + Y;
+ xPos = (d_x/256) + X;
+ yPos = (d_y/256) + Y;
textureIndex = (xPos&widthMask) + ((yPos&heightMask)<> 16;
screenY_lightspace = (XY_origin_y + dXY_xdirection_y * xPos + dXY_ydirection_y * yPos) >> 16;
-
+
int size = (screenX_lightspace + (screenY_lightspace << shadowmap_width_bit)) & shadowmap_size_;
-
+
if(z_lightspace - shadowBuffer[size] < shadowBias){
shadowBitmap[index] = 32;
}else{
shadowBitmap[index] = shadowLevel;
}
screen[index] = colorTable[texture[textureIndex]];
-
+
}
}
-
+
break;
}
}
}
-
+
public static void calculateDepthRangeAtGround() {
vector v = mainThread.my2Dto3DFactory.get3DLocation(poly, screen_width/2, 0);
v.subtract(camera.position);
diff --git a/core/sideBarManager.java b/core/sideBarManager.java
index 099d290..11925f4 100644
--- a/core/sideBarManager.java
+++ b/core/sideBarManager.java
@@ -2,6 +2,8 @@
import entity.*;
import gui.inputHandler;
+import audio.SoundEngine;
+import audio.SoundEngine.Sfx;
//this class handles player's interaction with the sidebar
public class sideBarManager {
@@ -43,7 +45,10 @@ public sideBarManager(playerCommander pc){
}
- public void update(){
+ public void update(){
+ if(leftMouseButtonClicked)
+ SoundEngine.play(Sfx.CLICK);
+
//reset sideBarInfo
for(int i = 0; i < 9; i++)
sideBarInfo[i] = -1;
diff --git a/entity/constructionVehicle.java b/entity/constructionVehicle.java
index 097c3c1..b535a0b 100644
--- a/entity/constructionVehicle.java
+++ b/entity/constructionVehicle.java
@@ -4,6 +4,8 @@
import core.*;
import enemyAI.enemyCommander;
+import audio.SoundEngine;
+import audio.SoundEngine.Sfx;
public class constructionVehicle extends solidObject {
@@ -1972,6 +1974,8 @@ public boolean canBeDeployed() {
public void expand() {
jobStatus = deploying;
+ if(teamNo == 0)
+ SoundEngine.play(Sfx.DEPLOY_VEHICLE);
float theXPos = ((boundary2D.x1 + 8) / 16 * 0.25f) + 0.125f;
float theYPos = ((boundary2D.y1 - 8 - 1) / 16 * 0.25f) + 0.125f;
diff --git a/entity/constructionYard.java b/entity/constructionYard.java
index 9276c34..651dcd3 100644
--- a/entity/constructionYard.java
+++ b/entity/constructionYard.java
@@ -5,6 +5,8 @@
import core.*;
import enemyAI.enemyCommander;
import gui.deployGrid;
+import audio.SoundEngine;
+import audio.SoundEngine.Sfx;
//the construction yard model
@@ -1065,7 +1067,8 @@ public int addPolygon(polygon3D[] polys, polygon3D poly){
//update the model
public void update(){
-
+ updateActiveBlastLight();
+
//update tech tree info
canBuildPowerPlant = theBaseInfo.canBuildPowerPlant;
canBuildRefinery = theBaseInfo.canBuildRefinery;
@@ -1104,17 +1107,17 @@ public void update(){
//process emerging from ground animation
if(centre.y < -0.79f){
centre.y+=0.01;
- for(int i = 0; i < polygons.length; i++){
+ for(int i = 0; i < polygons.length; i++){
polygons[i].origin.y+=0.01;
polygons[i].rightEnd.y+=0.01;
polygons[i].bottomEnd.y+=0.01;
-
+
for(int j = 0; j < polygons[i].vertex3D.length; j++){
polygons[i].vertex3D[j].y+=0.01;
}
}
-
-
+
+
shadowvertex0.y+=0.01;
shadowvertex1.y+=0.01;
shadowvertex2.y+=0.01;
@@ -1671,6 +1674,7 @@ public boolean isIdle(){
//create building
public void createBuilding(){
+ SoundEngine.play(Sfx.DEPLOY_BUILDING);
if(powerPlantProgress == 240){
int y = 127 - dg.gridOneIndex/128;
int x = dg.gridOneIndex%128 + 1;
diff --git a/entity/factory.java b/entity/factory.java
index 3b12c3b..5197764 100644
--- a/entity/factory.java
+++ b/entity/factory.java
@@ -815,6 +815,7 @@ public int addPolygon(polygon3D[] polys, polygon3D poly){
//update the model
public void update(){
+ updateActiveBlastLight();
//update tech tree info
canBuildLightTank = theBaseInfo.canBuildLightTank;
diff --git a/entity/goldMine.java b/entity/goldMine.java
index f183530..27307a4 100644
--- a/entity/goldMine.java
+++ b/entity/goldMine.java
@@ -263,6 +263,7 @@ private void makePolygons(){
//update the model
public void update(){
+ updateActiveBlastLight();
if(!mainThread.gameStarted) {
isRevealed = true;
}else {
diff --git a/entity/gunTurret.java b/entity/gunTurret.java
index e13a74a..e0c724e 100644
--- a/entity/gunTurret.java
+++ b/entity/gunTurret.java
@@ -292,7 +292,9 @@ public void makePolygons(){
//update the model
- public void update(){
+ public void update(){
+ updateActiveBlastLight();
+
//process emerging from ground animation
if(centre.y < -0.5f){
centre.y+=0.01f;
diff --git a/entity/harvester.java b/entity/harvester.java
index 4a4e5d7..faf2af0 100644
--- a/entity/harvester.java
+++ b/entity/harvester.java
@@ -578,9 +578,9 @@ public void makeTriangle(polygon3D[] triangles, int startIndex, int angle, float
//update the model
- public void update(){
-
-
+ public void update(){
+ updateActiveBlastLight();
+
//handle unloading gold ore event
if(unloadingCount > 0){
if(unloadingCount > 69 && cargoAngle > 300)
diff --git a/entity/heavyTank.java b/entity/heavyTank.java
index 2037410..8e59312 100644
--- a/entity/heavyTank.java
+++ b/entity/heavyTank.java
@@ -356,6 +356,8 @@ public void makePolygons(){
//update and draw model
public void update(){
+ updateActiveBlastLight();
+
//check if tank has been destroyed
if(currentHP <= 0){
//spawn an explosion when the tank is destroyed
diff --git a/entity/lightPole.java b/entity/lightPole.java
index 2b952d9..7a05b0b 100644
--- a/entity/lightPole.java
+++ b/entity/lightPole.java
@@ -195,16 +195,16 @@ private void makePolygons(){
polygons[i].parentObject = this;
}
-
-
-
+
+ theAssetManager = mainThread.theAssetManager;
}
-
- //update the model
+
+ //update the model
public void update(){
if(vanished)
- return;
-
+ return;
+
+ updateActiveBlastLight();
mainThread.gridMap.currentObstacleMap[tileIndex] = false;
//update center in camera coordinate
diff --git a/entity/lightTank.java b/entity/lightTank.java
index e9e8709..e7ebfac 100644
--- a/entity/lightTank.java
+++ b/entity/lightTank.java
@@ -251,7 +251,8 @@ public void makePolygons(){
//update and draw model
public void update(){
-
+ updateActiveBlastLight();
+
//check if tank has been destroyed
if(currentHP <= 0){
//spawn an explosion when the tank is destroyed
diff --git a/entity/palmTree.java b/entity/palmTree.java
index 7d04525..11b5d63 100644
--- a/entity/palmTree.java
+++ b/entity/palmTree.java
@@ -241,11 +241,13 @@ private void makePolygons(){
polygons[i].findDiffuse();
polygons[i].parentObject = this;
}
-
+
+ theAssetManager = mainThread.theAssetManager;
}
-
- //update the model
+
+ //update the model
public void update(){
+ updateActiveBlastLight();
mainThread.gridMap.currentObstacleMap[tileIndex] = false;
//update center in camera coordinate
diff --git a/entity/powerPlant.java b/entity/powerPlant.java
index 2cdf223..986e345 100644
--- a/entity/powerPlant.java
+++ b/entity/powerPlant.java
@@ -543,6 +543,7 @@ public void makePolygons(){
//update the model
public void update(){
+ updateActiveBlastLight();
//process emerging from ground animation
if(centre.y < -0.5f){
diff --git a/entity/refinery.java b/entity/refinery.java
index 4180cfd..31d6b7f 100644
--- a/entity/refinery.java
+++ b/entity/refinery.java
@@ -13,6 +13,7 @@ public class refinery extends solidObject{
public polygon3D storageCoverLeft;
public polygon3D storageCoverRight;
+ public polygon3D depositFloor;
public int unloadOreCountDown;
public final int unloadOreTime = 190;
@@ -367,7 +368,8 @@ public void makePolygons(){
addPolygon(polygons, storageCoverRight);
v = new vector[]{put(-0.34, 0.27, -0.01), put(-0.13, 0.27, -0.01), put(-0.13, 0.27, -0.24), put(-0.34, 0.27, -0.24)};
- addPolygon(polygons, new polygon3D(v, v[0].myClone(), v[1].myClone(), v[3].myClone(), mainThread.textures[34], 1,1f,3));
+ depositFloor = new polygon3D(v, v[0].myClone(), v[1].myClone(), v[3].myClone(), mainThread.textures[34], 1,1f,3);
+ addPolygon(polygons, depositFloor);
v = new vector[]{put(-0.34,0.3,0.08), put(-0.34,0.3,0.2), put(-0.2,0.4,0.2), put(-0.2,0.4,0.08)};
addPolygon(polygons, new polygon3D(v, v[0].myClone(), v[1].myClone(), v[3].myClone(), mainThread.textures[35], 0.5f,0.3f,1));
@@ -698,6 +700,8 @@ public int addPolygon(polygon3D[] polys, polygon3D poly){
//update the model
public void update(){
+ updateActiveBlastLight();
+
//process emerging from ground animation
if(centre.y < -0.79f){
centre.y+=0.02f;
@@ -1100,10 +1104,14 @@ public void draw(){
if(!visible)
return;
for(int i = 0; i < polygons.length; i++){
-
+
polygons[i].update();
}
-
+
+ //don't show the ore deposit while the refinery is still rising out of the ground
+ if(centre.y < -0.79f)
+ depositFloor.visible = false;
+
for(int i = 0; i < polygons.length; i++){
polygons[i].draw();
}
diff --git a/entity/solidObject.java b/entity/solidObject.java
index c17c3f8..8f84fe7 100644
--- a/entity/solidObject.java
+++ b/entity/solidObject.java
@@ -147,9 +147,60 @@ public abstract class solidObject{
public int ID;
public AssetManager theAssetManager;
-
+
public float height;
-
+
+ //the single strongest nearby explosion light this model is currently reacting to, if any
+ public vector activeBlastLightPos = new vector(0,0,0);
+ public float activeBlastLightIntensity;
+
+ //TEMPORARY DIAGNOSTIC: highest activeBlastLightIntensity ever observed on any model, shown in the
+ //title bar. score is supposed to be capped at 1f in updateActiveBlastLight() below - if this ever
+ //reads above 1.00 the cap itself is being bypassed somewhere; if it never exceeds 1.00 even during
+ //an "over bright" moment, the bug is in rendering, not in this scoring math
+ public static float debugPeakBlastIntensity = 0;
+
+ //finds the single strongest nearby blast light (if any) for this model to react to;
+ //deliberately picks only one light per model instead of summing every explosion in range,
+ //so each polygon only ever has to test against one light source
+ public void updateActiveBlastLight(){
+ activeBlastLightIntensity = 0;
+ if(theAssetManager == null)
+ return;
+
+ float[][] lights = theAssetManager.blastLights;
+ float range = 2.4f;
+
+ for(int i = 0; i < lights.length; i++){
+ float[] light = lights[i];
+ if(light[3] <= 0)
+ continue;
+
+ float dx = centre.x - light[0];
+ float dy = centre.y - light[1];
+ float dz = centre.z - light[2];
+ float distSq = dx*dx + dy*dy + dz*dz;
+ if(distSq >= range*range)
+ continue;
+
+ float dist = (float)Math.sqrt(distSq);
+ float distanceFalloff = 1f - dist/range;
+ distanceFalloff *= distanceFalloff; //steeper falloff so a closer light reliably wins over a bigger, farther one
+ float lifeFalloff = light[3]/11f;
+ float sizeFactor = Math.min(light[4]/2f, 1.5f); //bounds how much a bigger blast can outweigh distance
+ float score = Math.min(sizeFactor * distanceFalloff * lifeFalloff, 1f); //cap so a big, close, fresh blast can't inflate the boost beyond the range polygon3D's factor curve is tuned for
+
+ if(score > activeBlastLightIntensity){
+ activeBlastLightIntensity = score;
+ activeBlastLightPos.set(light[0], light[1], light[2]);
+ }
+ }
+
+ if(activeBlastLightIntensity > debugPeakBlastIntensity)
+ debugPeakBlastIntensity = activeBlastLightIntensity;
+ }
+
+
public static Rect fullSizedProbe = new Rect(0,0, 16, 16);
public int progressStatus = -1;
diff --git a/entity/stealthTank.java b/entity/stealthTank.java
index b63e5d7..b6f0571 100644
--- a/entity/stealthTank.java
+++ b/entity/stealthTank.java
@@ -4,6 +4,8 @@
import core.*;
import enemyAI.enemyCommander;
+import audio.SoundEngine;
+import audio.SoundEngine.Sfx;
public class stealthTank extends solidObject{
@@ -1149,7 +1151,8 @@ public void fireRailgunShot(int attackAngle){
tempVector.rotate_XZ(360 - attackAngle);
attackCoolDown = myAttackCooldown;
- cloakCooldownCount = 120;
+ cloakCooldownCount = 120;
+ SoundEngine.play(Sfx.SHOOT_RAILGUN);
for(float i = 0.1f; i < distanceToDesination; i+=0.1f){
if(theAssetManager.helixCount >= theAssetManager.helixInfo.length)
diff --git a/gui/SideBar.java b/gui/SideBar.java
index e9190db..7c4c57f 100644
--- a/gui/SideBar.java
+++ b/gui/SideBar.java
@@ -54,7 +54,7 @@ public void init(){
iconImages = new int[25][44 * 44];
iconImages_dark = new int[25][44 * 44];
- String folder = "../images/";
+ String folder = "/images/";
loadTexture(folder + "44.jpg", iconImages[0], 44, 44, iconImages_dark[0]);
loadTexture(folder + "47.jpg", iconImages[1], 44, 44, iconImages_dark[1]);
loadTexture(folder + "48.jpg", iconImages[2], 44, 44, iconImages_dark[2]);
diff --git a/gui/gameCursor.java b/gui/gameCursor.java
index 9c65dfc..5dbb201 100644
--- a/gui/gameCursor.java
+++ b/gui/gameCursor.java
@@ -13,6 +13,7 @@ public class gameCursor {
public int[][] arrowIcons;
public int[][] smallArrowIcons;
public int[] smallArrowIcons4;
+ public int[][] smallArrowCardinal; //0=right, 1=down, 2=left, 3=up - same thin style as smallArrowIcons, for the deploy cursor
public int[] cursorIcon;
public int[] screen;
public int[][] iconOverWriteBuffer;
@@ -23,7 +24,7 @@ public class gameCursor {
public void init() {
- String folder = "../images/";
+ String folder = "/images/";
arrowIcons = new int[8][24*24];
for(int i = 0; i < 8; i++) {
@@ -40,6 +41,12 @@ public void init() {
smallArrowIcons4 = new int[20*20];
loadTexture(folder + "smallArrow4.png", smallArrowIcons4, 20,20);
+
+ smallArrowCardinal = new int[4][20*20];
+ String[] cardinalNames = {"Right", "Down", "Left", "Up"};
+ for(int i = 0; i < 4; i++) {
+ loadTexture(folder + "smallArrow" + cardinalNames[i] + ".png", smallArrowCardinal[i], 20,20);
+ }
iconOverWriteBuffer = new int[1024][2];
for(int i = 0; i < 1024; i++) {
@@ -57,6 +64,7 @@ public void updateAndDraw(int[] screen) {
int mouseOverUnitType = mainThread.pc.mouseOverUnitType;
int mouseOverUnitTeam = mainThread.pc.mouseOverUnitTeam;
boolean mouseOverUnitIsSelected = mainThread.pc.mouseOverUnitIsSelected;
+ boolean mouseOverUnitCanDeploy = mainThread.pc.mouseOverUnitCanDeploy;
boolean hasConVehicleSelected = mainThread.pc.hasConVehicleSelected;
boolean hasHarvesterSelected = mainThread.pc.hasHarvesterSelected;
boolean hasTroopsSelected = mainThread.pc.hasTroopsSelected;
@@ -80,7 +88,16 @@ public void updateAndDraw(int[] screen) {
//draw arrow icons if the player is scrolling the screen with the mouse
int cursorX = 0;
int cursorY = 0;
- if(camera.MOVE_DOWN && !camera.MOVE_LEFT && ! camera.MOVE_RIGHT) {
+ if(inputHandler.isDraggingCamera && inputHandler.rightMouseButtonHeld) {
+ //isDraggingCamera alone isn't reset until the next press starts (playerCommander needs
+ //it to stay true through the release frame to suppress the move/attack click), so also
+ //require the button to still be physically held before showing the drag cursor icon
+ //show which of the 8 scroll directions the current drag offset corresponds to,
+ //reusing the same edge-scroll arrow icons but centred on the cursor itself
+ int dragIconIndex = dragDirectionIcon(mouseX - inputHandler.rightDragAnchorX, mouseY - inputHandler.rightDragAnchorY);
+ if(dragIconIndex >= 0)
+ drawIcon(arrowIcons[dragIconIndex], mouseX-12, mouseY-12);
+ }else if(camera.MOVE_DOWN && !camera.MOVE_LEFT && ! camera.MOVE_RIGHT) {
drawIcon(arrowIcons[1], mouseX-12,screen_height - 23);
}else if(camera.MOVE_UP && !camera.MOVE_LEFT && ! camera.MOVE_RIGHT) {
drawIcon(arrowIcons[3], mouseX-12,0);
@@ -143,7 +160,9 @@ public void updateAndDraw(int[] screen) {
drawIcon(arrowIcons[5], cursorX, cursorY);
}else if(mouseOverSelectableUnit && !cursorIsInMiniMap && !cursorIsInSideBar){
- if((hasTroopsSelected || hasTowerSelected) && mouseOverUnitTeam == 1) {
+ if(mouseOverUnitIsSelected && mouseOverUnitCanDeploy) {
+ drawDeployIcon(mouseX, mouseY);
+ }else if((hasTroopsSelected || hasTowerSelected) && mouseOverUnitTeam == 1) {
drawActionIcon(mouseX, mouseY, 1);
}else if(!hasHarvesterSelected && !hasTroopsSelected && !hasTowerSelected) {
if(!mouseOverUnitIsSelected)
@@ -400,7 +419,79 @@ public void drawActionIcon(int xPos, int yPos, int type) {
}
}
-
+
+ //construction-vehicle deploy cursor: like the move icon's 4 converging diagonal arrows, but
+ //mirrored - cardinal (up/down/left/right) arrows spreading outward from the cursor instead of
+ //diagonal arrows converging inward. Uses new thin cardinal arrow images (smallArrowCardinal, same
+ //20x20 slender style as the diagonal smallArrowIcons) rather than the bulkier edge-scroll icons.
+ public void drawDeployIcon(int xPos, int yPos) {
+ xPos -= 10;
+ yPos -= 10;
+
+ //radius grows outward over the cycle (the inverse of drawActionIcon's shrinking r), then snaps
+ //back, giving a "spreading away from the cursor" pulse instead of a "converging on it" one
+ int r = 9 + (mainThread.gameFrame%21)/2;
+
+ int arrowColor = 34 << 16 | 200 << 8 | 76;
+
+ drawSmallCardinalArrow(smallArrowCardinal[3], xPos, yPos - r, arrowColor); //up
+ drawSmallCardinalArrow(smallArrowCardinal[1], xPos, yPos + r, arrowColor); //down
+ drawSmallCardinalArrow(smallArrowCardinal[2], xPos - r, yPos, arrowColor); //left
+ drawSmallCardinalArrow(smallArrowCardinal[0], xPos + r, yPos, arrowColor); //right
+ }
+
+ private void drawSmallCardinalArrow(int[] icon, int xPos, int yPos, int arrowColor) {
+ int index = 0;
+ int color = 0;
+ int blue = 0;
+ int red = 0;
+ for(int i = 0; i < 20; i++) {
+ for(int j = 0; j < 20; j++) {
+ if(xPos + j < 0 || xPos + j >= screen_width || yPos + i < 0 || yPos + i >= screen_height)
+ continue;
+
+ index = xPos + j + (yPos + i)*screen_width;
+ color = icon[j+i*20];
+
+ blue = color&0xff;
+ red = (color&0xff0000) >> 16;
+ if(red < 100 && blue > 100)
+ continue;
+
+ if(pixelInsideSideArea(index))
+ continue;
+
+ if(red > 200)
+ color = arrowColor;
+
+ iconOverWriteBuffer[iconOverWriteBufferIndex][0] = index;
+ iconOverWriteBuffer[iconOverWriteBufferIndex][1] = screen[index];
+ iconOverWriteBufferIndex++;
+ screen[index] = color;
+ }
+ }
+ }
+
+ //maps a right-click-drag offset to the matching 8-direction arrow icon index (arrowIcons[0..7]:
+ //right, down, left, up, up-right, down-right, down-left, up-left), same set used for edge scrolling
+ public int dragDirectionIcon(int offsetX, int offsetY){
+ int deadZone = 8;
+ boolean right = offsetX > deadZone;
+ boolean left = offsetX < -deadZone;
+ boolean down = offsetY > deadZone;
+ boolean up = offsetY < -deadZone;
+
+ if(right && up) return 4;
+ if(right && down) return 5;
+ if(left && down) return 6;
+ if(left && up) return 7;
+ if(right) return 0;
+ if(down) return 1;
+ if(left) return 2;
+ if(up) return 3;
+ return -1;
+ }
+
public boolean pixelInsideSideArea(int index){
int x = index%screen_width;
int y = index/screen_width;
diff --git a/gui/gameMenu.java b/gui/gameMenu.java
index 109eeab..521ac2f 100644
--- a/gui/gameMenu.java
+++ b/gui/gameMenu.java
@@ -6,6 +6,8 @@
import java.awt.image.PixelGrabber;
import javax.imageio.ImageIO;
import core.*;
+import audio.SoundEngine;
+import audio.SoundEngine.Sfx;
public class gameMenu {
@@ -26,7 +28,7 @@ public class gameMenu {
public int[] titleImage, lightTankImage, rocketTankImage, stealthTankImage, heavyTankImage;
public button newGame, unpauseGame, showHelp, showOptions, showHighscores, quitGame, abortGame, easyGame, normalGame, hardGame, quitDifficulty, quitHelpMenu, quitOptionMenu, quitHighscoreMenu, nextPage, previousPage,
- enableMouseCapture, disableMouseCapture, enableFogOfWar, disableFogOfWar, confirmErrorLoadingHighscore, normalToHardButton, normalToEasyButton, hardToNormalButton, easyToNormalButton,
+ enableMouseCapture, disableMouseCapture, enableFogOfWar, disableFogOfWar, enableSound, disableSound, confirmErrorLoadingHighscore, normalToHardButton, normalToEasyButton, hardToNormalButton, easyToNormalButton,
backToMapDefeat, leaveGameDefeat, backToMapVictory, leaveGameVictory, uploadScore;
public char[] easyDescription, normalDescription, hardDescription, helpPage1, helpPage2, helpPage3, helpPage4, mouseMode;
@@ -65,7 +67,7 @@ public void init() {
for(int i = 0; i< 32; i++)
name[i] = 255;
- String folder = "../images/";
+ String folder = "/images/";
loadTexture(folder + "title.png", titleImage, 216, 35);
loadTexture(folder + "58.jpg", lightTankImage, 44, 44);
loadTexture(folder + "59.jpg", rocketTankImage, 44, 44);
@@ -114,17 +116,19 @@ public void init() {
+ "\"Esc\" -- Pause/Unpause the game.\n\n"
+ "\"Left Click\" -- Select a unit. Left click + mouse drag can be used to select up to \n100 units at a time. Double left click on a unit will automatically select surrounding \nunits of the same type.\n\n"
+ "\"Right Click\" -- Issue a move or attack command to the selected unit(s). You can \nalso use right click to set rally point or cancel build progress.\n\n"
+ + "\"Right Click + Drag\" -- Hold the right button and move the mouse to scroll the map, \nlike a modern RTS \"grab and drag\" camera.\n\n"
+ "\"a\" -- Force attack a unit. If no unit is under the cursor, then the selected units will \nbe set to attack move to the cursor location.\n\n"
+ "\"s\" -- stop current action for the selected unit(s).\n\n"
+ "\"Ctrl + number\" -- Create a control group and assigned the number to the group.\n\n"
- + "\"Ctrl + Left Click\" -- Add/Remove a unit to/from the selected units.\n\n"
- + "\"Ctrl + Mouse Drag\" -- Add units in the dragging box to the selected units.\n\n\n"
+ + "\"Ctrl + Left Click\" -- Add/Remove a unit to/from the selected units.\n\n\n\n"
+ " 1/4 ").toCharArray();
helpPage2 = (" Controls (Cont.) \n\n"
+ "\"Left and Right arrow keys\" -- Change camera view angle.\n\n"
+ "\"c\" -- Toggle between different construction yards under your control.\n\n"
- + "\"f\" -- Toggle between different factories under your control.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n"
+ + "\"f\" -- Toggle between different factories under your control.\n\n"
+ + "\"d\" -- Activate the deployment grid for a selected construction yard's building \nthat is ready to be placed, or deploy a selected construction vehicle, without having \nto click a sidebar icon first.\n\n"
+ + "\"Ctrl + Mouse Drag\" -- Add units in the dragging box to the selected units.\n\n\n\n\n\n\n\n\n\n\n"
+ " 2/4 ").toCharArray();
helpPage3 = (" Units \n\n"
@@ -152,7 +156,8 @@ public void init() {
+ " 4/4").toCharArray();
mouseMode = (" Options \n\n\nMouse capture. When enabled the game will prevent \nthe mouse cursor from leaving the current window.\n\n\n"
- + "Fog of war. When enabled, enemy units that are not \nin vision will be hidden. Note that your score will NOT \nbe saved when this option is disabled.").toCharArray();
+ + "Fog of war. When enabled, enemy units that are not \nin vision will be hidden. Note that your score will NOT \nbe saved when this option is disabled.\n\n\n"
+ + "Sound. Toggles all sound effects on or off.").toCharArray();
quitHelpMenu = new button("quitHelpMenu", "x", 670, 80, 18,16);
buttons.add(quitHelpMenu);
@@ -180,7 +185,13 @@ public void init() {
disableFogOfWar = new button("disableFogOfWar", "Enabled", 545, 215, 80, 25);
buttons.add(disableFogOfWar);
-
+
+ enableSound = new button("enableSound", "Disabled", 545, 285, 80, 25);
+ buttons.add(enableSound);
+
+ disableSound = new button("disableSound", "Enabled", 545, 285, 80, 25);
+ buttons.add(disableSound);
+
confirmErrorLoadingHighscore = new button("confirmErrorLoadingHighscore", "Ok", 350, 280, 80, 25);
buttons.add(confirmErrorLoadingHighscore);
@@ -477,7 +488,15 @@ public void updateAndDraw(int[] screen, boolean gameStarted, boolean gamePaused,
disableFogOfWar.display = true;
enableFogOfWar.display = false;
}
-
+
+ if(SoundEngine.enabled) {
+ disableSound.display = false;
+ enableSound.display = true;
+ }else {
+ disableSound.display = true;
+ enableSound.display = false;
+ }
+
quitOptionMenu.display = true;
if(postProcessingThread.gameStarted) {
@@ -648,6 +667,7 @@ public void updateButtons() {
uploadingScore = true;
}
+ SoundEngine.play(Sfx.CLICK);
postProcessingThread.buttonAction = buttons.get(i).name;
}
}
diff --git a/gui/inputHandler.java b/gui/inputHandler.java
index 2b2d3b7..c8b57c5 100644
--- a/gui/inputHandler.java
+++ b/gui/inputHandler.java
@@ -7,12 +7,13 @@
import core.camera;
import core.geometry;
import core.mainThread;
+import audio.SoundEngine;
public class inputHandler {
public static int mouse_x, mouse_y,mouse_x0, mouse_x1, mouse_y0, mouse_y1, cameraMovementAngle;
- public static boolean mouseIsInsideScreen, userIsHoldingA, userIsHoldingC, userIsHoldingF;
+ public static boolean mouseIsInsideScreen, userIsHoldingA, userIsHoldingC, userIsHoldingF, userIsHoldingD;
public static boolean leftKeyPressed,
rightKeyPressed,
@@ -25,8 +26,14 @@ public class inputHandler {
A_pressed,
C_pressed,
F_pressed,
+ D_pressed,
escapeKeyPressed,
- escapeKeyReleased;
+ escapeKeyReleased,
+ rightMouseButtonHeld,
+ isDraggingCamera,
+ wasRightMouseButtonHeldLastFrame;
+
+ public static int rightDragAnchorX, rightDragAnchorY;
public static int numberTyped;
@@ -80,8 +87,12 @@ public static void processInput(){
if(c == 'f' || c == 'F'){
F_pressed = true;
}
-
-
+
+ if(c == 'd' || c == 'D'){
+ D_pressed = true;
+ }
+
+
if(c >=49 && c <=53){
numberTyped = c - 48;
}
@@ -114,7 +125,11 @@ public static void processInput(){
if(c == 'f' || c == 'F'){
F_pressed = true;
}
-
+
+ if(c == 'd' || c == 'D'){
+ D_pressed = true;
+ }
+
if(c >=49 && c <=53){
numberTyped = c - 48;
}
@@ -148,6 +163,10 @@ public static void processInput(){
F_pressed = false;
userIsHoldingF = false;
}
+ if(c == 'd' || c == 'D'){
+ D_pressed = false;
+ userIsHoldingD = false;
+ }
keyReleaseBufferIndex++;
}
keyReleaseBufferIndex = 0;
@@ -172,7 +191,11 @@ public static void processInput(){
F_pressed = false;
userIsHoldingF = false;
}
-
+ if(c == 'd' || c == 'D'){
+ D_pressed = false;
+ userIsHoldingD = false;
+ }
+
keyReleaseBufferIndex++;
}
@@ -186,7 +209,34 @@ public static void processInput(){
camera.MOVE_DOWN = false;
camera.TURN_LEFT = false;
camera.TURN_RIGHT = false;
-
+
+ //right-click-drag map scrolling: press to anchor a point, then the screen keeps scrolling
+ //towards wherever the cursor is dragged - relative to that fixed anchor, not frame-to-frame
+ //movement - for as long as the button stays held, at a speed based on how far the cursor
+ //has been pulled away from the anchor. Releasing the button stops the scroll immediately.
+ if(rightMouseButtonHeld){
+ if(!wasRightMouseButtonHeldLastFrame){
+ rightDragAnchorX = mouse_x;
+ rightDragAnchorY = mouse_y;
+ isDraggingCamera = false;
+ }
+
+ int offsetX = mouse_x - rightDragAnchorX;
+ int offsetY = mouse_y - rightDragAnchorY;
+ if(!isDraggingCamera && Math.abs(offsetX) + Math.abs(offsetY) > 6)
+ isDraggingCamera = true;
+ if(isDraggingCamera)
+ camera.panByHeldOffset(offsetX, offsetY);
+
+ wasRightMouseButtonHeldLastFrame = true;
+ }else{
+ //isDraggingCamera itself is deliberately NOT reset here - playerCommander checks it on
+ //the same frame the release event fires to decide whether to suppress the move/attack
+ //command that a right-click would otherwise issue, and resetting it this early would
+ //defeat that check. gameCursor instead also requires rightMouseButtonHeld for its icon.
+ wasRightMouseButtonHeldLastFrame = false;
+ }
+
if(!mainThread.pc.isSelectingUnit){
mouse_x0 = mouse_x;
mouse_y0 = mouse_y;
@@ -314,6 +364,12 @@ public static void processInput(){
userIsHoldingF = true;
}
}
+ if(D_pressed) {
+ if(!userIsHoldingD) {
+ mainThread.pc.deployKeyPressed = true;
+ userIsHoldingD = true;
+ }
+ }
//handle escape key
@@ -363,12 +419,21 @@ public static void processInput(){
if(mainThread.buttonAction == "enableFogOfWar") {
mainThread.fogOfWarDisabled = false;
-
+
}else if(mainThread.buttonAction == "disableFogOfWar") {
mainThread.fogOfWarDisabled = true;
-
+
}
}
+
+ //toggle sound, allowed whether or not a game is in progress
+ if(mainThread.buttonAction == "enableSound") {
+ SoundEngine.enabled = true;
+
+ }else if(mainThread.buttonAction == "disableSound") {
+ SoundEngine.enabled = false;
+
+ }
//abort current game when the abort button is pressed
if(mainThread.gameStarted && mainThread.buttonAction == "abortGame") {
@@ -425,6 +490,7 @@ public static void processInput(){
S_pressed = false;
C_pressed = false;
F_pressed = false;
+ D_pressed = false;
numberTyped = 0;
}
diff --git a/gui/textRenderer.java b/gui/textRenderer.java
index 95dd448..6ee161f 100644
--- a/gui/textRenderer.java
+++ b/gui/textRenderer.java
@@ -27,7 +27,7 @@ public void init(){
//load font image
Image img = null;
try{
- img = ImageIO.read(getClass().getResource("../images/" + "font.jpg"));
+ img = ImageIO.read(getClass().getResource("/images/" +"font.jpg"));
}catch(Exception e){
e.printStackTrace();
}
@@ -40,7 +40,7 @@ public void init(){
}
try{
- img = ImageIO.read(getClass().getResource("../images/" + "menuFont.png"));
+ img = ImageIO.read(getClass().getResource("/images/" +"menuFont.png"));
}catch(Exception e){
e.printStackTrace();
}
@@ -86,7 +86,7 @@ public void init(){
//load half star images
try{
- img = ImageIO.read(getClass().getResource("../images/" + "84.jpg"));
+ img = ImageIO.read(getClass().getResource("/images/" +"84.jpg"));
}catch(Exception e){
e.printStackTrace();
}
@@ -101,7 +101,7 @@ public void init(){
//load star images
try{
- img = ImageIO.read(getClass().getResource("../images/" + "85.jpg"));
+ img = ImageIO.read(getClass().getResource("/images/" +"85.jpg"));
}catch(Exception e){
e.printStackTrace();
}
diff --git a/images/smallArrowDown.png b/images/smallArrowDown.png
new file mode 100644
index 0000000000000000000000000000000000000000..d163477988c9659b32a922bb8d9d451576ba79f5
GIT binary patch
literal 199
zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VV{wqX6T`Z5GB1G~mUKs7M+SzC
z{oH>NS%G}c0*}aI1_r*vAk26?e?YfN!@c}7E{`DljV82gCOq>#_x`Qj&L1`GH`!v7}k^>lB^rC*z}wI
piH!cp1i8qFdsUVGjy
literal 0
HcmV?d00001
diff --git a/images/smallArrowLeft.png b/images/smallArrowLeft.png
new file mode 100644
index 0000000000000000000000000000000000000000..5631115c1f78f745058d9cbd2234e3d616cde5bc
GIT binary patch
literal 213
zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VV{wqX6T`Z5GB1G~mUKs7M+SzC
z{oH>NS%G}c0*}aI1_r*vAk26?e?_#fC$r4k=1zVkZ5(EZ5`Tb{-Vo)vT{@;>PQ)2md41aAaLni*WxL
zCY$CLYbEOzw0uu=#CLKC~~Wl1OUBbMC*+REVR>gTe~DWM4fZL>*J
literal 0
HcmV?d00001
diff --git a/images/smallArrowRight.png b/images/smallArrowRight.png
new file mode 100644
index 0000000000000000000000000000000000000000..0a0b897f6a67b6ea4a4d7574b2f801bd4df4da41
GIT binary patch
literal 220
zcmeAS@N?(olHy`uVBq!ia0vp^A|TAc1|)ksWqE-VV{wqX6T`Z5GB1G~mUKs7M+SzC
z{oH>NS%G}c0*}aI1_r*vAk26?e?m*060|p!{fB!GneWZBR^w@$(
z2@U5NY*9){^H^QP1!5ga7$-V5er`B1;ejTXQgg&KcgK{B0@uYK-Y;yDcWd5$|C}3p
z(NS%G}c0*}aI1_r*vAk26?e?F~q|E?HNO^0|q?Kfp`C3(o?ONn4-S$
zrP$&4mP!Vtb83gLr>tnY8Dq3V;QiW#Mg`2LXD>Fo5)s#b>Vx!)jeDe~CiXq8yuvd_
sDTcd<@5y}bZ?PX(R(w1!w&ObUhI2_LG}hhs0$Rl2>FVdQ&MBb@06Nk}CjbBd
literal 0
HcmV?d00001
diff --git a/main.java b/main.java
index 7e6fcf9..1109c35 100644
--- a/main.java
+++ b/main.java
@@ -3,6 +3,7 @@
public class main {
public static void main(String[] args){
+ System.setProperty("sun.java2d.uiScale", "1.0");
new mainThread();
}
diff --git a/mysql-connector-java-5.1.47.jar b/mysql-connector-java-5.1.47.jar
deleted file mode 100644
index f3398ed145d1ec3b991c703e40ef2af7809f2580..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1007502
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