Skip to content

Commit cce33e5

Browse files
committed
feat: add fixed timestep physics, gamepad input support, and spatial hash broadphase collision detection
1 parent 0b8efa3 commit cce33e5

File tree

19 files changed

+1022
-252
lines changed

19 files changed

+1022
-252
lines changed

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-java@v4
13+
with:
14+
distribution: temurin
15+
java-version: 21
16+
- name: Make Gradle executable
17+
run: chmod +x gradlew
18+
- name: Run unit tests
19+
run: ./gradlew :core:test :scripting:test --no-daemon

core/src/main/java/com/jvn/core/engine/Engine.java

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ public class Engine {
1717
private long maxDeltaMs = 75; // clamp to avoid huge simulation jumps
1818
private double deltaSmoothing = 0.1; // exponential smoothing factor [0..1]; 0 disables smoothing
1919
private double smoothedDeltaMs = -1.0;
20+
private long fixedUpdateMs = 0; // 0 = disabled
21+
private int maxFixedSteps = 5;
22+
private double accumulatorMs = 0.0;
2023

2124
public Engine(ApplicationConfig config) {
2225
this.config = config;
@@ -45,10 +48,19 @@ public void update(long deltaMs) {
4548
input.endFrame();
4649
return;
4750
}
48-
tweens.update(effective);
49-
Scene current = sceneManager.peek();
50-
if (current != null) {
51-
current.update(effective);
51+
if (fixedUpdateMs > 0) {
52+
accumulatorMs += effective;
53+
int steps = 0;
54+
while (accumulatorMs >= fixedUpdateMs && steps < maxFixedSteps) {
55+
tick(fixedUpdateMs);
56+
accumulatorMs -= fixedUpdateMs;
57+
steps++;
58+
}
59+
if (steps == maxFixedSteps && accumulatorMs > fixedUpdateMs) {
60+
accumulatorMs = fixedUpdateMs;
61+
}
62+
} else {
63+
tick(effective);
5264
}
5365
input.endFrame();
5466
}
@@ -75,12 +87,25 @@ public void setDeltaSmoothing(double alpha) {
7587
this.deltaSmoothing = alpha;
7688
}
7789

90+
public void setFixedUpdateStepMs(long stepMs, int maxSteps) {
91+
this.fixedUpdateMs = stepMs <= 0 ? 0 : stepMs;
92+
this.maxFixedSteps = Math.max(1, maxSteps);
93+
}
94+
7895
private long clampDelta(long deltaMs) {
7996
if (deltaMs < 0) return 0;
8097
if (maxDeltaMs > 0 && deltaMs > maxDeltaMs) return maxDeltaMs;
8198
return deltaMs;
8299
}
83100

101+
private void tick(long deltaMs) {
102+
tweens.update(deltaMs);
103+
Scene current = sceneManager.peek();
104+
if (current != null) {
105+
current.update(deltaMs);
106+
}
107+
}
108+
84109
private long smoothDelta(long deltaMs) {
85110
if (deltaSmoothing <= 0) return deltaMs;
86111
if (smoothedDeltaMs < 0) smoothedDeltaMs = deltaMs;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.jvn.core.input;
2+
3+
import java.util.ArrayList;
4+
import java.util.HashMap;
5+
import java.util.HashSet;
6+
import java.util.List;
7+
import java.util.Map;
8+
import java.util.Set;
9+
import java.util.StringJoiner;
10+
11+
/**
12+
* Serializable snapshot of action bindings so users can persist/reload preferences.
13+
*/
14+
public class ActionBindingProfile {
15+
private final Map<String, Set<InputCode>> bindings = new HashMap<>();
16+
17+
public Map<String, Set<InputCode>> bindings() { return bindings; }
18+
19+
public ActionBindingProfile add(String action, InputCode code) {
20+
if (action == null || code == null) return this;
21+
bindings.computeIfAbsent(action, k -> new HashSet<>()).add(code);
22+
return this;
23+
}
24+
25+
public String serialize() {
26+
StringBuilder sb = new StringBuilder();
27+
for (Map.Entry<String, Set<InputCode>> e : bindings.entrySet()) {
28+
StringJoiner join = new StringJoiner(",");
29+
for (InputCode code : e.getValue()) {
30+
join.add(code.encode());
31+
}
32+
sb.append(e.getKey()).append('=').append(join.toString()).append('\n');
33+
}
34+
return sb.toString();
35+
}
36+
37+
public static ActionBindingProfile deserialize(String data) {
38+
ActionBindingProfile profile = new ActionBindingProfile();
39+
if (data == null || data.isBlank()) return profile;
40+
String[] lines = data.split("\\R");
41+
for (String line : lines) {
42+
if (line == null || line.isBlank()) continue;
43+
int idx = line.indexOf('=');
44+
if (idx <= 0) continue;
45+
String action = line.substring(0, idx);
46+
String codes = line.substring(idx + 1);
47+
for (String raw : codes.split(",")) {
48+
InputCode code = InputCode.decode(raw);
49+
if (code != null) profile.add(action, code);
50+
}
51+
}
52+
return profile;
53+
}
54+
55+
public List<String> actions() { return new ArrayList<>(bindings.keySet()); }
56+
}

core/src/main/java/com/jvn/core/input/ActionMap.java

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,57 +4,90 @@
44
import java.util.HashSet;
55
import java.util.Map;
66
import java.util.Set;
7+
import java.util.function.Predicate;
78

89
public class ActionMap {
910
private final Input input;
10-
private final Map<String, Set<String>> keyBindings = new HashMap<>();
11-
private final Map<String, Set<Integer>> mouseBindings = new HashMap<>();
11+
private final Map<String, Set<InputCode>> bindings = new HashMap<>();
1212

1313
public ActionMap(Input input) { this.input = input; }
1414

1515
public ActionMap bindKey(String action, String keyName) {
16-
keyBindings.computeIfAbsent(action, k -> new HashSet<>()).add(keyName);
17-
return this;
16+
return bind(action, InputCode.key(keyName));
17+
}
18+
19+
public ActionMap bindMouse(String action, int button) {
20+
return bind(action, InputCode.mouse(button));
21+
}
22+
23+
public ActionMap bindGamepadButton(String action, int pad, String button) {
24+
return bind(action, InputCode.gamepadButton(pad, button));
25+
}
26+
27+
public ActionMap bindGamepadAxis(String action, int pad, String axis) {
28+
return bind(action, InputCode.gamepadAxis(pad, axis));
1829
}
1930

2031
public ActionMap unbindKey(String action, String keyName) {
21-
Set<String> set = keyBindings.get(action);
22-
if (set != null) set.remove(keyName);
23-
return this;
32+
return unbind(action, InputCode.key(keyName));
2433
}
2534

26-
public ActionMap bindMouse(String action, int button) {
27-
mouseBindings.computeIfAbsent(action, k -> new HashSet<>()).add(button);
35+
public ActionMap unbindMouse(String action, int button) {
36+
return unbind(action, InputCode.mouse(button));
37+
}
38+
39+
public ActionMap unbindGamepadButton(String action, int pad, String button) {
40+
return unbind(action, InputCode.gamepadButton(pad, button));
41+
}
42+
43+
public ActionMap unbindGamepadAxis(String action, int pad, String axis) {
44+
return unbind(action, InputCode.gamepadAxis(pad, axis));
45+
}
46+
47+
private ActionMap bind(String action, InputCode code) {
48+
if (action == null || code == null) return this;
49+
bindings.computeIfAbsent(action, k -> new HashSet<>()).add(code);
2850
return this;
2951
}
3052

31-
public ActionMap unbindMouse(String action, int button) {
32-
Set<Integer> set = mouseBindings.get(action);
33-
if (set != null) set.remove(button);
53+
private ActionMap unbind(String action, InputCode code) {
54+
Set<InputCode> set = bindings.get(action);
55+
if (set != null) set.remove(code);
3456
return this;
3557
}
3658

3759
public boolean isDown(String action) {
38-
Set<String> ks = keyBindings.get(action);
39-
if (ks != null) for (String k : ks) if (input.isKeyDown(k)) return true;
40-
Set<Integer> ms = mouseBindings.get(action);
41-
if (ms != null) for (Integer b : ms) if (input.isMouseDown(b)) return true;
42-
return false;
60+
return test(action, input::isDown);
4361
}
4462

4563
public boolean wasPressed(String action) {
46-
Set<String> ks = keyBindings.get(action);
47-
if (ks != null) for (String k : ks) if (input.wasKeyPressed(k)) return true;
48-
Set<Integer> ms = mouseBindings.get(action);
49-
if (ms != null) for (Integer b : ms) if (input.wasMousePressed(b)) return true;
50-
return false;
64+
return test(action, input::wasPressed);
5165
}
5266

5367
public boolean wasReleased(String action) {
54-
Set<String> ks = keyBindings.get(action);
55-
if (ks != null) for (String k : ks) if (input.wasKeyReleased(k)) return true;
56-
Set<Integer> ms = mouseBindings.get(action);
57-
if (ms != null) for (Integer b : ms) if (input.wasMouseReleased(b)) return true;
68+
return test(action, input::wasReleased);
69+
}
70+
71+
public ActionBindingProfile toProfile() {
72+
ActionBindingProfile profile = new ActionBindingProfile();
73+
bindings.forEach((action, codes) -> {
74+
for (InputCode c : codes) profile.add(action, c);
75+
});
76+
return profile;
77+
}
78+
79+
public void loadProfile(ActionBindingProfile profile) {
80+
bindings.clear();
81+
if (profile == null) return;
82+
profile.bindings().forEach((action, codes) -> bindings.put(action, new HashSet<>(codes)));
83+
}
84+
85+
private boolean test(String action, Predicate<InputCode> predicate) {
86+
Set<InputCode> set = bindings.get(action);
87+
if (set == null) return false;
88+
for (InputCode code : set) {
89+
if (predicate.test(code)) return true;
90+
}
5891
return false;
5992
}
6093
}
Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,67 @@
11
package com.jvn.core.input;
22

3+
import java.util.HashMap;
34
import java.util.HashSet;
5+
import java.util.Map;
46
import java.util.Set;
57

68
public class Input {
7-
private final Set<String> downKeys = new HashSet<>();
8-
private final Set<String> pressedKeys = new HashSet<>();
9-
private final Set<String> releasedKeys = new HashSet<>();
10-
11-
private final Set<Integer> downButtons = new HashSet<>();
12-
private final Set<Integer> pressedButtons = new HashSet<>();
13-
private final Set<Integer> releasedButtons = new HashSet<>();
9+
private final Set<InputCode> down = new HashSet<>();
10+
private final Set<InputCode> pressed = new HashSet<>();
11+
private final Set<InputCode> released = new HashSet<>();
12+
private final Map<InputCode, Double> axisValues = new HashMap<>();
1413

1514
private double mouseX;
1615
private double mouseY;
1716
private double scrollDeltaY;
1817

1918
public void keyDown(String key) {
2019
if (key == null) return;
21-
if (downKeys.add(key)) pressedKeys.add(key);
20+
keyDown(InputCode.key(key));
21+
}
22+
23+
public void keyDown(InputCode code) {
24+
if (code == null) return;
25+
if (down.add(code)) pressed.add(code);
2226
}
2327

2428
public void keyUp(String key) {
2529
if (key == null) return;
26-
if (downKeys.remove(key)) releasedKeys.add(key);
30+
keyUp(InputCode.key(key));
2731
}
2832

29-
public boolean isKeyDown(String key) { return downKeys.contains(key); }
30-
public boolean wasKeyPressed(String key) { return pressedKeys.contains(key); }
31-
public boolean wasKeyReleased(String key) { return releasedKeys.contains(key); }
33+
public void keyUp(InputCode code) {
34+
if (code == null) return;
35+
if (down.remove(code)) released.add(code);
36+
}
3237

33-
public void mouseDown(int button) { if (downButtons.add(button)) pressedButtons.add(button); }
34-
public void mouseUp(int button) { if (downButtons.remove(button)) releasedButtons.add(button); }
38+
public boolean isKeyDown(String key) { return isDown(InputCode.key(key)); }
39+
public boolean wasKeyPressed(String key) { return wasPressed(InputCode.key(key)); }
40+
public boolean wasKeyReleased(String key) { return wasReleased(InputCode.key(key)); }
3541

36-
public boolean isMouseDown(int button) { return downButtons.contains(button); }
37-
public boolean wasMousePressed(int button) { return pressedButtons.contains(button); }
38-
public boolean wasMouseReleased(int button) { return releasedButtons.contains(button); }
42+
public void mouseDown(int button) { handleButton(InputCode.mouse(button), true); }
43+
public void mouseUp(int button) { handleButton(InputCode.mouse(button), false); }
44+
45+
public boolean isMouseDown(int button) { return isDown(InputCode.mouse(button)); }
46+
public boolean wasMousePressed(int button) { return wasPressed(InputCode.mouse(button)); }
47+
public boolean wasMouseReleased(int button) { return wasReleased(InputCode.mouse(button)); }
48+
49+
public void gamepadButtonDown(int pad, String button) { handleButton(InputCode.gamepadButton(pad, button), true); }
50+
public void gamepadButtonUp(int pad, String button) { handleButton(InputCode.gamepadButton(pad, button), false); }
51+
52+
public void setGamepadAxis(int pad, String axis, double value) {
53+
InputCode code = InputCode.gamepadAxis(pad, axis);
54+
axisValues.put(code, value);
55+
}
56+
57+
public double getGamepadAxis(int pad, String axis) {
58+
InputCode code = InputCode.gamepadAxis(pad, axis);
59+
return axisValues.getOrDefault(code, 0.0);
60+
}
61+
62+
public boolean isDown(InputCode code) { return down.contains(code); }
63+
public boolean wasPressed(InputCode code) { return pressed.contains(code); }
64+
public boolean wasReleased(InputCode code) { return released.contains(code); }
3965

4066
public void setMousePosition(double x, double y) { this.mouseX = x; this.mouseY = y; }
4167
public double getMouseX() { return mouseX; }
@@ -45,22 +71,27 @@ public void keyUp(String key) {
4571
public double getScrollDeltaY() { return scrollDeltaY; }
4672

4773
public void endFrame() {
48-
pressedKeys.clear();
49-
releasedKeys.clear();
50-
pressedButtons.clear();
51-
releasedButtons.clear();
74+
pressed.clear();
75+
released.clear();
5276
scrollDeltaY = 0;
5377
}
5478

5579
public void reset() {
56-
downKeys.clear();
57-
pressedKeys.clear();
58-
releasedKeys.clear();
59-
downButtons.clear();
60-
pressedButtons.clear();
61-
releasedButtons.clear();
80+
down.clear();
81+
pressed.clear();
82+
released.clear();
83+
axisValues.clear();
6284
mouseX = 0;
6385
mouseY = 0;
6486
scrollDeltaY = 0;
6587
}
88+
89+
private void handleButton(InputCode code, boolean downEvent) {
90+
if (code == null) return;
91+
if (downEvent) {
92+
if (down.add(code)) pressed.add(code);
93+
} else {
94+
if (down.remove(code)) released.add(code);
95+
}
96+
}
6697
}

0 commit comments

Comments
 (0)