plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
4745ae8
— Peter John
2026-11-06T15:20:27+05:30
Simpler language: plain games and one scene style
- compiler/cli/src/program.rs +4 -4
- compiler/cli/tests/cli/game_entry_test.rs +51 -2
- compiler/cli/tests/cli/game_web_test.rs +3 -3
- compiler/core/src/game_entry.rs +97 -0
- compiler/core/src/lib.rs +1 -0
- compiler/core/src/main_result.rs +1 -1
- compiler/game/src/host.rs +16 -6
- compiler/game/tests/fixtures/assets_fixture.plum +17 -19
- compiler/game/tests/fixtures/host_fixture.plum +13 -13
- compiler/game/tests/fixtures/main_game_fixture.plum +11 -12
- compiler/game/tests/fixtures/main_missing_fixture.plum +7 -8
- compiler/game/tests/fixtures/target_fixture.plum +15 -17
- compiler/game/tests/host_test.rs +12 -63
- compiler/game/web-test/README.md +3 -3
- compiler/game/web-test/fixtures/interleave.plum +11 -12
- compiler/game/web-test/fixtures/main_game.plum +9 -9
- compiler/game/web-test/fixtures/rendertarget.plum +34 -36
- compiler/game/web-test/fixtures/stress.plum +15 -16
- compiler/game/web-test/fixtures/stress_pure_scene.plum +0 -144
- compiler/game/web-test/fixtures/stress_scene.plum +0 -117
- compiler/game/web-test/fixtures/stress_state_view.plum +18 -18
- compiler/game/web-test/fixtures/stress_state_view_deep.plum +14 -15
- compiler/game/web-test/fixtures/stress_world.plum +66 -56
- compiler/game/web-test/fixtures/text_sizes.plum +20 -20
- compiler/game/web-test/tests/web_check.rs +6 -16
- compiler/game/web/plum_bridge.js +3 -3
- compiler/lsp/src/main.rs +2 -2
- compiler/wasm-codegen/src/lib.rs +13 -25
- compiler/wasm-codegen/tests/wasm_codegen/fusion_alloc_test.rs +1 -1
- docs/plans/2026-11-06-simpler-language.benchmark.md +4 -0
- examples/games/{imperative/game_loop.plum → game_loop.plum} +0 -0
- examples/games/imperative/game.plum +0 -435
- examples/games/imperative/game_scene.plum +0 -663
- examples/games/imperative/scene.plum +0 -433
- examples/games/{functional/kids → kids}/balloon_pop.plum +21 -23
- examples/games/{functional/game_state_view.plum → orb_run.plum} +45 -47
- examples/games/pure/game_pure_scene.plum +0 -880
- std/game/{functional/Anim.plum → Anim.plum} +0 -0
- std/game/Game.plum +54 -184
- std/game/Input.plum +46 -76
- std/game/{functional/FunctionalScene.plum → Scene.plum} +27 -26
- std/game/{functional/Ui.plum → Ui.plum} +2 -2
- std/game/imperative/Scene.plum +0 -851
- std/game/pure/Actions.plum +0 -402
- std/game/pure/PureScene.plum +0 -689
- std/game/pure/SceneInput.plum +0 -313
compiler/cli/src/program.rs
CHANGED
|
@@ -11,7 +11,7 @@ use plum_core::ast::{self, Item};
|
|
|
11
11
|
/// A compiled entry file.
|
|
12
12
|
pub struct Program {
|
|
13
13
|
pub wasm: Vec<u8>,
|
|
14
|
-
/// A game: `main` returns `
|
|
14
|
+
/// A game: `main` returns a type with `update(self, io, dt)` and `draw(self, io)`.
|
|
15
15
|
pub isGame: bool,
|
|
16
16
|
/// `main` returns a `Result`; the compiled `main` is a wrapper that crashes with the name
|
|
17
17
|
/// of the `Err` variant (see `plum_core::main_result`).
|
|
@@ -73,7 +73,7 @@ pub fn tryCompileProgram(
|
|
|
73
73
|
render(&ds, true)
|
|
74
74
|
})?;
|
|
75
75
|
if !out.isGame && hasLegacyGameExports(&ast) {
|
|
76
|
-
return Err(Failure { codegen: false, text: format!("{}: the `export fun init/update/draw` game entry is gone; a game is a
|
|
76
|
+
return Err(Failure { codegen: false, text: format!("{}: the `export fun init/update/draw` game entry is gone; a game is a type with `fun update(self, io: Io, dt: Float) -> Self` and `fun draw(self, io: Io)`, started by `fun main(io: Io) -> ThatType`", file.display()) });
|
|
77
77
|
}
|
|
78
78
|
Ok(Program { isGame: out.isGame, wasm: out.wasm, resultMain })
|
|
79
79
|
}
|
|
@@ -87,7 +87,7 @@ pub fn rebuildGame(
|
|
|
87
87
|
) -> std::result::Result<Vec<u8>, String> {
|
|
88
88
|
let program = tryCompileProgram(file, lib_path, fusion).map_err(|f| f.text)?;
|
|
89
89
|
if !program.isGame {
|
|
90
|
-
return Err(format!("{}: `main` no longer returns a `
|
|
90
|
+
return Err(format!("{}: `main` no longer returns a game (a type with `update` and `draw`); restart `plum run` to run it as a program", file.display()));
|
|
91
91
|
}
|
|
92
92
|
Ok(program.wasm)
|
|
93
93
|
}
|
|
@@ -109,6 +109,6 @@ mod rebuildTests {
|
|
|
109
109
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2).unwrap();
|
|
110
110
|
let got = rebuildGame(&src, root, opts());
|
|
111
111
|
let _ = std::fs::remove_dir_all(&dir);
|
|
112
|
-
assert!(got.unwrap_err().contains("no longer returns a
|
|
112
|
+
assert!(got.unwrap_err().contains("no longer returns a game"));
|
|
113
113
|
}
|
|
114
114
|
}
|
compiler/cli/tests/cli/game_entry_test.rs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#![allow(non_snake_case)]
|
|
2
|
-
//! What `main`
|
|
2
|
+
//! What a `main` return type compiles to: a game when it has `update` and `draw`, a plain program
|
|
3
|
+
//! when it has neither, an error when it has only one.
|
|
3
4
|
use std::process::Command;
|
|
4
5
|
|
|
5
6
|
fn plumBin() -> std::path::PathBuf {
|
|
@@ -13,7 +14,7 @@ fn plumBin() -> std::path::PathBuf {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
#[test]
|
|
16
|
-
fn
|
|
17
|
+
fn aStructWithoutUpdateAndDrawIsNotAGame() {
|
|
17
18
|
let dir = std::env::temp_dir().join(format!("plum_gameentry_{}", std::process::id()));
|
|
18
19
|
std::fs::create_dir_all(&dir).unwrap();
|
|
19
20
|
let src = dir.join("app.plum");
|
|
@@ -40,3 +41,51 @@ fn aGameThatCrashesInAFrameSaysSoOnThePage() {
|
|
|
40
41
|
let at = bridge.find("frame failed:").expect("frame failure handler");
|
|
41
42
|
assert!(bridge[at..at + 400].contains("textContent"), "a frame crash must reach the page");
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
fn compileSource(name: &str, text: &str) -> (bool, String) {
|
|
46
|
+
let dir = std::env::temp_dir().join(format!("plum_gameentry_{}_{name}", std::process::id()));
|
|
47
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
48
|
+
let src = dir.join("app.plum");
|
|
49
|
+
std::fs::write(&src, text).unwrap();
|
|
50
|
+
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2).unwrap();
|
|
51
|
+
let out = Command::new(plumBin())
|
|
52
|
+
.current_dir(root)
|
|
53
|
+
.args(["compile", src.to_str().unwrap(), "-o", dir.join("app.wasm").to_str().unwrap()])
|
|
54
|
+
.output()
|
|
55
|
+
.unwrap();
|
|
56
|
+
let wasm = std::fs::read(dir.join("app.wasm")).unwrap_or_default();
|
|
57
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
58
|
+
let err = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
59
|
+
let exports = ["update", "draw"].iter().all(|n| wasm.windows(n.len()).any(|w| w == n.as_bytes()));
|
|
60
|
+
(out.status.success() && exports, err)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const HEAD: &str = "module app\nimport std/Int\nimport std/Float\nimport std/Str\nimport std/Io\n\nstruct Counter(n: Int) =\n";
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn aTypeWithUpdateAndDrawIsAGame() {
|
|
67
|
+
let src = format!("{HEAD} fun update(self, io: Io, dt: Float) -> Counter =\n Counter(n: self.n + 1)\n\n fun draw(self, io: Io) =\n io.printLn(\"x\")\n\nfun main(io: Io) -> Counter =\n Counter(n: 0)\n");
|
|
68
|
+
let (ok, err) = compileSource("game", &src);
|
|
69
|
+
assert!(ok, "{err}");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[test]
|
|
73
|
+
fn aTypeWithOnlyUpdateNamesTheMissingDraw() {
|
|
74
|
+
let src = format!("{HEAD} fun update(self, io: Io, dt: Float) -> Counter =\n self\n\nfun main(io: Io) -> Counter =\n Counter(n: 0)\n");
|
|
75
|
+
let (ok, err) = compileSource("noDraw", &src);
|
|
76
|
+
assert!(!ok && err.contains("`Counter` has `update` but no `draw`"), "{err}");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[test]
|
|
80
|
+
fn aTypeWithOnlyDrawNamesTheMissingUpdate() {
|
|
81
|
+
let src = format!("{HEAD} fun draw(self, io: Io) =\n io.printLn(\"x\")\n\nfun main(io: Io) -> Counter =\n Counter(n: 0)\n");
|
|
82
|
+
let (ok, err) = compileSource("noUpdate", &src);
|
|
83
|
+
assert!(!ok && err.contains("`Counter` has `draw` but no `update`"), "{err}");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn anUpdateThatDoesNotReturnTheGameIsRejected() {
|
|
88
|
+
let src = format!("{HEAD} fun update(self, io: Io, dt: Float) =\n io.printLn(\"x\")\n\n fun draw(self, io: Io) =\n io.printLn(\"x\")\n\nfun main(io: Io) -> Counter =\n Counter(n: 0)\n");
|
|
89
|
+
let (ok, err) = compileSource("badUpdate", &src);
|
|
90
|
+
assert!(!ok && err.contains("a game's update is `fun update(self, io: Io, dt: Float) -> Counter`"), "{err}");
|
|
91
|
+
}
|
compiler/cli/tests/cli/game_web_test.rs
CHANGED
|
@@ -29,9 +29,9 @@ fn makeAssets(name: &str) -> (std::path::PathBuf, std::path::PathBuf) {
|
|
|
29
29
|
(scratch, assets)
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/// A game
|
|
32
|
+
/// A game whose `main` returns a type with `update` and `draw`, with host calls recorded in a fixed order.
|
|
33
33
|
const FIXTURE: &str = "compiler/game/tests/fixtures/host_fixture.plum";
|
|
34
|
-
/// A game whose entry is `
|
|
34
|
+
/// A game whose entry is `main` returning a type with `update` and `draw`.
|
|
35
35
|
const MAIN_GAME: &str = "compiler/game/web-test/fixtures/main_game.plum";
|
|
36
36
|
|
|
37
37
|
#[test]
|
|
@@ -172,7 +172,7 @@ fn oldInitEntryIsRejectedWithAPointer() {
|
|
|
172
172
|
let out = Command::new(plumBin()).current_dir(root()).args([cmd]).arg(&file).args(["--lib-path", "."]).output().unwrap();
|
|
173
173
|
let err = String::from_utf8_lossy(&out.stderr);
|
|
174
174
|
assert!(!out.status.success(), "{cmd} should fail");
|
|
175
|
-
assert!(err.contains("fun main(io: Io) ->
|
|
175
|
+
assert!(err.contains("fun main(io: Io) -> ThatType"), "{cmd}: {err}");
|
|
176
176
|
}
|
|
177
177
|
let _ = std::fs::remove_dir_all(&scratch);
|
|
178
178
|
}
|
compiler/core/src/game_entry.rs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//! A game is the type `main` returns, when it has `update(self, io: Io, dt: Float) -> T` and
|
|
2
|
+
//! `draw(self, io: Io)`. This lowering (run before checking) appends the two frame entry points
|
|
3
|
+
//! the host calls, `gameUpdate(io, g, dt)` and `gameDraw(io, g)`, which call those methods.
|
|
4
|
+
//! The wasm module exports them as `update(state, dt) -> state` and `draw(state)`. `Int`,
|
|
5
|
+
//! `Result` or no return type, or a type with neither method, is a command line program.
|
|
6
|
+
use crate::ast::*;
|
|
7
|
+
use crate::diag::{DiagKind, Diagnostic};
|
|
8
|
+
use crate::main_result::parseItems;
|
|
9
|
+
|
|
10
|
+
pub const GAME_UPDATE: &str = "gameUpdate";
|
|
11
|
+
pub const GAME_DRAW: &str = "gameDraw";
|
|
12
|
+
|
|
13
|
+
fn method<'a>(source: &'a Source, ty: &str, name: &str) -> Option<&'a Fn> {
|
|
14
|
+
source.items.iter().find_map(|i| match i {
|
|
15
|
+
Item::Fn(f) if f.name == name && f.has_self && f.type_param.as_deref() == Some(ty) => Some(f),
|
|
16
|
+
_ => None,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn isParam(f: &Fn, i: usize, name: &str, ty: &str) -> bool {
|
|
21
|
+
matches!(f.params.get(i), Some(p) if p.name == name
|
|
22
|
+
&& matches!(&p.ty, ParamType::Type(t) if t.name == ty && t.generics.is_empty()))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// The game type `main` returns: `Ok(None)` for a program that is not a game, an error for a
|
|
26
|
+
/// type with only one of the two methods or with the wrong signatures.
|
|
27
|
+
pub fn gameType(source: &Source) -> Result<Option<String>, Diagnostic> {
|
|
28
|
+
let Some(main) = source.items.iter().find_map(|i| match i {
|
|
29
|
+
Item::Fn(f) if f.name == "main" && f.type_param.is_none() => Some(f),
|
|
30
|
+
_ => None,
|
|
31
|
+
}) else {
|
|
32
|
+
return Ok(None);
|
|
33
|
+
};
|
|
34
|
+
let Some(ret) = main.returns.as_ref().filter(|t| t.generics.is_empty()) else { return Ok(None) };
|
|
35
|
+
let ty = ret.name.as_str();
|
|
36
|
+
let declared = source.items.iter().any(|i| matches!(i, Item::Enum(e) if e.name == ty));
|
|
37
|
+
if !declared {
|
|
38
|
+
return Ok(None);
|
|
39
|
+
}
|
|
40
|
+
let (update, draw) = (method(source, ty, "update"), method(source, ty, "draw"));
|
|
41
|
+
let at = |f: &Fn, msg: String| Diagnostic::new(DiagKind::Type, f.file.clone(), f.line, f.column, msg);
|
|
42
|
+
match (update, draw) {
|
|
43
|
+
(None, None) => Ok(None),
|
|
44
|
+
(Some(u), None) => Err(at(u, format!("`{ty}` has `update` but no `draw`: a game needs `fun draw(self, io: Io)` as well"))),
|
|
45
|
+
(None, Some(d)) => Err(at(d, format!("`{ty}` has `draw` but no `update`: a game needs `fun update(self, io: Io, dt: Float) -> {ty}` as well"))),
|
|
46
|
+
(Some(u), Some(d)) => {
|
|
47
|
+
let updateOk = u.params.len() == 2
|
|
48
|
+
&& isParam(u, 0, "io", "Io")
|
|
49
|
+
&& isParam(u, 1, "dt", "Float")
|
|
50
|
+
&& u.returns.as_ref().is_some_and(|r| r.name == ty && r.generics.is_empty());
|
|
51
|
+
if !updateOk {
|
|
52
|
+
return Err(at(u, format!("a game's update is `fun update(self, io: Io, dt: Float) -> {ty}`")));
|
|
53
|
+
}
|
|
54
|
+
if d.params.len() != 1 || !isParam(d, 0, "io", "Io") {
|
|
55
|
+
return Err(at(d, "a game's draw is `fun draw(self, io: Io)`".to_string()));
|
|
56
|
+
}
|
|
57
|
+
Ok(Some(ty.to_string()))
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Is `source` a lowered game: does it have the frame entry points?
|
|
63
|
+
pub fn isLoweredGame(source: &Source) -> bool {
|
|
64
|
+
let has = |n: &str| source.items.iter().any(|i| matches!(i, Item::Fn(f) if f.name == n && f.type_param.is_none()));
|
|
65
|
+
has(GAME_UPDATE) && has(GAME_DRAW)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// If `main` returns a game type, appends the frame entry points and numbers the new nodes.
|
|
69
|
+
/// Returns whether `source` is a game.
|
|
70
|
+
pub fn lowerGame(source: &mut Source) -> Result<bool, Diagnostic> {
|
|
71
|
+
let Some(ty) = gameType(source)? else { return Ok(false) };
|
|
72
|
+
let file = source
|
|
73
|
+
.items
|
|
74
|
+
.iter()
|
|
75
|
+
.find_map(|i| match i {
|
|
76
|
+
Item::Fn(f) if f.name == "main" => Some(f.file.clone()),
|
|
77
|
+
_ => None,
|
|
78
|
+
})
|
|
79
|
+
.unwrap_or_default();
|
|
80
|
+
let text = format!(
|
|
81
|
+
"fun {GAME_UPDATE}(io: Io, g: {ty}, dt: Float) -> {ty} = g.update(io, dt)\n\nfun {GAME_DRAW}(io: Io, g: {ty}) = g.draw(io)\n"
|
|
82
|
+
);
|
|
83
|
+
// The host holds the state as one reference between frames: a flat struct would travel as
|
|
84
|
+
// several wasm values, so the game's own struct is always boxed.
|
|
85
|
+
for item in &mut source.items {
|
|
86
|
+
if let Item::Enum(e) = item {
|
|
87
|
+
if e.name == ty {
|
|
88
|
+
if let Some(info) = e.structInfoMut() {
|
|
89
|
+
info.forceBoxed = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
source.items.extend(parseItems(&text, &file));
|
|
95
|
+
numberIds(source);
|
|
96
|
+
Ok(true)
|
|
97
|
+
}
|
compiler/core/src/lib.rs
CHANGED
|
@@ -14,6 +14,7 @@ pub mod enum_checks;
|
|
|
14
14
|
pub mod formatter;
|
|
15
15
|
pub mod fip;
|
|
16
16
|
pub mod forms;
|
|
17
|
+
pub mod game_entry;
|
|
17
18
|
pub mod generics;
|
|
18
19
|
pub mod layout;
|
|
19
20
|
pub mod loader;
|
compiler/core/src/main_result.rs
CHANGED
|
@@ -93,7 +93,7 @@ fn clearFnPositions(f: &mut Fn) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/// Parses `text` (a few top-level fns) into items with no positions.
|
|
96
|
-
fn parseItems(text: &str, file: &str) -> Vec<Item> {
|
|
96
|
+
pub(crate) fn parseItems(text: &str, file: &str) -> Vec<Item> {
|
|
97
97
|
let mut parser = tree_sitter::Parser::new();
|
|
98
98
|
parser.set_language(&tree_sitter_plum::LANGUAGE.into()).expect("plum grammar");
|
|
99
99
|
let tree = parser.parse(text, None).expect("parse the main wrapper");
|
compiler/game/src/host.rs
CHANGED
|
@@ -164,7 +164,7 @@ impl<B: Backend> PlumGame<B> {
|
|
|
164
164
|
for name in ["main", "update", "draw"] {
|
|
165
165
|
if module.get_export(name).is_none() {
|
|
166
166
|
let old = if name == "main" && module.get_export("init").is_some() {
|
|
167
|
-
" (the `export fun init` entry is gone: write `fun main(io: Io) ->
|
|
167
|
+
" (the `export fun init` entry is gone: write `fun main(io: Io) -> YourGame`)"
|
|
168
168
|
} else {
|
|
169
169
|
""
|
|
170
170
|
};
|
|
@@ -211,21 +211,31 @@ impl<B: Backend> PlumGame<B> {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/// Calls `name` with the state (and `extra`) inside a root scope, so the
|
|
214
|
-
/// per-frame `Rooted` handle is released instead of accumulating.
|
|
214
|
+
/// per-frame `Rooted` handle is released instead of accumulating. `update` returns
|
|
215
|
+
/// the next state, which replaces the current one.
|
|
215
|
-
fn call_with_state(&mut self, name: &str, extra: &[Val]) -> Result<()> {
|
|
216
|
+
fn call_with_state(&mut self, name: &str, extra: &[Val], returns_state: bool) -> Result<()> {
|
|
216
217
|
let func = self.func(name)?;
|
|
217
218
|
let s = self.state.clone().ok_or_else(|| wasmtime::Error::msg("`main` has not run"))?;
|
|
218
219
|
let mut scope = wasmtime::RootScope::new(&mut self.store);
|
|
219
220
|
let mut args = vec![Val::AnyRef(Some(s.to_rooted(&mut scope)))];
|
|
220
221
|
args.extend_from_slice(extra);
|
|
222
|
+
if !returns_state {
|
|
223
|
+
return func.call(&mut scope, &args, &mut []);
|
|
224
|
+
}
|
|
225
|
+
let mut out = [Val::AnyRef(None)];
|
|
221
|
-
func.call(&mut scope, &args, &mut
|
|
226
|
+
func.call(&mut scope, &args, &mut out)?;
|
|
227
|
+
let Val::AnyRef(Some(next)) = &out[0] else {
|
|
228
|
+
return Err(wasmtime::Error::msg("`update` returned no state"));
|
|
229
|
+
};
|
|
230
|
+
self.state = Some(next.to_owned_rooted(&mut scope)?);
|
|
231
|
+
Ok(())
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
pub fn update(&mut self, dt: f32) -> Result<()> {
|
|
225
|
-
self.call_with_state("update", &[fv(dt)])
|
|
235
|
+
self.call_with_state("update", &[fv(dt)], true)
|
|
226
236
|
}
|
|
227
237
|
|
|
228
238
|
pub fn draw(&mut self) -> Result<()> {
|
|
229
|
-
self.call_with_state("draw", &[])
|
|
239
|
+
self.call_with_state("draw", &[], false)
|
|
230
240
|
}
|
|
231
241
|
}
|
compiler/game/tests/fixtures/assets_fixture.plum
CHANGED
|
@@ -9,27 +9,25 @@ import std/game/Color
|
|
|
9
9
|
import std/game/Game
|
|
10
10
|
import std/Io
|
|
11
11
|
|
|
12
|
-
# Loads through the raw `io` (a missing file answers -1 or empty, where `
|
|
12
|
+
# Loads through the raw `io` (a missing file answers -1 or empty, where `Texture.load`
|
|
13
|
-
# and draws through
|
|
13
|
+
# would crash) and draws through it.
|
|
14
|
-
struct State(
|
|
14
|
+
struct State(frames: Int) =
|
|
15
|
-
|
|
15
|
+
fun update(self, io: Io, dt: Float) -> State =
|
|
16
|
-
|
|
16
|
+
State(frames: self.frames + 1)
|
|
17
17
|
|
|
18
18
|
# Loads a texture and files from the asset root and reports what came back as text.
|
|
19
|
-
|
|
19
|
+
fun draw(self, io: Io) =
|
|
20
|
-
io := self.io
|
|
21
|
-
c := self.canvas
|
|
22
20
|
t := Texture(id: io.textureLoad("sprites.png"))
|
|
23
|
-
|
|
21
|
+
io.drawText("size {io.textureWidth(t.id)}x{io.textureHeight(t.id)}", 0.0, 0.0, 8.0, Color.White)
|
|
24
|
-
|
|
22
|
+
t.setSmooth(io, true)
|
|
25
|
-
|
|
23
|
+
t.draw(io, 1.0, 2.0, Color.White)
|
|
26
|
-
|
|
24
|
+
io.drawTextureEx(t.id, 16.0, 0.0, 16.0, 16.0, 10.0, 20.0, 32.0, 48.0, 0.5, true, Color.Blue)
|
|
27
|
-
|
|
25
|
+
io.drawText("bad {io.textureWidth(99)} {io.textureLoad("missing.png")}", 0.0, 0.0, 8.0, Color.White)
|
|
28
26
|
if io.fileExists("hello.txt")
|
|
29
|
-
|
|
27
|
+
io.drawText("file [{io.fileLoad("hello.txt")}]", 0.0, 0.0, 8.0, Color.White)
|
|
30
|
-
|
|
28
|
+
io.drawText("missing [{io.fileLoad("nope.txt")}] {io.fileExists("nope.txt")}", 0.0, 0.0, 8.0, Color.White)
|
|
31
|
-
|
|
29
|
+
io.drawText("escape {io.textureLoad("../sprites.png")} {io.fileExists("../hello.txt")} [{io.fileLoad("../hello.txt")}]", 0.0, 0.0, 8.0, Color.White)
|
|
32
|
-
|
|
30
|
+
io.drawText("abs {io.textureLoad("/etc/hosts")} {io.fileExists("/etc/hosts")}", 0.0, 0.0, 8.0, Color.White)
|
|
33
31
|
|
|
34
|
-
fun main(io: Io) ->
|
|
32
|
+
fun main(io: Io) -> State =
|
|
35
|
-
State(
|
|
33
|
+
State(frames: 0)
|
compiler/game/tests/fixtures/host_fixture.plum
CHANGED
|
@@ -9,19 +9,19 @@ import std/game/Color
|
|
|
9
9
|
import std/game/Game
|
|
10
10
|
import std/Io
|
|
11
11
|
|
|
12
|
-
#
|
|
12
|
+
# One raw host read in `draw` (`mousePos`), so the recorded call order is fixed.
|
|
13
|
-
struct State(
|
|
13
|
+
struct State(frames: Int) =
|
|
14
|
-
|
|
14
|
+
fun update(self, io: Io, dt: Float) -> State =
|
|
15
|
-
|
|
15
|
+
State(frames: self.frames + 1)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
fun draw(self, io: Io) =
|
|
18
|
-
|
|
18
|
+
io.clear(Color.Black)
|
|
19
|
-
|
|
19
|
+
io.drawRect(1.0, 2.0, 3.0, 4.0, Color.Red)
|
|
20
|
-
|
|
20
|
+
io.drawCircle(5.0, 6.0, 7.0, Color.Green)
|
|
21
|
-
p :=
|
|
21
|
+
p := io.mousePos()
|
|
22
|
-
|
|
22
|
+
io.drawText("hi", p.x, p.y, 16.0, Color.White)
|
|
23
23
|
if self.frames > 0
|
|
24
|
-
|
|
24
|
+
io.drawText("frames {self.frames}", 0.0, 0.0, 8.0, Color.White)
|
|
25
25
|
|
|
26
|
-
fun main(io: Io) ->
|
|
26
|
+
fun main(io: Io) -> State =
|
|
27
|
-
State(
|
|
27
|
+
State(frames: 0)
|
compiler/game/tests/fixtures/main_game_fixture.plum
CHANGED
|
@@ -11,18 +11,17 @@ import std/game/Game
|
|
|
11
11
|
import std/game/Input
|
|
12
12
|
import std/Io
|
|
13
13
|
|
|
14
|
-
# A game whose entry is `main`, declared to return
|
|
14
|
+
# A game whose entry is `main`, declared to return `Orbs`, which has `update` and `draw`.
|
|
15
|
-
struct Orbs(
|
|
15
|
+
struct Orbs(x: Float, ship: Texture, input: Input, frame: Int) =
|
|
16
|
-
|
|
16
|
+
fun update(self, io: Io, dt: Float) -> Orbs =
|
|
17
|
-
|
|
17
|
+
input := self.input.poll(io)
|
|
18
|
-
|
|
18
|
+
x := if input.isKeyDown(Key.Right) then self.x + 100.0 * dt else self.x
|
|
19
|
+
Orbs(..self, x: x, input: input, frame: self.frame + 1)
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
fun draw(self, io: Io) =
|
|
21
|
-
first := self.input.events
|
|
22
|
+
first := self.input.events.length()
|
|
22
|
-
second := self.input.events
|
|
23
|
+
second := self.input.events.length()
|
|
23
|
-
again := self.assets.texture("sprites.png")
|
|
24
|
-
|
|
24
|
+
io.drawText("frame {self.frame} ev {first} {second} x {self.x} tex {self.ship.id} w {io.screenWidth()}", 0.0, 0.0, 8.0, Color.White)
|
|
25
25
|
|
|
26
26
|
fun main(io: Io) -> Orbs =
|
|
27
|
-
e := Engine.create(io)
|
|
28
|
-
Orbs(
|
|
27
|
+
Orbs(x: 0.0, ship: Texture.load(io, "sprites.png"), input: Input.create(), frame: 0)
|
compiler/game/tests/fixtures/main_missing_fixture.plum
CHANGED
|
@@ -3,17 +3,16 @@ module main_missing_fixture
|
|
|
3
3
|
import std/Int
|
|
4
4
|
import std/Float
|
|
5
5
|
import std/Str
|
|
6
|
+
import std/game/Color
|
|
6
7
|
import std/game/Game
|
|
7
8
|
import std/Io
|
|
8
9
|
|
|
9
|
-
struct Broken(
|
|
10
|
+
struct Broken(tex: Texture) =
|
|
10
|
-
|
|
11
|
+
fun update(self, io: Io, dt: Float) -> Broken =
|
|
11
|
-
|
|
12
|
+
self
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
fun draw(self, io: Io) =
|
|
14
|
-
|
|
15
|
+
io.clear(Color.Black)
|
|
15
16
|
|
|
16
17
|
fun main(io: Io) -> Broken =
|
|
17
|
-
e := Engine.create(io)
|
|
18
|
-
|
|
18
|
+
Broken(tex: Texture.load(io, "nope.png"))
|
|
19
|
-
Broken(engine: e)
|
compiler/game/tests/fixtures/target_fixture.plum
CHANGED
|
@@ -9,22 +9,20 @@ import std/game/Game
|
|
|
9
9
|
import std/Io
|
|
10
10
|
|
|
11
11
|
# `bad` is a raw `io.textureCreate` of an impossible size: it answers -1 where
|
|
12
|
-
# `
|
|
12
|
+
# `Texture.create` would crash.
|
|
13
|
-
struct State(
|
|
13
|
+
struct State(target: Texture, bad: Int) =
|
|
14
|
-
|
|
14
|
+
fun update(self, io: Io, dt: Float) -> State =
|
|
15
|
-
|
|
15
|
+
self
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
fun draw(self, io: Io) =
|
|
18
|
-
c := self.canvas
|
|
19
|
-
|
|
18
|
+
self.target.renderTarget(io)
|
|
20
|
-
|
|
19
|
+
io.clear(Color.Transparent)
|
|
21
|
-
|
|
20
|
+
io.drawRect(0.0, 0.0, 10.0, 10.0, Color.Red)
|
|
22
|
-
|
|
21
|
+
Texture.screen(io)
|
|
23
|
-
|
|
22
|
+
io.blendMode(Blend.Add)
|
|
24
|
-
|
|
23
|
+
self.target.draw(io, 20.0, 20.0, Color.White)
|
|
25
|
-
|
|
24
|
+
io.blendMode(Blend.Normal)
|
|
26
|
-
|
|
25
|
+
io.drawText("size {self.target.width(io)}x{self.target.height(io)} bad {self.bad}", 0.0, 0.0, 8.0, Color.White)
|
|
27
26
|
|
|
28
|
-
fun main(io: Io) ->
|
|
27
|
+
fun main(io: Io) -> State =
|
|
29
|
-
e := Engine.create(io)
|
|
30
|
-
State(
|
|
28
|
+
State(target: Texture.create(io, 64, 32), bad: io.textureCreate(0, 5))
|
compiler/game/tests/host_test.rs
CHANGED
|
@@ -96,7 +96,7 @@ fn oldInitEntryIsRejected() {
|
|
|
96
96
|
let out = plum_wasm_codegen::compile(&source, &plum_wasm_codegen::CompileOptions::default()).expect("compile");
|
|
97
97
|
assert!(!out.isGame);
|
|
98
98
|
let err = PlumGame::load(&out.wasm, Recorder::new()).err().expect("should fail");
|
|
99
|
-
assert!(err.to_string().contains("`main`") && err.to_string().contains("fun main(io: Io) ->
|
|
99
|
+
assert!(err.to_string().contains("`main`") && err.to_string().contains("fun main(io: Io) -> YourGame"), "{err}");
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
#[test]
|
|
@@ -106,7 +106,7 @@ fn unknownImportTraps() {
|
|
|
106
106
|
let path = dir.join("m.plum");
|
|
107
107
|
std::fs::write(
|
|
108
108
|
&path,
|
|
109
|
-
"import std/Int\nimport std/Float\nimport std/Str\nimport std/Io\nimport std/game/Game\nstruct State(
|
|
109
|
+
"import std/Int\nimport std/Float\nimport std/Str\nimport std/Io\nimport std/game/Game\nstruct State(n: Int) =\n fun update(self, io: Io, dt: Float) -> State =\n self\n fun draw(self, io: Io) =\n x := io.rawExists(\"a\")\n\nfun main(io: Io) -> State =\n State(n: 0)\n",
|
|
110
110
|
)
|
|
111
111
|
.unwrap();
|
|
112
112
|
let wasm = compile(&path);
|
|
@@ -207,7 +207,7 @@ fn assetPathEscapeRejected() {
|
|
|
207
207
|
|
|
208
208
|
#[test]
|
|
209
209
|
fn exampleGameRunsFrames() {
|
|
210
|
-
let wasm = compile(&root().join("examples/games/
|
|
210
|
+
let wasm = compile(&root().join("examples/games/orb_run.plum"));
|
|
211
211
|
let mut g = PlumGame::load(&wasm, Recorder::with_assets(examples_assets())).expect("load game");
|
|
212
212
|
g.init().expect("init");
|
|
213
213
|
for _ in 0..60 {
|
|
@@ -270,8 +270,8 @@ fn soundPathEscapeRejected() {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
#[test]
|
|
273
|
-
fn
|
|
273
|
+
fn balloonExampleRunsFrames() {
|
|
274
|
-
let wasm = compile(&root().join("examples/games/
|
|
274
|
+
let wasm = compile(&root().join("examples/games/kids/balloon_pop.plum"));
|
|
275
275
|
let mut g = PlumGame::load(&wasm, Recorder::with_assets(examples_assets())).expect("load game");
|
|
276
276
|
g.init().expect("init");
|
|
277
277
|
for _ in 0..10 {
|
|
@@ -281,57 +281,6 @@ fn sceneExampleRunsFrames() {
|
|
|
281
281
|
assert!(g.backend().calls.iter().any(|c| c.starts_with("draw")), "{:?}", g.backend().calls);
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
#[test]
|
|
285
|
-
fn pureSceneExampleRunsFrames() {
|
|
286
|
-
let wasm = compile(&root().join("examples/games/pure/game_pure_scene.plum"));
|
|
287
|
-
let mut g = PlumGame::load(&wasm, Recorder::with_assets(examples_assets())).expect("load game");
|
|
288
|
-
g.init().expect("init");
|
|
289
|
-
for _ in 0..10 {
|
|
290
|
-
g.update(1.0 / 60.0).expect("update");
|
|
291
|
-
}
|
|
292
|
-
g.draw().expect("draw");
|
|
293
|
-
assert!(g.backend().calls.iter().any(|c| c.starts_with("draw")), "{:?}", g.backend().calls);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
#[test]
|
|
297
|
-
fn pureSceneMatchesSceneDrawCounts() {
|
|
298
|
-
let load = |name: &str| {
|
|
299
|
-
let wasm = compile(&root().join(name));
|
|
300
|
-
let mut g = PlumGame::load(&wasm, Recorder::with_assets(examples_assets())).expect("load game");
|
|
301
|
-
g.init().expect("init");
|
|
302
|
-
g
|
|
303
|
-
};
|
|
304
|
-
let mut a = load("examples/games/imperative/game_scene.plum");
|
|
305
|
-
let mut b = load("examples/games/pure/game_pure_scene.plum");
|
|
306
|
-
let tex_ids = |calls: &[String]| {
|
|
307
|
-
let mut v: Vec<String> = calls
|
|
308
|
-
.iter()
|
|
309
|
-
.filter(|c| c.starts_with("drawTextureEx("))
|
|
310
|
-
.map(|c| c["drawTextureEx(".len()..].split(',').next().unwrap().to_string())
|
|
311
|
-
.collect();
|
|
312
|
-
v.sort();
|
|
313
|
-
v.dedup();
|
|
314
|
-
v
|
|
315
|
-
};
|
|
316
|
-
let count = |calls: &[String], p: &str| calls.iter().filter(|c| c.starts_with(p)).count();
|
|
317
|
-
for frame in 0..60 {
|
|
318
|
-
let mut calls = Vec::new();
|
|
319
|
-
for g in [&mut a, &mut b] {
|
|
320
|
-
g.backend_mut().calls.clear();
|
|
321
|
-
g.update(1.0 / 60.0).expect("update");
|
|
322
|
-
g.draw().expect("draw");
|
|
323
|
-
calls.push(g.backend().calls.clone());
|
|
324
|
-
}
|
|
325
|
-
let (ca, cb) = (&calls[0], &calls[1]);
|
|
326
|
-
assert!(
|
|
327
|
-
count(ca, "drawTextureEx(") == count(cb, "drawTextureEx(")
|
|
328
|
-
&& count(ca, "drawText(") == count(cb, "drawText(")
|
|
329
|
-
&& tex_ids(ca) == tex_ids(cb),
|
|
330
|
-
"frame {frame}\nscene: {ca:?}\npure: {cb:?}"
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
284
|
#[test]
|
|
336
285
|
fn renderTargetAndBlendCallsAreRecorded() {
|
|
337
286
|
let wasm = compile(&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/target_fixture.plum"));
|
|
@@ -365,7 +314,7 @@ fn randomSeedProvidedByHost() {
|
|
|
365
314
|
let path = dir.join("m.plum");
|
|
366
315
|
std::fs::write(
|
|
367
316
|
&path,
|
|
368
|
-
"import std/Int\nimport std/Float\nimport std/Str\nimport std/Io\nimport std/game/Color\nimport std/game/Game\nstruct State(
|
|
317
|
+
"import std/Int\nimport std/Float\nimport std/Str\nimport std/Io\nimport std/game/Color\nimport std/game/Game\nstruct State(seed: Int) =\n fun update(self, io: Io, dt: Float) -> State =\n self\n fun draw(self, io: Io) =\n io.drawText(\"seed {self.seed}\", 0.0, 0.0, 8.0, Color.White)\n\nfun main(io: Io) -> State =\n State(seed: io.randomSeed())\n",
|
|
369
318
|
)
|
|
370
319
|
.unwrap();
|
|
371
320
|
let wasm = compile(&path);
|
|
@@ -394,7 +343,7 @@ fn mainGame(rec: Recorder) -> PlumGame<Recorder> {
|
|
|
394
343
|
}
|
|
395
344
|
|
|
396
345
|
#[test]
|
|
397
|
-
fn
|
|
346
|
+
fn mainReturningATypeWithUpdateAndDrawIsAGame() {
|
|
398
347
|
assert!(fixtureOut("main_game_fixture.plum").isGame);
|
|
399
348
|
assert!(!fixtureOut("main_cli_fixture.plum").isGame);
|
|
400
349
|
assert!(fixtureOut("host_fixture.plum").isGame);
|
|
@@ -408,7 +357,7 @@ fn nonGameMainStaysACliProgram() {
|
|
|
408
357
|
}
|
|
409
358
|
|
|
410
359
|
#[test]
|
|
411
|
-
fn
|
|
360
|
+
fn stateReturnedByUpdateIsTheNextFramesSelf() {
|
|
412
361
|
let mut g = mainGame(Recorder::with_assets(examples_assets()));
|
|
413
362
|
for _ in 0..3 {
|
|
414
363
|
g.update(0.016).unwrap();
|
|
@@ -416,8 +365,8 @@ fn clockTicksOncePerFrameAndIsShared() {
|
|
|
416
365
|
g.draw().unwrap();
|
|
417
366
|
}
|
|
418
367
|
let last = g.backend().calls.iter().rev().find(|c| c.starts_with("drawText")).unwrap().clone();
|
|
419
|
-
// Drawing twice per frame does not
|
|
368
|
+
// Drawing twice per frame does not advance the state; each update does.
|
|
420
|
-
assert!(last.contains("
|
|
369
|
+
assert!(last.contains("frame 3 "), "{last}");
|
|
421
370
|
}
|
|
422
371
|
|
|
423
372
|
#[test]
|
|
@@ -437,7 +386,7 @@ fn eventsPolledOncePerFrame() {
|
|
|
437
386
|
}
|
|
438
387
|
|
|
439
388
|
#[test]
|
|
440
|
-
fn
|
|
389
|
+
fn gameUpdateDispatchesAndTheTextureLoadsOnce() {
|
|
441
390
|
let mut rec = Recorder::with_assets(examples_assets());
|
|
442
391
|
rec.keys_down.insert(262);
|
|
443
392
|
let mut g = mainGame(rec);
|
|
@@ -446,7 +395,7 @@ fn gameUpdateDispatchesAndAssetsCache() {
|
|
|
446
395
|
let calls = &g.backend().calls;
|
|
447
396
|
assert_eq!(calls.iter().filter(|c| c.starts_with("textureLoad")).count(), 1, "{calls:?}");
|
|
448
397
|
let last = calls.iter().rev().find(|c| c.starts_with("drawText")).unwrap();
|
|
449
|
-
assert!(last.contains("x 50.0 tex 0
|
|
398
|
+
assert!(last.contains("x 50.0 tex 0 "), "{last}");
|
|
450
399
|
assert!(last.contains("w 800.0"), "{last}");
|
|
451
400
|
}
|
|
452
401
|
|
compiler/game/web-test/README.md
CHANGED
|
@@ -19,10 +19,10 @@ Downloads the Chromium and WebKit builds matching the bundled Playwright driver
|
|
|
19
19
|
|
|
20
20
|
## Usage
|
|
21
21
|
|
|
22
|
-
cargo run --manifest-path compiler/game/web-test/Cargo.toml -- examples/games/
|
|
22
|
+
cargo run --manifest-path compiler/game/web-test/Cargo.toml -- examples/games/orb_run.plum --screenshot /tmp/orb-run.png
|
|
23
23
|
|
|
24
|
-
Options: `--browser chromium|webkit` (default chromium; WebKit is Playwright's Safari-engine build and has WebGPU, so the same render assertions apply; a WebKit build without `navigator.gpu` is checked for the "WebGPU is not supported in this browser" message and a loadable plum.wasm instead), `--expect-unsupported-webgpu` (hides `navigator.gpu` in any browser and checks that path), `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw the TypeError Safari gives for typed GC references), `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--assets DIR` (default: the nearest `assets` folder beside the source or in a parent, e.g. `examples/games/assets`), `--require-audio`, `--json` (adds a one-line JSON object with the backend and frame stats), `--headed` (a real GPU window instead of headless software WebGPU), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the mean per-frame update/draw/render split; `--json` adds `update_ms`, `draw_ms`, `render_ms`) and `--uncapped` (lifts vsync so the frame time is the work per frame), `--dpr N` (Playwright `deviceScaleFactor`; also checks the backing store is CSS size x ratio), `--lose-device` (destroys the GPU device from the page after a few frames and requires the `GPU device lost (...); restored` log and resumed rendering; the page exposes the device only when the harness asks) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y), default tolerance 2). `--json` also reports `draws`, the GPU draw calls per frame. `fixtures/interleave.plum`, `text_sizes.plum` and `rendertarget.plum` exercise multi-texture batching, SDF text and render targets/blend modes. `fixtures/stress.plum` and `fixtures/
|
|
24
|
+
Options: `--browser chromium|webkit` (default chromium; WebKit is Playwright's Safari-engine build and has WebGPU, so the same render assertions apply; a WebKit build without `navigator.gpu` is checked for the "WebGPU is not supported in this browser" message and a loadable plum.wasm instead), `--expect-unsupported-webgpu` (hides `navigator.gpu` in any browser and checks that path), `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw the TypeError Safari gives for typed GC references), `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--assets DIR` (default: the nearest `assets` folder beside the source or in a parent, e.g. `examples/games/assets`), `--require-audio`, `--json` (adds a one-line JSON object with the backend and frame stats), `--headed` (a real GPU window instead of headless software WebGPU), `--measure SECS` (records requestAnimationFrame intervals in the page and prints avg / p50 / p95 / p99 ms, fps and the mean per-frame update/draw/render split; `--json` adds `update_ms`, `draw_ms`, `render_ms`) and `--uncapped` (lifts vsync so the frame time is the work per frame), `--dpr N` (Playwright `deviceScaleFactor`; also checks the backing store is CSS size x ratio), `--lose-device` (destroys the GPU device from the page after a few frames and requires the `GPU device lost (...); restored` log and resumed rendering; the page exposes the device only when the harness asks) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y), default tolerance 2). `--json` also reports `draws`, the GPU draw calls per frame. `fixtures/interleave.plum`, `text_sizes.plum` and `rendertarget.plum` exercise multi-texture batching, SDF text and render targets/blend modes. `fixtures/stress.plum`, `fixtures/stress_state_view.plum` and `fixtures/stress_world.plum` are the scenes behind [BENCHMARK.md](BENCHMARK.md).
|
|
25
25
|
Exit code is non-zero with a message on failure.
|
|
26
26
|
|
|
27
27
|
`cargo test --manifest-path compiler/game/web-test/Cargo.toml` runs the same check on
|
|
28
|
-
`examples/games/
|
|
28
|
+
`examples/games/orb_run.plum`, `examples/games/kids/balloon_pop.plum` and `fixtures/stress_state_view.plum`, plus WebKit runs; each skips with a note if its browser is not installed.
|
compiler/game/web-test/fixtures/interleave.plum
CHANGED
|
@@ -17,23 +17,22 @@ primitive Scene =
|
|
|
17
17
|
WorldW = 1264
|
|
18
18
|
WorldH = 704
|
|
19
19
|
|
|
20
|
-
struct Interleave(
|
|
20
|
+
struct Interleave(tex: Array[Texture], t: Int) =
|
|
21
|
-
|
|
21
|
+
fun update(self, io: Io, dt: Float) -> Interleave =
|
|
22
|
-
|
|
22
|
+
Interleave(..self, t: self.t + 1)
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
fun draw(self, io: Io) =
|
|
25
|
-
drawSprites(
|
|
25
|
+
drawSprites(io, self.tex, self.t)
|
|
26
26
|
|
|
27
|
-
fun main(io: Io) ->
|
|
27
|
+
fun main(io: Io) -> Interleave =
|
|
28
|
-
e := Engine.create(io)
|
|
29
28
|
tex := Array.filled(Scene.Textures, Texture(id: -1))
|
|
30
29
|
for i := range Scene.Textures
|
|
31
|
-
tex.set(i,
|
|
30
|
+
tex.set(i, Texture.load(io, "tex{i}.png"))
|
|
32
|
-
Interleave(
|
|
31
|
+
Interleave(tex: tex, t: 0)
|
|
33
32
|
|
|
34
|
-
fun drawSprites(
|
|
33
|
+
fun drawSprites(io: Io, tex: Array[Texture], t: Int) =
|
|
35
|
-
|
|
34
|
+
io.clear(Color.rgb(0.05, 0.06, 0.1))
|
|
36
35
|
for i := range Scene.Sprites
|
|
37
36
|
x := {i * 37 + t * {1 + i % 5}} % Scene.WorldW
|
|
38
37
|
y := {i * 53 + t * {1 + i % 3}} % Scene.WorldH
|
|
39
|
-
|
|
38
|
+
tex.get(i % Scene.Textures).draw(io, Float(x), Float(y), Color.White)
|
compiler/game/web-test/fixtures/main_game.plum
CHANGED
|
@@ -8,15 +8,15 @@ import std/game/Color
|
|
|
8
8
|
import std/game/Game
|
|
9
9
|
import std/Io
|
|
10
10
|
|
|
11
|
-
# A game whose entry is `main` returning a
|
|
11
|
+
# A game whose entry is `main` returning a type with `update` and `draw`; draws a square that moves with the frame count.
|
|
12
|
-
struct Mover(
|
|
12
|
+
struct Mover(frame: Int) =
|
|
13
|
-
|
|
13
|
+
fun update(self, io: Io, dt: Float) -> Mover =
|
|
14
|
-
|
|
14
|
+
Mover(frame: self.frame + 1)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
fun draw(self, io: Io) =
|
|
17
|
-
|
|
17
|
+
io.clear(Color.rgb(0.2, 0.2, 0.2))
|
|
18
|
-
|
|
18
|
+
io.drawRect(Float(self.frame % 600), 100.0, 40.0, 40.0, Color.Yellow)
|
|
19
|
-
|
|
19
|
+
io.drawText("frame {self.frame}", 20.0, 20.0, 16.0, Color.White)
|
|
20
20
|
|
|
21
21
|
fun main(io: Io) -> Mover =
|
|
22
|
-
Mover(
|
|
22
|
+
Mover(frame: 0)
|
compiler/game/web-test/fixtures/rendertarget.plum
CHANGED
|
@@ -19,42 +19,40 @@ import std/game/Color
|
|
|
19
19
|
import std/game/Game
|
|
20
20
|
import std/Io
|
|
21
21
|
|
|
22
|
-
struct Targets(
|
|
22
|
+
struct Targets(big: Texture, small: Texture, glass: Texture, t: Int) =
|
|
23
|
-
|
|
23
|
+
fun update(self, io: Io, dt: Float) -> Targets =
|
|
24
|
-
|
|
24
|
+
Targets(..self, t: self.t + 1)
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
fun draw(self, io: Io) =
|
|
27
|
-
drawTargets(
|
|
27
|
+
drawTargets(io, self)
|
|
28
28
|
|
|
29
|
-
fun main(io: Io) ->
|
|
29
|
+
fun main(io: Io) -> Targets =
|
|
30
|
-
e := Engine.create(io)
|
|
31
|
-
c := e.canvas
|
|
32
|
-
Targets(
|
|
30
|
+
Targets(big: Texture.create(io, 64, 64), small: Texture.create(io, 16, 16), glass: Texture.create(io, 16, 16), t: 0)
|
|
33
31
|
|
|
34
|
-
fun drawTargets(
|
|
32
|
+
fun drawTargets(io: Io, g: Targets) =
|
|
35
|
-
|
|
33
|
+
io.setCamera(10.0, 0.0, 2.0)
|
|
36
|
-
|
|
34
|
+
g.big.renderTarget(io)
|
|
37
|
-
|
|
35
|
+
io.clear(Color.Transparent)
|
|
38
|
-
|
|
36
|
+
io.drawRect(0.0, 0.0, 32.0, 32.0, Color.rgb(1.0, 0.0, 0.0))
|
|
39
|
-
|
|
37
|
+
io.drawRect(32.0, 32.0, 32.0, 32.0, Color.rgb(0.0, 1.0, 0.0))
|
|
40
|
-
|
|
38
|
+
g.small.renderTarget(io)
|
|
41
|
-
|
|
39
|
+
io.clear(Color.rgb(0.0, 0.0, 1.0))
|
|
42
|
-
|
|
40
|
+
g.glass.renderTarget(io)
|
|
43
|
-
|
|
41
|
+
io.clear(Color.Transparent)
|
|
44
|
-
|
|
42
|
+
io.drawRect(0.0, 0.0, 16.0, 16.0, Color(r: 1.0, g: 0.0, b: 0.0, a: 0.5))
|
|
45
|
-
|
|
43
|
+
Texture.screen(io)
|
|
46
|
-
|
|
44
|
+
io.clear(Color.rgb(0.2, 0.2, 0.2))
|
|
47
|
-
|
|
45
|
+
g.big.draw(io, 60.0, 50.0, Color.White)
|
|
48
|
-
|
|
46
|
+
io.resetCamera()
|
|
49
|
-
|
|
47
|
+
io.blendMode(Blend.Add)
|
|
50
|
-
|
|
48
|
+
io.drawRect(400.0, 100.0, 100.0, 100.0, Color.rgb(0.2, 0.4, 0.6))
|
|
51
|
-
|
|
49
|
+
io.blendMode(Blend.Multiply)
|
|
52
|
-
|
|
50
|
+
io.drawRect(550.0, 100.0, 100.0, 100.0, Color.rgb(0.5, 1.0, 0.5))
|
|
53
|
-
|
|
51
|
+
io.blendMode(Blend.Screen)
|
|
54
|
-
|
|
52
|
+
io.drawRect(700.0, 100.0, 100.0, 100.0, Color.rgb(0.5, 0.5, 0.5))
|
|
55
|
-
|
|
53
|
+
io.blendMode(Blend.Normal)
|
|
56
|
-
|
|
54
|
+
io.drawRect(850.0, 100.0, 100.0, 100.0, Color(r: 1.0, g: 0.0, b: 0.0, a: 0.5))
|
|
57
|
-
|
|
55
|
+
g.small.draw(io, 400.0, 300.0, Color.White)
|
|
58
|
-
|
|
56
|
+
g.glass.draw(io, 400.0, 400.0, Color.White)
|
|
59
|
-
|
|
57
|
+
io.drawText("frame {g.t}", 20.0, 500.0, 16.0, Color.White)
|
|
60
|
-
|
|
58
|
+
io.drawRect(Float(g.t % 600), 600.0, 40.0, 6.0, Color.Yellow)
|
compiler/game/web-test/fixtures/stress.plum
CHANGED
|
@@ -19,36 +19,35 @@ primitive Scene =
|
|
|
19
19
|
WorldW = 1600
|
|
20
20
|
WorldH = 900
|
|
21
21
|
|
|
22
|
-
struct Stress(
|
|
22
|
+
struct Stress(sheet: Texture, t: Int) =
|
|
23
|
-
|
|
23
|
+
fun update(self, io: Io, dt: Float) -> Stress =
|
|
24
|
-
|
|
24
|
+
Stress(..self, t: self.t + 1)
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
fun draw(self, io: Io) =
|
|
27
|
-
drawStress(
|
|
27
|
+
drawStress(io, self.sheet, self.t)
|
|
28
28
|
|
|
29
|
-
fun main(io: Io) ->
|
|
29
|
+
fun main(io: Io) -> Stress =
|
|
30
|
-
e := Engine.create(io)
|
|
31
|
-
Stress(
|
|
30
|
+
Stress(sheet: Texture.load(io, "sprites.png"), t: 0)
|
|
32
31
|
|
|
33
32
|
fun hue(i: Int) -> Color =
|
|
34
33
|
Color(r: Float(i % 7) / 7.0, g: Float(i % 5) / 5.0, b: 0.4 + Float(i % 3) / 5.0, a: 0.8)
|
|
35
34
|
|
|
36
|
-
fun drawStress(
|
|
35
|
+
fun drawStress(io: Io, sheet: Texture, t: Int) =
|
|
37
|
-
|
|
36
|
+
io.clear(Color.rgb(0.05, 0.06, 0.1))
|
|
38
|
-
|
|
37
|
+
io.setCamera(0.0, 0.0, 0.8)
|
|
39
38
|
for i := range Scene.Sprites
|
|
40
39
|
x := {i * 37 + t * {1 + i % 5}} % Scene.WorldW
|
|
41
40
|
y := {i * 53 + t * {1 + i % 3}} % Scene.WorldH
|
|
42
41
|
frame := {i + t / 6} % 4
|
|
43
|
-
|
|
42
|
+
io.drawTextureEx(sheet.id, Float(frame * 16), 0.0, 16.0, 16.0, Float(x), Float(y), 16.0, 16.0, Float(t) * 0.02 + Float(i), i % 2 == 0, Color.White)
|
|
44
43
|
for i := range Scene.Rects
|
|
45
44
|
x := {i * 61 + t * 2} % Scene.WorldW
|
|
46
45
|
y := {i * 29} % Scene.WorldH
|
|
47
|
-
|
|
46
|
+
io.drawRect(Float(x), Float(y), 12.0, 8.0, hue(i))
|
|
48
47
|
for i := range Scene.Circles
|
|
49
48
|
x := {i * 83 + t * 3} % Scene.WorldW
|
|
50
49
|
y := {i * 47 + t} % Scene.WorldH
|
|
51
|
-
|
|
50
|
+
io.drawCircle(Float(x), Float(y), 6.0, hue(i + 3))
|
|
52
51
|
for i := range Scene.Texts
|
|
53
|
-
|
|
52
|
+
io.drawText("n {t + i}", Float(i * 30 % Scene.WorldW), Float(i * 17 % Scene.WorldH), 14.0, Color.White)
|
|
54
|
-
|
|
53
|
+
io.resetCamera()
|
compiler/game/web-test/fixtures/stress_pure_scene.plum
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
# The stress scene of stress_scene.plum on the PURE scene graph (std/game/pure/PureScene):
|
|
2
|
-
# 5000 sprites in 50 slowly rotating groups, 500 rects and 200 circles (sprites of a
|
|
3
|
-
# 1x1 white texture), 50 texts with a changing number, drawn through a camera. Every
|
|
4
|
-
# node is a unique value: each frame changes the tree in place with Node.update. Group and
|
|
5
|
-
# sprite spin comes from `forever(rotateBy(...))` actions; drifting and counting
|
|
6
|
-
# actors do theirs in `act`.
|
|
7
|
-
module stress_pure_scene
|
|
8
|
-
import std/Str
|
|
9
|
-
import std/Int
|
|
10
|
-
import std/Float
|
|
11
|
-
import std/Slice
|
|
12
|
-
import std/Option
|
|
13
|
-
import std/game/Vec2
|
|
14
|
-
import std/game/Color
|
|
15
|
-
import std/game/Affine
|
|
16
|
-
import std/game/Game
|
|
17
|
-
import std/game/pure/Actions
|
|
18
|
-
import std/game/pure/PureScene
|
|
19
|
-
import std/game/pure/SceneInput
|
|
20
|
-
import std/Io
|
|
21
|
-
|
|
22
|
-
primitive Counts =
|
|
23
|
-
Sprites = 5000
|
|
24
|
-
Rects = 500
|
|
25
|
-
Circles = 200
|
|
26
|
-
Texts = 50
|
|
27
|
-
Groups = 50
|
|
28
|
-
WorldW = 1600
|
|
29
|
-
WorldH = 900
|
|
30
|
-
|
|
31
|
-
struct World(sheet: Texture, mut stage: Stage)
|
|
32
|
-
|
|
33
|
-
fun hue(i: Int) -> Color =
|
|
34
|
-
Color(r: Float(i % 7) / 7.0, g: Float(i % 5) / 5.0, b: 0.4 + Float(i % 3) / 5.0, a: 0.8)
|
|
35
|
-
|
|
36
|
-
fun quad(canvas: Canvas, m: Affine, tint: Color, w: Float, h: Float, tex: Texture, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
|
|
37
|
-
sx := w * m.scaleX()
|
|
38
|
-
sy := h * m.scaleY()
|
|
39
|
-
c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
|
|
40
|
-
canvas.drawTextureEx(tex, srcX, srcY, srcW, srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, tint)
|
|
41
|
-
|
|
42
|
-
fun caption(canvas: Canvas, m: Affine, tint: Color, s: Str, size: Float) =
|
|
43
|
-
p := m.apply(Vec2(x: 0.0, y: 0.0))
|
|
44
|
-
canvas.drawText(s, p.x, p.y, size * m.scaleY(), tint)
|
|
45
|
-
|
|
46
|
-
# A square of the white texture that moves by (dx, dy) per frame and wraps.
|
|
47
|
-
struct Drifter(Actor, tex: Texture, dx: Float, dy: Float) =
|
|
48
|
-
override fun act(self, n: Node, dt: Float) -> Node =
|
|
49
|
-
x := n.x + self.dx
|
|
50
|
-
y := n.y + self.dy
|
|
51
|
-
if x > Float(Counts.WorldW)
|
|
52
|
-
x = x - Float(Counts.WorldW)
|
|
53
|
-
if y > Float(Counts.WorldH)
|
|
54
|
-
y = y - Float(Counts.WorldH)
|
|
55
|
-
Node(..n, x: x, y: y)
|
|
56
|
-
|
|
57
|
-
override fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
58
|
-
quad(canvas, m, tint, n.width, n.height, self.tex, 0.0, 0.0, 0.0, 0.0)
|
|
59
|
-
|
|
60
|
-
# Shows "n <count>" and counts up each frame.
|
|
61
|
-
struct CountLabel(Actor, n: Int, text: Str) =
|
|
62
|
-
override fun act(self, n: Node, dt: Float) -> Node =
|
|
63
|
-
k := self.n + 1
|
|
64
|
-
Node(..n, kind: Custom(CountLabel(n: k, text: "n {k}")))
|
|
65
|
-
|
|
66
|
-
override fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
67
|
-
caption(canvas, m, tint, self.text, 14.0)
|
|
68
|
-
|
|
69
|
-
fun drifter(id: Int, tex: Texture, c: Color, x: Float, y: Float, w: Float, h: Float, dx: Float, dy: Float) -> Node =
|
|
70
|
-
d := Drifter(tex: tex, dx: dx, dy: dy)
|
|
71
|
-
me: Actor := d
|
|
72
|
-
n := Node.custom(id, x, y, w, h, me)
|
|
73
|
-
Node(..n, color: c)
|
|
74
|
-
|
|
75
|
-
fun countLabel(id: Int, n: Int, x: Float, y: Float) -> Node =
|
|
76
|
-
l := CountLabel(n: n, text: "n {n}")
|
|
77
|
-
me: Actor := l
|
|
78
|
-
Node.custom(id, x, y, 60.0, 14.0, me)
|
|
79
|
-
|
|
80
|
-
fun spinning(rate: Float) -> Slice[Action] =
|
|
81
|
-
acts := Slice[Action]()
|
|
82
|
-
acts.push(Action.forever(Action.rotateBy(rate, 1.0)))
|
|
83
|
-
acts
|
|
84
|
-
|
|
85
|
-
fun dancer(id: Int, sheet: Texture, j: Int, i: Int) -> Node =
|
|
86
|
-
s := Node.sprite(id, sheet, Float({j * j * 13 + j * 37} % 150), Float({j * j * 29 + j * 53} % 170), 16.0, 16.0)
|
|
87
|
-
Node(..s, originX: 8.0, originY: 8.0, rotation: Float(i),
|
|
88
|
-
kind: Sprite(tex: sheet, srcX: Float(i % 4 * 16), srcY: 0.0, srcW: 16.0, srcH: 16.0), actions: spinning(1.2))
|
|
89
|
-
|
|
90
|
-
fun buildWorld(sheet: Texture, white: Texture) -> World =
|
|
91
|
-
root := Slice[Node]()
|
|
92
|
-
per := Counts.Sprites / Counts.Groups
|
|
93
|
-
for gi := range Counts.Groups
|
|
94
|
-
kids := Slice[Node]()
|
|
95
|
-
for j := range per
|
|
96
|
-
i := gi * per + j
|
|
97
|
-
kids.push(dancer(1000 + i, sheet, j, i))
|
|
98
|
-
g := Node.group(100 + gi, Float(gi % 10 * 160), Float(gi / 10 * 180), 150.0, 170.0, kids)
|
|
99
|
-
root.push(Node(..g, originX: 75.0, originY: 85.0, actions: spinning(0.24)))
|
|
100
|
-
for i := range Counts.Rects
|
|
101
|
-
root.push(drifter(10000 + i, white, hue(i), Float(i * 61 % Counts.WorldW), Float(i * 29 % Counts.WorldH), 12.0, 8.0, 2.0, 0.0))
|
|
102
|
-
for i := range Counts.Circles
|
|
103
|
-
root.push(drifter(20000 + i, white, hue(i + 3), Float(i * 83 % Counts.WorldW), Float(i * 47 % Counts.WorldH), 12.0, 12.0, 3.0, 1.0))
|
|
104
|
-
for i := range Counts.Texts
|
|
105
|
-
root.push(countLabel(30000 + i, i, Float(i * 30 % Counts.WorldW), Float(i * 17 % Counts.WorldH)))
|
|
106
|
-
top := Node.group(1, 0.0, 0.0, Float(Counts.WorldW), Float(Counts.WorldH), root)
|
|
107
|
-
cam := Affine(a: 0.8, b: 0.0, c: 0.0, d: 0.8, e: 0.0, f: 0.0)
|
|
108
|
-
World(sheet: sheet, stage: Stage(root: top, camera: cam, captured: None, focus: None))
|
|
109
|
-
|
|
110
|
-
fun whiteTexture(canvas: Canvas) -> Texture =
|
|
111
|
-
t := canvas.createTexture(1, 1)
|
|
112
|
-
canvas.renderTarget(t)
|
|
113
|
-
canvas.clear(Color.White)
|
|
114
|
-
canvas.screenTarget()
|
|
115
|
-
t
|
|
116
|
-
|
|
117
|
-
fun frameStage(s: Stage, dt: Float) -> Stage =
|
|
118
|
-
s.frame(dt, Slice[InputEvent]()).intoStage()
|
|
119
|
-
|
|
120
|
-
fun advance(w: World, dt: Float) =
|
|
121
|
-
w.stage = frameStage(w.stage, dt)
|
|
122
|
-
|
|
123
|
-
# The game around the world, so the test below needs no engine.
|
|
124
|
-
struct PureGame(Game(engine), world: World) =
|
|
125
|
-
override fun update(self, dt: Float) =
|
|
126
|
-
advance(self.world, dt)
|
|
127
|
-
|
|
128
|
-
override fun draw(self) =
|
|
129
|
-
self.canvas.clear(Color.rgb(0.05, 0.06, 0.1))
|
|
130
|
-
self.world.stage.draw(self.canvas)
|
|
131
|
-
|
|
132
|
-
fun main(io: Io) -> Game =
|
|
133
|
-
e := Engine.create(io)
|
|
134
|
-
PureGame(engine: e, world: buildWorld(e.assets.texture("sprites.png"), whiteTexture(e.canvas)))
|
|
135
|
-
|
|
136
|
-
test "ten frames of update run"
|
|
137
|
-
w := buildWorld(Texture(id: 0), Texture(id: 0))
|
|
138
|
-
for i := range 10
|
|
139
|
-
advance(w, 0.016)
|
|
140
|
-
if Some(k) := w.stage.root.children()
|
|
141
|
-
assert k.length() == 50 + 500 + 200 + 50
|
|
142
|
-
assert {k.at(50).x - 20.0}.abs() < 0.000001
|
|
143
|
-
else
|
|
144
|
-
assert false
|
compiler/game/web-test/fixtures/stress_scene.plum
DELETED
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
# The stress scene of stress.plum built as a Scene2D-like scene graph (std/game/imperative/Scene):
|
|
2
|
-
# 5000 sprites in 50 slowly rotating groups, 500 rects, 200 circles and 50 texts
|
|
3
|
-
# with a changing number, drawn through a camera. Every node moves in its own
|
|
4
|
-
# `act`, so each frame runs ~5800 override-dispatched acts and a composed-transform draw.
|
|
5
|
-
module stress_scene
|
|
6
|
-
import std/Str
|
|
7
|
-
import std/Int
|
|
8
|
-
import std/Float
|
|
9
|
-
import std/Slice
|
|
10
|
-
import std/game/Vec2
|
|
11
|
-
import std/game/Color
|
|
12
|
-
import std/game/Game
|
|
13
|
-
import std/game/imperative/Scene
|
|
14
|
-
import std/Io
|
|
15
|
-
|
|
16
|
-
primitive Counts =
|
|
17
|
-
Sprites = 5000
|
|
18
|
-
Rects = 500
|
|
19
|
-
Circles = 200
|
|
20
|
-
Texts = 50
|
|
21
|
-
Groups = 50
|
|
22
|
-
WorldW = 1600
|
|
23
|
-
WorldH = 900
|
|
24
|
-
|
|
25
|
-
struct Demo(Game(engine), sheet: Texture, stage: Stage) =
|
|
26
|
-
override fun update(self, dt: Float) =
|
|
27
|
-
self.stage.act(dt)
|
|
28
|
-
|
|
29
|
-
override fun draw(self) =
|
|
30
|
-
self.canvas.clear(Color.rgb(0.05, 0.06, 0.1))
|
|
31
|
-
self.stage.draw(self.canvas)
|
|
32
|
-
|
|
33
|
-
fun hue(i: Int) -> Color =
|
|
34
|
-
Color(r: Float(i % 7) / 7.0, g: Float(i % 5) / 5.0, b: 0.4 + Float(i % 3) / 5.0, a: 0.8)
|
|
35
|
-
|
|
36
|
-
# The actor kinds of this scene: structs embedding the Actor base struct (its subtypes), each with its own `act`.
|
|
37
|
-
struct SpinGroup(Actor) =
|
|
38
|
-
fun init(self) =
|
|
39
|
-
self.originX = 75.0
|
|
40
|
-
self.originY = 85.0
|
|
41
|
-
override fun act(self, dt: Float) =
|
|
42
|
-
self.rotation = self.rotation + 0.004
|
|
43
|
-
self.actChildren(dt)
|
|
44
|
-
|
|
45
|
-
# Turns the sprite; its frame follows the angle, advancing every 6 frames.
|
|
46
|
-
struct Dancer(Actor, tex: Texture, mut srcX: Float) =
|
|
47
|
-
fun init(self) =
|
|
48
|
-
self.width = 16.0
|
|
49
|
-
self.height = 16.0
|
|
50
|
-
self.originX = 8.0
|
|
51
|
-
self.originY = 8.0
|
|
52
|
-
override fun act(self, dt: Float) =
|
|
53
|
-
self.rotation = self.rotation + 0.02
|
|
54
|
-
self.srcX = Float(Int(self.rotation * 50.0) / 6 % 4 * 16)
|
|
55
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
56
|
-
drawSpriteFrame(canvas, m, alpha, self.width, self.height, self.color, self.tex, self.srcX, 0.0, 16.0, 16.0)
|
|
57
|
-
|
|
58
|
-
struct DriftRect(Actor(color)) =
|
|
59
|
-
fun init(self) =
|
|
60
|
-
self.width = 12.0
|
|
61
|
-
self.height = 8.0
|
|
62
|
-
override fun act(self, dt: Float) =
|
|
63
|
-
self.x = self.x + 2.0
|
|
64
|
-
if self.x > Float(Counts.WorldW)
|
|
65
|
-
self.x = self.x - Float(Counts.WorldW)
|
|
66
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
67
|
-
drawRectBox(canvas, m, alpha, self.width, self.height, self.color)
|
|
68
|
-
|
|
69
|
-
struct DriftCircle(Actor(color)) =
|
|
70
|
-
fun init(self) =
|
|
71
|
-
self.width = 12.0
|
|
72
|
-
self.height = 12.0
|
|
73
|
-
override fun act(self, dt: Float) =
|
|
74
|
-
self.x = self.x + 3.0
|
|
75
|
-
self.y = self.y + 1.0
|
|
76
|
-
if self.x > Float(Counts.WorldW)
|
|
77
|
-
self.x = self.x - Float(Counts.WorldW)
|
|
78
|
-
if self.y > Float(Counts.WorldH)
|
|
79
|
-
self.y = self.y - Float(Counts.WorldH)
|
|
80
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
81
|
-
drawDisc(canvas, m, alpha, self.width / 2.0, self.color)
|
|
82
|
-
|
|
83
|
-
# `n` is the number shown.
|
|
84
|
-
struct CountText(Actor, mut n: Int, mut text: Str) =
|
|
85
|
-
fun init(self) =
|
|
86
|
-
self.width = 60.0
|
|
87
|
-
self.height = 14.0
|
|
88
|
-
self.text = "n {self.n}"
|
|
89
|
-
override fun act(self, dt: Float) =
|
|
90
|
-
self.n = self.n + 1
|
|
91
|
-
self.text = "n {self.n}"
|
|
92
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
93
|
-
drawLabel(canvas, m, alpha, self.text, 14.0, self.color)
|
|
94
|
-
|
|
95
|
-
fun main(io: Io) -> Game =
|
|
96
|
-
e := Engine.create(io)
|
|
97
|
-
sheet := e.assets.texture("sprites.png")
|
|
98
|
-
stage := Stage.create()
|
|
99
|
-
stage.camScale = 0.8
|
|
100
|
-
per := Counts.Sprites / Counts.Groups
|
|
101
|
-
for gi := range Counts.Groups
|
|
102
|
-
g := SpinGroup().at(Float(gi % 10 * 160), Float(gi / 10 * 180))
|
|
103
|
-
for j := range per
|
|
104
|
-
i := gi * per + j
|
|
105
|
-
s := Dancer(tex: sheet, srcX: Float(i % 4 * 16))
|
|
106
|
-
s.x = Float({j * j * 13 + j * 37} % 150)
|
|
107
|
-
s.y = Float({j * j * 29 + j * 53} % 170)
|
|
108
|
-
s.rotation = Float(i)
|
|
109
|
-
g.add(s)
|
|
110
|
-
stage.root.add(g)
|
|
111
|
-
for i := range Counts.Rects
|
|
112
|
-
stage.root.add(DriftRect(color: hue(i)).at(Float(i * 61 % Counts.WorldW), Float(i * 29 % Counts.WorldH)))
|
|
113
|
-
for i := range Counts.Circles
|
|
114
|
-
stage.root.add(DriftCircle(color: hue(i + 3)).at(Float(i * 83 % Counts.WorldW), Float(i * 47 % Counts.WorldH)))
|
|
115
|
-
for i := range Counts.Texts
|
|
116
|
-
stage.root.add(CountText(n: i, text: "").at(Float(i * 30 % Counts.WorldW), Float(i * 17 % Counts.WorldH)))
|
|
117
|
-
Demo(engine: e, sheet: sheet, stage: stage)
|
compiler/game/web-test/fixtures/stress_state_view.plum
CHANGED
|
@@ -22,9 +22,9 @@ import std/List
|
|
|
22
22
|
import std/game/Vec2
|
|
23
23
|
import std/game/Color
|
|
24
24
|
import std/game/Game
|
|
25
|
-
import std/game/
|
|
25
|
+
import std/game/Scene
|
|
26
|
-
import std/game/
|
|
26
|
+
import std/game/Anim
|
|
27
|
-
import std/game/
|
|
27
|
+
import std/game/Ui
|
|
28
28
|
import std/game/Input
|
|
29
29
|
import std/Io
|
|
30
30
|
|
|
@@ -198,28 +198,28 @@ fun view(s: State) -> SceneNode[Int] =
|
|
|
198
198
|
world: SceneNode[Int] := Group(transform: Scale(sx: 0.8, sy: 0.8), children: toNodes(s.spinners, |p| spinnerView(s.sheet, p), pulses))
|
|
199
199
|
Group(transform: Identity, children: [world, buttonsView(s.ui)])
|
|
200
200
|
|
|
201
|
-
fun whiteTexture(
|
|
201
|
+
fun whiteTexture(io: Io) -> Texture =
|
|
202
|
-
t :=
|
|
202
|
+
t := Texture.create(io, 1, 1)
|
|
203
|
-
|
|
203
|
+
t.renderTarget(io)
|
|
204
|
-
|
|
204
|
+
io.clear(Color.White)
|
|
205
|
-
|
|
205
|
+
Texture.screen(io)
|
|
206
206
|
t
|
|
207
207
|
|
|
208
|
-
struct World(
|
|
208
|
+
struct World(state: State, input: Input) =
|
|
209
|
-
|
|
209
|
+
fun update(self, io: Io, dt: Float) -> World =
|
|
210
|
+
input := self.input.poll(io)
|
|
210
|
-
inputs :=
|
|
211
|
+
inputs := input.events
|
|
211
212
|
s := step(self.state, dt)
|
|
212
213
|
for i := range inputs.length()
|
|
213
214
|
s = stepUi(s, inputs.at(i))
|
|
214
|
-
|
|
215
|
+
World(state: s, input: input)
|
|
215
216
|
|
|
216
|
-
|
|
217
|
+
fun draw(self, io: Io) =
|
|
217
|
-
|
|
218
|
+
io.clear(Color.rgb(0.05, 0.06, 0.1))
|
|
218
|
-
view(self.state).draw(
|
|
219
|
+
view(self.state).draw(io)
|
|
219
220
|
|
|
220
|
-
fun main(io: Io) ->
|
|
221
|
+
fun main(io: Io) -> World =
|
|
221
|
-
e := Engine.create(io)
|
|
222
|
-
World(
|
|
222
|
+
World(state: newState(Texture.load(io, "sprites.png"), whiteTexture(io)), input: Input.create())
|
|
223
223
|
|
|
224
224
|
test "ten frames of step run"
|
|
225
225
|
s := newState(Texture(id: 0), Texture(id: 0))
|
compiler/game/web-test/fixtures/stress_state_view_deep.plum
CHANGED
|
@@ -17,7 +17,7 @@ import std/List
|
|
|
17
17
|
import std/game/Vec2
|
|
18
18
|
import std/game/Color
|
|
19
19
|
import std/game/Game
|
|
20
|
-
import std/game/
|
|
20
|
+
import std/game/Scene
|
|
21
21
|
import std/Io
|
|
22
22
|
|
|
23
23
|
primitive Counts =
|
|
@@ -162,24 +162,23 @@ fun view(s: State) -> SceneNode[Int] =
|
|
|
162
162
|
drifts := toNodes(s.drifters, |d| drifterView(s.white, d), texts)
|
|
163
163
|
Group(transform: Scale(sx: 0.8, sy: 0.8), children: spinnerNodes(s.sheet, s.spinners, s.dancers, drifts))
|
|
164
164
|
|
|
165
|
-
fun whiteTexture(
|
|
165
|
+
fun whiteTexture(io: Io) -> Texture =
|
|
166
|
-
t :=
|
|
166
|
+
t := Texture.create(io, 1, 1)
|
|
167
|
-
|
|
167
|
+
t.renderTarget(io)
|
|
168
|
-
|
|
168
|
+
io.clear(Color.White)
|
|
169
|
-
|
|
169
|
+
Texture.screen(io)
|
|
170
170
|
t
|
|
171
171
|
|
|
172
|
-
struct World(
|
|
172
|
+
struct World(state: State) =
|
|
173
|
-
|
|
173
|
+
fun update(self, io: Io, dt: Float) -> World =
|
|
174
|
-
|
|
174
|
+
World(state: step(self.state, dt))
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
fun draw(self, io: Io) =
|
|
177
|
-
|
|
177
|
+
io.clear(Color.rgb(0.05, 0.06, 0.1))
|
|
178
|
-
view(self.state).draw(
|
|
178
|
+
view(self.state).draw(io)
|
|
179
179
|
|
|
180
|
-
fun main(io: Io) ->
|
|
180
|
+
fun main(io: Io) -> World =
|
|
181
|
-
e := Engine.create(io)
|
|
182
|
-
World(
|
|
181
|
+
World(state: newState(Texture.load(io, "sprites.png"), whiteTexture(io)))
|
|
183
182
|
|
|
184
183
|
test "ten frames of step run"
|
|
185
184
|
s := newState(Texture(id: 0), Texture(id: 0))
|
compiler/game/web-test/fixtures/stress_world.plum
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
# A large y-sorted world
|
|
1
|
+
# A large y-sorted world as state and view: 20000 sprites in one world layer, about 2000
|
|
2
|
-
#
|
|
2
|
+
# of them in the 1280x720 view. Every frame a few hundred sprites walk, the whole list is
|
|
3
|
-
#
|
|
3
|
+
# sorted by y, and only the sprites in the view become scene nodes. The state is the
|
|
4
|
-
#
|
|
4
|
+
# list, the frame number and the camera; `step` returns the next state and `view` builds
|
|
5
|
-
#
|
|
5
|
+
# the scene from it.
|
|
6
6
|
module stress_world
|
|
7
7
|
import std/Str
|
|
8
8
|
import std/Int
|
|
9
9
|
import std/Float
|
|
10
10
|
import std/Math
|
|
11
|
+
import std/List
|
|
11
12
|
import std/Slice
|
|
12
13
|
import std/game/Vec2
|
|
13
14
|
import std/game/Color
|
|
14
15
|
import std/game/Game
|
|
15
|
-
import std/game/
|
|
16
|
+
import std/game/Scene
|
|
16
17
|
import std/Io
|
|
17
18
|
|
|
18
19
|
primitive World =
|
|
@@ -22,59 +23,68 @@ primitive World =
|
|
|
22
23
|
Height = 2300
|
|
23
24
|
ViewW = 1280.0
|
|
24
25
|
ViewH = 720.0
|
|
26
|
+
|
|
27
|
+
# `walker` is the index among the walking sprites, or -1 for one that stands still.
|
|
25
|
-
|
|
28
|
+
struct Spr(x: Float, y: Float, srcX: Float, walker: Int)
|
|
26
|
-
|
|
29
|
+
|
|
27
|
-
struct
|
|
30
|
+
struct State(sheet: Texture, sprites: List[Spr], frame: Int, camX: Float, camY: Float)
|
|
31
|
+
|
|
28
|
-
|
|
32
|
+
fun walkerOf(i: Int) -> Int =
|
|
33
|
+
if i % 37 == 0 && i / 37 < World.Walkers then i / 37 else -1
|
|
34
|
+
|
|
29
|
-
fun spriteAt(i: Int) ->
|
|
35
|
+
fun spriteAt(i: Int) -> Spr =
|
|
30
|
-
|
|
36
|
+
Spr(x: Float({i * 7919 + 13} % World.Width), y: Float({i * i * 31 / 7 + i * 1013} % World.Height), srcX: Float(i % 4 * 16), walker: walkerOf(i))
|
|
37
|
+
|
|
31
|
-
|
|
38
|
+
fun spritesFrom(i: Int) -> List[Spr] =
|
|
39
|
+
if i >= World.Sprites then Nil else Cons(head: spriteAt(i), tail: spritesFrom(i + 1))
|
|
40
|
+
|
|
32
|
-
fun
|
|
41
|
+
fun newState(sheet: Texture) -> State =
|
|
33
|
-
stage := Stage.create()
|
|
34
|
-
layer := Group()
|
|
35
|
-
for i := range World.Sprites
|
|
36
|
-
p := spriteAt(i)
|
|
37
|
-
layer.add(Sprite.create(sheet, Float(i % 4 * 16), 0.0, 16.0, 16.0, 16.0, 16.0).at(p.x, p.y))
|
|
38
|
-
stage.root.add(layer)
|
|
39
|
-
|
|
42
|
+
State(sheet: sheet, sprites: spritesFrom(0), frame: 0, camX: 0.0, camY: 0.0)
|
|
43
|
+
|
|
40
|
-
|
|
44
|
+
fun walk(frame: Int, s: Spr) -> Spr =
|
|
45
|
+
if s.walker < 0
|
|
46
|
+
s
|
|
47
|
+
else
|
|
48
|
+
Spr(..s, y: s.y + Math.sin(Float(frame) * 0.05 + Float(s.walker)))
|
|
49
|
+
|
|
41
|
-
# One frame: the camera drifts, some sprites walk, the
|
|
50
|
+
# One frame: the camera drifts, some sprites walk, the list is sorted by y.
|
|
42
|
-
fun step(
|
|
51
|
+
fun step(s: State) -> State =
|
|
43
|
-
|
|
52
|
+
frame := s.frame + 1
|
|
44
|
-
t := Float(
|
|
53
|
+
t := Float(frame) * 0.01
|
|
45
|
-
g.stage.camX = 1300.0 + 600.0 * Math.sin(t)
|
|
46
|
-
g.stage.camY = 800.0 + 350.0 * Math.cos(t * 0.7)
|
|
47
|
-
for i := range World.Walkers
|
|
48
|
-
|
|
54
|
+
moved := s.sprites.map(|k| walk(frame, k))
|
|
49
|
-
k.y = k.y + Math.sin(Float(g.frame) * 0.05 + Float(i))
|
|
50
|
-
|
|
55
|
+
sorted := List.fromSlice(moved.toSlice().sort(|a, b| a.y < b.y))
|
|
51
|
-
if World.Culling == 1
|
|
52
|
-
|
|
56
|
+
State(..s, sprites: sorted, frame: frame, camX: 1300.0 + 600.0 * Math.sin(t), camY: 800.0 + 350.0 * Math.cos(t * 0.7))
|
|
57
|
+
|
|
53
|
-
|
|
58
|
+
fun inView(s: State, k: Spr) -> Bool =
|
|
59
|
+
k.x + 16.0 >= s.camX && k.x <= s.camX + World.ViewW && k.y + 16.0 >= s.camY && k.y <= s.camY + World.ViewH
|
|
60
|
+
|
|
61
|
+
fun visible(s: State) -> List[Spr] =
|
|
62
|
+
s.sprites.filter(|k| inView(s, k))
|
|
63
|
+
|
|
54
|
-
|
|
64
|
+
fun spriteNode(sheet: Texture, k: Spr) -> SceneNode[Int] =
|
|
65
|
+
Sprite(tex: sheet, x: k.x, y: k.y, w: 16.0, h: 16.0, srcX: k.srcX, srcY: 0.0, srcW: 16.0, srcH: 16.0, tint: Color.White)
|
|
66
|
+
|
|
67
|
+
fun view(s: State) -> SceneNode[Int] =
|
|
68
|
+
Group(transform: Translate(dx: 0.0 - s.camX, dy: 0.0 - s.camY), children: visible(s).map(|k| spriteNode(s.sheet, k)))
|
|
69
|
+
|
|
55
|
-
struct
|
|
70
|
+
struct Demo(state: State) =
|
|
71
|
+
fun update(self, io: Io, dt: Float) -> Demo =
|
|
72
|
+
Demo(state: step(self.state))
|
|
73
|
+
|
|
56
|
-
|
|
74
|
+
fun draw(self, io: Io) =
|
|
57
|
-
step(self.demo)
|
|
58
|
-
|
|
59
|
-
override fun draw(self) =
|
|
60
|
-
|
|
75
|
+
io.clear(Color.rgb(0.05, 0.06, 0.1))
|
|
61
|
-
self.
|
|
76
|
+
view(self.state).draw(io)
|
|
62
|
-
|
|
77
|
+
|
|
63
|
-
fun main(io: Io) ->
|
|
78
|
+
fun main(io: Io) -> Demo =
|
|
64
|
-
e := Engine.create(io)
|
|
65
|
-
|
|
79
|
+
Demo(state: newState(Texture.load(io, "sprites.png")))
|
|
66
80
|
|
|
67
81
|
# --- tests ---
|
|
68
82
|
|
|
69
83
|
test "about 2000 of the 20000 sprites are in view, sorted by y"
|
|
70
|
-
|
|
84
|
+
s := step(newState(Texture(id: -1)))
|
|
71
|
-
step(g)
|
|
72
|
-
assert
|
|
85
|
+
assert s.sprites.length() == 20000
|
|
86
|
+
all := s.sprites.toSlice()
|
|
73
87
|
for i := range 19999
|
|
74
|
-
assert
|
|
88
|
+
assert all.at(i).y <= all.at(i + 1).y
|
|
75
|
-
shown :=
|
|
89
|
+
shown := visible(s).length()
|
|
76
90
|
assert shown > 1500 && shown < 2600
|
|
77
|
-
|
|
78
|
-
test "without a culling area every sprite is listed"
|
|
79
|
-
g := build(Texture(id: -1))
|
|
80
|
-
assert flatten(g.stage.root).length() == 20002
|
compiler/game/web-test/fixtures/text_sizes.plum
CHANGED
|
@@ -10,26 +10,26 @@ import std/game/Color
|
|
|
10
10
|
import std/game/Game
|
|
11
11
|
import std/Io
|
|
12
12
|
|
|
13
|
-
struct Sizes(
|
|
13
|
+
struct Sizes(t: Int) =
|
|
14
|
-
|
|
14
|
+
fun update(self, io: Io, dt: Float) -> Sizes =
|
|
15
|
-
|
|
15
|
+
Sizes(t: self.t + 1)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
fun draw(self, io: Io) =
|
|
18
|
-
drawSizes(
|
|
18
|
+
drawSizes(io, self.t)
|
|
19
19
|
|
|
20
|
-
fun main(io: Io) ->
|
|
20
|
+
fun main(io: Io) -> Sizes =
|
|
21
|
-
Sizes(
|
|
21
|
+
Sizes(t: 0)
|
|
22
22
|
|
|
23
|
-
fun drawSizes(
|
|
23
|
+
fun drawSizes(io: Io, t: Int) =
|
|
24
|
-
|
|
24
|
+
io.clear(Color.rgb(0.08, 0.09, 0.14))
|
|
25
|
-
|
|
25
|
+
io.drawText("Size 8: The quick brown fox jumps over the lazy dog 0123456789", 10.0, 6.0, 8.0, Color.White)
|
|
26
|
-
|
|
26
|
+
io.drawText("Size 16: The quick brown fox jumps over the lazy dog", 10.0, 24.0, 16.0, Color.White)
|
|
27
|
-
|
|
27
|
+
io.drawText("Size 48: Quick brown fox", 10.0, 52.0, 48.0, Color.Yellow)
|
|
28
|
-
|
|
28
|
+
io.drawText("Size 128 Wg", 10.0, 112.0, 128.0, Color.rgb(0.5, 0.9, 1.0))
|
|
29
|
-
|
|
29
|
+
io.drawText("Latin-1: café naïve über ½ ©", 10.0, 250.0, 24.0, Color.White)
|
|
30
|
-
|
|
30
|
+
io.drawText("frame {t}", 700.0, 8.0, 16.0, Color.Green)
|
|
31
|
-
|
|
31
|
+
io.drawRect(Float(t % 400), 300.0, 60.0, 4.0, Color.Red)
|
|
32
|
-
|
|
32
|
+
io.setCamera(0.0, 0.0, 3.0)
|
|
33
|
-
|
|
33
|
+
io.drawText("Zoomed x3: size 16", 10.0, 112.0, 16.0, Color.White)
|
|
34
|
-
|
|
34
|
+
io.drawText("Zoomed x3: size 8", 10.0, 134.0, 8.0, Color.White)
|
|
35
|
-
|
|
35
|
+
io.resetCamera()
|
compiler/game/web-test/tests/web_check.rs
CHANGED
|
@@ -2,17 +2,12 @@ use plum_web_test::{chromium_installed, repo_root, run, webkit_installed, Browse
|
|
|
2
2
|
|
|
3
3
|
#[test]
|
|
4
4
|
fn orb_run_renders_and_animates() {
|
|
5
|
-
orb_run("examples/games/
|
|
5
|
+
orb_run("examples/games/orb_run.plum");
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
#[test]
|
|
9
|
-
fn
|
|
9
|
+
fn balloon_pop_renders_and_animates() {
|
|
10
|
-
orb_run("examples/games/imperative/game_scene.plum");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
#[test]
|
|
14
|
-
fn pure_scene_renders_and_animates() {
|
|
15
|
-
orb_run("examples/games/
|
|
10
|
+
orb_run("examples/games/kids/balloon_pop.plum");
|
|
16
11
|
}
|
|
17
12
|
|
|
18
13
|
#[test]
|
|
@@ -20,11 +15,6 @@ fn state_view_renders_and_animates() {
|
|
|
20
15
|
orb_run("compiler/game/web-test/fixtures/stress_state_view.plum");
|
|
21
16
|
}
|
|
22
17
|
|
|
23
|
-
#[test]
|
|
24
|
-
fn scene_run_renders_and_animates() {
|
|
25
|
-
orb_run("examples/games/imperative/scene.plum");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
18
|
#[test]
|
|
29
19
|
fn stats_json_is_one_object() {
|
|
30
20
|
use plum_web_test::{stats_json, FrameStats, Options};
|
|
@@ -55,7 +45,7 @@ fn chromium_runs_without_module_introspection() {
|
|
|
55
45
|
}
|
|
56
46
|
let opts = Options { no_module_introspection: true, ..Options::default() };
|
|
57
47
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
58
|
-
let report = rt.block_on(run(&repo_root().join("examples/games/
|
|
48
|
+
let report = rt.block_on(run(&repo_root().join("examples/games/orb_run.plum"), &opts)).unwrap_or_else(|e| panic!("{e}"));
|
|
59
49
|
println!("{report}");
|
|
60
50
|
}
|
|
61
51
|
|
|
@@ -67,7 +57,7 @@ fn webkit_runs_without_module_introspection() {
|
|
|
67
57
|
}
|
|
68
58
|
let opts = Options { browser: Browser::Webkit, no_module_introspection: true, ..Options::default() };
|
|
69
59
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
70
|
-
let report = rt.block_on(run(&repo_root().join("examples/games/
|
|
60
|
+
let report = rt.block_on(run(&repo_root().join("examples/games/orb_run.plum"), &opts)).unwrap_or_else(|e| {
|
|
71
61
|
assert!(!e.contains("unable to produce import descriptors"), "{e}");
|
|
72
62
|
panic!("{e}")
|
|
73
63
|
});
|
|
@@ -82,7 +72,7 @@ fn webkit_without_webgpu_loads_the_wasm_and_says_so() {
|
|
|
82
72
|
}
|
|
83
73
|
let opts = Options { browser: Browser::Webkit, expect_unsupported_webgpu: true, no_module_introspection: true, ..Options::default() };
|
|
84
74
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
|
85
|
-
let report = rt.block_on(run(&repo_root().join("examples/games/
|
|
75
|
+
let report = rt.block_on(run(&repo_root().join("examples/games/orb_run.plum"), &opts)).unwrap_or_else(|e| panic!("{e}"));
|
|
86
76
|
assert!(report.contains("plum.wasm load probe: ok"), "{report}");
|
|
87
77
|
}
|
|
88
78
|
|
compiler/game/web/plum_bridge.js
CHANGED
|
@@ -1216,8 +1216,8 @@ fn shade(i: VOut) -> vec4f {
|
|
|
1216
1216
|
const instance = await instantiatePlum(host);
|
|
1217
1217
|
ctx.plum = instance.exports;
|
|
1218
1218
|
const ex = ctx.plum;
|
|
1219
|
-
// A game's entry is `main`, which returns the game.
|
|
1219
|
+
// A game's entry is `main`, which returns the game; `update` returns the next state.
|
|
1220
|
-
|
|
1220
|
+
let state = ex.main();
|
|
1221
1221
|
|
|
1222
1222
|
let last = performance.now();
|
|
1223
1223
|
function frame(now) {
|
|
@@ -1229,7 +1229,7 @@ fn shade(i: VOut) -> vec4f {
|
|
|
1229
1229
|
b.beginFrame(view.w, view.h, view.dpr);
|
|
1230
1230
|
const m = globalThis.__plumMeasure; // set by plum-web-test --measure; null otherwise
|
|
1231
1231
|
const t0 = m ? performance.now() : 0;
|
|
1232
|
-
ex.update(state, dt);
|
|
1232
|
+
state = ex.update(state, dt);
|
|
1233
1233
|
const t1 = m ? performance.now() : 0;
|
|
1234
1234
|
ex.draw(state);
|
|
1235
1235
|
const t2 = m ? performance.now() : 0;
|
compiler/lsp/src/main.rs
CHANGED
|
@@ -835,7 +835,7 @@ mod tests {
|
|
|
835
835
|
"sequences.plum",
|
|
836
836
|
"match.plum",
|
|
837
837
|
"unions.plum",
|
|
838
|
-
"games/
|
|
838
|
+
"games/game_loop.plum",
|
|
839
839
|
] {
|
|
840
840
|
let path = repoRoot().join("examples").join(f);
|
|
841
841
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
@@ -974,7 +974,7 @@ mod tests {
|
|
|
974
974
|
("Testing", "std/Testing"),
|
|
975
975
|
("Http", "std/Http"),
|
|
976
976
|
("Int", "std/Int"),
|
|
977
|
-
("
|
|
977
|
+
("SceneNode", "std/game/Scene"),
|
|
978
978
|
("Io", "std/Io"),
|
|
979
979
|
("Key", "std/game/Game"),
|
|
980
980
|
("Vec2", "std/game/Vec2"),
|
compiler/wasm-codegen/src/lib.rs
CHANGED
|
@@ -76,30 +76,15 @@ pub struct Output {
|
|
|
76
76
|
pub fn_keys: Vec<String>,
|
|
77
77
|
/// With `opts.tests`: each test's display name, in the order of its `"test#<name>"` export.
|
|
78
78
|
pub test_names: Vec<String>,
|
|
79
|
-
/// `main` returns `
|
|
79
|
+
/// `main` returns a type with `update(self, io, dt) -> T` and `draw(self, io)`: the module
|
|
80
|
-
/// `update(state, dt)` and `draw(state)` for the host, and `main` builds the state.
|
|
80
|
+
/// exports `update(state, dt) -> state` and `draw(state)` for the host, and `main` builds the state.
|
|
81
81
|
pub isGame: bool,
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/// Is `main` declared `-> T` with `T` the std/game `Game` struct or a subtype of it?
|
|
85
|
-
pub fn isGame(source: &ast::Source) -> bool {
|
|
86
|
-
// A user's own struct named `Game` is not std/game's: its frame entry points are missing.
|
|
87
|
-
let hasEntryPoints = GAME_EXPORTS.iter().all(|(inner, _)| {
|
|
88
|
-
source.items.iter().any(|i| matches!(i, ast::Item::Fn(f) if f.name == *inner && f.type_param.is_none()))
|
|
89
|
-
});
|
|
90
|
-
hasEntryPoints && source.items.iter().any(|i| match i {
|
|
91
|
-
ast::Item::Fn(f) if f.name == "main" && f.type_param.is_none() => f
|
|
92
|
-
.returns
|
|
93
|
-
.as_ref()
|
|
94
|
-
.is_some_and(|t| plum_core::subtyping::isSubtype(source, &t.name, "Game")),
|
|
95
|
-
_ => false,
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const GAME_EXPORTS: [(&str, &str); 2] = [(
|
|
84
|
+
const GAME_EXPORTS: [(&str, &str); 2] = [(plum_core::game_entry::GAME_UPDATE, "update"), (plum_core::game_entry::GAME_DRAW, "draw")];
|
|
100
85
|
|
|
101
|
-
fn gameExports(
|
|
86
|
+
fn gameExports(game: bool) -> Vec<(String, String)> {
|
|
102
|
-
if !
|
|
87
|
+
if !game {
|
|
103
88
|
return Vec::new();
|
|
104
89
|
}
|
|
105
90
|
GAME_EXPORTS.iter().map(|(i, e)| (i.to_string(), e.to_string())).collect()
|
|
@@ -107,8 +92,11 @@ fn gameExports(source: &ast::Source) -> Vec<(String, String)> {
|
|
|
107
92
|
|
|
108
93
|
/// The one compile entry: type-checks `source` (passes per `opts.passes`) and compiles it.
|
|
109
94
|
/// With `opts.tests`, `test` blocks are first lowered to ordinary fns (see
|
|
110
|
-
/// `plum_core::tests_lower`) and exported.
|
|
95
|
+
/// `plum_core::tests_lower`) and exported. A game's frame entry points are added first.
|
|
111
96
|
pub fn compile(source: &ast::Source, opts: &CompileOptions) -> Result<Output, Vec<Diagnostic>> {
|
|
97
|
+
let mut gameSource = source.clone();
|
|
98
|
+
let game = plum_core::game_entry::lowerGame(&mut gameSource).map_err(|d| vec![d])?;
|
|
99
|
+
let source = &gameSource;
|
|
112
100
|
if opts.passes.explain {
|
|
113
101
|
for line in explainLayouts(source) {
|
|
114
102
|
eprintln!("{line}");
|
|
@@ -118,13 +106,13 @@ pub fn compile(source: &ast::Source, opts: &CompileOptions) -> Result<Output, Ve
|
|
|
118
106
|
let prog = plum_core::tests_lower::lowerTests(source);
|
|
119
107
|
let typed = plum_driver::check(&prog.source, &opts.passes)?;
|
|
120
108
|
let mut extra = prog.extraExports.clone();
|
|
121
|
-
extra.extend(gameExports(
|
|
109
|
+
extra.extend(gameExports(game));
|
|
122
110
|
let (wasm, fn_keys) = compileSourceInner(&prog.source, &extra, typed).map_err(codegenDiag)?;
|
|
123
|
-
Ok(Output { wasm, fn_keys, test_names: prog.names, isGame:
|
|
111
|
+
Ok(Output { wasm, fn_keys, test_names: prog.names, isGame: game })
|
|
124
112
|
} else {
|
|
125
113
|
let typed = plum_driver::check(source, &opts.passes)?;
|
|
126
|
-
let (wasm, fn_keys) = compileSourceInner(source, &gameExports(
|
|
114
|
+
let (wasm, fn_keys) = compileSourceInner(source, &gameExports(game), typed).map_err(codegenDiag)?;
|
|
127
|
-
Ok(Output { wasm, fn_keys, test_names: Vec::new(), isGame:
|
|
115
|
+
Ok(Output { wasm, fn_keys, test_names: Vec::new(), isGame: game })
|
|
128
116
|
}
|
|
129
117
|
}
|
|
130
118
|
|
compiler/wasm-codegen/tests/wasm_codegen/fusion_alloc_test.rs
CHANGED
|
@@ -215,7 +215,7 @@ fn list_pipeline_loop_has_no_cons_alloc() {
|
|
|
215
215
|
/// `List.map(|o| o.render(..))` children (each `render` resolved by its receiver type).
|
|
216
216
|
#[test]
|
|
217
217
|
fn state_view_draw_has_no_scenenode_alloc() {
|
|
218
|
-
let g = loadFile(&repoRoot().join("examples/games/
|
|
218
|
+
let g = loadFile(&repoRoot().join("examples/games/orb_run.plum"));
|
|
219
219
|
let ns = notes(&g);
|
|
220
220
|
say(format!("state_view: fusion notes {ns:?}"));
|
|
221
221
|
let w = compile(&g, true);
|
docs/plans/2026-11-06-simpler-language.benchmark.md
CHANGED
|
@@ -108,3 +108,7 @@ Five probes: `for` with a `total =` accumulator, `while`, a tail-recursive `fold
|
|
|
108
108
|
- Under the default build, `find` reads 0 ms because fusion moves the pure `findFirst` call past the timer, to where its answer is printed. The answer is printed in the fixed file, and with `--no-fusion` the call runs at the right place and takes 14 ms, the same as the others. Use the `--no-fusion` figure for `find`.
|
|
109
109
|
- All five forms take 13 to 15 ms for 50 million iterations (about 0.28 ns each), so the compiler flattens `for`, `while`, the closure fold and the struct state loop to the same code on this machine. Timer resolution is 1 ms. The earlier "loops +75%" figure does not show up in these probes.
|
|
110
110
|
- Spread is 1 to 2 ms, which is about 10%; the forms cannot be told apart.
|
|
111
|
+
|
|
112
|
+
## Branch fixtures after T2 (plain games, one scene)
|
|
113
|
+
|
|
114
|
+
On `simpler-language` the fixtures under `compiler/game/web-test/fixtures` are all plain games (`main` returns a type with `update(self, io, dt) -> Self` and `draw(self, io)`). `stress_scene` and `stress_pure_scene` are deleted (the baseline keeps the originals); `stress_state_view` is the functional twin of both, drawing the same 5000/500/200/50 scene. `stress_world` is ported to state and view with the same 20000 sprites, 500 walkers, camera drift, per-frame y-sort of the whole list and a 1280x720 cull. One difference: the old version walked `children.at(i * 37 % 20000)` after each re-sort, so which sprites walked changed every frame; the port gives walker `w` the fixed sprite `37 * w`. The view builds scene nodes only for the visible sprites. Nothing is tuned for speed; T14 measures.
|
examples/games/{imperative/game_loop.plum → game_loop.plum}
RENAMED
|
File without changes
|
examples/games/imperative/game.plum
DELETED
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
# Orb Run: a small arcade game for the game host (`plum run examples/games/imperative/game.plum`).
|
|
2
|
-
# Steer the ship with the arrow keys or WASD, collect the drifting orbs and avoid
|
|
3
|
-
# the mines. Three lives; Enter or R restarts after the game is over. The music
|
|
4
|
-
# loops from `main`; orbs, mines and the end of a run each play a sound.
|
|
5
|
-
#
|
|
6
|
-
# All rules are methods of `World` (plus a few free helpers) that never call the host, so
|
|
7
|
-
# the `test` blocks below run under `plum test`; only `OrbRun`, the `Game` struct over a
|
|
8
|
-
# `World`, and `main` talk to the host.
|
|
9
|
-
module game
|
|
10
|
-
import std/Str
|
|
11
|
-
import std/Int
|
|
12
|
-
import std/Float
|
|
13
|
-
import std/Slice
|
|
14
|
-
import std/Math
|
|
15
|
-
import std/Random
|
|
16
|
-
import std/game/Vec2
|
|
17
|
-
import std/game/Color
|
|
18
|
-
import std/game/Game
|
|
19
|
-
import std/game/Input
|
|
20
|
-
import std/Io
|
|
21
|
-
|
|
22
|
-
primitive Rules =
|
|
23
|
-
ArenaW = 800.0
|
|
24
|
-
ArenaH = 600.0
|
|
25
|
-
ShipRadius = 10.0
|
|
26
|
-
OrbRadius = 10.0
|
|
27
|
-
MineRadius = 11.0
|
|
28
|
-
Thrust = 900.0
|
|
29
|
-
Drag = 2.2
|
|
30
|
-
OrbCount = 3
|
|
31
|
-
StartLives = 3
|
|
32
|
-
HurtTime = 1.5
|
|
33
|
-
OrbPoints = 10
|
|
34
|
-
Cell = 16.0
|
|
35
|
-
Zoom = 3.0
|
|
36
|
-
|
|
37
|
-
# A moving circle. `angle` is the ship's heading or a mine's spin, in radians clockwise.
|
|
38
|
-
struct Body(mut x: Float, mut y: Float, mut vx: Float, mut vy: Float, mut angle: Float)
|
|
39
|
-
|
|
40
|
-
struct World(
|
|
41
|
-
ship: Body,
|
|
42
|
-
orbs: Slice[Body],
|
|
43
|
-
mines: Slice[Body],
|
|
44
|
-
mut rng: Random,
|
|
45
|
-
mut score: Int,
|
|
46
|
-
mut best: Int,
|
|
47
|
-
mut lives: Int,
|
|
48
|
-
mut hurt: Float,
|
|
49
|
-
mut clock: Float,
|
|
50
|
-
mut over: Bool) =
|
|
51
|
-
|
|
52
|
-
fun create(seed: Int) -> World =
|
|
53
|
-
ship := Body(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
54
|
-
w := World(
|
|
55
|
-
ship: ship, orbs: Slice[Body](), mines: Slice[Body](), rng: Random.seeded(seed), score: 0, best: 0,
|
|
56
|
-
lives: Rules.StartLives, hurt: 0.0, clock: 0.0, over: false)
|
|
57
|
-
for i := range Rules.OrbCount
|
|
58
|
-
sp := spawn(w.rng, ship, 120.0, 40.0)
|
|
59
|
-
w.rng = sp.rng
|
|
60
|
-
w.orbs.push(sp.at)
|
|
61
|
-
for i := range mineCount(0)
|
|
62
|
-
sp := spawn(w.rng, ship, 220.0, 90.0)
|
|
63
|
-
w.rng = sp.rng
|
|
64
|
-
w.mines.push(sp.at)
|
|
65
|
-
w
|
|
66
|
-
|
|
67
|
-
# A world with the orbs and mines moved out of the way.
|
|
68
|
-
fun empty() -> World =
|
|
69
|
-
w := World.create(7)
|
|
70
|
-
for i := range w.orbs.length()
|
|
71
|
-
w.orbs.at(i).x = 5000.0
|
|
72
|
-
w.orbs.at(i).vx = 0.0
|
|
73
|
-
w.orbs.at(i).vy = 0.0
|
|
74
|
-
for i := range w.mines.length()
|
|
75
|
-
w.mines.at(i).x = 5000.0
|
|
76
|
-
w.mines.at(i).vx = 0.0
|
|
77
|
-
w.mines.at(i).vy = 0.0
|
|
78
|
-
w
|
|
79
|
-
|
|
80
|
-
# Starts a fresh game, keeping the best score and the random stream.
|
|
81
|
-
fun restart(self) =
|
|
82
|
-
self.best = self.best.max(self.score)
|
|
83
|
-
self.score = 0
|
|
84
|
-
self.lives = Rules.StartLives
|
|
85
|
-
self.hurt = 0.0
|
|
86
|
-
self.over = false
|
|
87
|
-
self.ship.x = Rules.ArenaW / 2.0
|
|
88
|
-
self.ship.y = Rules.ArenaH / 2.0
|
|
89
|
-
self.ship.vx = 0.0
|
|
90
|
-
self.ship.vy = 0.0
|
|
91
|
-
for i := range self.orbs.length()
|
|
92
|
-
sp := spawn(self.rng, self.ship, 120.0, 40.0)
|
|
93
|
-
self.rng = sp.rng
|
|
94
|
-
moved := sp.at
|
|
95
|
-
self.orbs.at(i).x = moved.x
|
|
96
|
-
self.orbs.at(i).y = moved.y
|
|
97
|
-
self.orbs.at(i).vx = moved.vx
|
|
98
|
-
self.orbs.at(i).vy = moved.vy
|
|
99
|
-
while self.mines.length() > mineCount(0)
|
|
100
|
-
_ := self.mines.pop()!
|
|
101
|
-
for i := range self.mines.length()
|
|
102
|
-
sp := spawn(self.rng, self.ship, 220.0, 90.0)
|
|
103
|
-
self.rng = sp.rng
|
|
104
|
-
moved := sp.at
|
|
105
|
-
self.mines.at(i).x = moved.x
|
|
106
|
-
self.mines.at(i).y = moved.y
|
|
107
|
-
self.mines.at(i).vx = moved.vx
|
|
108
|
-
self.mines.at(i).vy = moved.vy
|
|
109
|
-
|
|
110
|
-
# One simulation step. Does nothing once the game is over.
|
|
111
|
-
fun step(self, ax: Float, ay: Float, dt: Float) =
|
|
112
|
-
self.clock = self.clock + dt
|
|
113
|
-
if self.over
|
|
114
|
-
return
|
|
115
|
-
self.hurt = {self.hurt - dt}.max(0.0)
|
|
116
|
-
steer(self.ship, ax, ay, dt)
|
|
117
|
-
for i := range self.orbs.length()
|
|
118
|
-
orb := self.orbs.at(i)
|
|
119
|
-
drift(orb, Rules.OrbRadius, dt)
|
|
120
|
-
if overlaps(self.ship, Rules.ShipRadius, orb, Rules.OrbRadius)
|
|
121
|
-
self.score = self.score + Rules.OrbPoints
|
|
122
|
-
sp := spawn(self.rng, self.ship, 120.0, 40.0)
|
|
123
|
-
self.rng = sp.rng
|
|
124
|
-
moved := sp.at
|
|
125
|
-
orb.x = moved.x
|
|
126
|
-
orb.y = moved.y
|
|
127
|
-
orb.vx = moved.vx
|
|
128
|
-
orb.vy = moved.vy
|
|
129
|
-
while self.mines.length() < mineCount(self.score)
|
|
130
|
-
sp := spawn(self.rng, self.ship, 220.0, 90.0)
|
|
131
|
-
self.rng = sp.rng
|
|
132
|
-
self.mines.push(sp.at)
|
|
133
|
-
for i := range self.mines.length()
|
|
134
|
-
mine := self.mines.at(i)
|
|
135
|
-
drift(mine, Rules.MineRadius, dt)
|
|
136
|
-
mine.angle = mine.angle + 2.0 * dt
|
|
137
|
-
if self.hurt <= 0.0 && overlaps(self.ship, Rules.ShipRadius, mine, Rules.MineRadius)
|
|
138
|
-
self.lives = self.lives - 1
|
|
139
|
-
self.hurt = Rules.HurtTime
|
|
140
|
-
if self.lives <= 0
|
|
141
|
-
self.over = true
|
|
142
|
-
self.best = self.best.max(self.score)
|
|
143
|
-
|
|
144
|
-
# The game: a `World` and the host parts it plays and draws with.
|
|
145
|
-
struct OrbRun(Game(engine), world: World, sheet: Texture, pickupSound: Sound, hitSound: Sound, overSound: Sound, musicSound: Sound) =
|
|
146
|
-
|
|
147
|
-
# Restarts the music loop from the top.
|
|
148
|
-
fun startMusic(self) =
|
|
149
|
-
self.audio.stop(self.musicSound)
|
|
150
|
-
self.audio.loop(self.musicSound, musicVolume())
|
|
151
|
-
|
|
152
|
-
# Draws frame `frame` of sheet row `row`, centred on (x, y), at `Rules.Zoom` times its size.
|
|
153
|
-
fun sprite(self, row: Int, frame: Int, x: Float, y: Float, angle: Float, tint: Color) =
|
|
154
|
-
size := Rules.Cell * Rules.Zoom
|
|
155
|
-
self.canvas.drawTextureEx(
|
|
156
|
-
self.sheet, Float(frame) * Rules.Cell, Float(row) * Rules.Cell, Rules.Cell, Rules.Cell,
|
|
157
|
-
x - size / 2.0, y - size / 2.0, size, size, angle, false, tint)
|
|
158
|
-
|
|
159
|
-
fun drawStars(self) =
|
|
160
|
-
for i := range 40
|
|
161
|
-
sx := Float({i * 7919} % 800)
|
|
162
|
-
sy := Float({i * 104729} % 600)
|
|
163
|
-
twinkle := 0.25 + 0.2 * Math.sin(self.world.clock * 2.0 + Float(i))
|
|
164
|
-
self.canvas.drawCircle(sx, sy, 1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
|
|
165
|
-
|
|
166
|
-
# Enter or R restarts a finished game, the held keys steer.
|
|
167
|
-
override fun update(self, dt: Float) =
|
|
168
|
-
w := self.world
|
|
169
|
-
inputs := self.input.events()
|
|
170
|
-
for i := range inputs.length()
|
|
171
|
-
match inputs.at(i)
|
|
172
|
-
KeyDown(k) =>
|
|
173
|
-
if w.over && {k == Key.Enter || k == Key.KeyR}
|
|
174
|
-
w.restart()
|
|
175
|
-
self.startMusic()
|
|
176
|
-
_ => 0
|
|
177
|
-
left := self.input.isKeyDown(Key.Left) || self.input.isKeyDown(Key.KeyA)
|
|
178
|
-
right := self.input.isKeyDown(Key.Right) || self.input.isKeyDown(Key.KeyD)
|
|
179
|
-
up := self.input.isKeyDown(Key.Up) || self.input.isKeyDown(Key.KeyW)
|
|
180
|
-
down := self.input.isKeyDown(Key.Down) || self.input.isKeyDown(Key.KeyS)
|
|
181
|
-
score := w.score
|
|
182
|
-
lives := w.lives
|
|
183
|
-
w.step(axis(left, right), axis(up, down), dt)
|
|
184
|
-
if w.score > score
|
|
185
|
-
self.audio.play(self.pickupSound, 0.8)
|
|
186
|
-
if w.lives < lives
|
|
187
|
-
if w.over
|
|
188
|
-
self.audio.stop(self.musicSound)
|
|
189
|
-
self.audio.play(self.overSound, 1.0)
|
|
190
|
-
else
|
|
191
|
-
self.audio.play(self.hitSound, 1.0)
|
|
192
|
-
|
|
193
|
-
override fun draw(self) =
|
|
194
|
-
w := self.world
|
|
195
|
-
canvas := self.canvas
|
|
196
|
-
sw := canvas.width()
|
|
197
|
-
sh := canvas.height()
|
|
198
|
-
zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
|
|
199
|
-
canvas.clear(Color.rgb(0.04, 0.05, 0.1))
|
|
200
|
-
canvas.setCamera(0.0 - {sw / zoom - Rules.ArenaW} / 2.0, 0.0 - {sh / zoom - Rules.ArenaH} / 2.0, zoom)
|
|
201
|
-
canvas.drawRect(0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16))
|
|
202
|
-
self.drawStars()
|
|
203
|
-
for i := range w.orbs.length()
|
|
204
|
-
orb := w.orbs.at(i)
|
|
205
|
-
canvas.blendMode(Blend.Add)
|
|
206
|
-
canvas.drawCircle(orb.x, orb.y, 26.0, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5))
|
|
207
|
-
canvas.drawCircle(orb.x, orb.y, 17.0, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))
|
|
208
|
-
canvas.blendMode(Blend.Normal)
|
|
209
|
-
self.sprite(1, frameOf(w.clock + Float(i) * 0.1, 4, 8.0), orb.x, orb.y, 0.0, Color.White)
|
|
210
|
-
for i := range w.mines.length()
|
|
211
|
-
mine := w.mines.at(i)
|
|
212
|
-
self.sprite(2, frameOf(w.clock, 4, 4.0), mine.x, mine.y, mine.angle, Color.White)
|
|
213
|
-
blink := w.hurt > 0.0 && frameOf(w.clock, 2, 10.0) == 0
|
|
214
|
-
if !w.over && !blink
|
|
215
|
-
flame := frameOf(w.clock, 4, 14.0)
|
|
216
|
-
canvas.drawCircle(w.ship.x, w.ship.y, 17.0, Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12))
|
|
217
|
-
self.sprite(0, flame, w.ship.x, w.ship.y, w.ship.angle, Color.White)
|
|
218
|
-
canvas.drawText("Score {w.score}", 16.0, 12.0, 28.0, Color.White)
|
|
219
|
-
canvas.drawText("Best {w.best.max(w.score)}", 16.0, 44.0, 20.0, Color.Yellow)
|
|
220
|
-
for i := range w.lives
|
|
221
|
-
canvas.drawTextureEx(
|
|
222
|
-
self.sheet, 0.0, 0.0, Rules.Cell, Rules.Cell,
|
|
223
|
-
Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0, 0.0, false, Color.White)
|
|
224
|
-
if w.over
|
|
225
|
-
canvas.drawRect(0.0, 240.0, Rules.ArenaW, 120.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6))
|
|
226
|
-
canvas.drawText("Game over", 300.0, 258.0, 48.0, Color.Red)
|
|
227
|
-
canvas.drawText("Press Enter to play again", 270.0, 316.0, 24.0, Color.White)
|
|
228
|
-
canvas.resetCamera()
|
|
229
|
-
|
|
230
|
-
fun distance(a: Body, b: Body) -> Float =
|
|
231
|
-
Math.hypot(a.x - b.x, a.y - b.y)
|
|
232
|
-
|
|
233
|
-
fun overlaps(a: Body, ra: Float, b: Body, rb: Float) -> Bool =
|
|
234
|
-
distance(a, b) < ra + rb
|
|
235
|
-
|
|
236
|
-
# Which of `count` animation frames is showing after `clock` seconds at `fps` frames per second.
|
|
237
|
-
fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
|
|
238
|
-
Int(clock * fps) % count
|
|
239
|
-
|
|
240
|
-
# A body at a random spot at least `gap` away from `avoid`, drifting at up to `speed`.
|
|
241
|
-
# A Body and the generator to continue from.
|
|
242
|
-
struct Spawned(at: Body, rng: Random)
|
|
243
|
-
|
|
244
|
-
fun spawn(rng: Random, avoid: Body, gap: Float, speed: Float) -> Spawned =
|
|
245
|
-
margin := 30.0
|
|
246
|
-
b := Body(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
247
|
-
r := rng
|
|
248
|
-
tries := 0
|
|
249
|
-
while tries < 20
|
|
250
|
-
dx := r.nextFloat()
|
|
251
|
-
dy := dx.rng.nextFloat()
|
|
252
|
-
r = dy.rng
|
|
253
|
-
b.x = margin + dx.value * {Rules.ArenaW - 2.0 * margin}
|
|
254
|
-
b.y = margin + dy.value * {Rules.ArenaH - 2.0 * margin}
|
|
255
|
-
if distance(b, avoid) >= gap
|
|
256
|
-
break
|
|
257
|
-
tries = tries + 1
|
|
258
|
-
dh := r.nextFloat()
|
|
259
|
-
dp := dh.rng.nextFloat()
|
|
260
|
-
heading := dh.value * Float.Tau
|
|
261
|
-
pace := speed * {0.4 + 0.6 * dp.value}
|
|
262
|
-
b.vx = Math.cos(heading) * pace
|
|
263
|
-
b.vy = Math.sin(heading) * pace
|
|
264
|
-
Spawned(at: b, rng: dp.rng)
|
|
265
|
-
|
|
266
|
-
# How many mines are out: more as the score grows.
|
|
267
|
-
fun mineCount(score: Int) -> Int =
|
|
268
|
-
{3 + score / 50}.min(8)
|
|
269
|
-
|
|
270
|
-
# Accelerates the ship by input axes (-1..1), applies drag and keeps it in the arena.
|
|
271
|
-
fun steer(ship: Body, ax: Float, ay: Float, dt: Float) =
|
|
272
|
-
ship.vx = ship.vx + ax * Rules.Thrust * dt
|
|
273
|
-
ship.vy = ship.vy + ay * Rules.Thrust * dt
|
|
274
|
-
keep := {1.0 - Rules.Drag * dt}.max(0.0)
|
|
275
|
-
ship.vx = ship.vx * keep
|
|
276
|
-
ship.vy = ship.vy * keep
|
|
277
|
-
ship.x = ship.x + ship.vx * dt
|
|
278
|
-
ship.y = ship.y + ship.vy * dt
|
|
279
|
-
r := Rules.ShipRadius
|
|
280
|
-
if ship.x < r
|
|
281
|
-
ship.x = r
|
|
282
|
-
ship.vx = 0.0
|
|
283
|
-
if ship.x > Rules.ArenaW - r
|
|
284
|
-
ship.x = Rules.ArenaW - r
|
|
285
|
-
ship.vx = 0.0
|
|
286
|
-
if ship.y < r
|
|
287
|
-
ship.y = r
|
|
288
|
-
ship.vy = 0.0
|
|
289
|
-
if ship.y > Rules.ArenaH - r
|
|
290
|
-
ship.y = Rules.ArenaH - r
|
|
291
|
-
ship.vy = 0.0
|
|
292
|
-
if ship.vx * ship.vx + ship.vy * ship.vy > 400.0
|
|
293
|
-
ship.angle = Math.atan2(ship.vx, 0.0 - ship.vy)
|
|
294
|
-
|
|
295
|
-
# Moves a drifter and bounces it off the arena walls.
|
|
296
|
-
fun drift(b: Body, r: Float, dt: Float) =
|
|
297
|
-
b.x = b.x + b.vx * dt
|
|
298
|
-
b.y = b.y + b.vy * dt
|
|
299
|
-
if b.x < r || b.x > Rules.ArenaW - r
|
|
300
|
-
b.vx = 0.0 - b.vx
|
|
301
|
-
b.x = b.x.max(r).min(Rules.ArenaW - r)
|
|
302
|
-
if b.y < r || b.y > Rules.ArenaH - r
|
|
303
|
-
b.vy = 0.0 - b.vy
|
|
304
|
-
b.y = b.y.max(r).min(Rules.ArenaH - r)
|
|
305
|
-
|
|
306
|
-
# --- host glue: input, drawing ---
|
|
307
|
-
|
|
308
|
-
fun musicVolume() -> Float =
|
|
309
|
-
0.3
|
|
310
|
-
|
|
311
|
-
fun main(io: Io) -> Game =
|
|
312
|
-
e := Engine.create(io)
|
|
313
|
-
game := OrbRun(
|
|
314
|
-
engine: e, world: World.create(io.randomSeed()), sheet: e.assets.texture("sprites.png"),
|
|
315
|
-
pickupSound: e.assets.sound("pickup.wav"), hitSound: e.assets.sound("hit.wav"),
|
|
316
|
-
overSound: e.assets.sound("gameover.wav"), musicSound: e.assets.sound("music.wav"))
|
|
317
|
-
game.startMusic()
|
|
318
|
-
game
|
|
319
|
-
|
|
320
|
-
fun axis(negative: Bool, positive: Bool) -> Float =
|
|
321
|
-
if negative && !positive
|
|
322
|
-
-1.0
|
|
323
|
-
else if positive && !negative
|
|
324
|
-
1.0
|
|
325
|
-
else
|
|
326
|
-
0.0
|
|
327
|
-
|
|
328
|
-
# --- tests ---
|
|
329
|
-
|
|
330
|
-
fun still(x: Float, y: Float) -> Body =
|
|
331
|
-
Body(x: x, y: y, vx: 0.0, vy: 0.0, angle: 0.0)
|
|
332
|
-
|
|
333
|
-
test "steering accelerates and drag slows the ship"
|
|
334
|
-
ship := still(400.0, 300.0)
|
|
335
|
-
steer(ship, 1.0, 0.0, 0.1)
|
|
336
|
-
assert ship.vx > 0.0
|
|
337
|
-
assert ship.x > 400.0
|
|
338
|
-
fast := ship.vx
|
|
339
|
-
for i := range 30
|
|
340
|
-
steer(ship, 0.0, 0.0, 0.1)
|
|
341
|
-
assert ship.vx < fast / 4.0
|
|
342
|
-
|
|
343
|
-
test "the ship stops at the arena walls"
|
|
344
|
-
ship := still(20.0, 20.0)
|
|
345
|
-
for i := range 20
|
|
346
|
-
steer(ship, -1.0, -1.0, 0.1)
|
|
347
|
-
assert ship.x == Rules.ShipRadius
|
|
348
|
-
assert ship.y == Rules.ShipRadius
|
|
349
|
-
assert ship.vx == 0.0
|
|
350
|
-
|
|
351
|
-
test "drifters bounce off the walls"
|
|
352
|
-
b := Body(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0, angle: 0.0)
|
|
353
|
-
drift(b, 10.0, 0.1)
|
|
354
|
-
assert b.vx == -100.0
|
|
355
|
-
assert b.x == Rules.ArenaW - 10.0
|
|
356
|
-
|
|
357
|
-
test "collecting an orb scores and respawns it elsewhere"
|
|
358
|
-
w := World.empty()
|
|
359
|
-
orb := w.orbs.at(0)
|
|
360
|
-
orb.x = w.ship.x
|
|
361
|
-
orb.y = w.ship.y
|
|
362
|
-
w.step(0.0, 0.0, 0.016)
|
|
363
|
-
assert w.score == Rules.OrbPoints
|
|
364
|
-
assert distance(orb, w.ship) >= 120.0
|
|
365
|
-
assert orb.x < 5000.0
|
|
366
|
-
|
|
367
|
-
test "a mine costs one life, then the ship is briefly safe"
|
|
368
|
-
w := World.empty()
|
|
369
|
-
mine := w.mines.at(0)
|
|
370
|
-
mine.x = w.ship.x
|
|
371
|
-
mine.y = w.ship.y
|
|
372
|
-
w.step(0.0, 0.0, 0.016)
|
|
373
|
-
assert w.lives == Rules.StartLives - 1
|
|
374
|
-
assert w.hurt > 0.0
|
|
375
|
-
w.step(0.0, 0.0, 0.016)
|
|
376
|
-
assert w.lives == Rules.StartLives - 1
|
|
377
|
-
mine.x = 5000.0
|
|
378
|
-
for i := range 100
|
|
379
|
-
w.step(0.0, 0.0, 0.016)
|
|
380
|
-
assert w.hurt == 0.0
|
|
381
|
-
|
|
382
|
-
test "the game ends at zero lives and then ignores steps"
|
|
383
|
-
w := World.empty()
|
|
384
|
-
w.lives = 1
|
|
385
|
-
w.score = 40
|
|
386
|
-
mine := w.mines.at(0)
|
|
387
|
-
mine.x = w.ship.x
|
|
388
|
-
mine.y = w.ship.y
|
|
389
|
-
w.step(0.0, 0.0, 0.016)
|
|
390
|
-
assert w.over
|
|
391
|
-
assert w.lives == 0
|
|
392
|
-
assert w.best == 40
|
|
393
|
-
x := w.ship.x
|
|
394
|
-
w.step(1.0, 0.0, 0.5)
|
|
395
|
-
assert w.ship.x == x
|
|
396
|
-
|
|
397
|
-
test "restart resets the run and keeps the best score"
|
|
398
|
-
w := World.empty()
|
|
399
|
-
w.score = 90
|
|
400
|
-
w.lives = 0
|
|
401
|
-
w.over = true
|
|
402
|
-
w.restart()
|
|
403
|
-
assert !w.over
|
|
404
|
-
assert w.score == 0
|
|
405
|
-
assert w.best == 90
|
|
406
|
-
assert w.lives == Rules.StartLives
|
|
407
|
-
assert w.ship.x == Rules.ArenaW / 2.0
|
|
408
|
-
assert w.mines.length() == mineCount(0)
|
|
409
|
-
|
|
410
|
-
test "more mines appear as the score grows"
|
|
411
|
-
assert mineCount(0) == 3
|
|
412
|
-
assert mineCount(100) == 5
|
|
413
|
-
assert mineCount(10000) == 8
|
|
414
|
-
w := World.empty()
|
|
415
|
-
w.score = 100
|
|
416
|
-
w.step(0.0, 0.0, 0.016)
|
|
417
|
-
assert w.mines.length() == 5
|
|
418
|
-
|
|
419
|
-
test "the same seed plays out the same way"
|
|
420
|
-
a := World.create(11)
|
|
421
|
-
b := World.create(11)
|
|
422
|
-
for i := range 120
|
|
423
|
-
a.step(1.0, -1.0, 0.016)
|
|
424
|
-
b.step(1.0, -1.0, 0.016)
|
|
425
|
-
assert a.ship.x == b.ship.x
|
|
426
|
-
assert a.score == b.score
|
|
427
|
-
assert a.mines.at(0).x == b.mines.at(0).x
|
|
428
|
-
assert World.create(12).orbs.at(0).x != a.orbs.at(0).x
|
|
429
|
-
|
|
430
|
-
test "animation frames cycle"
|
|
431
|
-
assert frameOf(0.0, 4, 8.0) == 0
|
|
432
|
-
assert frameOf(0.13, 4, 8.0) == 1
|
|
433
|
-
assert frameOf(0.5, 4, 8.0) == 0
|
|
434
|
-
assert axis(true, false) == -1.0
|
|
435
|
-
assert axis(true, true) == 0.0
|
examples/games/imperative/game_scene.plum
DELETED
|
@@ -1,663 +0,0 @@
|
|
|
1
|
-
# Orb Run on the scene graph (`plum run examples/games/imperative/game_scene.plum`): the same game as
|
|
2
|
-
# game.plum (same arena, rules, sprites, sounds, keys and scoring), rebuilt in retained
|
|
3
|
-
# mode on std/game/imperative/Scene. Steer the ship with the arrow keys or WASD, collect the
|
|
4
|
-
# drifting orbs and avoid the mines. Three lives; Enter or R restarts after the game
|
|
5
|
-
# is over.
|
|
6
|
-
#
|
|
7
|
-
# What differs from game.plum. There, one `World` record holds plain `Body` values,
|
|
8
|
-
# `step` is the only place that moves anything and `draw` issues every call by hand.
|
|
9
|
-
# Here each kind is a struct embedding the scene's `Actor` base struct (`Ship`, `Orb`,
|
|
10
|
-
# `Mine`, `Star`) with its own `act` (motion, spin, blink, twinkle) and `draw`
|
|
11
|
-
# (its sprite frames), and the world is a tree: layer Groups under `world.root`
|
|
12
|
-
# (backdrop, stars, mines, orbs, ship, HUD), so `world.act(dt)` and `world.draw()` do
|
|
13
|
-
# the per-kind work and z-order is the order of the layers. Collisions, scoring,
|
|
14
|
-
# spawning, lives and restart are methods of `World`.
|
|
15
|
-
#
|
|
16
|
-
# Prefer game.plum's immediate mode for a flat arcade game with a handful of kinds
|
|
17
|
-
# where one state record and one pure `update` are easiest to test. Prefer this
|
|
18
|
-
# retained mode when the game has layers, parent-relative transforms, hit-testing
|
|
19
|
-
# or many things that each behave and draw differently.
|
|
20
|
-
#
|
|
21
|
-
# The rules never call the host, so the `test` blocks below run under `plum test`;
|
|
22
|
-
# only `init`, `update` and `draw` talk to the host.
|
|
23
|
-
module game_scene
|
|
24
|
-
import std/Str
|
|
25
|
-
import std/Int
|
|
26
|
-
import std/Float
|
|
27
|
-
import std/Slice
|
|
28
|
-
import std/Option
|
|
29
|
-
import std/Math
|
|
30
|
-
import std/Random
|
|
31
|
-
import std/game/Vec2
|
|
32
|
-
import std/game/Color
|
|
33
|
-
import std/game/Game
|
|
34
|
-
import std/game/imperative/Scene
|
|
35
|
-
import std/game/Input
|
|
36
|
-
import std/Io
|
|
37
|
-
|
|
38
|
-
primitive Rules =
|
|
39
|
-
ArenaW = 800.0
|
|
40
|
-
ArenaH = 600.0
|
|
41
|
-
ShipRadius = 10.0
|
|
42
|
-
OrbRadius = 10.0
|
|
43
|
-
MineRadius = 11.0
|
|
44
|
-
Thrust = 900.0
|
|
45
|
-
Drag = 2.2
|
|
46
|
-
OrbCount = 3
|
|
47
|
-
StartLives = 3
|
|
48
|
-
HurtTime = 1.5
|
|
49
|
-
OrbPoints = 10
|
|
50
|
-
Cell = 16.0
|
|
51
|
-
Zoom = 3.0
|
|
52
|
-
|
|
53
|
-
# A position and velocity: what a spawn returns and what drifters are bounced with.
|
|
54
|
-
struct Motion(mut x: Float, mut y: Float, mut vx: Float, mut vy: Float)
|
|
55
|
-
|
|
56
|
-
# Which of `count` animation frames is showing after `clock` seconds at `fps` frames per second.
|
|
57
|
-
fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
|
|
58
|
-
Int(clock * fps) % count
|
|
59
|
-
|
|
60
|
-
# A random spot at least `gap` away from (ax, ay), drifting at up to `speed`.
|
|
61
|
-
# A Motion and the generator to continue from.
|
|
62
|
-
struct Spawned(at: Motion, rng: Random)
|
|
63
|
-
|
|
64
|
-
fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Spawned =
|
|
65
|
-
margin := 30.0
|
|
66
|
-
b := Motion(x: 0.0, y: 0.0, vx: 0.0, vy: 0.0)
|
|
67
|
-
r := rng
|
|
68
|
-
tries := 0
|
|
69
|
-
while tries < 20
|
|
70
|
-
dx := r.nextFloat()
|
|
71
|
-
dy := dx.rng.nextFloat()
|
|
72
|
-
r = dy.rng
|
|
73
|
-
b.x = margin + dx.value * {Rules.ArenaW - 2.0 * margin}
|
|
74
|
-
b.y = margin + dy.value * {Rules.ArenaH - 2.0 * margin}
|
|
75
|
-
if Math.hypot(b.x - ax, b.y - ay) >= gap
|
|
76
|
-
break
|
|
77
|
-
tries = tries + 1
|
|
78
|
-
dh := r.nextFloat()
|
|
79
|
-
dp := dh.rng.nextFloat()
|
|
80
|
-
heading := dh.value * Float.Tau
|
|
81
|
-
pace := speed * {0.4 + 0.6 * dp.value}
|
|
82
|
-
b.vx = Math.cos(heading) * pace
|
|
83
|
-
b.vy = Math.sin(heading) * pace
|
|
84
|
-
Spawned(at: b, rng: dp.rng)
|
|
85
|
-
|
|
86
|
-
# Moves a drifter and bounces it off the arena walls.
|
|
87
|
-
fun drift(b: Motion, r: Float, dt: Float) =
|
|
88
|
-
b.x = b.x + b.vx * dt
|
|
89
|
-
b.y = b.y + b.vy * dt
|
|
90
|
-
if b.x < r || b.x > Rules.ArenaW - r
|
|
91
|
-
b.vx = 0.0 - b.vx
|
|
92
|
-
b.x = b.x.max(r).min(Rules.ArenaW - r)
|
|
93
|
-
if b.y < r || b.y > Rules.ArenaH - r
|
|
94
|
-
b.vy = 0.0 - b.vy
|
|
95
|
-
b.y = b.y.max(r).min(Rules.ArenaH - r)
|
|
96
|
-
|
|
97
|
-
# How many mines are out: more as the score grows.
|
|
98
|
-
fun mineCount(score: Int) -> Int =
|
|
99
|
-
{3 + score / 50}.min(8)
|
|
100
|
-
|
|
101
|
-
# Actor kinds of this game. Each is a sprite-sized actor centred on its position (x, y)
|
|
102
|
-
# (origin at the middle of the box, so `rotation` turns it about its centre).
|
|
103
|
-
|
|
104
|
-
# The player. `ax` and `ay` are the input axes (-1..1) for the next `act`.
|
|
105
|
-
struct Ship(Actor(x, y), tex: Texture, mut vx: Float, mut vy: Float, mut ax: Float, mut ay: Float, mut hurt: Float, mut clock: Float, mut live: Bool) =
|
|
106
|
-
|
|
107
|
-
fun init(self) =
|
|
108
|
-
self.width = Rules.Cell * Rules.Zoom
|
|
109
|
-
self.height = self.width
|
|
110
|
-
self.originX = self.width / 2.0
|
|
111
|
-
self.originY = self.height / 2.0
|
|
112
|
-
|
|
113
|
-
# Accelerates by the input axes, applies drag, keeps the ship in the arena and
|
|
114
|
-
# blinks it while it is hurt (the blink is just `visible`).
|
|
115
|
-
override fun act(self, dt: Float) =
|
|
116
|
-
self.clock = self.clock + dt
|
|
117
|
-
if !self.live
|
|
118
|
-
return
|
|
119
|
-
self.hurt = {self.hurt - dt}.max(0.0)
|
|
120
|
-
self.vx = self.vx + self.ax * Rules.Thrust * dt
|
|
121
|
-
self.vy = self.vy + self.ay * Rules.Thrust * dt
|
|
122
|
-
keep := {1.0 - Rules.Drag * dt}.max(0.0)
|
|
123
|
-
self.vx = self.vx * keep
|
|
124
|
-
self.vy = self.vy * keep
|
|
125
|
-
self.x = self.x + self.vx * dt
|
|
126
|
-
self.y = self.y + self.vy * dt
|
|
127
|
-
r := Rules.ShipRadius
|
|
128
|
-
if self.x < r
|
|
129
|
-
self.x = r
|
|
130
|
-
self.vx = 0.0
|
|
131
|
-
if self.x > Rules.ArenaW - r
|
|
132
|
-
self.x = Rules.ArenaW - r
|
|
133
|
-
self.vx = 0.0
|
|
134
|
-
if self.y < r
|
|
135
|
-
self.y = r
|
|
136
|
-
self.vy = 0.0
|
|
137
|
-
if self.y > Rules.ArenaH - r
|
|
138
|
-
self.y = Rules.ArenaH - r
|
|
139
|
-
self.vy = 0.0
|
|
140
|
-
if self.vx * self.vx + self.vy * self.vy > 400.0
|
|
141
|
-
self.rotation = Math.atan2(self.vx, 0.0 - self.vy)
|
|
142
|
-
self.visible = !{self.hurt > 0.0 && frameOf(self.clock, 2, 10.0) == 0}
|
|
143
|
-
|
|
144
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
145
|
-
c := m.apply(Vec2(x: self.width / 2.0, y: self.height / 2.0))
|
|
146
|
-
canvas.drawCircle(c.x, c.y, 17.0 * m.scaleX(), Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12 * alpha))
|
|
147
|
-
frame := Float(frameOf(self.clock, 4, 14.0))
|
|
148
|
-
drawSpriteFrame(canvas, m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
|
|
149
|
-
|
|
150
|
-
# A glowing orb drifting about. `phase` offsets its animation from the other orbs.
|
|
151
|
-
struct Orb(Actor(x, y), mut vx: Float, mut vy: Float, phase: Float, tex: Texture, mut clock: Float, mut live: Bool) =
|
|
152
|
-
|
|
153
|
-
fun init(self) =
|
|
154
|
-
self.width = Rules.Cell * Rules.Zoom
|
|
155
|
-
self.height = self.width
|
|
156
|
-
self.originX = self.width / 2.0
|
|
157
|
-
self.originY = self.height / 2.0
|
|
158
|
-
|
|
159
|
-
fun create(sheet: Texture, phase: Float, at: Motion) -> Orb =
|
|
160
|
-
Orb(x: at.x, y: at.y, vx: at.vx, vy: at.vy, phase: phase, tex: sheet, clock: 0.0, live: true)
|
|
161
|
-
|
|
162
|
-
fun motion(self) -> Motion =
|
|
163
|
-
Motion(x: self.x, y: self.y, vx: self.vx, vy: self.vy)
|
|
164
|
-
|
|
165
|
-
fun place(self, m: Motion) =
|
|
166
|
-
self.x = m.x
|
|
167
|
-
self.y = m.y
|
|
168
|
-
self.vx = m.vx
|
|
169
|
-
self.vy = m.vy
|
|
170
|
-
|
|
171
|
-
override fun act(self, dt: Float) =
|
|
172
|
-
self.clock = self.clock + dt
|
|
173
|
-
if !self.live
|
|
174
|
-
return
|
|
175
|
-
m := self.motion()
|
|
176
|
-
drift(m, Rules.OrbRadius, dt)
|
|
177
|
-
self.place(m)
|
|
178
|
-
|
|
179
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
180
|
-
c := m.apply(Vec2(x: self.width / 2.0, y: self.height / 2.0))
|
|
181
|
-
k := m.scaleX()
|
|
182
|
-
canvas.blendMode(Blend.Add)
|
|
183
|
-
canvas.drawCircle(c.x, c.y, 26.0 * k, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5 * alpha))
|
|
184
|
-
canvas.drawCircle(c.x, c.y, 17.0 * k, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5 * alpha))
|
|
185
|
-
canvas.blendMode(Blend.Normal)
|
|
186
|
-
frame := Float(frameOf(self.clock + self.phase, 4, 8.0))
|
|
187
|
-
drawSpriteFrame(canvas, m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
|
|
188
|
-
|
|
189
|
-
# A spinning mine drifting about.
|
|
190
|
-
struct Mine(Actor(x, y), mut vx: Float, mut vy: Float, tex: Texture, mut clock: Float, mut live: Bool) =
|
|
191
|
-
|
|
192
|
-
fun init(self) =
|
|
193
|
-
self.width = Rules.Cell * Rules.Zoom
|
|
194
|
-
self.height = self.width
|
|
195
|
-
self.originX = self.width / 2.0
|
|
196
|
-
self.originY = self.height / 2.0
|
|
197
|
-
|
|
198
|
-
fun create(sheet: Texture, at: Motion) -> Mine =
|
|
199
|
-
Mine(x: at.x, y: at.y, vx: at.vx, vy: at.vy, tex: sheet, clock: 0.0, live: true)
|
|
200
|
-
|
|
201
|
-
fun motion(self) -> Motion =
|
|
202
|
-
Motion(x: self.x, y: self.y, vx: self.vx, vy: self.vy)
|
|
203
|
-
|
|
204
|
-
fun place(self, m: Motion) =
|
|
205
|
-
self.x = m.x
|
|
206
|
-
self.y = m.y
|
|
207
|
-
self.vx = m.vx
|
|
208
|
-
self.vy = m.vy
|
|
209
|
-
|
|
210
|
-
override fun act(self, dt: Float) =
|
|
211
|
-
self.clock = self.clock + dt
|
|
212
|
-
if !self.live
|
|
213
|
-
return
|
|
214
|
-
m := self.motion()
|
|
215
|
-
drift(m, Rules.MineRadius, dt)
|
|
216
|
-
self.place(m)
|
|
217
|
-
self.rotation = self.rotation + 2.0 * dt
|
|
218
|
-
|
|
219
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
220
|
-
frame := Float(frameOf(self.clock, 4, 4.0))
|
|
221
|
-
drawSpriteFrame(canvas, m, alpha, self.width, self.height, self.color, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
|
|
222
|
-
|
|
223
|
-
# A background star that twinkles.
|
|
224
|
-
struct Star(Actor(x, y, width, height), seed: Float, mut clock: Float) =
|
|
225
|
-
|
|
226
|
-
fun create(i: Int) -> Star =
|
|
227
|
-
r := 1.0 + Float(i % 3) * 0.5
|
|
228
|
-
Star(
|
|
229
|
-
x: Float({i * 7919} % 800) - r, y: Float({i * 104729} % 600) - r, width: r * 2.0,
|
|
230
|
-
height: r * 2.0, seed: Float(i), clock: 0.0)
|
|
231
|
-
|
|
232
|
-
override fun act(self, dt: Float) =
|
|
233
|
-
self.clock = self.clock + dt
|
|
234
|
-
|
|
235
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
236
|
-
twinkle := 0.25 + 0.2 * Math.sin(self.clock * 2.0 + self.seed)
|
|
237
|
-
drawDisc(canvas, m, alpha, self.width / 2.0, Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
|
|
238
|
-
|
|
239
|
-
# The whole game: it embeds the Stage (camera and root) and has its layers, typed handles to the actors the rules
|
|
240
|
-
# touch, and the run's state. `mines` and `orbs` are the layer Groups; `mineList`
|
|
241
|
-
# and `orbList` are the same actors, typed.
|
|
242
|
-
# Enter or R going down.
|
|
243
|
-
fun isRestartKey(e: InputEvent) -> Bool = match e
|
|
244
|
-
KeyDown(k) => k == Key.Enter || k == Key.KeyR
|
|
245
|
-
_ => false
|
|
246
|
-
|
|
247
|
-
struct World(
|
|
248
|
-
Stage(camX, camY, camScale, focus),
|
|
249
|
-
ship: Ship,
|
|
250
|
-
mines: Group,
|
|
251
|
-
orbs: Group,
|
|
252
|
-
mineList: Slice[Mine],
|
|
253
|
-
orbList: Slice[Orb],
|
|
254
|
-
overlay: Group,
|
|
255
|
-
scoreText: Text,
|
|
256
|
-
bestText: Text,
|
|
257
|
-
lifeIcons: Slice[Sprite],
|
|
258
|
-
mut rng: Random,
|
|
259
|
-
mut score: Int,
|
|
260
|
-
mut best: Int,
|
|
261
|
-
mut lives: Int,
|
|
262
|
-
mut over: Bool,
|
|
263
|
-
mut clock: Float,
|
|
264
|
-
sheet: Texture) =
|
|
265
|
-
|
|
266
|
-
fun create(seed: Int, sheet: Texture) -> World =
|
|
267
|
-
ship := Ship(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, tex: sheet, vx: 0.0, vy: 0.0, ax: 0.0, ay: 0.0, hurt: 0.0, clock: 0.0, live: true)
|
|
268
|
-
stars := Group()
|
|
269
|
-
for i := range 40
|
|
270
|
-
stars.add(Star.create(i))
|
|
271
|
-
mines := Group()
|
|
272
|
-
orbs := Group()
|
|
273
|
-
hud := Group()
|
|
274
|
-
scoreText := Text.create("Score 0", 28.0, Color.White)
|
|
275
|
-
scoreText.x = 16.0
|
|
276
|
-
scoreText.y = 12.0
|
|
277
|
-
bestText := Text.create("Best 0", 20.0, Color.Yellow)
|
|
278
|
-
bestText.x = 16.0
|
|
279
|
-
bestText.y = 44.0
|
|
280
|
-
hud.add(scoreText)
|
|
281
|
-
hud.add(bestText)
|
|
282
|
-
icons := Slice[Sprite]()
|
|
283
|
-
for i := range Rules.StartLives
|
|
284
|
-
icon := Sprite.create(sheet, 0.0, 0.0, Rules.Cell, Rules.Cell, 32.0, 32.0)
|
|
285
|
-
icon.x = Rules.ArenaW - 40.0 - Float(i) * 36.0
|
|
286
|
-
icon.y = 12.0
|
|
287
|
-
hud.add(icon)
|
|
288
|
-
icons.push(icon)
|
|
289
|
-
overlay := Group()
|
|
290
|
-
overlay.visible = false
|
|
291
|
-
overlay.add(Rect.create(Rules.ArenaW, 120.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)).at(0.0, 240.0))
|
|
292
|
-
overlay.add(Text.create("Game over", 48.0, Color.Red).at(300.0, 258.0))
|
|
293
|
-
overlay.add(Text.create("Press Enter to play again", 24.0, Color.White).at(270.0, 316.0))
|
|
294
|
-
hud.add(overlay)
|
|
295
|
-
w := World(
|
|
296
|
-
camX: 0.0, camY: 0.0, camScale: 1.0, focus: None, ship: ship, mines: mines, orbs: orbs, mineList: Slice[Mine](), orbList: Slice[Orb](),
|
|
297
|
-
overlay: overlay, scoreText: scoreText, bestText: bestText, lifeIcons: icons,
|
|
298
|
-
rng: Random.seeded(seed), score: 0, best: 0, lives: Rules.StartLives, over: false, clock: 0.0, sheet: sheet)
|
|
299
|
-
w.root.add(Rect.create(Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)))
|
|
300
|
-
w.root.add(stars)
|
|
301
|
-
w.root.add(mines)
|
|
302
|
-
w.root.add(orbs)
|
|
303
|
-
w.root.add(ship)
|
|
304
|
-
w.root.add(hud)
|
|
305
|
-
for i := range Rules.OrbCount
|
|
306
|
-
sp := spawn(w.rng, ship.x, ship.y, 120.0, 40.0)
|
|
307
|
-
w.rng = sp.rng
|
|
308
|
-
orb := Orb.create(sheet, Float(i) * 0.1, sp.at)
|
|
309
|
-
orbs.add(orb)
|
|
310
|
-
w.orbList.push(orb)
|
|
311
|
-
for i := range mineCount(0)
|
|
312
|
-
w.addMine()
|
|
313
|
-
w
|
|
314
|
-
|
|
315
|
-
# A world with the orbs and mines moved out of the way.
|
|
316
|
-
fun empty() -> World =
|
|
317
|
-
w := World.create(7, Texture(id: -1))
|
|
318
|
-
for i := range w.orbList.length()
|
|
319
|
-
w.orbList.at(i).place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
|
|
320
|
-
for i := range w.mineList.length()
|
|
321
|
-
w.mineList.at(i).place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
|
|
322
|
-
w
|
|
323
|
-
|
|
324
|
-
# Adds one mine, far from the ship, to the mines layer.
|
|
325
|
-
fun addMine(self) =
|
|
326
|
-
sp := spawn(self.rng, self.ship.x, self.ship.y, 220.0, 90.0)
|
|
327
|
-
self.rng = sp.rng
|
|
328
|
-
mine := Mine.create(self.sheet, sp.at)
|
|
329
|
-
self.mines.add(mine)
|
|
330
|
-
self.mineList.push(mine)
|
|
331
|
-
|
|
332
|
-
# Starts a fresh game, keeping the best score and the random stream.
|
|
333
|
-
fun restart(self) =
|
|
334
|
-
self.best = self.best.max(self.score)
|
|
335
|
-
self.score = 0
|
|
336
|
-
self.lives = Rules.StartLives
|
|
337
|
-
self.over = false
|
|
338
|
-
ship := self.ship
|
|
339
|
-
ship.hurt = 0.0
|
|
340
|
-
ship.visible = true
|
|
341
|
-
ship.x = Rules.ArenaW / 2.0
|
|
342
|
-
ship.y = Rules.ArenaH / 2.0
|
|
343
|
-
ship.vx = 0.0
|
|
344
|
-
ship.vy = 0.0
|
|
345
|
-
for i := range self.orbList.length()
|
|
346
|
-
sp := spawn(self.rng, ship.x, ship.y, 120.0, 40.0)
|
|
347
|
-
self.rng = sp.rng
|
|
348
|
-
self.orbList.at(i).place(sp.at)
|
|
349
|
-
while self.mineList.length() > mineCount(0)
|
|
350
|
-
gone := self.mineList.pop()!
|
|
351
|
-
self.mines.remove(gone)
|
|
352
|
-
for i := range self.mineList.length()
|
|
353
|
-
sp := spawn(self.rng, ship.x, ship.y, 220.0, 90.0)
|
|
354
|
-
self.rng = sp.rng
|
|
355
|
-
self.mineList.at(i).place(sp.at)
|
|
356
|
-
self.sync()
|
|
357
|
-
|
|
358
|
-
# Copies the score, best score, lives and game-over panel into the HUD actors.
|
|
359
|
-
fun sync(self) =
|
|
360
|
-
self.scoreText.text = "Score {self.score}"
|
|
361
|
-
self.bestText.text = "Best {self.best.max(self.score)}"
|
|
362
|
-
for i := range self.lifeIcons.length()
|
|
363
|
-
self.lifeIcons.at(i).visible = i < self.lives
|
|
364
|
-
self.overlay.visible = self.over
|
|
365
|
-
|
|
366
|
-
# Lets the actors move (while the game runs) or only animate (once it is over).
|
|
367
|
-
fun setLive(self, live: Bool) =
|
|
368
|
-
self.ship.live = live
|
|
369
|
-
for i := range self.orbList.length()
|
|
370
|
-
self.orbList.at(i).live = live
|
|
371
|
-
for i := range self.mineList.length()
|
|
372
|
-
self.mineList.at(i).live = live
|
|
373
|
-
|
|
374
|
-
# One simulation step: every actor acts through the embedded Stage, then the rules look at
|
|
375
|
-
# the result. Once the game is over the actors only animate; nothing moves or scores.
|
|
376
|
-
fun step(self, ax: Float, ay: Float, dt: Float) =
|
|
377
|
-
self.clock = self.clock + dt
|
|
378
|
-
self.setLive(!self.over)
|
|
379
|
-
self.ship.ax = ax
|
|
380
|
-
self.ship.ay = ay
|
|
381
|
-
self.act(dt)
|
|
382
|
-
if self.over
|
|
383
|
-
return
|
|
384
|
-
ship := self.ship
|
|
385
|
-
for i := range self.orbList.length()
|
|
386
|
-
orb := self.orbList.at(i)
|
|
387
|
-
if overlaps(ship.x, ship.y, Rules.ShipRadius, orb.x, orb.y, Rules.OrbRadius)
|
|
388
|
-
self.score = self.score + Rules.OrbPoints
|
|
389
|
-
sp := spawn(self.rng, ship.x, ship.y, 120.0, 40.0)
|
|
390
|
-
self.rng = sp.rng
|
|
391
|
-
orb.place(sp.at)
|
|
392
|
-
while self.mineList.length() < mineCount(self.score)
|
|
393
|
-
self.addMine()
|
|
394
|
-
for i := range self.mineList.length()
|
|
395
|
-
mine := self.mineList.at(i)
|
|
396
|
-
if ship.hurt <= 0.0 && overlaps(ship.x, ship.y, Rules.ShipRadius, mine.x, mine.y, Rules.MineRadius)
|
|
397
|
-
self.lives = self.lives - 1
|
|
398
|
-
ship.hurt = Rules.HurtTime
|
|
399
|
-
if self.lives <= 0
|
|
400
|
-
self.over = true
|
|
401
|
-
self.best = self.best.max(self.score)
|
|
402
|
-
ship.visible = false
|
|
403
|
-
self.sync()
|
|
404
|
-
|
|
405
|
-
# Fits the arena into a screen of `sw` by `sh` pixels, centred.
|
|
406
|
-
fun fit(self, sw: Float, sh: Float) =
|
|
407
|
-
zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
|
|
408
|
-
self.camScale = zoom
|
|
409
|
-
self.camX = 0.0 - {sw / zoom - Rules.ArenaW} / 2.0
|
|
410
|
-
self.camY = 0.0 - {sh / zoom - Rules.ArenaH} / 2.0
|
|
411
|
-
|
|
412
|
-
override fun draw(self, canvas: Canvas) =
|
|
413
|
-
self.fit(canvas.width(), canvas.height())
|
|
414
|
-
canvas.clear(Color.rgb(0.04, 0.05, 0.1))
|
|
415
|
-
self.Stage.draw(canvas)
|
|
416
|
-
|
|
417
|
-
fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
|
|
418
|
-
Math.hypot(ax - bx, ay - by) < ra + rb
|
|
419
|
-
|
|
420
|
-
# --- host glue: input, sound, drawing ---
|
|
421
|
-
|
|
422
|
-
fun musicVolume() -> Float =
|
|
423
|
-
0.3
|
|
424
|
-
|
|
425
|
-
# The game: a `World` and the host parts it plays with.
|
|
426
|
-
struct OrbScene(Game(engine), world: World, pickupSound: Sound, hitSound: Sound, overSound: Sound, musicSound: Sound) =
|
|
427
|
-
|
|
428
|
-
# Restarts the music loop from the top.
|
|
429
|
-
fun startMusic(self) =
|
|
430
|
-
self.audio.stop(self.musicSound)
|
|
431
|
-
self.audio.loop(self.musicSound, musicVolume())
|
|
432
|
-
|
|
433
|
-
# Offers each input to the stage and handles what no actor consumed: Enter or R
|
|
434
|
-
# restarts a finished game.
|
|
435
|
-
override fun update(self, dt: Float) =
|
|
436
|
-
w := self.world
|
|
437
|
-
inputs := self.input.events()
|
|
438
|
-
for i := range inputs.length()
|
|
439
|
-
e := inputs.at(i)
|
|
440
|
-
if !w.input(e) && w.over && isRestartKey(e)
|
|
441
|
-
w.restart()
|
|
442
|
-
self.startMusic()
|
|
443
|
-
left := self.input.isKeyDown(Key.Left) || self.input.isKeyDown(Key.KeyA)
|
|
444
|
-
right := self.input.isKeyDown(Key.Right) || self.input.isKeyDown(Key.KeyD)
|
|
445
|
-
up := self.input.isKeyDown(Key.Up) || self.input.isKeyDown(Key.KeyW)
|
|
446
|
-
down := self.input.isKeyDown(Key.Down) || self.input.isKeyDown(Key.KeyS)
|
|
447
|
-
score := w.score
|
|
448
|
-
lives := w.lives
|
|
449
|
-
w.step(axis(left, right), axis(up, down), dt)
|
|
450
|
-
if w.score > score
|
|
451
|
-
self.audio.play(self.pickupSound, 0.8)
|
|
452
|
-
if w.lives < lives
|
|
453
|
-
if w.over
|
|
454
|
-
self.audio.stop(self.musicSound)
|
|
455
|
-
self.audio.play(self.overSound, 1.0)
|
|
456
|
-
else
|
|
457
|
-
self.audio.play(self.hitSound, 1.0)
|
|
458
|
-
|
|
459
|
-
override fun draw(self) =
|
|
460
|
-
self.world.draw(self.canvas)
|
|
461
|
-
|
|
462
|
-
fun main(io: Io) -> Game =
|
|
463
|
-
e := Engine.create(io)
|
|
464
|
-
game := OrbScene(
|
|
465
|
-
engine: e, world: World.create(io.randomSeed(), e.assets.texture("sprites.png")),
|
|
466
|
-
pickupSound: e.assets.sound("pickup.wav"), hitSound: e.assets.sound("hit.wav"),
|
|
467
|
-
overSound: e.assets.sound("gameover.wav"), musicSound: e.assets.sound("music.wav"))
|
|
468
|
-
game.startMusic()
|
|
469
|
-
game
|
|
470
|
-
|
|
471
|
-
fun axis(negative: Bool, positive: Bool) -> Float =
|
|
472
|
-
if negative && !positive
|
|
473
|
-
-1.0
|
|
474
|
-
else if positive && !negative
|
|
475
|
-
1.0
|
|
476
|
-
else
|
|
477
|
-
0.0
|
|
478
|
-
|
|
479
|
-
fun near(a: Float, b: Float) -> Bool =
|
|
480
|
-
{a - b}.abs() < 0.01
|
|
481
|
-
|
|
482
|
-
test "steering accelerates and drag slows the ship"
|
|
483
|
-
ship := Ship(x: 400.0, y: 300.0, tex: Texture(id: -1), vx: 0.0, vy: 0.0, ax: 0.0, ay: 0.0, hurt: 0.0, clock: 0.0, live: true)
|
|
484
|
-
ship.ax = 1.0
|
|
485
|
-
ship.act(0.1)
|
|
486
|
-
assert ship.vx > 0.0
|
|
487
|
-
assert ship.x > 400.0
|
|
488
|
-
fast := ship.vx
|
|
489
|
-
ship.ax = 0.0
|
|
490
|
-
for i := range 30
|
|
491
|
-
ship.act(0.1)
|
|
492
|
-
assert ship.vx < fast / 4.0
|
|
493
|
-
|
|
494
|
-
test "the ship stops at the arena walls"
|
|
495
|
-
ship := Ship(x: 20.0, y: 20.0, tex: Texture(id: -1), vx: 0.0, vy: 0.0, ax: 0.0, ay: 0.0, hurt: 0.0, clock: 0.0, live: true)
|
|
496
|
-
ship.ax = -1.0
|
|
497
|
-
ship.ay = -1.0
|
|
498
|
-
for i := range 20
|
|
499
|
-
ship.act(0.1)
|
|
500
|
-
assert ship.x == Rules.ShipRadius
|
|
501
|
-
assert ship.y == Rules.ShipRadius
|
|
502
|
-
assert ship.vx == 0.0
|
|
503
|
-
|
|
504
|
-
test "drifters bounce off the walls"
|
|
505
|
-
orb := Orb.create(Texture(id: -1), 0.0, Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0))
|
|
506
|
-
orb.act(0.1)
|
|
507
|
-
assert orb.vx == -100.0
|
|
508
|
-
assert orb.x == Rules.ArenaW - 10.0
|
|
509
|
-
|
|
510
|
-
test "a mine spins as it drifts"
|
|
511
|
-
mine := Mine.create(Texture(id: -1), Motion(x: 400.0, y: 300.0, vx: 10.0, vy: 0.0))
|
|
512
|
-
mine.act(0.5)
|
|
513
|
-
assert near(mine.rotation, 1.0)
|
|
514
|
-
assert near(mine.x, 405.0)
|
|
515
|
-
|
|
516
|
-
test "the scene has a backdrop, stars, mines, orbs, ship and HUD layers"
|
|
517
|
-
w := World.create(7, Texture(id: -1))
|
|
518
|
-
assert w.root.children.length() == 6
|
|
519
|
-
assert w.root.children.at(1).children.length() == 40
|
|
520
|
-
assert w.mines.children.length() == mineCount(0)
|
|
521
|
-
assert w.orbs.children.length() == Rules.OrbCount
|
|
522
|
-
assert same(w.root.children.at(4), w.ship)
|
|
523
|
-
assert same(w.root.children.at(2), w.mines)
|
|
524
|
-
assert same(w.root.children.at(3), w.orbs)
|
|
525
|
-
|
|
526
|
-
test "collecting an orb scores and respawns it elsewhere, still in the orbs layer"
|
|
527
|
-
w := World.empty()
|
|
528
|
-
orb := w.orbList.at(0)
|
|
529
|
-
orb.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
|
|
530
|
-
w.step(0.0, 0.0, 0.016)
|
|
531
|
-
assert w.score == Rules.OrbPoints
|
|
532
|
-
assert Math.hypot(orb.x - w.ship.x, orb.y - w.ship.y) >= 120.0
|
|
533
|
-
assert orb.x < 5000.0
|
|
534
|
-
assert w.orbs.children.length() == Rules.OrbCount
|
|
535
|
-
assert same(orb.parent.unwrap(), w.orbs)
|
|
536
|
-
assert w.scoreText.text == "Score 10"
|
|
537
|
-
|
|
538
|
-
test "a mine costs one life and makes the ship flash through its own act"
|
|
539
|
-
w := World.empty()
|
|
540
|
-
mine := w.mineList.at(0)
|
|
541
|
-
mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
|
|
542
|
-
w.step(0.0, 0.0, 0.016)
|
|
543
|
-
assert w.lives == Rules.StartLives - 1
|
|
544
|
-
assert w.ship.hurt > 0.0
|
|
545
|
-
assert !w.lifeIcons.at(2).visible
|
|
546
|
-
assert w.lifeIcons.at(1).visible
|
|
547
|
-
mine.place(Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0))
|
|
548
|
-
hidden := 0
|
|
549
|
-
shown := 0
|
|
550
|
-
for i := range 100
|
|
551
|
-
w.step(0.0, 0.0, 0.016)
|
|
552
|
-
if w.ship.visible
|
|
553
|
-
shown = shown + 1
|
|
554
|
-
else
|
|
555
|
-
hidden = hidden + 1
|
|
556
|
-
assert hidden > 0
|
|
557
|
-
assert shown > 0
|
|
558
|
-
assert w.ship.hurt == 0.0
|
|
559
|
-
assert w.ship.visible
|
|
560
|
-
assert w.lives == Rules.StartLives - 1
|
|
561
|
-
|
|
562
|
-
test "the ship is briefly safe after a hit"
|
|
563
|
-
w := World.empty()
|
|
564
|
-
mine := w.mineList.at(0)
|
|
565
|
-
mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
|
|
566
|
-
w.step(0.0, 0.0, 0.016)
|
|
567
|
-
w.step(0.0, 0.0, 0.016)
|
|
568
|
-
assert w.lives == Rules.StartLives - 1
|
|
569
|
-
|
|
570
|
-
test "the game ends at zero lives and then ignores steps"
|
|
571
|
-
w := World.empty()
|
|
572
|
-
w.lives = 1
|
|
573
|
-
w.score = 40
|
|
574
|
-
mine := w.mineList.at(0)
|
|
575
|
-
mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0))
|
|
576
|
-
w.step(0.0, 0.0, 0.016)
|
|
577
|
-
assert w.over
|
|
578
|
-
assert w.lives == 0
|
|
579
|
-
assert w.best == 40
|
|
580
|
-
assert w.overlay.visible
|
|
581
|
-
assert !w.ship.visible
|
|
582
|
-
x := w.ship.x
|
|
583
|
-
w.step(1.0, 0.0, 0.5)
|
|
584
|
-
assert w.ship.x == x
|
|
585
|
-
|
|
586
|
-
test "after game over nothing moves but the animations keep running"
|
|
587
|
-
w := World.empty()
|
|
588
|
-
w.lives = 1
|
|
589
|
-
mine := w.mineList.at(0)
|
|
590
|
-
mine.place(Motion(x: w.ship.x, y: w.ship.y, vx: 10.0, vy: 0.0))
|
|
591
|
-
w.step(0.0, 0.0, 0.016)
|
|
592
|
-
assert w.over
|
|
593
|
-
x := mine.x
|
|
594
|
-
rot := mine.rotation
|
|
595
|
-
clock := mine.clock
|
|
596
|
-
orbClock := w.orbList.at(0).clock
|
|
597
|
-
star := Star.create(0)
|
|
598
|
-
w.root.children.at(1).add(star)
|
|
599
|
-
for i := range 10
|
|
600
|
-
w.step(1.0, 0.0, 0.1)
|
|
601
|
-
assert mine.x == x
|
|
602
|
-
assert mine.rotation == rot
|
|
603
|
-
assert w.ship.x == Rules.ArenaW / 2.0
|
|
604
|
-
assert !w.ship.visible
|
|
605
|
-
assert mine.clock > clock + 0.9
|
|
606
|
-
assert w.orbList.at(0).clock > orbClock + 0.9
|
|
607
|
-
assert star.clock > 0.9
|
|
608
|
-
assert w.score == 0
|
|
609
|
-
|
|
610
|
-
test "restart resets the run and keeps the best score"
|
|
611
|
-
w := World.empty()
|
|
612
|
-
w.score = 90
|
|
613
|
-
w.lives = 0
|
|
614
|
-
w.over = true
|
|
615
|
-
w.overlay.visible = true
|
|
616
|
-
w.restart()
|
|
617
|
-
assert !w.over
|
|
618
|
-
assert !w.overlay.visible
|
|
619
|
-
assert w.ship.visible
|
|
620
|
-
assert w.score == 0
|
|
621
|
-
assert w.best == 90
|
|
622
|
-
assert w.lives == Rules.StartLives
|
|
623
|
-
assert w.ship.x == Rules.ArenaW / 2.0
|
|
624
|
-
assert w.mineList.length() == mineCount(0)
|
|
625
|
-
assert w.bestText.text == "Best 90"
|
|
626
|
-
|
|
627
|
-
test "more mines appear as the score grows and restart removes them from the layer"
|
|
628
|
-
assert mineCount(0) == 3
|
|
629
|
-
assert mineCount(100) == 5
|
|
630
|
-
assert mineCount(10000) == 8
|
|
631
|
-
w := World.empty()
|
|
632
|
-
w.score = 100
|
|
633
|
-
w.step(0.0, 0.0, 0.016)
|
|
634
|
-
assert w.mineList.length() == 5
|
|
635
|
-
assert w.mines.children.length() == 5
|
|
636
|
-
w.restart()
|
|
637
|
-
assert w.mineList.length() == 3
|
|
638
|
-
assert w.mines.children.length() == 3
|
|
639
|
-
|
|
640
|
-
test "the same seed plays out the same way"
|
|
641
|
-
a := World.create(11, Texture(id: -1))
|
|
642
|
-
b := World.create(11, Texture(id: -1))
|
|
643
|
-
for i := range 120
|
|
644
|
-
a.step(1.0, -1.0, 0.016)
|
|
645
|
-
b.step(1.0, -1.0, 0.016)
|
|
646
|
-
assert a.ship.x == b.ship.x
|
|
647
|
-
assert a.score == b.score
|
|
648
|
-
assert a.mineList.at(0).x == b.mineList.at(0).x
|
|
649
|
-
assert World.create(12, Texture(id: -1)).orbList.at(0).x != a.orbList.at(0).x
|
|
650
|
-
|
|
651
|
-
test "fit centres the arena on the screen"
|
|
652
|
-
w := World.create(7, Texture(id: -1))
|
|
653
|
-
w.fit(1600.0, 600.0)
|
|
654
|
-
assert near(w.camScale, 1.0)
|
|
655
|
-
assert near(w.camX, -400.0)
|
|
656
|
-
assert near(w.camY, 0.0)
|
|
657
|
-
|
|
658
|
-
test "animation frames cycle"
|
|
659
|
-
assert frameOf(0.0, 4, 8.0) == 0
|
|
660
|
-
assert frameOf(0.13, 4, 8.0) == 1
|
|
661
|
-
assert frameOf(0.5, 4, 8.0) == 0
|
|
662
|
-
assert axis(true, false) == -1.0
|
|
663
|
-
assert axis(true, true) == 0.0
|
examples/games/imperative/scene.plum
DELETED
|
@@ -1,433 +0,0 @@
|
|
|
1
|
-
# Clockwork: a scene-graph demo (`plum run examples/games/imperative/scene.plum`). A sun, four
|
|
2
|
-
# planets and their moons orbit by nesting rotating groups (std/game/imperative/Scene): each orbit
|
|
3
|
-
# is a group whose rotation carries everything inside it, so a moon only ever
|
|
4
|
-
# says "I sit 40 units from my planet" and the maths composes the rest. Click a
|
|
5
|
-
# body to select it (a halo and its name), click again or the background to clear;
|
|
6
|
-
# Space pauses. A crowd of small walkers crosses the bottom of the screen: they are
|
|
7
|
-
# y-sorted every frame with `sortChildren` (lower ones draw in front), culled
|
|
8
|
-
# against the camera view with `setCullingArea`, and a clicked walker is brought
|
|
9
|
-
# to the front with `toFront`.
|
|
10
|
-
#
|
|
11
|
-
# The scene is built and queried by plain functions that never call the host, so
|
|
12
|
-
# the `test` blocks below run under `plum test`; only `Clockwork`, the `Game` struct
|
|
13
|
-
# over a `Sky`, and `main` talk to the host.
|
|
14
|
-
module scene
|
|
15
|
-
import std/Str
|
|
16
|
-
import std/Int
|
|
17
|
-
import std/Float
|
|
18
|
-
import std/Slice
|
|
19
|
-
import std/Option
|
|
20
|
-
import std/Math
|
|
21
|
-
import std/game/Vec2
|
|
22
|
-
import std/game/Color
|
|
23
|
-
import std/game/Game
|
|
24
|
-
import std/game/imperative/Scene
|
|
25
|
-
import std/game/Input
|
|
26
|
-
import std/Io
|
|
27
|
-
import std/List
|
|
28
|
-
|
|
29
|
-
primitive Layout =
|
|
30
|
-
Width = 800.0
|
|
31
|
-
Height = 600.0
|
|
32
|
-
CenterX = 400.0
|
|
33
|
-
CenterY = 300.0
|
|
34
|
-
Cell = 16.0
|
|
35
|
-
MapW = 160
|
|
36
|
-
MapH = 120
|
|
37
|
-
MapScale = 0.2
|
|
38
|
-
Planets = 4
|
|
39
|
-
Walkers = 60
|
|
40
|
-
WalkMin = 500.0
|
|
41
|
-
WalkMax = 1300.0
|
|
42
|
-
|
|
43
|
-
# Actor kinds of this game: `struct X(Actor(width, height), ...)` embeds the scene's `Actor` struct first, so
|
|
44
|
-
# an X is a subtype of Actor: it goes wherever an Actor is expected and its own `act` and `draw` run in the tree.
|
|
45
|
-
|
|
46
|
-
# A group that turns `speed` radians per second and carries its children round.
|
|
47
|
-
struct Orbit(Actor, speed: Float) =
|
|
48
|
-
|
|
49
|
-
override fun act(self, dt: Float) =
|
|
50
|
-
self.rotation = self.rotation + self.speed * dt
|
|
51
|
-
self.actChildren(dt)
|
|
52
|
-
|
|
53
|
-
# A sprite that turns on its own axis, `speed` radians per second.
|
|
54
|
-
struct Spinner(Actor(width, height, originX, originY), speed: Float, tex: Texture, mut srcX: Float, srcY: Float) =
|
|
55
|
-
|
|
56
|
-
fun init(self) =
|
|
57
|
-
self.srcW: Float = Layout.Cell
|
|
58
|
-
self.srcH: Float = Layout.Cell
|
|
59
|
-
|
|
60
|
-
fun create(sheet: Texture, sx: Float, sy: Float, size: Float, speed: Float) -> Spinner =
|
|
61
|
-
Spinner(
|
|
62
|
-
width: size, height: size, originX: size / 2.0, originY: size / 2.0, speed: speed, tex: sheet,
|
|
63
|
-
srcX: sx, srcY: sy)
|
|
64
|
-
|
|
65
|
-
override fun act(self, dt: Float) =
|
|
66
|
-
self.rotation = self.rotation + self.speed * dt
|
|
67
|
-
self.actChildren(dt)
|
|
68
|
-
|
|
69
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
70
|
-
drawSpriteFrame(canvas, m, alpha, self.width, self.height, self.color, self.tex, self.srcX, self.srcY, self.srcW, self.srcH)
|
|
71
|
-
|
|
72
|
-
# A walker: moves along x at `pace` and bobs on a wave of its x.
|
|
73
|
-
struct Walker(Actor, pace: Float, phase: Float, tex: Texture, srcX: Float) =
|
|
74
|
-
|
|
75
|
-
fun init(self) =
|
|
76
|
-
self.width = 24.0
|
|
77
|
-
self.height = 24.0
|
|
78
|
-
self.srcY: Float = Layout.Cell
|
|
79
|
-
self.srcW: Float = Layout.Cell
|
|
80
|
-
self.srcH: Float = Layout.Cell
|
|
81
|
-
|
|
82
|
-
override fun act(self, dt: Float) =
|
|
83
|
-
nx := self.x + dt * self.pace
|
|
84
|
-
if nx > Layout.WalkMax
|
|
85
|
-
nx = 0.0 - Layout.WalkMin
|
|
86
|
-
if nx < 0.0 - Layout.WalkMin
|
|
87
|
-
nx = Layout.WalkMax
|
|
88
|
-
self.x = nx
|
|
89
|
-
self.y = 510.0 + 50.0 * Math.sin(nx / 90.0 + self.phase)
|
|
90
|
-
|
|
91
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
92
|
-
drawSpriteFrame(canvas, m, alpha, self.width, self.height, self.color, self.tex, self.srcX, self.srcY, self.srcW, self.srcH)
|
|
93
|
-
|
|
94
|
-
# Everything `update` and `draw` need. `bodies` and `names` are parallel (every
|
|
95
|
-
# planet and moon, each planet followed by its moons), as are `halos` (the glow shown when selected).
|
|
96
|
-
struct Sky(
|
|
97
|
-
stage: Stage,
|
|
98
|
-
bodies: Slice[Actor],
|
|
99
|
-
halos: Slice[Actor],
|
|
100
|
-
names: Slice[Str],
|
|
101
|
-
sun: Spinner,
|
|
102
|
-
label: Text,
|
|
103
|
-
crowd: Group,
|
|
104
|
-
mut front: Option[Actor],
|
|
105
|
-
mut selected: Int,
|
|
106
|
-
mut clock: Float,
|
|
107
|
-
mut paused: Bool,
|
|
108
|
-
map: Texture)
|
|
109
|
-
|
|
110
|
-
# A body sprite and its (hidden) halo in a group `slot` centred on the group's own
|
|
111
|
-
# origin. The caller places the slot; the sprite spins on its own axis.
|
|
112
|
-
fun addBody(sky: Sky, sheet: Texture, name: Str, row: Int, frame: Int, size: Float, spin: Float) -> Actor =
|
|
113
|
-
slot := Group()
|
|
114
|
-
halo := Circle.create(size * 0.8, Color(r: 1.0, g: 0.9, b: 0.5, a: 0.35)).at(0.0 - size * 0.8, 0.0 - size * 0.8)
|
|
115
|
-
halo.visible = false
|
|
116
|
-
body := Spinner.create(sheet, Float(frame) * Layout.Cell, Float(row) * Layout.Cell, size, spin).at(0.0 - size / 2.0, 0.0 - size / 2.0)
|
|
117
|
-
slot.add(halo)
|
|
118
|
-
slot.add(body)
|
|
119
|
-
sky.halos.push(halo)
|
|
120
|
-
sky.bodies.push(body)
|
|
121
|
-
sky.names.push(name)
|
|
122
|
-
slot
|
|
123
|
-
|
|
124
|
-
fun newSky(sheet: Texture, map: Texture) -> Sky =
|
|
125
|
-
stage := Stage.create()
|
|
126
|
-
backdrop := Rect.create(Layout.Width, Layout.Height, Color.rgb(0.04, 0.05, 0.1))
|
|
127
|
-
stage.root.add(backdrop)
|
|
128
|
-
for i := range 40
|
|
129
|
-
star := Circle.create(1.0 + Float(i % 3) * 0.5, Color(r: 0.7, g: 0.8, b: 1.0, a: 0.4)).at(Float({i * 7919} % 800), Float({i * 104729} % 600))
|
|
130
|
-
stage.root.add(star)
|
|
131
|
-
sun := Spinner.create(sheet, 0.0, Layout.Cell, 96.0, 0.3)
|
|
132
|
-
sun.x = Layout.CenterX - 48.0
|
|
133
|
-
sun.y = Layout.CenterY - 48.0
|
|
134
|
-
label := Text.create("Click a planet", 24.0, Color.White)
|
|
135
|
-
label.x = 16.0
|
|
136
|
-
label.y = 12.0
|
|
137
|
-
sky := Sky(
|
|
138
|
-
stage: stage, bodies: Slice[Actor](), halos: Slice[Actor](), names: Slice[Str](),
|
|
139
|
-
sun: sun, label: label, crowd: Group(), front: None, selected: -1, clock: 0.0, paused: false, map: map)
|
|
140
|
-
# faint orbit rings, added first so every body draws (and is hit) above them
|
|
141
|
-
for i := range Layout.Planets
|
|
142
|
-
r := 90.0 + Float(i) * 60.0
|
|
143
|
-
stage.root.add(Circle.create(r, Color(r: 1.0, g: 1.0, b: 1.0, a: 0.04)).at(Layout.CenterX - r, Layout.CenterY - r))
|
|
144
|
-
planetNames := ["Ember", "Cinder", "Ashen", "Glacier"].toSlice()
|
|
145
|
-
moons := [0, 1, 2, 1].toSlice()
|
|
146
|
-
for i := range Layout.Planets
|
|
147
|
-
orbit := Orbit(speed: 0.9 / Float(1 + i)).at(Layout.CenterX, Layout.CenterY)
|
|
148
|
-
planet := addBody(sky, sheet, planetNames.at(i), 2, i, 40.0, 1.5).at(90.0 + Float(i) * 60.0, 0.0)
|
|
149
|
-
orbit.add(planet)
|
|
150
|
-
for m := range moons.at(i)
|
|
151
|
-
moonOrbit := Orbit(speed: 2.0 + Float(m))
|
|
152
|
-
moon := addBody(sky, sheet, "{planetNames.at(i)} moon {m + 1}", 1, m, 18.0, 3.0).at(36.0 + Float(m) * 16.0, 0.0)
|
|
153
|
-
moonOrbit.add(moon)
|
|
154
|
-
planet.add(moonOrbit)
|
|
155
|
-
stage.root.add(orbit)
|
|
156
|
-
stage.root.add(sun)
|
|
157
|
-
for i := range Layout.Walkers
|
|
158
|
-
speed := 20.0 + Float(i % 7) * 9.0
|
|
159
|
-
if i % 2 == 0
|
|
160
|
-
speed = 0.0 - speed
|
|
161
|
-
walker := Walker(pace: speed, phase: Float(i), tex: sheet, srcX: Float(i % 3) * Layout.Cell).at(Layout.WalkMax - 1800.0 + Float(i * 61 % 1800), 510.0)
|
|
162
|
-
sky.crowd.add(walker)
|
|
163
|
-
stage.root.add(sky.crowd)
|
|
164
|
-
stage.root.add(label)
|
|
165
|
-
sky
|
|
166
|
-
|
|
167
|
-
# Fits the 800 by 600 scene into a screen of `sw` by `sh` pixels, centred.
|
|
168
|
-
fun fit(sky: Sky, sw: Float, sh: Float) =
|
|
169
|
-
zoom := {sw / Layout.Width}.min(sh / Layout.Height)
|
|
170
|
-
sky.stage.camScale = zoom
|
|
171
|
-
sky.stage.camX = 0.0 - {sw / zoom - Layout.Width} / 2.0
|
|
172
|
-
sky.stage.camY = 0.0 - {sh / zoom - Layout.Height} / 2.0
|
|
173
|
-
sky.crowd.setCullingArea(sky.stage.camX, sky.stage.camY, sw / zoom, sh / zoom)
|
|
174
|
-
|
|
175
|
-
# The index of a body (or its halo) in `bodies`, or -1.
|
|
176
|
-
fun indexOf(sky: Sky, n: Actor) -> Int =
|
|
177
|
-
for i := range sky.bodies.length()
|
|
178
|
-
if same(sky.bodies.at(i), n) || same(sky.halos.at(i), n)
|
|
179
|
-
return i
|
|
180
|
-
-1
|
|
181
|
-
|
|
182
|
-
# Selects body `i` (or nothing for -1): shows its halo and name.
|
|
183
|
-
fun select(sky: Sky, i: Int) =
|
|
184
|
-
sky.selected = i
|
|
185
|
-
for k := range sky.halos.length()
|
|
186
|
-
sky.halos.at(k).visible = k == i
|
|
187
|
-
if i < 0
|
|
188
|
-
sky.label.text = "Click a planet"
|
|
189
|
-
else
|
|
190
|
-
sky.label.text = sky.names.at(i)
|
|
191
|
-
|
|
192
|
-
# A click at stage point (x, y): selects the body there, deselects it if it was
|
|
193
|
-
# selected already, and clears the selection on empty space.
|
|
194
|
-
fun clickAt(sky: Sky, x: Float, y: Float) =
|
|
195
|
-
i := -1
|
|
196
|
-
if Some(n) := sky.stage.hit(x, y)
|
|
197
|
-
i = indexOf(sky, n)
|
|
198
|
-
if Some(p) := n.parent
|
|
199
|
-
if p.children == sky.crowd.children
|
|
200
|
-
n.toFront()
|
|
201
|
-
sky.front = Some(n)
|
|
202
|
-
if i == sky.selected
|
|
203
|
-
i = -1
|
|
204
|
-
select(sky, i)
|
|
205
|
-
|
|
206
|
-
# Where a body's centre is on the stage.
|
|
207
|
-
fun centerOf(sky: Sky, i: Int) -> Vec2 =
|
|
208
|
-
b := sky.bodies.at(i)
|
|
209
|
-
b.localToStage(Vec2(x: b.width / 2.0, y: b.height / 2.0))
|
|
210
|
-
|
|
211
|
-
# Advances the clock and every behaviour, and animates the sun's frames.
|
|
212
|
-
fun tick(sky: Sky, dt: Float) =
|
|
213
|
-
if sky.paused
|
|
214
|
-
return
|
|
215
|
-
sky.clock = sky.clock + dt
|
|
216
|
-
sky.stage.act(dt)
|
|
217
|
-
sky.crowd.sortChildren(|a, b| a.y < b.y)
|
|
218
|
-
if Some(f) := sky.front
|
|
219
|
-
f.toFront()
|
|
220
|
-
sky.sun.srcX = Float(Int(sky.clock * 6.0) % 4) * Layout.Cell
|
|
221
|
-
|
|
222
|
-
# --- host glue ---
|
|
223
|
-
|
|
224
|
-
# The game: a `Sky` and the host parts. Space pauses, a press selects a body.
|
|
225
|
-
struct Clockwork(Game(engine), sky: Sky) =
|
|
226
|
-
override fun update(self, dt: Float) =
|
|
227
|
-
inputs := self.input.events()
|
|
228
|
-
for i := range inputs.length()
|
|
229
|
-
match inputs.at(i)
|
|
230
|
-
KeyDown(k) =>
|
|
231
|
-
if k == Key.Space
|
|
232
|
-
self.sky.paused = !self.sky.paused
|
|
233
|
-
_ => 0
|
|
234
|
-
tick(self.sky, dt)
|
|
235
|
-
fit(self.sky, self.canvas.width(), self.canvas.height())
|
|
236
|
-
for i := range inputs.length()
|
|
237
|
-
match inputs.at(i)
|
|
238
|
-
PointerDown(x, y) =>
|
|
239
|
-
p := self.sky.stage.screenToStage(x, y)
|
|
240
|
-
clickAt(self.sky, p.x, p.y)
|
|
241
|
-
_ => 0
|
|
242
|
-
|
|
243
|
-
override fun draw(self) =
|
|
244
|
-
self.canvas.clear(Color.Black)
|
|
245
|
-
self.sky.stage.draw(self.canvas)
|
|
246
|
-
drawMinimap(self.canvas, self.sky)
|
|
247
|
-
|
|
248
|
-
fun main(io: Io) -> Game =
|
|
249
|
-
e := Engine.create(io)
|
|
250
|
-
sheet := e.assets.texture("sprites.png")
|
|
251
|
-
map := e.canvas.createTexture(Layout.MapW, Layout.MapH)
|
|
252
|
-
Clockwork(engine: e, sky: newSky(sheet, map))
|
|
253
|
-
|
|
254
|
-
# Redraws the minimap texture (every body as a dot, the selected one highlighted)
|
|
255
|
-
# and shows it in the bottom-right corner of the screen.
|
|
256
|
-
fun drawMinimap(canvas: Canvas, sky: Sky) =
|
|
257
|
-
if sky.map.id < 0
|
|
258
|
-
return
|
|
259
|
-
k := Layout.MapScale
|
|
260
|
-
w := Float(Layout.MapW)
|
|
261
|
-
h := Float(Layout.MapH)
|
|
262
|
-
canvas.renderTarget(sky.map)
|
|
263
|
-
canvas.clear(Color(r: 0.05, g: 0.1, b: 0.2, a: 0.85))
|
|
264
|
-
canvas.drawRect(0.0, 0.0, w, 1.0, Color.White)
|
|
265
|
-
canvas.drawRect(0.0, h - 1.0, w, 1.0, Color.White)
|
|
266
|
-
canvas.drawRect(0.0, 0.0, 1.0, h, Color.White)
|
|
267
|
-
canvas.drawRect(w - 1.0, 0.0, 1.0, h, Color.White)
|
|
268
|
-
canvas.drawCircle(Layout.CenterX * k, Layout.CenterY * k, 5.0, Color.Yellow)
|
|
269
|
-
for i := range sky.bodies.length()
|
|
270
|
-
c := centerOf(sky, i)
|
|
271
|
-
dot := Color(r: 0.6, g: 0.8, b: 1.0, a: 1.0)
|
|
272
|
-
if i == sky.selected
|
|
273
|
-
dot = Color.Yellow
|
|
274
|
-
canvas.drawCircle(c.x * k, c.y * k, 2.0, dot)
|
|
275
|
-
canvas.screenTarget()
|
|
276
|
-
canvas.drawTexture(sky.map, canvas.width() - w - 8.0, canvas.height() - h - 8.0, Color.White)
|
|
277
|
-
|
|
278
|
-
# --- tests ---
|
|
279
|
-
|
|
280
|
-
# Test probes (the std library keeps its own tests free of extra Actor kinds).
|
|
281
|
-
# A group that turns by dt each act and acts its children too.
|
|
282
|
-
struct ProbeSpinner(Actor) =
|
|
283
|
-
|
|
284
|
-
override fun act(self, dt: Float) =
|
|
285
|
-
self.rotation = self.rotation + dt
|
|
286
|
-
self.actChildren(dt)
|
|
287
|
-
|
|
288
|
-
# A walker: moves along x in its own `act` and counts the frames it draws itself in.
|
|
289
|
-
struct ProbeWalker(Actor, speed: Float, mut drawn: Int) =
|
|
290
|
-
|
|
291
|
-
fun init(self) =
|
|
292
|
-
self.width = 10.0
|
|
293
|
-
self.height = 10.0
|
|
294
|
-
|
|
295
|
-
override fun act(self, dt: Float) =
|
|
296
|
-
self.x = self.x + self.speed * dt
|
|
297
|
-
|
|
298
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
299
|
-
self.drawn = self.drawn + 1
|
|
300
|
-
|
|
301
|
-
fun near(a: Float, b: Float) -> Bool =
|
|
302
|
-
{a - b}.abs() < 0.01
|
|
303
|
-
|
|
304
|
-
test "the scene has a sun, four planets and their moons"
|
|
305
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
306
|
-
assert sky.bodies.length() == 8
|
|
307
|
-
assert sky.halos.length() == 8
|
|
308
|
-
assert sky.names.length() == 8
|
|
309
|
-
assert sky.names.at(0) == "Ember"
|
|
310
|
-
assert sky.names.at(2) == "Cinder moon 1"
|
|
311
|
-
assert sky.names.at(6) == "Glacier"
|
|
312
|
-
assert flatten(sky.stage.root).length() > 60
|
|
313
|
-
|
|
314
|
-
test "planets start on the right of the sun and orbit clockwise"
|
|
315
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
316
|
-
c := centerOf(sky, 0)
|
|
317
|
-
assert near(c.x, 490.0)
|
|
318
|
-
assert near(c.y, 300.0)
|
|
319
|
-
# planet 0 turns 0.9 rad/s: a quarter turn takes HalfPi / 0.9 seconds
|
|
320
|
-
tick(sky, Float.HalfPi / 0.9)
|
|
321
|
-
c = centerOf(sky, 0)
|
|
322
|
-
assert near(c.x, 400.0)
|
|
323
|
-
assert near(c.y, 390.0)
|
|
324
|
-
|
|
325
|
-
test "a moon stays its orbit radius from its planet while both move"
|
|
326
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
327
|
-
for i := range 50
|
|
328
|
-
tick(sky, 0.1)
|
|
329
|
-
pc := centerOf(sky, 3)
|
|
330
|
-
mc := centerOf(sky, 4)
|
|
331
|
-
assert near(pc.distance(mc), 36.0)
|
|
332
|
-
sc := Vec2(x: Layout.CenterX, y: Layout.CenterY)
|
|
333
|
-
assert near(sc.distance(pc), 210.0)
|
|
334
|
-
|
|
335
|
-
test "clicking selects a body even after it has moved, and again to deselect"
|
|
336
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
337
|
-
tick(sky, 3.0)
|
|
338
|
-
c := centerOf(sky, 1)
|
|
339
|
-
clickAt(sky, c.x, c.y)
|
|
340
|
-
assert sky.selected == 1
|
|
341
|
-
assert sky.label.text == "Cinder"
|
|
342
|
-
assert sky.halos.at(1).visible
|
|
343
|
-
assert !sky.halos.at(0).visible
|
|
344
|
-
clickAt(sky, c.x, c.y)
|
|
345
|
-
assert sky.selected == -1
|
|
346
|
-
assert !sky.halos.at(1).visible
|
|
347
|
-
assert sky.label.text == "Click a planet"
|
|
348
|
-
|
|
349
|
-
test "clicking a moon or empty space"
|
|
350
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
351
|
-
m := centerOf(sky, 2)
|
|
352
|
-
clickAt(sky, m.x, m.y)
|
|
353
|
-
assert sky.names.at(sky.selected) == "Cinder moon 1"
|
|
354
|
-
clickAt(sky, 2.0, 2.0)
|
|
355
|
-
assert sky.selected == -1
|
|
356
|
-
|
|
357
|
-
test "pausing freezes the scene"
|
|
358
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
359
|
-
sky.paused = true
|
|
360
|
-
tick(sky, 1.0)
|
|
361
|
-
c := centerOf(sky, 0)
|
|
362
|
-
assert near(c.x, 490.0)
|
|
363
|
-
assert sky.clock == 0.0
|
|
364
|
-
|
|
365
|
-
test "fit centres the scene and screen points map back"
|
|
366
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
367
|
-
fit(sky, 1600.0, 600.0)
|
|
368
|
-
assert near(sky.stage.camScale, 1.0)
|
|
369
|
-
assert near(sky.stage.camX, -400.0)
|
|
370
|
-
p := sky.stage.screenToStage(800.0, 300.0)
|
|
371
|
-
assert near(p.x, 400.0)
|
|
372
|
-
assert near(p.y, 300.0)
|
|
373
|
-
|
|
374
|
-
test "the crowd is y-sorted every tick and culled against the view"
|
|
375
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
376
|
-
tick(sky, 0.5)
|
|
377
|
-
tick(sky, 0.5)
|
|
378
|
-
kids := sky.crowd.children
|
|
379
|
-
assert kids.length() == 60
|
|
380
|
-
for i := range 59
|
|
381
|
-
assert kids.at(i).y <= kids.at(i + 1).y
|
|
382
|
-
fit(sky, 800.0, 600.0)
|
|
383
|
-
shown := flatten(sky.stage.root).length()
|
|
384
|
-
fit(sky, 100000.0, 600.0)
|
|
385
|
-
assert flatten(sky.stage.root).length() > shown
|
|
386
|
-
for i := range 60
|
|
387
|
-
assert kids.at(i).x >= 0.0 - 500.0 && kids.at(i).x <= 1300.0
|
|
388
|
-
|
|
389
|
-
test "clicking a walker brings it to the front, even after the next sort"
|
|
390
|
-
sky := newSky(Texture(id: -1), Texture(id: -1))
|
|
391
|
-
fit(sky, 1800.0, 600.0)
|
|
392
|
-
w := sky.crowd.children.at(0)
|
|
393
|
-
p := w.localToStage(Vec2(x: 14.0, y: 14.0))
|
|
394
|
-
clickAt(sky, p.x, p.y)
|
|
395
|
-
assert w.zIndex() == 59
|
|
396
|
-
tick(sky, 0.1)
|
|
397
|
-
assert w.zIndex() == 59
|
|
398
|
-
|
|
399
|
-
test "act runs behaviours depth first, including invisible actors"
|
|
400
|
-
stage := Stage.create()
|
|
401
|
-
g := ProbeSpinner()
|
|
402
|
-
stage.root.add(g)
|
|
403
|
-
k := ProbeSpinner()
|
|
404
|
-
g.add(k)
|
|
405
|
-
g.visible = false
|
|
406
|
-
stage.act(0.5)
|
|
407
|
-
stage.act(0.25)
|
|
408
|
-
assert near(g.rotation, 0.75)
|
|
409
|
-
assert near(k.rotation, 0.75)
|
|
410
|
-
assert stage.root.rotation == 0.0
|
|
411
|
-
|
|
412
|
-
test "walker_record: a Walker in a Stage moves via its own act and draws via its own draw"
|
|
413
|
-
stage := Stage.create()
|
|
414
|
-
w := ProbeWalker(speed: 4.0, drawn: 0)
|
|
415
|
-
stage.root.add(w)
|
|
416
|
-
stage.root.add(Group())
|
|
417
|
-
canvas := Canvas(io: io)
|
|
418
|
-
stage.act(0.5)
|
|
419
|
-
stage.act(0.5)
|
|
420
|
-
assert near(w.x, 4.0)
|
|
421
|
-
assert w.drawn == 0
|
|
422
|
-
stage.root.drawTree(canvas, Affine.Identity, 1.0)
|
|
423
|
-
stage.root.drawTree(canvas, Affine.Identity, 1.0)
|
|
424
|
-
assert w.drawn == 2
|
|
425
|
-
w.visible = false
|
|
426
|
-
stage.root.drawTree(canvas, Affine.Identity, 1.0)
|
|
427
|
-
assert w.drawn == 2
|
|
428
|
-
w.visible = true
|
|
429
|
-
# a base-typed value reaches the same struct
|
|
430
|
-
a: Actor := w
|
|
431
|
-
a.act(1.0)
|
|
432
|
-
assert near(w.x, 8.0)
|
|
433
|
-
assert same(stage.hit(8.5, 5.0).unwrap(), w)
|
examples/games/{functional/kids → kids}/balloon_pop.plum
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Balloon Pop (`plum run examples/games/
|
|
1
|
+
# Balloon Pop (`plum run examples/games/kids/balloon_pop.plum`): a
|
|
2
2
|
# game for small children. Balloons float up the sky; tap or click one to pop it.
|
|
3
3
|
# A popped balloon bursts into dots and its colour's name floats up ("Red!"), so the
|
|
4
4
|
# child hears the pop and sees the word. Every 10 pops a "Hooray!" party with
|
|
@@ -22,8 +22,8 @@ import std/Random
|
|
|
22
22
|
import std/game/Vec2
|
|
23
23
|
import std/game/Color
|
|
24
24
|
import std/game/Game
|
|
25
|
-
import std/game/
|
|
25
|
+
import std/game/Scene
|
|
26
|
-
import std/game/
|
|
26
|
+
import std/game/Anim
|
|
27
27
|
import std/game/Input
|
|
28
28
|
import std/Io
|
|
29
29
|
|
|
@@ -307,38 +307,36 @@ fun cloud(x: Float, y: Float) -> SceneNode[Msg] =
|
|
|
307
307
|
|
|
308
308
|
# --- the host shell ---
|
|
309
309
|
|
|
310
|
-
struct Balloons(
|
|
310
|
+
struct Balloons(state: State, input: Input, popSound: Sound, partySound: Sound, musicSound: Sound) =
|
|
311
311
|
|
|
312
312
|
# Sends every input to the state, then one tick of time; plays a pop for each new
|
|
313
|
-
# pop and a fanfare when a party starts.
|
|
313
|
+
# pop and a fanfare when a party starts. Returns the game with the next state.
|
|
314
|
-
|
|
314
|
+
fun update(self, io: Io, dt: Float) -> Balloons =
|
|
315
|
+
input := self.input.poll(io)
|
|
315
|
-
s := self.state.update(Resize(w:
|
|
316
|
+
s := self.state.update(Resize(w: io.screenWidth(), h: io.screenHeight()))
|
|
316
317
|
before := s
|
|
317
|
-
inputs :=
|
|
318
|
+
inputs := input.events
|
|
318
319
|
for i := range inputs.length()
|
|
319
320
|
s = s.update(Event(input: inputs.at(i)))
|
|
320
321
|
if s.pops > before.pops
|
|
321
|
-
self.
|
|
322
|
+
self.popSound.play(io, 0.9)
|
|
322
323
|
if s.partyAt != before.partyAt
|
|
323
|
-
self.
|
|
324
|
+
self.partySound.play(io, 0.6)
|
|
324
|
-
|
|
325
|
+
Balloons(..self, state: s.update(Tick(dt: dt)), input: input)
|
|
325
326
|
|
|
326
|
-
|
|
327
|
+
fun draw(self, io: Io) =
|
|
327
|
-
s := self.state
|
|
328
|
-
canvas := self.canvas
|
|
329
|
-
|
|
328
|
+
io.clear(Color.rgb(0.55, 0.8, 1.0))
|
|
330
|
-
|
|
329
|
+
self.state.render().draw(io)
|
|
331
330
|
|
|
332
|
-
fun main(io: Io) ->
|
|
331
|
+
fun main(io: Io) -> Balloons =
|
|
333
|
-
e := Engine.create(io)
|
|
334
332
|
game := Balloons(
|
|
335
|
-
engine: e,
|
|
336
333
|
state: State.start(io.randomSeed(), Rules.ArenaW, Rules.ArenaH),
|
|
334
|
+
input: Input.create(),
|
|
337
|
-
popSound:
|
|
335
|
+
popSound: Sound.load(io, "pickup.wav"),
|
|
338
|
-
partySound:
|
|
336
|
+
partySound: Sound.load(io, "gameover.wav"),
|
|
339
|
-
musicSound:
|
|
337
|
+
musicSound: Sound.load(io, "music.wav")
|
|
340
338
|
)
|
|
341
|
-
|
|
339
|
+
game.musicSound.loop(io, 0.2)
|
|
342
340
|
game
|
|
343
341
|
|
|
344
342
|
# --- tests ---
|
examples/games/{functional/game_state_view.plum → orb_run.plum}
RENAMED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
# Orb Run as components (`plum run examples/games/
|
|
1
|
+
# Orb Run as components (`plum run examples/games/orb_run.plum`), written in the
|
|
2
|
-
# game as game_pure_scene.plum (same arena, rules, sprites, sounds, keys and scoring),
|
|
3
|
-
#
|
|
2
|
+
# component style of Elm and React but purely functional. Steer the ship
|
|
4
3
|
# with the arrow keys or WASD, collect the drifting orbs and avoid the mines. Three
|
|
5
4
|
# lives; Enter, R or the "Again" button restarts after the game is over.
|
|
6
5
|
#
|
|
@@ -20,9 +19,10 @@
|
|
|
20
19
|
# message (`RestartClicked`): `Ui.messages` reads it from the picture, and it goes to
|
|
21
20
|
# `State.update` like any other `Msg`. The picture is a `SceneNode[Msg]`.
|
|
22
21
|
# The game-over title and the ship's hurt flash are `Anim`s of time since they began.
|
|
23
|
-
#
|
|
22
|
+
# `OrbGame` below is the host shell: `main` returns it, and its `update(self, io, dt)`
|
|
23
|
+
# returns the next one. It holds the current `State`, turns the frame's input into
|
|
24
|
-
#
|
|
24
|
+
# messages, and plays sounds.
|
|
25
|
-
module
|
|
25
|
+
module orb_run
|
|
26
26
|
import std/Str
|
|
27
27
|
import std/Int
|
|
28
28
|
import std/Float
|
|
@@ -34,9 +34,9 @@ import std/Random
|
|
|
34
34
|
import std/game/Vec2
|
|
35
35
|
import std/game/Color
|
|
36
36
|
import std/game/Game
|
|
37
|
-
import std/game/
|
|
37
|
+
import std/game/Scene
|
|
38
|
-
import std/game/
|
|
38
|
+
import std/game/Anim
|
|
39
|
-
import std/game/
|
|
39
|
+
import std/game/Ui
|
|
40
40
|
import std/game/Input
|
|
41
41
|
import std/Io
|
|
42
42
|
|
|
@@ -523,57 +523,55 @@ fun isRestartKey(e: InputEvent) -> Bool = match e
|
|
|
523
523
|
|
|
524
524
|
# --- the host shell ---
|
|
525
525
|
|
|
526
|
-
struct OrbGame(
|
|
526
|
+
struct OrbGame(state: State, input: Input, pickupSound: Sound, hitSound: Sound, overSound: Sound, musicSound: Sound) =
|
|
527
527
|
|
|
528
528
|
# Restarts the music loop from the top.
|
|
529
|
-
fun startMusic(self) =
|
|
529
|
+
fun startMusic(self, io: Io) =
|
|
530
|
-
self.
|
|
530
|
+
self.musicSound.stop(io)
|
|
531
|
-
self.
|
|
531
|
+
self.musicSound.loop(io, musicVolume())
|
|
532
|
-
|
|
532
|
+
|
|
533
|
-
#
|
|
533
|
+
# Polls the input once, sends its events to the state, then one tick steered by the
|
|
534
|
-
# plays sounds for what changed.
|
|
534
|
+
# held keys; plays sounds for what changed. Returns the game with the next state.
|
|
535
|
-
|
|
535
|
+
fun update(self, io: Io, dt: Float) -> OrbGame =
|
|
536
|
+
input := self.input.poll(io)
|
|
536
|
-
s := self.state.update(Resize(w:
|
|
537
|
+
s := self.state.update(Resize(w: io.screenWidth(), h: io.screenHeight()))
|
|
537
538
|
clicked := s
|
|
538
|
-
inputs :=
|
|
539
|
+
inputs := input.events
|
|
539
540
|
for i := range inputs.length()
|
|
540
541
|
clicked = clicked.update(Event(input: inputs.at(i)))
|
|
541
542
|
if s.over && !clicked.over
|
|
542
|
-
self.state = clicked
|
|
543
|
-
self.startMusic()
|
|
543
|
+
self.startMusic(io)
|
|
544
|
+
OrbGame(..self, state: clicked, input: input)
|
|
544
|
-
|
|
545
|
+
else
|
|
545
|
-
|
|
546
|
+
t := clicked.update(Tick(dt: dt, ax: clicked.axisX(), ay: clicked.axisY()))
|
|
546
|
-
|
|
547
|
+
if t.score > clicked.score
|
|
547
|
-
|
|
548
|
+
self.pickupSound.play(io, 0.8)
|
|
548
|
-
|
|
549
|
+
if t.lives < clicked.lives
|
|
549
|
-
|
|
550
|
+
if t.over
|
|
550
|
-
|
|
551
|
+
self.musicSound.stop(io)
|
|
551
|
-
|
|
552
|
+
self.overSound.play(io, 1.0)
|
|
552
|
-
|
|
553
|
+
else
|
|
553
|
-
|
|
554
|
+
self.hitSound.play(io, 1.0)
|
|
554
|
-
|
|
555
|
+
OrbGame(..self, state: t, input: input)
|
|
555
|
-
|
|
556
|
+
|
|
556
|
-
|
|
557
|
+
fun draw(self, io: Io) =
|
|
557
|
-
s := self.state
|
|
558
|
-
canvas := self.canvas
|
|
559
|
-
|
|
558
|
+
io.clear(Color.rgb(0.04, 0.05, 0.1))
|
|
560
|
-
|
|
559
|
+
self.state.render().draw(io)
|
|
561
560
|
|
|
562
561
|
fun musicVolume() -> Float =
|
|
563
562
|
0.3
|
|
564
563
|
|
|
565
|
-
fun main(io: Io) ->
|
|
564
|
+
fun main(io: Io) -> OrbGame =
|
|
566
|
-
e := Engine.create(io)
|
|
567
|
-
s := State.start(io.randomSeed(), 0,
|
|
565
|
+
s := State.start(io.randomSeed(), 0, Texture.load(io, "sprites.png"), Texture.create(io, 1600, 1200), Rules.ArenaW, Rules.ArenaH)
|
|
568
566
|
game := OrbGame(
|
|
569
|
-
engine: e,
|
|
570
567
|
state: s,
|
|
568
|
+
input: Input.create(),
|
|
571
|
-
pickupSound:
|
|
569
|
+
pickupSound: Sound.load(io, "pickup.wav"),
|
|
572
|
-
hitSound:
|
|
570
|
+
hitSound: Sound.load(io, "hit.wav"),
|
|
573
|
-
overSound:
|
|
571
|
+
overSound: Sound.load(io, "gameover.wav"),
|
|
574
|
-
musicSound:
|
|
572
|
+
musicSound: Sound.load(io, "music.wav")
|
|
575
573
|
)
|
|
576
|
-
game.startMusic()
|
|
574
|
+
game.startMusic(io)
|
|
577
575
|
game
|
|
578
576
|
|
|
579
577
|
fun axis(negative: Bool, positive: Bool) -> Float =
|
examples/games/pure/game_pure_scene.plum
DELETED
|
@@ -1,880 +0,0 @@
|
|
|
1
|
-
# Orb Run on the pure scene graph (`plum game examples/games/pure/game_pure_scene.plum`): the same
|
|
2
|
-
# game as game_scene.plum (same arena, rules, sprites, sounds, keys and scoring), but the
|
|
3
|
-
# scene is made of values. Steer the ship with the arrow keys or WASD, collect the
|
|
4
|
-
# drifting orbs and avoid the mines. Three lives; Enter, R or the "Again" button
|
|
5
|
-
# restarts after the game is over.
|
|
6
|
-
#
|
|
7
|
-
# What differs from game_scene.plum. There, actors are mutable objects that point at
|
|
8
|
-
# their parent and `stage.act` changes them in place. Here the scene is made of `Node` values
|
|
9
|
-
# (std `Group`, `Sprite`, `Label`, `Button` kinds, and `Custom` nodes whose behaviour is a
|
|
10
|
-
# record embedding `Actor`: `Ship`, `Orb`, `Mine`, `Star`, `Panel`):
|
|
11
|
-
# `act` returns the next node, `Stage.frame` returns a new `Stage`, and a change from the
|
|
12
|
-
# rules ("this orb is collected", "show the game-over panel") is a `Node.modify`
|
|
13
|
-
# on an actor id that returns a new tree. The `World` record below is the mutable shell the
|
|
14
|
-
# host talks to: it keeps the current `Stage` and the run's state (score, lives, the ship's
|
|
15
|
-
# velocity) and replaces its `stage` every frame. Nothing that a function was given
|
|
16
|
-
# is ever changed; only `init`, `update` and `draw` talk to the host.
|
|
17
|
-
module game_pure_scene
|
|
18
|
-
import std/Str
|
|
19
|
-
import std/Int
|
|
20
|
-
import std/Float
|
|
21
|
-
import std/Slice
|
|
22
|
-
import std/Option
|
|
23
|
-
import std/Math
|
|
24
|
-
import std/Random
|
|
25
|
-
import std/game/Vec2
|
|
26
|
-
import std/game/Color
|
|
27
|
-
import std/game/Affine
|
|
28
|
-
import std/game/Game
|
|
29
|
-
import std/game/pure/Actions
|
|
30
|
-
import std/game/pure/PureScene
|
|
31
|
-
import std/game/pure/SceneInput
|
|
32
|
-
import std/game/Input
|
|
33
|
-
import std/Io
|
|
34
|
-
import std/List
|
|
35
|
-
|
|
36
|
-
primitive Rules =
|
|
37
|
-
ArenaW = 800.0
|
|
38
|
-
ArenaH = 600.0
|
|
39
|
-
ShipRadius = 10.0
|
|
40
|
-
OrbRadius = 10.0
|
|
41
|
-
MineRadius = 11.0
|
|
42
|
-
Thrust = 900.0
|
|
43
|
-
Drag = 2.2
|
|
44
|
-
OrbCount = 3
|
|
45
|
-
StartLives = 3
|
|
46
|
-
HurtTime = 1.5
|
|
47
|
-
OrbPoints = 10
|
|
48
|
-
Cell = 16.0
|
|
49
|
-
Size = 48.0
|
|
50
|
-
Half = 24.0
|
|
51
|
-
|
|
52
|
-
# Actor ids: unique in the tree, so the rules can find an actor with `Node.modify`.
|
|
53
|
-
primitive Id =
|
|
54
|
-
Root = 1
|
|
55
|
-
Backdrop = 2
|
|
56
|
-
StarLayer = 3
|
|
57
|
-
MineLayer = 4
|
|
58
|
-
OrbLayer = 5
|
|
59
|
-
Ship = 6
|
|
60
|
-
Hud = 7
|
|
61
|
-
Score = 8
|
|
62
|
-
Best = 9
|
|
63
|
-
Overlay = 20
|
|
64
|
-
OverlayPanel = 21
|
|
65
|
-
OverTitle = 22
|
|
66
|
-
OverHint = 23
|
|
67
|
-
Restart = 24
|
|
68
|
-
Icon = 30
|
|
69
|
-
Orb = 100
|
|
70
|
-
Mine = 200
|
|
71
|
-
Star = 1000
|
|
72
|
-
|
|
73
|
-
# A position and velocity: what a spawn returns and what drifters are bounced with.
|
|
74
|
-
struct Motion(x: Float, y: Float, vx: Float, vy: Float)
|
|
75
|
-
|
|
76
|
-
# Which of `count` animation frames is showing after `clock` seconds at `fps` frames per second.
|
|
77
|
-
fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
|
|
78
|
-
Int(clock * fps) % count
|
|
79
|
-
|
|
80
|
-
# A random spot at least `gap` away from (ax, ay), drifting at up to `speed`.
|
|
81
|
-
# A Motion and the generator to continue from.
|
|
82
|
-
struct Spawned(at: Motion, rng: Random)
|
|
83
|
-
|
|
84
|
-
fun spawn(rng: Random, ax: Float, ay: Float, gap: Float, speed: Float) -> Spawned =
|
|
85
|
-
margin := 30.0
|
|
86
|
-
bx := 0.0
|
|
87
|
-
by := 0.0
|
|
88
|
-
r := rng
|
|
89
|
-
tries := 0
|
|
90
|
-
while tries < 20
|
|
91
|
-
dx := r.nextFloat()
|
|
92
|
-
dy := dx.rng.nextFloat()
|
|
93
|
-
r = dy.rng
|
|
94
|
-
bx = margin + dx.value * {Rules.ArenaW - 2.0 * margin}
|
|
95
|
-
by = margin + dy.value * {Rules.ArenaH - 2.0 * margin}
|
|
96
|
-
if Math.hypot(bx - ax, by - ay) >= gap
|
|
97
|
-
break
|
|
98
|
-
tries = tries + 1
|
|
99
|
-
dh := r.nextFloat()
|
|
100
|
-
dp := dh.rng.nextFloat()
|
|
101
|
-
heading := dh.value * Float.Tau
|
|
102
|
-
pace := speed * {0.4 + 0.6 * dp.value}
|
|
103
|
-
Spawned(at: Motion(x: bx, y: by, vx: Math.cos(heading) * pace, vy: Math.sin(heading) * pace), rng: dp.rng)
|
|
104
|
-
|
|
105
|
-
# `b` moved for `dt` seconds and bounced off the arena walls; `b` itself is unchanged.
|
|
106
|
-
fun driftOf(b: Motion, r: Float, dt: Float) -> Motion =
|
|
107
|
-
x := b.x + b.vx * dt
|
|
108
|
-
y := b.y + b.vy * dt
|
|
109
|
-
vx := b.vx
|
|
110
|
-
vy := b.vy
|
|
111
|
-
if x < r || x > Rules.ArenaW - r
|
|
112
|
-
vx = 0.0 - b.vx
|
|
113
|
-
x = x.max(r).min(Rules.ArenaW - r)
|
|
114
|
-
if y < r || y > Rules.ArenaH - r
|
|
115
|
-
vy = 0.0 - b.vy
|
|
116
|
-
y = y.max(r).min(Rules.ArenaH - r)
|
|
117
|
-
Motion(x: x, y: y, vx: vx, vy: vy)
|
|
118
|
-
|
|
119
|
-
# How many mines are out: more as the score grows.
|
|
120
|
-
fun mineCount(score: Int) -> Int =
|
|
121
|
-
{3 + score / 50}.min(8)
|
|
122
|
-
|
|
123
|
-
# The ship's motion state. The ship actor in the tree only shows it.
|
|
124
|
-
struct ShipState(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt: Float)
|
|
125
|
-
|
|
126
|
-
# Accelerates by the input axes, applies drag and keeps the ship in the arena.
|
|
127
|
-
fun shipStep(s: ShipState, ax: Float, ay: Float, dt: Float) -> ShipState =
|
|
128
|
-
hurt := {s.hurt - dt}.max(0.0)
|
|
129
|
-
keep := {1.0 - Rules.Drag * dt}.max(0.0)
|
|
130
|
-
vx := {s.vx + ax * Rules.Thrust * dt} * keep
|
|
131
|
-
vy := {s.vy + ay * Rules.Thrust * dt} * keep
|
|
132
|
-
x := s.x + vx * dt
|
|
133
|
-
y := s.y + vy * dt
|
|
134
|
-
r := Rules.ShipRadius
|
|
135
|
-
if x < r
|
|
136
|
-
x = r
|
|
137
|
-
vx = 0.0
|
|
138
|
-
if x > Rules.ArenaW - r
|
|
139
|
-
x = Rules.ArenaW - r
|
|
140
|
-
vx = 0.0
|
|
141
|
-
if y < r
|
|
142
|
-
y = r
|
|
143
|
-
vy = 0.0
|
|
144
|
-
if y > Rules.ArenaH - r
|
|
145
|
-
y = Rules.ArenaH - r
|
|
146
|
-
vy = 0.0
|
|
147
|
-
rotation := s.rotation
|
|
148
|
-
if vx * vx + vy * vy > 400.0
|
|
149
|
-
rotation = Math.atan2(vx, 0.0 - vy)
|
|
150
|
-
ShipState(x: x, y: y, vx: vx, vy: vy, rotation: rotation, hurt: hurt)
|
|
151
|
-
|
|
152
|
-
fun newShip() -> ShipState =
|
|
153
|
-
ShipState(x: Rules.ArenaW / 2.0, y: Rules.ArenaH / 2.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
|
|
154
|
-
|
|
155
|
-
# The centre of a node drawn as a sprite-sized box.
|
|
156
|
-
fun cx(n: Node) -> Float =
|
|
157
|
-
n.x + n.width / 2.0
|
|
158
|
-
|
|
159
|
-
fun cy(n: Node) -> Float =
|
|
160
|
-
n.y + n.height / 2.0
|
|
161
|
-
|
|
162
|
-
# Actor kinds of this game. Each holds only its own fields; position and size live on
|
|
163
|
-
# the `Node`. Each `create` returns a sprite-sized node; it takes the centre.
|
|
164
|
-
|
|
165
|
-
# A filled rectangle; its colour is the node's.
|
|
166
|
-
struct Panel(Actor) =
|
|
167
|
-
|
|
168
|
-
fun create(id: Int, x: Float, y: Float, w: Float, h: Float, c: Color) -> Node =
|
|
169
|
-
n := Node.custom(id, x, y, w, h, Panel())
|
|
170
|
-
Node(..n, color: c)
|
|
171
|
-
|
|
172
|
-
override fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
173
|
-
p := m.apply(Vec2(x: 0.0, y: 0.0))
|
|
174
|
-
canvas.drawRect(p.x, p.y, n.width * m.scaleX(), n.height * m.scaleY(), tint)
|
|
175
|
-
|
|
176
|
-
# The player: only a picture that follows `ShipState`; `act` runs its animation clock.
|
|
177
|
-
struct Ship(Actor, mut clock: Float, tex: Texture) =
|
|
178
|
-
|
|
179
|
-
fun create(sheet: Texture, s: ShipState) -> Node =
|
|
180
|
-
n := Node.custom(Id.Ship, s.x - Rules.Half, s.y - Rules.Half, Rules.Size, Rules.Size, Ship(clock: 0.0, tex: sheet))
|
|
181
|
-
Node(..n, originX: Rules.Half, originY: Rules.Half)
|
|
182
|
-
|
|
183
|
-
override fun act(self, n: Node, dt: Float) -> Node =
|
|
184
|
-
self.clock = self.clock + dt
|
|
185
|
-
n
|
|
186
|
-
|
|
187
|
-
override fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
188
|
-
c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
|
|
189
|
-
canvas.drawCircle(c.x, c.y, 17.0 * m.scaleX(), Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12 * tint.a))
|
|
190
|
-
frame := Float(frameOf(self.clock, 4, 14.0))
|
|
191
|
-
Node.drawSprite(canvas, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 0.0, Rules.Cell, Rules.Cell)
|
|
192
|
-
|
|
193
|
-
# A glowing orb drifting about. `phase` offsets its animation from the other orbs.
|
|
194
|
-
struct Orb(Actor, mut vx: Float, mut vy: Float, phase: Float, mut clock: Float, tex: Texture) =
|
|
195
|
-
|
|
196
|
-
fun create(sheet: Texture, phase: Float, id: Int, at: Motion) -> Node =
|
|
197
|
-
Orb.createAt(sheet, phase, id, at, 0.0)
|
|
198
|
-
|
|
199
|
-
# An orb that carries on an animation `clock`, so a respawn or freeze never jumps a frame.
|
|
200
|
-
fun createAt(sheet: Texture, phase: Float, id: Int, at: Motion, clock: Float) -> Node =
|
|
201
|
-
n := Node.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
|
|
202
|
-
Orb(vx: at.vx, vy: at.vy, phase: phase, clock: clock, tex: sheet))
|
|
203
|
-
Node(..n, originX: Rules.Half, originY: Rules.Half)
|
|
204
|
-
|
|
205
|
-
override fun act(self, n: Node, dt: Float) -> Node =
|
|
206
|
-
m := driftOf(Motion(x: n.x + Rules.Half, y: n.y + Rules.Half, vx: self.vx, vy: self.vy), Rules.OrbRadius, dt)
|
|
207
|
-
self.clock = self.clock + dt
|
|
208
|
-
self.vx = m.vx
|
|
209
|
-
self.vy = m.vy
|
|
210
|
-
Node(..n, x: m.x - Rules.Half, y: m.y - Rules.Half)
|
|
211
|
-
|
|
212
|
-
override fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
213
|
-
c := m.apply(Vec2(x: n.width / 2.0, y: n.height / 2.0))
|
|
214
|
-
k := m.scaleX()
|
|
215
|
-
canvas.blendMode(Blend.Add)
|
|
216
|
-
canvas.drawCircle(c.x, c.y, 26.0 * k, Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5 * tint.a))
|
|
217
|
-
canvas.drawCircle(c.x, c.y, 17.0 * k, Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5 * tint.a))
|
|
218
|
-
canvas.blendMode(Blend.Normal)
|
|
219
|
-
frame := Float(frameOf(self.clock + self.phase, 4, 8.0))
|
|
220
|
-
Node.drawSprite(canvas, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, Rules.Cell, Rules.Cell, Rules.Cell)
|
|
221
|
-
|
|
222
|
-
# A mine drifting about, turning `spin` radians per second (0 once the game is over).
|
|
223
|
-
struct Mine(Actor, mut vx: Float, mut vy: Float, spin: Float, mut clock: Float, tex: Texture) =
|
|
224
|
-
|
|
225
|
-
fun create(sheet: Texture, id: Int, at: Motion, spin: Float) -> Node =
|
|
226
|
-
Mine.createAt(sheet, id, at, spin, 0.0)
|
|
227
|
-
|
|
228
|
-
fun createAt(sheet: Texture, id: Int, at: Motion, spin: Float, clock: Float) -> Node =
|
|
229
|
-
n := Node.custom(id, at.x - Rules.Half, at.y - Rules.Half, Rules.Size, Rules.Size,
|
|
230
|
-
Mine(vx: at.vx, vy: at.vy, spin: spin, clock: clock, tex: sheet))
|
|
231
|
-
Node(..n, originX: Rules.Half, originY: Rules.Half)
|
|
232
|
-
|
|
233
|
-
override fun act(self, n: Node, dt: Float) -> Node =
|
|
234
|
-
m := driftOf(Motion(x: n.x + Rules.Half, y: n.y + Rules.Half, vx: self.vx, vy: self.vy), Rules.MineRadius, dt)
|
|
235
|
-
self.clock = self.clock + dt
|
|
236
|
-
self.vx = m.vx
|
|
237
|
-
self.vy = m.vy
|
|
238
|
-
Node(..n, x: m.x - Rules.Half, y: m.y - Rules.Half, rotation: n.rotation + self.spin * dt)
|
|
239
|
-
|
|
240
|
-
override fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
241
|
-
frame := Float(frameOf(self.clock, 4, 4.0))
|
|
242
|
-
Node.drawSprite(canvas, m, tint, n.width, n.height, self.tex, frame * Rules.Cell, 2.0 * Rules.Cell, Rules.Cell, Rules.Cell)
|
|
243
|
-
|
|
244
|
-
# A background star that twinkles.
|
|
245
|
-
struct Star(Actor, seed: Float, mut clock: Float) =
|
|
246
|
-
|
|
247
|
-
fun create(i: Int) -> Node =
|
|
248
|
-
r := 1.0 + Float(i % 3) * 0.5
|
|
249
|
-
Node.custom(Id.Star + i, Float({i * 7919} % 800) - r, Float({i * 104729} % 600) - r, r * 2.0, r * 2.0,
|
|
250
|
-
Star(seed: Float(i), clock: 0.0))
|
|
251
|
-
|
|
252
|
-
override fun act(self, n: Node, dt: Float) -> Node =
|
|
253
|
-
self.clock = self.clock + dt
|
|
254
|
-
n
|
|
255
|
-
|
|
256
|
-
override fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
257
|
-
twinkle := 0.25 + 0.2 * Math.sin(self.clock * 2.0 + self.seed)
|
|
258
|
-
r := n.width / 2.0
|
|
259
|
-
c := m.apply(Vec2(x: r, y: r))
|
|
260
|
-
canvas.drawCircle(c.x, c.y, r * m.scaleX(), Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle * tint.a))
|
|
261
|
-
|
|
262
|
-
# --- reading and changing the tree ---
|
|
263
|
-
|
|
264
|
-
# The child of `a` reached by `path` (not empty), borrowed from the tree.
|
|
265
|
-
fun nodeAt(a: Node, path: Slice[Int], depth: Int) -> Node =
|
|
266
|
-
ks := kidsOf(a)
|
|
267
|
-
i := path.at(depth)
|
|
268
|
-
if depth + 1 >= path.length()
|
|
269
|
-
return ks.at(i)
|
|
270
|
-
nodeAt(ks.at(i), path, depth + 1)
|
|
271
|
-
|
|
272
|
-
# The node with `id` below `root`, searched depth first, borrowed from the tree.
|
|
273
|
-
fun find(root: Node, id: Int) -> Node =
|
|
274
|
-
path := root.findPath(id).unwrap()
|
|
275
|
-
nodeAt(root, path, 0)
|
|
276
|
-
|
|
277
|
-
fun kidsOf(n: Node) -> Slice[Node] =
|
|
278
|
-
n.children().unwrap()
|
|
279
|
-
|
|
280
|
-
fun setVisible(n: Node, v: Bool) -> Node =
|
|
281
|
-
Node(..n, visible: v)
|
|
282
|
-
|
|
283
|
-
# The group `g` with `k` added as its last child.
|
|
284
|
-
fun addKid(g: Node, k: Node) -> Node =
|
|
285
|
-
ks := kidsOf(g)
|
|
286
|
-
ks.push(k)
|
|
287
|
-
g
|
|
288
|
-
|
|
289
|
-
fun actorClock(a: Actor) -> Float =
|
|
290
|
-
match a
|
|
291
|
-
Mine(_, _, _, clock, _) => clock
|
|
292
|
-
Orb(_, _, _, clock, _) => clock
|
|
293
|
-
Star(_, clock) => clock
|
|
294
|
-
_ => 0.0
|
|
295
|
-
|
|
296
|
-
fun clockOf(n: Node) -> Float =
|
|
297
|
-
match n.kind
|
|
298
|
-
Custom(a) => actorClock(a)
|
|
299
|
-
_ => 0.0
|
|
300
|
-
|
|
301
|
-
fun textOf(n: Node) -> Str =
|
|
302
|
-
match n.kind
|
|
303
|
-
Label(text, _) => text
|
|
304
|
-
_ => ""
|
|
305
|
-
|
|
306
|
-
fun hudLabel(id: Int, text: Str, x: Float, y: Float, size: Float, c: Color) -> Node =
|
|
307
|
-
l := Node.label(id, text, x, y, size)
|
|
308
|
-
Node(..l, color: c)
|
|
309
|
-
|
|
310
|
-
fun icon(sheet: Texture, i: Int) -> Node =
|
|
311
|
-
s := Node.sprite(Id.Icon + i, sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 32.0)
|
|
312
|
-
Node(..s, kind: Sprite(tex: sheet, srcX: 0.0, srcY: 0.0, srcW: Rules.Cell, srcH: Rules.Cell))
|
|
313
|
-
|
|
314
|
-
# The whole scene: backdrop, stars, mines, orbs, ship and HUD layers, in drawing order.
|
|
315
|
-
# The layer groups are 0 x 0 so they never swallow a click; the HUD holds the
|
|
316
|
-
# game-over overlay with its restart button.
|
|
317
|
-
# The starting spots of the orbs and mines, and the generator to continue from.
|
|
318
|
-
struct Spots(orbs: Slice[Motion], mines: Slice[Motion], rng: Random)
|
|
319
|
-
|
|
320
|
-
fun startSpots(rng: Random, ship: ShipState) -> Spots =
|
|
321
|
-
orbs := Slice[Motion]()
|
|
322
|
-
mines := Slice[Motion]()
|
|
323
|
-
r := rng
|
|
324
|
-
for i := range Rules.OrbCount
|
|
325
|
-
sp := spawn(r, ship.x, ship.y, 120.0, 40.0)
|
|
326
|
-
r = sp.rng
|
|
327
|
-
orbs.push(sp.at)
|
|
328
|
-
for i := range mineCount(0)
|
|
329
|
-
sp := spawn(r, ship.x, ship.y, 220.0, 90.0)
|
|
330
|
-
r = sp.rng
|
|
331
|
-
mines.push(sp.at)
|
|
332
|
-
Spots(orbs: orbs, mines: mines, rng: r)
|
|
333
|
-
|
|
334
|
-
fun buildRoot(spots: Spots, sheet: Texture, best: Int) -> Node =
|
|
335
|
-
ship := newShip()
|
|
336
|
-
stars := Slice[Node]()
|
|
337
|
-
for i := range 40
|
|
338
|
-
stars.push(Star.create(i))
|
|
339
|
-
mines := Slice[Node]()
|
|
340
|
-
orbs := Slice[Node]()
|
|
341
|
-
for i := range Rules.OrbCount
|
|
342
|
-
orbs.push(Orb.create(sheet, Float(i) * 0.1, Id.Orb + i, spots.orbs.at(i)))
|
|
343
|
-
for i := range mineCount(0)
|
|
344
|
-
mines.push(Mine.create(sheet, Id.Mine + i, spots.mines.at(i), 2.0))
|
|
345
|
-
b0 := Node.button(Id.Restart, "Again", sheet, sheet, 340.0, 342.0, 120.0, 44.0)
|
|
346
|
-
btn := Node(..b0, color: Color(r: 0.5, g: 0.8, b: 1.0, a: 0.9), visible: false)
|
|
347
|
-
over := [
|
|
348
|
-
Panel.create(Id.OverlayPanel, 0.0, 230.0, Rules.ArenaW, 170.0, Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
|
|
349
|
-
hudLabel(Id.OverTitle, "Game over", 300.0, 244.0, 48.0, Color.Red),
|
|
350
|
-
hudLabel(Id.OverHint, "Press Enter to play again", 270.0, 300.0, 24.0, Color.White),
|
|
351
|
-
btn].toSlice()
|
|
352
|
-
o0 := Node.group(Id.Overlay, 0.0, 0.0, 0.0, 0.0, over)
|
|
353
|
-
overlay := Node(..o0, visible: false)
|
|
354
|
-
hud := [
|
|
355
|
-
hudLabel(Id.Score, "Score 0", 16.0, 12.0, 28.0, Color.White),
|
|
356
|
-
hudLabel(Id.Best, "Best {best}", 16.0, 44.0, 20.0, Color.Yellow)].toSlice()
|
|
357
|
-
for i := range Rules.StartLives
|
|
358
|
-
hud.push(icon(sheet, i))
|
|
359
|
-
hud.push(overlay)
|
|
360
|
-
layers := [
|
|
361
|
-
Panel.create(Id.Backdrop, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, Color.rgb(0.07, 0.08, 0.16)),
|
|
362
|
-
Node.group(Id.StarLayer, 0.0, 0.0, 0.0, 0.0, stars),
|
|
363
|
-
Node.group(Id.MineLayer, 0.0, 0.0, 0.0, 0.0, mines),
|
|
364
|
-
Node.group(Id.OrbLayer, 0.0, 0.0, 0.0, 0.0, orbs),
|
|
365
|
-
Ship.create(sheet, ship),
|
|
366
|
-
Node.group(Id.Hud, 0.0, 0.0, 0.0, 0.0, hud)].toSlice()
|
|
367
|
-
Node.group(Id.Root, 0.0, 0.0, Rules.ArenaW, Rules.ArenaH, layers)
|
|
368
|
-
|
|
369
|
-
# --- the world ---
|
|
370
|
-
|
|
371
|
-
# The host-facing shell: the current pure `Stage` plus the run's state. It is the
|
|
372
|
-
# only thing that is replaced from frame to frame.
|
|
373
|
-
struct World(
|
|
374
|
-
mut stage: Stage,
|
|
375
|
-
mut ship: ShipState,
|
|
376
|
-
mut rng: Random,
|
|
377
|
-
mut mines: Int,
|
|
378
|
-
mut score: Int,
|
|
379
|
-
mut best: Int,
|
|
380
|
-
mut lives: Int,
|
|
381
|
-
mut over: Bool,
|
|
382
|
-
mut clock: Float,
|
|
383
|
-
sheet: Texture)
|
|
384
|
-
|
|
385
|
-
fun newWorld(seed: Int, sheet: Texture) -> World =
|
|
386
|
-
ship := newShip()
|
|
387
|
-
spots := startSpots(Random.seeded(seed), ship)
|
|
388
|
-
next := spots.rng
|
|
389
|
-
World(
|
|
390
|
-
stage: Stage.create(buildRoot(spots, sheet, 0)), ship: ship, rng: next, mines: mineCount(0), score: 0, best: 0,
|
|
391
|
-
lives: Rules.StartLives, over: false, clock: 0.0, sheet: sheet)
|
|
392
|
-
|
|
393
|
-
fun modifyRoot(st: Stage, id: Int, f: fn(Node) -> Node) -> Stage =
|
|
394
|
-
match st
|
|
395
|
-
Stage(root, camera, captured, focus) => Stage(root: root.modify(id, f), camera: camera, captured: captured, focus: focus)
|
|
396
|
-
|
|
397
|
-
fun withCamera(st: Stage, camera: Affine) -> Stage =
|
|
398
|
-
match st
|
|
399
|
-
Stage(root, _, captured, focus) => Stage(root: root, camera: camera, captured: captured, focus: focus)
|
|
400
|
-
|
|
401
|
-
fun swap(w: World, id: Int, f: fn(Node) -> Node) =
|
|
402
|
-
w.stage = modifyRoot(w.stage, id, f)
|
|
403
|
-
|
|
404
|
-
# Starts a fresh game, keeping the best score, the camera and the random stream.
|
|
405
|
-
fun restart(w: World) =
|
|
406
|
-
w.best = w.best.max(w.score)
|
|
407
|
-
w.score = 0
|
|
408
|
-
w.lives = Rules.StartLives
|
|
409
|
-
w.over = false
|
|
410
|
-
w.ship = newShip()
|
|
411
|
-
w.mines = mineCount(0)
|
|
412
|
-
spots := startSpots(w.rng, w.ship)
|
|
413
|
-
next := spots.rng
|
|
414
|
-
w.rng = next
|
|
415
|
-
w.stage = Stage(root: buildRoot(spots, w.sheet, w.best), camera: w.stage.camera, captured: None, focus: None)
|
|
416
|
-
|
|
417
|
-
# Copies the score, best score, lives and game-over panel into the HUD actors.
|
|
418
|
-
fun syncHud(w: World) =
|
|
419
|
-
score := "Score {w.score}"
|
|
420
|
-
best := "Best {w.best.max(w.score)}"
|
|
421
|
-
swap(w, Id.Score, |a| hudLabel(Id.Score, score, 16.0, 12.0, 28.0, Color.White))
|
|
422
|
-
swap(w, Id.Best, |a| hudLabel(Id.Best, best, 16.0, 44.0, 20.0, Color.Yellow))
|
|
423
|
-
for i := range Rules.StartLives
|
|
424
|
-
shown := i < w.lives
|
|
425
|
-
swap(w, Id.Icon + i, |a| setVisible(a, shown))
|
|
426
|
-
over := w.over
|
|
427
|
-
swap(w, Id.Overlay, |a| setVisible(a, over))
|
|
428
|
-
swap(w, Id.Restart, |a| setVisible(a, over))
|
|
429
|
-
|
|
430
|
-
# Stops every orb and mine: after game over they only animate.
|
|
431
|
-
fun freeze(w: World) =
|
|
432
|
-
for i := range Rules.OrbCount
|
|
433
|
-
id := Id.Orb + i
|
|
434
|
-
phase := Float(i) * 0.1
|
|
435
|
-
sheet := w.sheet
|
|
436
|
-
swap(w, id, |a| orbKeepClock(sheet, phase, id, Motion(x: cx(a), y: cy(a), vx: 0.0, vy: 0.0), a))
|
|
437
|
-
for i := range w.mines
|
|
438
|
-
id := Id.Mine + i
|
|
439
|
-
sheet := w.sheet
|
|
440
|
-
swap(w, id, |a| stillMine(sheet, id, a))
|
|
441
|
-
|
|
442
|
-
fun orbActor(sheet: Texture, phase: Float, id: Int, at: Motion) -> Node =
|
|
443
|
-
Orb.create(sheet, phase, id, at)
|
|
444
|
-
|
|
445
|
-
# A new orb that carries on `old`'s animation clock, so a respawn or freeze never jumps a frame.
|
|
446
|
-
fun orbKeepClock(sheet: Texture, phase: Float, id: Int, at: Motion, old: Node) -> Node =
|
|
447
|
-
Orb.createAt(sheet, phase, id, at, clockOf(old))
|
|
448
|
-
|
|
449
|
-
fun mineActor(sheet: Texture, id: Int, at: Motion, spin: Float) -> Node =
|
|
450
|
-
Mine.create(sheet, id, at, spin)
|
|
451
|
-
|
|
452
|
-
fun stillMine(sheet: Texture, id: Int, a: Node) -> Node =
|
|
453
|
-
m := Mine.createAt(sheet, id, Motion(x: cx(a), y: cy(a), vx: 0.0, vy: 0.0), 0.0, clockOf(a))
|
|
454
|
-
Node(..m, rotation: a.rotation)
|
|
455
|
-
|
|
456
|
-
# Moves the ship node to the ship state; it blinks while it is hurt, and is hidden once the game is over.
|
|
457
|
-
fun showShip(w: World) =
|
|
458
|
-
blink := w.ship.hurt > 0.0 && frameOf(w.clock, 2, 10.0) == 0
|
|
459
|
-
visible := !w.over && !blink
|
|
460
|
-
s := w.ship
|
|
461
|
-
swap(w, Id.Ship, |a| shipActor(a, s, visible))
|
|
462
|
-
|
|
463
|
-
fun shipActor(a: Node, s: ShipState, visible: Bool) -> Node =
|
|
464
|
-
Node(..a, x: s.x - Rules.Half, y: s.y - Rules.Half, rotation: s.rotation, visible: visible)
|
|
465
|
-
|
|
466
|
-
fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
|
|
467
|
-
Math.hypot(ax - bx, ay - by) < ra + rb
|
|
468
|
-
|
|
469
|
-
# The stage after `inputs` and `dt`, appending the events it raised to `events`.
|
|
470
|
-
fun frameInto(s: Stage, dt: Float, inputs: Slice[InputEvent], events: Slice[UiEvent]) -> Stage =
|
|
471
|
-
h := s.frame(dt, inputs)
|
|
472
|
-
for i := range h.events.length()
|
|
473
|
-
events.push(h.events.at(i))
|
|
474
|
-
h.intoStage()
|
|
475
|
-
|
|
476
|
-
# One frame: the stage takes the pointer `inputs` and advances every actor by `dt`
|
|
477
|
-
# (a click on the restart button restarts), then the rules look at the result. Once
|
|
478
|
-
# the game is over the actors only animate. Returns true when it restarted.
|
|
479
|
-
fun advance(w: World, ax: Float, ay: Float, dt: Float, inputs: Slice[InputEvent]) -> Bool =
|
|
480
|
-
w.clock = w.clock + dt
|
|
481
|
-
events := Slice[UiEvent]()
|
|
482
|
-
w.stage = frameInto(w.stage, dt, inputs, events)
|
|
483
|
-
for i := range events.length()
|
|
484
|
-
e := events.at(i)
|
|
485
|
-
if e.id == Id.Restart && e.kind == Click && w.over
|
|
486
|
-
restart(w)
|
|
487
|
-
return true
|
|
488
|
-
if w.over
|
|
489
|
-
return false
|
|
490
|
-
w.ship = shipStep(w.ship, ax, ay, dt)
|
|
491
|
-
ship := w.ship
|
|
492
|
-
changed := false
|
|
493
|
-
for i := range Rules.OrbCount
|
|
494
|
-
orb := find(w.stage.root, Id.Orb + i)
|
|
495
|
-
if overlaps(ship.x, ship.y, Rules.ShipRadius, cx(orb), cy(orb), Rules.OrbRadius)
|
|
496
|
-
w.score = w.score + Rules.OrbPoints
|
|
497
|
-
changed = true
|
|
498
|
-
sp := spawn(w.rng, ship.x, ship.y, 120.0, 40.0)
|
|
499
|
-
w.rng = sp.rng
|
|
500
|
-
at := sp.at
|
|
501
|
-
sheet := w.sheet
|
|
502
|
-
id := Id.Orb + i
|
|
503
|
-
phase := Float(i) * 0.1
|
|
504
|
-
swap(w, id, |a| orbKeepClock(sheet, phase, id, at, a))
|
|
505
|
-
while w.mines < mineCount(w.score)
|
|
506
|
-
sheet := w.sheet
|
|
507
|
-
id := Id.Mine + w.mines
|
|
508
|
-
sp := spawn(w.rng, ship.x, ship.y, 220.0, 90.0)
|
|
509
|
-
w.rng = sp.rng
|
|
510
|
-
at := sp.at
|
|
511
|
-
swap(w, Id.MineLayer, |g| addKid(g, Mine.create(sheet, id, at, 2.0)))
|
|
512
|
-
w.mines = w.mines + 1
|
|
513
|
-
for i := range w.mines
|
|
514
|
-
mine := find(w.stage.root, Id.Mine + i)
|
|
515
|
-
if w.ship.hurt <= 0.0 && overlaps(ship.x, ship.y, Rules.ShipRadius, cx(mine), cy(mine), Rules.MineRadius)
|
|
516
|
-
w.lives = w.lives - 1
|
|
517
|
-
cur := w.ship
|
|
518
|
-
w.ship = ShipState(..cur, hurt: Rules.HurtTime)
|
|
519
|
-
changed = true
|
|
520
|
-
if w.lives <= 0
|
|
521
|
-
w.over = true
|
|
522
|
-
w.best = w.best.max(w.score)
|
|
523
|
-
if changed
|
|
524
|
-
syncHud(w)
|
|
525
|
-
if w.over
|
|
526
|
-
freeze(w)
|
|
527
|
-
showShip(w)
|
|
528
|
-
false
|
|
529
|
-
|
|
530
|
-
# One simulation step without pointer input.
|
|
531
|
-
fun step(w: World, ax: Float, ay: Float, dt: Float) -> Bool =
|
|
532
|
-
advance(w, ax, ay, dt, Slice[InputEvent]())
|
|
533
|
-
|
|
534
|
-
# The camera that fits the arena into a screen of `sw` by `sh` pixels, centred.
|
|
535
|
-
fun fitCamera(sw: Float, sh: Float) -> Affine =
|
|
536
|
-
zoom := {sw / Rules.ArenaW}.min(sh / Rules.ArenaH)
|
|
537
|
-
Affine(a: zoom, b: 0.0, c: 0.0, d: zoom, e: {sw - zoom * Rules.ArenaW} / 2.0, f: {sh - zoom * Rules.ArenaH} / 2.0)
|
|
538
|
-
|
|
539
|
-
fun fit(w: World, sw: Float, sh: Float) =
|
|
540
|
-
w.stage = withCamera(w.stage, fitCamera(sw, sh))
|
|
541
|
-
|
|
542
|
-
# --- host glue: input, sound, drawing ---
|
|
543
|
-
|
|
544
|
-
fun musicVolume() -> Float =
|
|
545
|
-
0.3
|
|
546
|
-
|
|
547
|
-
fun axis(negative: Bool, positive: Bool) -> Float =
|
|
548
|
-
if negative && !positive
|
|
549
|
-
-1.0
|
|
550
|
-
else if positive && !negative
|
|
551
|
-
1.0
|
|
552
|
-
else
|
|
553
|
-
0.0
|
|
554
|
-
|
|
555
|
-
# Enter or R going down.
|
|
556
|
-
fun isRestartKey(e: InputEvent) -> Bool = match e
|
|
557
|
-
KeyDown(k) => k == Key.Enter || k == Key.KeyR
|
|
558
|
-
_ => false
|
|
559
|
-
|
|
560
|
-
# The host shell: the pure `World` plus the sounds. `update` reads the frame's inputs
|
|
561
|
-
# from `self.input` (the only input read); the held keys steer the ship.
|
|
562
|
-
struct PureGame(Game(engine), world: World, pickupSound: Sound, hitSound: Sound, overSound: Sound, musicSound: Sound) =
|
|
563
|
-
|
|
564
|
-
# Restarts the music loop from the top.
|
|
565
|
-
fun startMusic(self) =
|
|
566
|
-
self.audio.stop(self.musicSound)
|
|
567
|
-
self.audio.loop(self.musicSound, musicVolume())
|
|
568
|
-
|
|
569
|
-
override fun update(self, dt: Float) =
|
|
570
|
-
w := self.world
|
|
571
|
-
inputs := self.input.events()
|
|
572
|
-
for i := range inputs.length()
|
|
573
|
-
if w.over && isRestartKey(inputs.at(i))
|
|
574
|
-
restart(w)
|
|
575
|
-
self.startMusic()
|
|
576
|
-
left := self.input.isKeyDown(Key.Left) || self.input.isKeyDown(Key.KeyA)
|
|
577
|
-
right := self.input.isKeyDown(Key.Right) || self.input.isKeyDown(Key.KeyD)
|
|
578
|
-
up := self.input.isKeyDown(Key.Up) || self.input.isKeyDown(Key.KeyW)
|
|
579
|
-
down := self.input.isKeyDown(Key.Down) || self.input.isKeyDown(Key.KeyS)
|
|
580
|
-
score := w.score
|
|
581
|
-
lives := w.lives
|
|
582
|
-
restarted := advance(w, axis(left, right), axis(up, down), dt, inputs)
|
|
583
|
-
if restarted
|
|
584
|
-
self.startMusic()
|
|
585
|
-
return
|
|
586
|
-
if w.score > score
|
|
587
|
-
self.audio.play(self.pickupSound, 0.8)
|
|
588
|
-
if w.lives < lives
|
|
589
|
-
if w.over
|
|
590
|
-
self.audio.stop(self.musicSound)
|
|
591
|
-
self.audio.play(self.overSound, 1.0)
|
|
592
|
-
else
|
|
593
|
-
self.audio.play(self.hitSound, 1.0)
|
|
594
|
-
|
|
595
|
-
override fun draw(self) =
|
|
596
|
-
w := self.world
|
|
597
|
-
fit(w, self.canvas.width(), self.canvas.height())
|
|
598
|
-
self.canvas.clear(Color.rgb(0.04, 0.05, 0.1))
|
|
599
|
-
w.stage.draw(self.canvas)
|
|
600
|
-
|
|
601
|
-
fun main(io: Io) -> Game =
|
|
602
|
-
e := Engine.create(io)
|
|
603
|
-
game := PureGame(
|
|
604
|
-
engine: e,
|
|
605
|
-
world: newWorld(io.randomSeed(), e.assets.texture("sprites.png")),
|
|
606
|
-
pickupSound: e.assets.sound("pickup.wav"),
|
|
607
|
-
hitSound: e.assets.sound("hit.wav"),
|
|
608
|
-
overSound: e.assets.sound("gameover.wav"),
|
|
609
|
-
musicSound: e.assets.sound("music.wav")
|
|
610
|
-
)
|
|
611
|
-
game.startMusic()
|
|
612
|
-
game
|
|
613
|
-
|
|
614
|
-
# --- tests ---
|
|
615
|
-
|
|
616
|
-
fun near(a: Float, b: Float) -> Bool =
|
|
617
|
-
{a - b}.abs() < 0.01
|
|
618
|
-
|
|
619
|
-
fun placeOrb(w: World, i: Int, m: Motion) =
|
|
620
|
-
sheet := w.sheet
|
|
621
|
-
id := Id.Orb + i
|
|
622
|
-
swap(w, id, |a| orbActor(sheet, Float(i) * 0.1, id, m))
|
|
623
|
-
|
|
624
|
-
fun placeMine(w: World, i: Int, m: Motion) =
|
|
625
|
-
sheet := w.sheet
|
|
626
|
-
id := Id.Mine + i
|
|
627
|
-
swap(w, id, |a| mineActor(sheet, id, m, 2.0))
|
|
628
|
-
|
|
629
|
-
fun away() -> Motion =
|
|
630
|
-
Motion(x: 5000.0, y: 0.0, vx: 0.0, vy: 0.0)
|
|
631
|
-
|
|
632
|
-
# A world with the orbs and mines moved out of the way.
|
|
633
|
-
fun emptyWorld() -> World =
|
|
634
|
-
w := newWorld(7, Texture(id: -1))
|
|
635
|
-
for i := range Rules.OrbCount
|
|
636
|
-
placeOrb(w, i, away())
|
|
637
|
-
for i := range w.mines
|
|
638
|
-
placeMine(w, i, away())
|
|
639
|
-
w
|
|
640
|
-
|
|
641
|
-
fun onShip(w: World) -> Motion =
|
|
642
|
-
Motion(x: w.ship.x, y: w.ship.y, vx: 0.0, vy: 0.0)
|
|
643
|
-
|
|
644
|
-
fun forceOver(w: World) =
|
|
645
|
-
w.lives = 0
|
|
646
|
-
w.over = true
|
|
647
|
-
syncHud(w)
|
|
648
|
-
|
|
649
|
-
test "steering accelerates and drag slows the ship"
|
|
650
|
-
s := shipStep(newShip(), 1.0, 0.0, 0.1)
|
|
651
|
-
assert s.vx > 0.0
|
|
652
|
-
assert s.x > Rules.ArenaW / 2.0
|
|
653
|
-
fast := s.vx
|
|
654
|
-
t := s
|
|
655
|
-
for i := range 30
|
|
656
|
-
t = shipStep(t, 0.0, 0.0, 0.1)
|
|
657
|
-
assert t.vx < fast / 4.0
|
|
658
|
-
|
|
659
|
-
test "the ship stops at the arena walls"
|
|
660
|
-
s := ShipState(x: 20.0, y: 20.0, vx: 0.0, vy: 0.0, rotation: 0.0, hurt: 0.0)
|
|
661
|
-
for i := range 20
|
|
662
|
-
s = shipStep(s, -1.0, -1.0, 0.1)
|
|
663
|
-
assert s.x == Rules.ShipRadius
|
|
664
|
-
assert s.y == Rules.ShipRadius
|
|
665
|
-
assert s.vx == 0.0
|
|
666
|
-
|
|
667
|
-
test "shipStep leaves its input unchanged"
|
|
668
|
-
s := newShip()
|
|
669
|
-
_ := shipStep(s, 1.0, 1.0, 0.1)
|
|
670
|
-
assert s.vx == 0.0
|
|
671
|
-
assert s.x == Rules.ArenaW / 2.0
|
|
672
|
-
|
|
673
|
-
test "drifters bounce off the walls"
|
|
674
|
-
b := driftOf(Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0), Rules.OrbRadius, 0.1)
|
|
675
|
-
assert b.vx == -100.0
|
|
676
|
-
assert b.x == Rules.ArenaW - 10.0
|
|
677
|
-
orb := Orb.create(Texture(id: -1), 0.0, Id.Orb, Motion(x: 795.0, y: 300.0, vx: 100.0, vy: 0.0))
|
|
678
|
-
moved := orb.update(0.1)
|
|
679
|
-
assert near(cx(moved), Rules.ArenaW - 10.0)
|
|
680
|
-
|
|
681
|
-
test "a mine spins as it drifts"
|
|
682
|
-
mine := Mine.create(Texture(id: -1), Id.Mine, Motion(x: 400.0, y: 300.0, vx: 10.0, vy: 0.0), 2.0)
|
|
683
|
-
next := mine.update(0.5)
|
|
684
|
-
assert near(next.rotation, 1.0)
|
|
685
|
-
assert near(cx(next), 405.0)
|
|
686
|
-
|
|
687
|
-
test "the scene has a backdrop, stars, mines, orbs, ship and HUD layers"
|
|
688
|
-
w := newWorld(7, Texture(id: -1))
|
|
689
|
-
assert kidsOf(w.stage.root).length() == 6
|
|
690
|
-
assert kidsOf(kidsOf(w.stage.root).at(1)).length() == 40
|
|
691
|
-
assert kidsOf(kidsOf(w.stage.root).at(2)).length() == mineCount(0)
|
|
692
|
-
assert kidsOf(kidsOf(w.stage.root).at(3)).length() == Rules.OrbCount
|
|
693
|
-
assert kidsOf(w.stage.root).at(4).id == Id.Ship
|
|
694
|
-
assert kidsOf(w.stage.root).at(2).id == Id.MineLayer
|
|
695
|
-
assert kidsOf(w.stage.root).at(3).id == Id.OrbLayer
|
|
696
|
-
assert kidsOf(w.stage.root).at(5).id == Id.Hud
|
|
697
|
-
|
|
698
|
-
test "collecting an orb scores and respawns it elsewhere, still in the orbs layer"
|
|
699
|
-
w := emptyWorld()
|
|
700
|
-
placeOrb(w, 0, onShip(w))
|
|
701
|
-
_ := step(w, 0.0, 0.0, 0.016)
|
|
702
|
-
orb := find(w.stage.root, Id.Orb)
|
|
703
|
-
assert w.score == Rules.OrbPoints
|
|
704
|
-
assert Math.hypot(cx(orb) - w.ship.x, cy(orb) - w.ship.y) >= 120.0
|
|
705
|
-
assert orb.x < 5000.0
|
|
706
|
-
assert kidsOf(find(w.stage.root, Id.OrbLayer)).length() == Rules.OrbCount
|
|
707
|
-
assert textOf(find(w.stage.root, Id.Score)) == "Score 10"
|
|
708
|
-
|
|
709
|
-
test "a mine costs one life and makes the ship flash"
|
|
710
|
-
w := emptyWorld()
|
|
711
|
-
placeMine(w, 0, onShip(w))
|
|
712
|
-
_ := step(w, 0.0, 0.0, 0.016)
|
|
713
|
-
assert w.lives == Rules.StartLives - 1
|
|
714
|
-
assert w.ship.hurt > 0.0
|
|
715
|
-
assert !find(w.stage.root, Id.Icon + 2).visible
|
|
716
|
-
assert find(w.stage.root, Id.Icon + 1).visible
|
|
717
|
-
placeMine(w, 0, away())
|
|
718
|
-
hidden := 0
|
|
719
|
-
shown := 0
|
|
720
|
-
for i := range 100
|
|
721
|
-
_ := step(w, 0.0, 0.0, 0.016)
|
|
722
|
-
if find(w.stage.root, Id.Ship).visible
|
|
723
|
-
shown = shown + 1
|
|
724
|
-
else
|
|
725
|
-
hidden = hidden + 1
|
|
726
|
-
assert hidden > 0
|
|
727
|
-
assert shown > 0
|
|
728
|
-
assert w.ship.hurt == 0.0
|
|
729
|
-
assert find(w.stage.root, Id.Ship).visible
|
|
730
|
-
assert w.lives == Rules.StartLives - 1
|
|
731
|
-
|
|
732
|
-
test "the ship is briefly safe after a hit"
|
|
733
|
-
w := emptyWorld()
|
|
734
|
-
placeMine(w, 0, onShip(w))
|
|
735
|
-
_ := step(w, 0.0, 0.0, 0.016)
|
|
736
|
-
_ := step(w, 0.0, 0.0, 0.016)
|
|
737
|
-
assert w.lives == Rules.StartLives - 1
|
|
738
|
-
|
|
739
|
-
test "the game ends at zero lives and then ignores steps"
|
|
740
|
-
w := emptyWorld()
|
|
741
|
-
w.lives = 1
|
|
742
|
-
w.score = 40
|
|
743
|
-
placeMine(w, 0, onShip(w))
|
|
744
|
-
_ := step(w, 0.0, 0.0, 0.016)
|
|
745
|
-
assert w.over
|
|
746
|
-
assert w.lives == 0
|
|
747
|
-
assert w.best == 40
|
|
748
|
-
assert find(w.stage.root, Id.Overlay).visible
|
|
749
|
-
assert !find(w.stage.root, Id.Ship).visible
|
|
750
|
-
x := w.ship.x
|
|
751
|
-
_ := step(w, 1.0, 0.0, 0.5)
|
|
752
|
-
assert w.ship.x == x
|
|
753
|
-
|
|
754
|
-
test "after game over nothing moves but the animations keep running"
|
|
755
|
-
w := emptyWorld()
|
|
756
|
-
w.lives = 1
|
|
757
|
-
placeMine(w, 0, Motion(x: w.ship.x, y: w.ship.y, vx: 10.0, vy: 0.0))
|
|
758
|
-
_ := step(w, 0.0, 0.0, 0.016)
|
|
759
|
-
assert w.over
|
|
760
|
-
mine := find(w.stage.root, Id.Mine)
|
|
761
|
-
x := mine.x
|
|
762
|
-
rot := mine.rotation
|
|
763
|
-
clock := clockOf(mine)
|
|
764
|
-
orbClock := clockOf(find(w.stage.root, Id.Orb))
|
|
765
|
-
for i := range 10
|
|
766
|
-
_ := step(w, 1.0, 0.0, 0.1)
|
|
767
|
-
after := find(w.stage.root, Id.Mine)
|
|
768
|
-
assert after.x == x
|
|
769
|
-
assert after.rotation == rot
|
|
770
|
-
assert w.ship.x == Rules.ArenaW / 2.0
|
|
771
|
-
assert !find(w.stage.root, Id.Ship).visible
|
|
772
|
-
assert clockOf(after) > clock + 0.9
|
|
773
|
-
assert clockOf(find(w.stage.root, Id.Orb)) > orbClock + 0.9
|
|
774
|
-
assert clockOf(find(w.stage.root, Id.Star)) > 0.9
|
|
775
|
-
assert w.score == 0
|
|
776
|
-
|
|
777
|
-
test "restart resets the run and keeps the best score"
|
|
778
|
-
w := emptyWorld()
|
|
779
|
-
w.score = 90
|
|
780
|
-
forceOver(w)
|
|
781
|
-
restart(w)
|
|
782
|
-
assert !w.over
|
|
783
|
-
assert !find(w.stage.root, Id.Overlay).visible
|
|
784
|
-
assert find(w.stage.root, Id.Ship).visible
|
|
785
|
-
assert w.score == 0
|
|
786
|
-
assert w.best == 90
|
|
787
|
-
assert w.lives == Rules.StartLives
|
|
788
|
-
assert w.ship.x == Rules.ArenaW / 2.0
|
|
789
|
-
assert w.mines == mineCount(0)
|
|
790
|
-
assert textOf(find(w.stage.root, Id.Best)) == "Best 90"
|
|
791
|
-
|
|
792
|
-
test "more mines appear as the score grows and restart removes them from the layer"
|
|
793
|
-
assert mineCount(0) == 3
|
|
794
|
-
assert mineCount(100) == 5
|
|
795
|
-
assert mineCount(10000) == 8
|
|
796
|
-
w := emptyWorld()
|
|
797
|
-
w.score = 100
|
|
798
|
-
_ := step(w, 0.0, 0.0, 0.016)
|
|
799
|
-
assert w.mines == 5
|
|
800
|
-
assert kidsOf(find(w.stage.root, Id.MineLayer)).length() == 5
|
|
801
|
-
restart(w)
|
|
802
|
-
assert w.mines == 3
|
|
803
|
-
assert kidsOf(find(w.stage.root, Id.MineLayer)).length() == 3
|
|
804
|
-
|
|
805
|
-
test "the same seed plays out the same way"
|
|
806
|
-
a := newWorld(11, Texture(id: -1))
|
|
807
|
-
b := newWorld(11, Texture(id: -1))
|
|
808
|
-
for i := range 120
|
|
809
|
-
_ := step(a, 1.0, -1.0, 0.016)
|
|
810
|
-
_ := step(b, 1.0, -1.0, 0.016)
|
|
811
|
-
assert a.ship.x == b.ship.x
|
|
812
|
-
assert a.score == b.score
|
|
813
|
-
assert find(a.stage.root, Id.Mine).x == find(b.stage.root, Id.Mine).x
|
|
814
|
-
assert newWorld(12, Texture(id: -1)).stage.root.id == Id.Root
|
|
815
|
-
assert find(newWorld(12, Texture(id: -1)).stage.root, Id.Orb).x != find(a.stage.root, Id.Orb).x
|
|
816
|
-
|
|
817
|
-
test "fit centres the arena on the screen"
|
|
818
|
-
w := newWorld(7, Texture(id: -1))
|
|
819
|
-
fit(w, 1600.0, 600.0)
|
|
820
|
-
assert near(w.stage.camera.a, 1.0)
|
|
821
|
-
assert near(w.stage.camera.e, 400.0)
|
|
822
|
-
assert near(w.stage.camera.f, 0.0)
|
|
823
|
-
|
|
824
|
-
test "animation frames cycle"
|
|
825
|
-
assert frameOf(0.0, 4, 8.0) == 0
|
|
826
|
-
assert frameOf(0.13, 4, 8.0) == 1
|
|
827
|
-
assert frameOf(0.5, 4, 8.0) == 0
|
|
828
|
-
assert axis(true, false) == -1.0
|
|
829
|
-
assert axis(true, true) == 0.0
|
|
830
|
-
|
|
831
|
-
test "restart_button_hidden_while_playing"
|
|
832
|
-
w := newWorld(7, Texture(id: -1))
|
|
833
|
-
assert !find(w.stage.root, Id.Restart).visible
|
|
834
|
-
|
|
835
|
-
test "restart_button_click_restarts"
|
|
836
|
-
w := emptyWorld()
|
|
837
|
-
w.score = 30
|
|
838
|
-
forceOver(w)
|
|
839
|
-
assert find(w.stage.root, Id.Restart).visible
|
|
840
|
-
down := PointerDown(x: 400.0, y: 364.0)
|
|
841
|
-
up := PointerUp(x: 400.0, y: 364.0)
|
|
842
|
-
taps := Slice[InputEvent]()
|
|
843
|
-
taps.push(down)
|
|
844
|
-
taps.push(up)
|
|
845
|
-
restarted := advance(w, 0.0, 0.0, 0.016, taps)
|
|
846
|
-
assert restarted
|
|
847
|
-
assert w.lives == Rules.StartLives
|
|
848
|
-
assert w.score == 0
|
|
849
|
-
assert !w.over
|
|
850
|
-
assert !find(w.stage.root, Id.Restart).visible
|
|
851
|
-
|
|
852
|
-
test "a click on the restart button while playing does nothing"
|
|
853
|
-
w := emptyWorld()
|
|
854
|
-
w.score = 30
|
|
855
|
-
taps := Slice[InputEvent]()
|
|
856
|
-
taps.push(PointerDown(x: 400.0, y: 364.0))
|
|
857
|
-
taps.push(PointerUp(x: 400.0, y: 364.0))
|
|
858
|
-
restarted := advance(w, 0.0, 0.0, 0.016, taps)
|
|
859
|
-
assert !restarted
|
|
860
|
-
assert w.score == 30
|
|
861
|
-
|
|
862
|
-
test "world_update_is_pure"
|
|
863
|
-
w := emptyWorld()
|
|
864
|
-
x := find(w.stage.root, Id.Ship).x
|
|
865
|
-
_ := step(w, 1.0, 0.0, 0.1)
|
|
866
|
-
assert find(w.stage.root, Id.Ship).x > x
|
|
867
|
-
|
|
868
|
-
test "freezing at game over keeps the orb and mine animation clocks"
|
|
869
|
-
w := emptyWorld()
|
|
870
|
-
for i := range 5
|
|
871
|
-
_ := step(w, 0.0, 0.0, 0.1)
|
|
872
|
-
orbClock := clockOf(find(w.stage.root, Id.Orb))
|
|
873
|
-
mineClock := clockOf(find(w.stage.root, Id.Mine))
|
|
874
|
-
assert orbClock > 0.4
|
|
875
|
-
w.lives = 1
|
|
876
|
-
placeMine(w, 1, onShip(w))
|
|
877
|
-
_ := step(w, 0.0, 0.0, 0.1)
|
|
878
|
-
assert w.over
|
|
879
|
-
assert clockOf(find(w.stage.root, Id.Orb)) > orbClock
|
|
880
|
-
assert clockOf(find(w.stage.root, Id.Mine)) > mineClock
|
std/game/{functional/Anim.plum → Anim.plum}
RENAMED
|
File without changes
|
std/game/Game.plum
CHANGED
|
@@ -6,17 +6,14 @@ import std/Str
|
|
|
6
6
|
import std/game/Vec2
|
|
7
7
|
import std/game/Color
|
|
8
8
|
import std/Io
|
|
9
|
-
import std/Map
|
|
10
|
-
import std/Option
|
|
11
|
-
import std/game/Input
|
|
12
9
|
|
|
13
|
-
# The host API of a game: drawing, textures, sounds, files and input, all
|
|
10
|
+
# The host API of a game: drawing, textures, sounds, files and input, all provided by
|
|
14
|
-
#
|
|
11
|
+
# the game host (`plum run`) as methods of `Io` (see std/Io). A game is any type that
|
|
12
|
+
# `main(io: Io)` returns and that has `update(self, io: Io, dt: Float) -> Self` and
|
|
15
|
-
#
|
|
13
|
+
# `draw(self, io: Io)`; the host calls `update` then `draw` every frame, and the state
|
|
16
|
-
#
|
|
14
|
+
# `update` returns is the next frame's `self`. This file holds the key codes and the
|
|
17
|
-
#
|
|
15
|
+
# `Texture` and `Sound` handles. A path that escapes the asset root fails like a
|
|
18
|
-
#
|
|
16
|
+
# missing file. Coordinates are world units once `io.setCamera` is set.
|
|
19
|
-
# `setCamera` is set.
|
|
20
17
|
|
|
21
18
|
# Key codes (GLFW numbering) for `isKeyDown` / `isKeyPressed`.
|
|
22
19
|
# Letter keys are `KeyA`..`KeyZ` (a constant name needs two characters).
|
|
@@ -83,203 +80,76 @@ primitive Mouse =
|
|
|
83
80
|
Right = 1
|
|
84
81
|
Middle = 2
|
|
85
82
|
|
|
86
|
-
# A texture handle; the id is the host's.
|
|
83
|
+
# A texture handle; the id is the host's. A handle of -1 is no texture.
|
|
87
|
-
struct Texture(id: Int)
|
|
84
|
+
struct Texture(id: Int) =
|
|
88
|
-
|
|
89
|
-
# A sound handle; the id is the host's. Made by `Assets.sound`.
|
|
90
|
-
struct Sound(id: Int)
|
|
91
|
-
|
|
92
|
-
# Name to host id, remembered. No host calls: `Assets` loads on a miss and keeps the id.
|
|
93
|
-
struct NameIds(mut ids: Map[Str, Int]) =
|
|
94
|
-
fun create() -> NameIds =
|
|
95
|
-
NameIds(ids: Map.empty())
|
|
96
|
-
|
|
97
|
-
fun lookup(self, name: Str) -> Option[Int] =
|
|
98
|
-
self.ids.get(name)
|
|
99
|
-
|
|
100
|
-
fun remember(self, name: Str, id: Int) =
|
|
101
|
-
self.ids.set(name, id)
|
|
102
|
-
|
|
103
|
-
fun missingAsset(kind: Str, name: Str) -> Str =
|
|
104
|
-
"missing {kind}: {name}"
|
|
105
|
-
|
|
106
|
-
#
|
|
85
|
+
# Loads an image relative to the asset root; crashes naming the file if it is missing.
|
|
107
|
-
# loaded the first time its name is asked for and the same handle comes back after;
|
|
108
|
-
# a missing file crashes with its name.
|
|
109
|
-
struct Assets(io: Io, textures: NameIds, sounds: NameIds) =
|
|
110
|
-
fun create(io: Io) -> Assets =
|
|
111
|
-
Assets(io: io, textures: NameIds.create(), sounds: NameIds.create())
|
|
112
|
-
|
|
113
|
-
fun
|
|
86
|
+
fun load(io: Io, name: Str) -> Texture =
|
|
114
|
-
if Some(id) := self.textures.lookup(name)
|
|
115
|
-
return Texture(id: id)
|
|
116
|
-
id :=
|
|
87
|
+
id := io.textureLoad(name)
|
|
117
88
|
if id < 0
|
|
118
89
|
crash missingAsset("texture", name)
|
|
119
|
-
self.textures.remember(name, id)
|
|
120
90
|
Texture(id: id)
|
|
121
91
|
|
|
122
|
-
fun sound(self, name: Str) -> Sound =
|
|
123
|
-
if Some(id) := self.sounds.lookup(name)
|
|
124
|
-
return Sound(id: id)
|
|
125
|
-
id := self.io.soundLoad(name)
|
|
126
|
-
if id < 0
|
|
127
|
-
crash missingAsset("sound", name)
|
|
128
|
-
self.sounds.remember(name, id)
|
|
129
|
-
Sound(id: id)
|
|
130
|
-
|
|
131
|
-
fun exists(self, name: Str) -> Bool =
|
|
132
|
-
self.io.fileExists(name)
|
|
133
|
-
|
|
134
|
-
# The whole file as text; crashes naming the file if it is missing.
|
|
135
|
-
fun file(self, name: Str) -> Str =
|
|
136
|
-
if !self.io.fileExists(name)
|
|
137
|
-
crash missingAsset("file", name)
|
|
138
|
-
self.io.fileLoad(name)
|
|
139
|
-
|
|
140
|
-
# Drawing. Coordinates are world units once `setCamera` is set. Draws go to the
|
|
141
|
-
# screen unless `renderTarget` selects a texture made by `createTexture`.
|
|
142
|
-
struct Canvas(io: Io) =
|
|
143
|
-
fun width(self) -> Float = self.io.screenWidth()
|
|
144
|
-
|
|
145
|
-
fun height(self) -> Float = self.io.screenHeight()
|
|
146
|
-
|
|
147
|
-
fun clear(self, c: Color) =
|
|
148
|
-
self.io.clear(c)
|
|
149
|
-
|
|
150
|
-
fun drawRect(self, x: Float, y: Float, w: Float, h: Float, c: Color) =
|
|
151
|
-
self.io.drawRect(x, y, w, h, c)
|
|
152
|
-
|
|
153
|
-
fun drawCircle(self, x: Float, y: Float, r: Float, c: Color) =
|
|
154
|
-
self.io.drawCircle(x, y, r, c)
|
|
155
|
-
|
|
156
|
-
fun drawLine(self, x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, c: Color) =
|
|
157
|
-
self.io.drawLine(x1, y1, x2, y2, thickness, c)
|
|
158
|
-
|
|
159
|
-
fun drawText(self, s: Str, x: Float, y: Float, size: Float, c: Color) =
|
|
160
|
-
self.io.drawText(s, x, y, size, c)
|
|
161
|
-
|
|
162
|
-
fun setCamera(self, x: Float, y: Float, scale: Float) =
|
|
163
|
-
self.io.setCamera(x, y, scale)
|
|
164
|
-
|
|
165
|
-
fun resetCamera(self) =
|
|
166
|
-
self.io.resetCamera()
|
|
167
|
-
|
|
168
|
-
# The texture at its natural size, top-left at (x, y).
|
|
169
|
-
fun drawTexture(self, t: Texture, x: Float, y: Float, tint: Color = Color.White) =
|
|
170
|
-
self.io.drawTexture(t.id, x, y, tint)
|
|
171
|
-
|
|
172
|
-
# The source rect (sx, sy, sw, sh) in texture pixels into the dest rect (x, y, w, h),
|
|
173
|
-
# rotated `rotation` radians clockwise about the dest centre.
|
|
174
|
-
fun drawTextureEx(self, t: Texture, sx: Float, sy: Float, sw: Float, sh: Float, x: Float, y: Float, w: Float, h: Float, rotation: Float, flipX: Bool, tint: Color) =
|
|
175
|
-
self.io.drawTextureEx(t.id, sx, sy, sw, sh, x, y, w, h, rotation, flipX, tint)
|
|
176
|
-
|
|
177
|
-
fun textureWidth(self, t: Texture) -> Int = self.io.textureWidth(t.id)
|
|
178
|
-
|
|
179
|
-
fun textureHeight(self, t: Texture) -> Int = self.io.textureHeight(t.id)
|
|
180
|
-
|
|
181
|
-
# Linear (true) or nearest (false, the default) filtering.
|
|
182
|
-
fun setSmooth(self, t: Texture, smooth: Bool) =
|
|
183
|
-
self.io.textureSetSmooth(t.id, smooth)
|
|
184
|
-
|
|
185
92
|
# A blank transparent texture to draw into with `renderTarget`; crashes if a side is
|
|
186
93
|
# below 1 or above 8192.
|
|
187
|
-
fun
|
|
94
|
+
fun create(io: Io, w: Int, h: Int) -> Texture =
|
|
188
|
-
id :=
|
|
95
|
+
id := io.textureCreate(w, h)
|
|
189
96
|
if id < 0
|
|
190
97
|
crash "cannot create a {w}x{h} texture"
|
|
191
98
|
Texture(id: id)
|
|
192
99
|
|
|
193
|
-
# Sends the following draws into `t`; `screenTarget` goes back to the screen. Each
|
|
194
|
-
# target has its own camera, which starts as its pixels.
|
|
195
|
-
fun renderTarget(self, t: Texture) =
|
|
196
|
-
self.io.renderTarget(t.id)
|
|
197
|
-
|
|
198
|
-
fun screenTarget(self) =
|
|
199
|
-
self.io.renderTarget(-1)
|
|
200
|
-
|
|
201
|
-
# A `Blend` constant for the following draws; every frame starts with `Blend.Normal`.
|
|
202
|
-
fun blendMode(self, m: Int) =
|
|
203
|
-
self.io.blendMode(m)
|
|
204
|
-
|
|
205
|
-
# Sound. Volume is 0..1.
|
|
206
|
-
struct Audio(io: Io) =
|
|
207
|
-
fun
|
|
100
|
+
fun width(self, io: Io) -> Int = io.textureWidth(self.id)
|
|
208
|
-
self.io.soundPlay(s.id, volume)
|
|
209
101
|
|
|
210
|
-
fun
|
|
102
|
+
fun height(self, io: Io) -> Int = io.textureHeight(self.id)
|
|
211
|
-
self.io.soundLoop(s.id, volume)
|
|
212
103
|
|
|
213
|
-
|
|
104
|
+
# Linear (true) or nearest (false, the default) filtering.
|
|
214
|
-
self.io.soundStop(s.id)
|
|
215
|
-
|
|
216
|
-
fun
|
|
105
|
+
fun setSmooth(self, io: Io, smooth: Bool) =
|
|
217
|
-
|
|
106
|
+
io.textureSetSmooth(self.id, smooth)
|
|
218
|
-
|
|
219
|
-
# Time: the seconds run so far, the frame count, and the last frame's length. The
|
|
220
|
-
# host export calls `tick` once per frame before `update`.
|
|
221
|
-
struct Clock(mut time: Float, mut frame: Int, mut dt: Float) =
|
|
222
|
-
fun create() -> Clock =
|
|
223
|
-
Clock(time: 0.0, frame: 0, dt: 0.0)
|
|
224
|
-
|
|
225
|
-
fun tick(self, dt: Float) =
|
|
226
|
-
self.time = self.time + dt
|
|
227
|
-
self.frame = self.frame + 1
|
|
228
|
-
self.dt = dt
|
|
229
107
|
|
|
108
|
+
# The texture at its natural size, top-left at (x, y).
|
|
109
|
+
fun draw(self, io: Io, x: Float, y: Float, tint: Color = Color.White) =
|
|
110
|
+
io.drawTexture(self.id, x, y, tint)
|
|
111
|
+
|
|
112
|
+
# Sends the following draws into this texture; `Texture.screen` goes back to the screen.
|
|
230
|
-
#
|
|
113
|
+
# Each target has its own camera, which starts as its pixels.
|
|
114
|
+
fun renderTarget(self, io: Io) =
|
|
115
|
+
io.renderTarget(self.id)
|
|
116
|
+
|
|
117
|
+
fun screen(io: Io) =
|
|
118
|
+
io.renderTarget(-1)
|
|
119
|
+
|
|
120
|
+
# A sound handle; the id is the host's. Volume is 0..1.
|
|
121
|
+
struct Sound(id: Int) =
|
|
231
|
-
|
|
122
|
+
# Loads a sound relative to the asset root; crashes naming the file if it is missing.
|
|
232
|
-
fun
|
|
123
|
+
fun load(io: Io, name: Str) -> Sound =
|
|
124
|
+
id := io.soundLoad(name)
|
|
125
|
+
if id < 0
|
|
233
|
-
|
|
126
|
+
crash missingAsset("sound", name)
|
|
127
|
+
Sound(id: id)
|
|
234
128
|
|
|
235
|
-
# What the export does at the start of each frame: tick the clock, poll the input once.
|
|
236
|
-
fun
|
|
129
|
+
fun play(self, io: Io, volume: Float = 1.0) =
|
|
237
|
-
self.clock.tick(dt)
|
|
238
|
-
|
|
130
|
+
io.soundPlay(self.id, volume)
|
|
239
131
|
|
|
240
|
-
# A game is a struct embedding `Game` with `update` and `draw`. The parts are
|
|
241
|
-
# properties: `self.input`, `self.canvas`, `self.audio`, `self.assets`, `self.clock`.
|
|
242
|
-
struct Game(engine: Engine) =
|
|
243
|
-
fun init(self) =
|
|
244
|
-
|
|
132
|
+
fun loop(self, io: Io, volume: Float = 1.0) =
|
|
245
|
-
self.canvas: Canvas = self.engine.canvas
|
|
246
|
-
|
|
133
|
+
io.soundLoop(self.id, volume)
|
|
247
|
-
self.assets: Assets = self.engine.assets
|
|
248
|
-
self.clock: Clock = self.engine.clock
|
|
249
134
|
|
|
250
|
-
|
|
135
|
+
fun stop(self, io: Io) =
|
|
251
|
-
|
|
136
|
+
io.soundStop(self.id)
|
|
252
137
|
|
|
253
|
-
# The frame entry points the compiler exports as `update(state, dt)` and `draw(state)`
|
|
254
|
-
# when `main` returns a `Game`: the clock ticks and the input is polled once, then the
|
|
255
|
-
# game's own `update` runs. Not exported (and never compiled) for any other program.
|
|
256
|
-
fun
|
|
138
|
+
fun setVolume(self, io: Io, volume: Float) =
|
|
257
|
-
|
|
139
|
+
io.soundSetVolume(self.id, volume)
|
|
258
|
-
g.update(dt)
|
|
259
140
|
|
|
141
|
+
fun missingAsset(kind: Str, name: Str) -> Str =
|
|
260
|
-
|
|
142
|
+
"missing {kind}: {name}"
|
|
261
|
-
g.draw()
|
|
262
143
|
|
|
144
|
+
# The whole file as text, relative to the asset root; crashes naming the file if it is missing.
|
|
263
|
-
|
|
145
|
+
fun loadFile(io: Io, name: Str) -> Str =
|
|
264
|
-
|
|
146
|
+
if !io.fileExists(name)
|
|
265
|
-
assert ids.lookup("a.png").isNone()
|
|
266
|
-
|
|
147
|
+
crash missingAsset("file", name)
|
|
267
|
-
assert ids.lookup("a.png").unwrapOr(-1) == 7
|
|
268
|
-
assert ids.lookup("b.png").isNone()
|
|
269
|
-
|
|
148
|
+
io.fileLoad(name)
|
|
270
|
-
assert ids.lookup("a.png").unwrapOr(-1) == 7
|
|
271
149
|
|
|
272
150
|
test "a missing asset message names the file"
|
|
273
151
|
assert missingAsset("texture", "ship.png") == "missing texture: ship.png"
|
|
274
152
|
|
|
275
|
-
test "clock ticks time and frames"
|
|
276
|
-
c := Clock.create()
|
|
277
|
-
c.tick(0.5)
|
|
278
|
-
c.tick(0.25)
|
|
279
|
-
assert c.frame == 2
|
|
280
|
-
assert c.time == 0.75
|
|
281
|
-
assert c.dt == 0.25
|
|
282
|
-
|
|
283
153
|
test "key and mouse constants use GLFW numbering"
|
|
284
154
|
assert Key.KeyW == 87
|
|
285
155
|
assert Key.Left == 263
|
std/game/Input.plum
CHANGED
|
@@ -135,85 +135,62 @@ struct Held(mouse: Bool, keys: Slice[Int]) =
|
|
|
135
135
|
# A poll's inputs and what is held after them (pass it to the next poll).
|
|
136
136
|
struct Polled(inputs: Slice[InputEvent], held: Held)
|
|
137
137
|
|
|
138
|
-
# A frame's input state, with no host calls: `accept` stores what one poll saw.
|
|
139
|
-
# `Input.beginFrame` feeds it once per frame, so every read in that frame agrees.
|
|
140
|
-
struct InputState(mut held: Held, mut events: Slice[InputEvent], mut pos: Vec2, mut wheel: Float) =
|
|
141
|
-
fun create() -> InputState =
|
|
142
|
-
InputState(held: Held.none(), events: Slice[InputEvent](), pos: Vec2(x: 0.0, y: 0.0), wheel: 0.0)
|
|
143
|
-
|
|
144
|
-
fun accept(self, p: Polled, pos: Vec2, wheel: Float) =
|
|
145
|
-
self.held = p.held
|
|
146
|
-
self.events = p.inputs
|
|
147
|
-
self.pos = pos
|
|
148
|
-
self.wheel = wheel
|
|
149
|
-
|
|
150
|
-
# True if this frame's events hold a key-down for `key`.
|
|
151
|
-
fun pressed(self, key: Int) -> Bool =
|
|
152
|
-
for i := range self.events.length()
|
|
153
|
-
if self.events.at(i).isKeyDownOf(key)
|
|
154
|
-
return true
|
|
155
|
-
false
|
|
156
|
-
|
|
157
|
-
fun pointerPressed(self) -> Bool =
|
|
158
|
-
for i := range self.events.length()
|
|
159
|
-
if self.events.at(i).isPointerDown()
|
|
160
|
-
return true
|
|
161
|
-
false
|
|
162
|
-
|
|
163
|
-
fun pointerReleased(self) -> Bool =
|
|
164
|
-
for i := range self.events.length()
|
|
165
|
-
if self.events.at(i).isPointerUp()
|
|
166
|
-
return true
|
|
167
|
-
false
|
|
168
|
-
|
|
169
138
|
# The mouse or a single touch this frame (the bridge reports touch as the mouse):
|
|
170
139
|
# its position in screen pixels, whether it is down, and whether it went down or
|
|
171
140
|
# up this frame.
|
|
172
141
|
struct Pointer(pos: Vec2, down: Bool, pressed: Bool, released: Bool)
|
|
173
142
|
|
|
174
|
-
# The player's input
|
|
143
|
+
# The player's input for one frame. A game holds one and calls `poll` at the start of
|
|
144
|
+
# `update`: `self.input.poll(io)` reads the host once and returns the input of this frame,
|
|
175
|
-
#
|
|
145
|
+
# so `events()`, `isKeyDown` and the pointer reads agree for the whole frame however
|
|
176
|
-
#
|
|
146
|
+
# often they are called. `poll` is the only host read.
|
|
177
|
-
struct Input(
|
|
147
|
+
struct Input(held: Held, events: Slice[InputEvent], pos: Vec2, wheel: Float) =
|
|
178
|
-
fun create(
|
|
148
|
+
fun create() -> Input =
|
|
179
|
-
Input(
|
|
149
|
+
Input(held: Held.none(), events: Slice[InputEvent](), pos: Vec2(x: 0.0, y: 0.0), wheel: 0.0)
|
|
180
150
|
|
|
181
|
-
#
|
|
151
|
+
# The input of this frame: the host polled once and compared with what was held before.
|
|
182
|
-
fun
|
|
152
|
+
fun poll(self, io: Io) -> Input =
|
|
183
|
-
p := InputEvent.poll(
|
|
153
|
+
p := InputEvent.poll(io, self.held)
|
|
184
|
-
|
|
154
|
+
Input(held: p.held, events: p.inputs, pos: io.mousePos(), wheel: io.mouseWheel())
|
|
185
|
-
|
|
186
|
-
# This frame's inputs, as `InputEvent.poll` builds them. The same slice every call.
|
|
187
|
-
fun events(self) -> Slice[InputEvent] =
|
|
188
|
-
self.state.events
|
|
189
155
|
|
|
190
156
|
# Held at the start of this frame (a `Key` code).
|
|
191
157
|
fun isKeyDown(self, key: Int) -> Bool =
|
|
192
|
-
self.
|
|
158
|
+
self.held.isDown(key)
|
|
193
159
|
|
|
194
160
|
# Went down this frame.
|
|
195
161
|
fun isKeyPressed(self, key: Int) -> Bool =
|
|
162
|
+
for i := range self.events.length()
|
|
196
|
-
|
|
163
|
+
if self.events.at(i).isKeyDownOf(key)
|
|
164
|
+
return true
|
|
165
|
+
false
|
|
197
166
|
|
|
198
|
-
#
|
|
167
|
+
# The left mouse button, or a touch.
|
|
199
|
-
fun isMouseDown(self
|
|
168
|
+
fun isMouseDown(self) -> Bool =
|
|
200
|
-
if button == Mouse.Left
|
|
201
|
-
|
|
169
|
+
self.held.mouse
|
|
202
|
-
self.io.isMouseDown(button)
|
|
203
170
|
|
|
204
|
-
fun isMousePressed(self
|
|
171
|
+
fun isMousePressed(self) -> Bool =
|
|
205
|
-
if button == Mouse.Left
|
|
206
|
-
|
|
172
|
+
self.pointerPressed()
|
|
207
|
-
self.io.isMousePressed(button)
|
|
208
173
|
|
|
209
174
|
fun mousePos(self) -> Vec2 =
|
|
210
|
-
self.
|
|
175
|
+
self.pos
|
|
211
176
|
|
|
212
177
|
fun mouseWheel(self) -> Float =
|
|
213
|
-
self.
|
|
178
|
+
self.wheel
|
|
214
179
|
|
|
215
180
|
fun pointer(self) -> Pointer =
|
|
216
|
-
Pointer(pos: self.
|
|
181
|
+
Pointer(pos: self.pos, down: self.held.mouse, pressed: self.pointerPressed(), released: self.pointerReleased())
|
|
182
|
+
|
|
183
|
+
fun pointerPressed(self) -> Bool =
|
|
184
|
+
for i := range self.events.length()
|
|
185
|
+
if self.events.at(i).isPointerDown()
|
|
186
|
+
return true
|
|
187
|
+
false
|
|
188
|
+
|
|
189
|
+
fun pointerReleased(self) -> Bool =
|
|
190
|
+
for i := range self.events.length()
|
|
191
|
+
if self.events.at(i).isPointerUp()
|
|
192
|
+
return true
|
|
193
|
+
false
|
|
217
194
|
|
|
218
195
|
# What happened to a UI element: pressed on it, released after a press on it,
|
|
219
196
|
# clicked (pressed and released on it), or the pointer entered or left it.
|
|
@@ -265,24 +242,17 @@ test "through_moves_pointers_only"
|
|
|
265
242
|
assert !KeyDown(key: 5).isPointer()
|
|
266
243
|
assert PointerMove(x: 0.0, y: 0.0).isPointer()
|
|
267
244
|
|
|
268
|
-
test "input
|
|
245
|
+
test "input agrees for the whole frame"
|
|
269
|
-
|
|
246
|
+
held := Held(mouse: true, keys: [Key.KeyA].toSlice())
|
|
270
|
-
|
|
247
|
+
st := Input(held: held, events: [PointerDown(x: 3.0, y: 4.0), KeyDown(key: Key.KeyA)].toSlice(), pos: Vec2(x: 3.0, y: 4.0), wheel: 1.5)
|
|
271
|
-
st.accept(p, Vec2(x: 3.0, y: 4.0), 1.5)
|
|
272
248
|
assert st.events.length() == 2
|
|
273
249
|
assert st.events.length() == 2
|
|
274
|
-
assert st.
|
|
250
|
+
assert st.isKeyPressed(Key.KeyA)
|
|
275
|
-
assert !st.
|
|
251
|
+
assert !st.isKeyPressed(Key.KeyB)
|
|
252
|
+
assert st.isKeyDown(Key.KeyA)
|
|
276
253
|
assert st.pointerPressed()
|
|
277
254
|
assert !st.pointerReleased()
|
|
278
|
-
assert st.
|
|
255
|
+
assert st.isMouseDown()
|
|
279
|
-
assert st.
|
|
256
|
+
assert st.mouseWheel() == 1.5
|
|
280
|
-
assert st.
|
|
257
|
+
assert st.mousePos().x == 3.0
|
|
281
|
-
|
|
282
|
-
test "a new frame replaces the last frame's events"
|
|
283
|
-
st := InputState.create()
|
|
284
|
-
st.accept(Polled(inputs: [PointerDown(x: 0.0, y: 0.0)].toSlice(), held: Held(mouse: true, keys: Slice[Int]())), Vec2(x: 0.0, y: 0.0), 0.0)
|
|
285
|
-
st.accept(Polled(inputs: [PointerUp(x: 0.0, y: 0.0)].toSlice(), held: Held.none()), Vec2(x: 0.0, y: 0.0), 0.0)
|
|
286
|
-
assert
|
|
258
|
+
assert st.pointer().pressed
|
|
287
|
-
assert st.pointerReleased()
|
|
288
|
-
assert !st.held.mouse
|
std/game/{functional/FunctionalScene.plum → Scene.plum}
RENAMED
|
@@ -10,6 +10,7 @@ import std/game/Vec2
|
|
|
10
10
|
import std/game/Color
|
|
11
11
|
import std/game/Affine
|
|
12
12
|
import std/game/Game
|
|
13
|
+
import std/Io
|
|
13
14
|
import std/Option
|
|
14
15
|
|
|
15
16
|
# The functional scene: a `SceneNode[M]` is plain data (shapes, sprites, text, groups
|
|
@@ -83,7 +84,7 @@ struct Rect(x: Float, y: Float, w: Float, h: Float) =
|
|
|
83
84
|
|
|
84
85
|
# A Sprite is a texture region `w` by `h` with its top-left at (x, y).
|
|
85
86
|
# A Filter draws its child into `target`, then composites it with the effect.
|
|
86
|
-
# `target` is a texture the game creates ONCE at load with `
|
|
87
|
+
# `target` is a texture the game creates ONCE at load with `Texture.create`; draw
|
|
87
88
|
# creates nothing, so a view does not leak a texture per frame. The size of
|
|
88
89
|
# `target` bounds what the filter can show: child pixels beyond it are cut off.
|
|
89
90
|
# A negative target draws nothing.
|
|
@@ -98,8 +99,8 @@ enum SceneNode[M] =
|
|
|
98
99
|
| Clickable(id: Int, msg: M, child: SceneNode[M])
|
|
99
100
|
|
|
100
101
|
# Draws the scene to the current target.
|
|
101
|
-
fun draw(self,
|
|
102
|
+
fun draw(self, io: Io) =
|
|
102
|
-
self.drawWith(
|
|
103
|
+
self.drawWith(io, Affine.Identity, 1.0, Texture(id: 0 - 1))
|
|
103
104
|
|
|
104
105
|
# The bounding box of the scene in its own space (all zero when empty).
|
|
105
106
|
fun bounds(self) -> Rect =
|
|
@@ -135,53 +136,53 @@ enum SceneNode[M] =
|
|
|
135
136
|
# --- drawing ---
|
|
136
137
|
|
|
137
138
|
# Draws under the parent matrix `m` and alpha into `target` (negative: the screen).
|
|
138
|
-
fun drawWith(self,
|
|
139
|
+
fun drawWith(self, io: Io, m: Affine, alpha: Float, target: Texture) = match self
|
|
139
140
|
RectShape(x, y, w, h, color) =>
|
|
140
141
|
sx := w * m.scaleX()
|
|
141
142
|
sy := h * m.scaleY()
|
|
142
143
|
c := m.apply(Vec2(x: x + w / 2.0, y: y + h / 2.0))
|
|
143
|
-
|
|
144
|
+
io.drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, color.withAlpha(color.a * alpha))
|
|
144
145
|
CircleShape(cx, cy, r, ccolor) =>
|
|
145
146
|
cc := m.apply(Vec2(x: cx, y: cy))
|
|
146
|
-
|
|
147
|
+
io.drawCircle(cc.x, cc.y, r * m.scaleX(), ccolor.withAlpha(ccolor.a * alpha))
|
|
147
148
|
LineShape(x1, y1, x2, y2, th, lcolor) =>
|
|
148
149
|
p := m.apply(Vec2(x: x1, y: y1))
|
|
149
150
|
q := m.apply(Vec2(x: x2, y: y2))
|
|
150
|
-
|
|
151
|
+
io.drawLine(p.x, p.y, q.x, q.y, th * m.scaleX(), lcolor.withAlpha(lcolor.a * alpha))
|
|
151
152
|
Sprite(tex, sx0, sy0, sw0, sh0, srcX, srcY, srcW, srcH, tint) =>
|
|
152
153
|
sw := sw0 * m.scaleX()
|
|
153
154
|
sh := sh0 * m.scaleY()
|
|
154
155
|
sc := m.apply(Vec2(x: sx0 + sw0 / 2.0, y: sy0 + sh0 / 2.0))
|
|
155
|
-
|
|
156
|
+
io.drawTextureEx(tex.id, srcX, srcY, srcW, srcH, sc.x - sw / 2.0, sc.y - sh / 2.0, sw, sh, m.rotation(), false, tint.withAlpha(tint.a * alpha))
|
|
156
157
|
Text(txt, tx, ty, size, tcolor) =>
|
|
157
158
|
tp := m.apply(Vec2(x: tx, y: ty))
|
|
158
|
-
|
|
159
|
+
io.drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
|
|
159
|
-
Group(tr, kids) => SceneNode.drawKids(
|
|
160
|
+
Group(tr, kids) => SceneNode.drawKids(io, kids, m.mul(tr.matrix()), alpha, target)
|
|
160
|
-
Filter(effect, child, tex) => SceneNode.drawFiltered(
|
|
161
|
+
Filter(effect, child, tex) => SceneNode.drawFiltered(io, effect, child, tex, m, alpha, target)
|
|
161
|
-
Clickable(_, _, child) => child.drawWith(
|
|
162
|
+
Clickable(_, _, child) => child.drawWith(io, m, alpha, target)
|
|
162
163
|
|
|
163
|
-
fun drawKids(
|
|
164
|
+
fun drawKids(io: Io, kids: List[SceneNode[M]], m: Affine, alpha: Float, target: Texture) = match kids
|
|
164
165
|
Nil => 0
|
|
165
166
|
Cons(head, tail) =>
|
|
166
|
-
head.drawWith(
|
|
167
|
+
head.drawWith(io, m, alpha, target)
|
|
167
|
-
SceneNode.drawKids(
|
|
168
|
+
SceneNode.drawKids(io, tail, m, alpha, target)
|
|
168
169
|
|
|
169
|
-
fun drawFiltered(
|
|
170
|
+
fun drawFiltered(io: Io, effect: Effect, child: SceneNode[M], tex: Texture, m: Affine, alpha: Float, target: Texture) = match effect
|
|
170
171
|
BlendFx(mode) =>
|
|
171
|
-
|
|
172
|
+
io.blendMode(mode)
|
|
172
|
-
child.drawWith(
|
|
173
|
+
child.drawWith(io, m, alpha, target)
|
|
173
|
-
|
|
174
|
+
io.blendMode(Blend.Normal)
|
|
174
175
|
_ =>
|
|
175
176
|
if Some(b) := child.boundsWith(m)
|
|
176
177
|
if tex.id >= 0
|
|
177
|
-
w := {Int(b.w) + 1}.min(
|
|
178
|
+
w := {Int(b.w) + 1}.min(io.textureWidth(tex.id))
|
|
178
|
-
h := {Int(b.h) + 1}.min(
|
|
179
|
+
h := {Int(b.h) + 1}.min(io.textureHeight(tex.id))
|
|
179
|
-
|
|
180
|
+
io.renderTarget(tex.id)
|
|
180
|
-
|
|
181
|
+
io.clear(Color.Transparent)
|
|
181
182
|
shift := Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0 - b.x, f: 0.0 - b.y)
|
|
182
|
-
child.drawWith(
|
|
183
|
+
child.drawWith(io, shift.mul(m), 1.0, tex)
|
|
183
|
-
|
|
184
|
+
io.renderTarget(target.id)
|
|
184
|
-
|
|
185
|
+
io.drawTextureEx(tex.id, 0.0, 0.0, Float(w), Float(h), b.x, b.y, Float(w), Float(h), 0.0, false, effect.tint(alpha))
|
|
185
186
|
|
|
186
187
|
# --- bounds ---
|
|
187
188
|
|
std/game/{functional/Ui.plum → Ui.plum}
RENAMED
|
@@ -7,7 +7,7 @@ import std/Slice
|
|
|
7
7
|
import std/List
|
|
8
8
|
import std/Option
|
|
9
9
|
import std/game/Color
|
|
10
|
-
import std/game/
|
|
10
|
+
import std/game/Scene
|
|
11
11
|
import std/game/Input
|
|
12
12
|
|
|
13
13
|
# A pure UI layer for the functional scene style. `Ui.step` takes the previous
|
|
@@ -15,7 +15,7 @@ import std/game/Input
|
|
|
15
15
|
# the current view) and returns the new state plus the UiEvents it raised.
|
|
16
16
|
# `Ui.messages` turns the Clicks among them into the game's own messages, read from
|
|
17
17
|
# the `Clickable` nodes of that view. Same
|
|
18
|
-
# button rules as
|
|
18
|
+
# button rules as the old pure scene: Down captures the pressed id; while captured only
|
|
19
19
|
# that id gets Enter/Exit; Up releases (Click only when still over it).
|
|
20
20
|
|
|
21
21
|
struct UiState(x: Float, y: Float, down: Bool, hot: Option[Int], captured: Option[Int])
|
std/game/imperative/Scene.plum
DELETED
|
@@ -1,851 +0,0 @@
|
|
|
1
|
-
module std
|
|
2
|
-
|
|
3
|
-
import std/Int
|
|
4
|
-
import std/Float
|
|
5
|
-
import std/Str
|
|
6
|
-
import std/Slice
|
|
7
|
-
import std/Option
|
|
8
|
-
import std/Math
|
|
9
|
-
import std/game/Vec2
|
|
10
|
-
import std/game/Color
|
|
11
|
-
import std/game/Affine
|
|
12
|
-
import std/game/Game
|
|
13
|
-
import std/game/Input
|
|
14
|
-
import std/List
|
|
15
|
-
|
|
16
|
-
# A Scene2D-like scene graph: a tree of actors under a `Stage`. `Actor` is a base
|
|
17
|
-
# struct; every kind of actor is a struct that embeds it FIRST, which makes it a
|
|
18
|
-
# subtype of `Actor` (`Group`, `Sprite`, `Rect`, `Circle`, `Text` here, your own
|
|
19
|
-
# `struct Walker(Actor, speed: Float)` in a game). An `Actor` slot holds any of them.
|
|
20
|
-
# An actor has a position, size, origin (the pivot of its rotation and scale),
|
|
21
|
-
# scale, rotation (radians, clockwise), colour, visibility and children. Children
|
|
22
|
-
# are placed in their parent's coordinates, so moving, turning or scaling a group
|
|
23
|
-
# moves everything in it. Colour alpha multiplies down the tree and an invisible
|
|
24
|
-
# actor hides its whole subtree.
|
|
25
|
-
#
|
|
26
|
-
# A struct overrides the default methods `act(dt)` (its behaviour), `draw(m, alpha)`
|
|
27
|
-
# (what it paints, with its stage transform `m`) and `hit(p)` (whether a point in
|
|
28
|
-
# its own coordinates is on it). The default `act` runs the children's `act`; an
|
|
29
|
-
# override that has children calls `self.actChildren(dt)` itself.
|
|
30
|
-
#
|
|
31
|
-
# stage := Stage.create()
|
|
32
|
-
# ring := Group()
|
|
33
|
-
# stage.root.add(ring)
|
|
34
|
-
# ring.add(Rect.create(40.0, 20.0, Color.Red).at(10.0, 5.0))
|
|
35
|
-
# stage.act(dt) # runs every actor's `act`, depth first
|
|
36
|
-
# stage.draw() # draws through `std/game/Game`
|
|
37
|
-
#
|
|
38
|
-
# Actors are compared with `same(a, b)`, which is just `==`: an actor is a heap
|
|
39
|
-
# struct and `==` on it is identity (an actor is never a copy of another). Every
|
|
40
|
-
# `Actor` field has a default (a fresh `children` list per construction), so a
|
|
41
|
-
# subtype sets only what differs.
|
|
42
|
-
#
|
|
43
|
-
# The transform maths (`Affine`, `localMatrix`, `worldMatrix`, `flatten`,
|
|
44
|
-
# `hitNode`) never calls the host, so it runs under `plum test`.
|
|
45
|
-
#
|
|
46
|
-
# Limitations: a sprite is drawn with the composed centre, size and rotation.
|
|
47
|
-
# A rect, circle or text under a rotated or non-uniformly scaled parent is drawn
|
|
48
|
-
# axis-aligned at its transformed centre (text: top-left) with the composed
|
|
49
|
-
# scale, since the host has no rotated shapes. Non-uniform scale under rotation
|
|
50
|
-
# (skew) is not supported for drawing; hit testing handles it exactly.
|
|
51
|
-
|
|
52
|
-
struct Actor(
|
|
53
|
-
mut x: Float, mut y: Float, mut width: Float, mut height: Float,
|
|
54
|
-
mut originX: Float, mut originY: Float,
|
|
55
|
-
mut scaleX: Float, mut scaleY: Float,
|
|
56
|
-
mut rotation: Float,
|
|
57
|
-
mut color: Color,
|
|
58
|
-
mut visible: Bool,
|
|
59
|
-
mut children: Slice[Actor],
|
|
60
|
-
mut parent: Option[Actor],
|
|
61
|
-
mut scratch: Slice[Actor],
|
|
62
|
-
mut cull: Bool, mut cullX: Float, mut cullY: Float, mut cullW: Float, mut cullH: Float) =
|
|
63
|
-
|
|
64
|
-
# Runs for every construction, before the subtype's own `init`. A subtype's header
|
|
65
|
-
# exposes the fields a caller may pass (`Actor(width, height)`); init sets the rest.
|
|
66
|
-
fun init(self) =
|
|
67
|
-
self.x = 0.0
|
|
68
|
-
self.y = 0.0
|
|
69
|
-
self.width = 0.0
|
|
70
|
-
self.height = 0.0
|
|
71
|
-
self.originX = 0.0
|
|
72
|
-
self.originY = 0.0
|
|
73
|
-
self.scaleX = 1.0
|
|
74
|
-
self.scaleY = 1.0
|
|
75
|
-
self.rotation = 0.0
|
|
76
|
-
self.color = Color.White
|
|
77
|
-
self.visible = true
|
|
78
|
-
self.children = Slice[Actor]()
|
|
79
|
-
self.parent = None
|
|
80
|
-
self.scratch = Slice[Actor]()
|
|
81
|
-
self.cull = false
|
|
82
|
-
self.cullX = 0.0
|
|
83
|
-
self.cullY = 0.0
|
|
84
|
-
self.cullW = 0.0
|
|
85
|
-
self.cullH = 0.0
|
|
86
|
-
|
|
87
|
-
# This actor's behaviour. The default acts the children; an override that keeps
|
|
88
|
-
# children calls `self.actChildren(dt)` too.
|
|
89
|
-
fun act(self, dt: Float) =
|
|
90
|
-
self.actChildren(dt)
|
|
91
|
-
|
|
92
|
-
fun actChildren(self, dt: Float) =
|
|
93
|
-
i := 0
|
|
94
|
-
while i < self.children.length()
|
|
95
|
-
self.children.at(i).act(dt)
|
|
96
|
-
i = i + 1
|
|
97
|
-
|
|
98
|
-
# Paints this actor only, given its stage transform `m` and accumulated alpha.
|
|
99
|
-
# The default paints nothing (a group).
|
|
100
|
-
fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
101
|
-
return
|
|
102
|
-
|
|
103
|
-
# Whether `p`, a point in this actor's own coordinates, is on it: inside its
|
|
104
|
-
# width by height box. A group with no size is never hit itself.
|
|
105
|
-
fun hit(self, p: Vec2) -> Bool =
|
|
106
|
-
self.width > 0.0 && self.height > 0.0 && p.x >= 0.0 && p.x <= self.width && p.y >= 0.0 && p.y <= self.height
|
|
107
|
-
|
|
108
|
-
# Handles `e`, with a pointer position in this actor's own coordinates. Return true
|
|
109
|
-
# to consume it; otherwise it goes on to the parent. The default ignores it.
|
|
110
|
-
fun onInput(self, e: InputEvent) -> Bool =
|
|
111
|
-
false
|
|
112
|
-
|
|
113
|
-
fun at(self, x: Float, y: Float) -> Actor =
|
|
114
|
-
self.x = x
|
|
115
|
-
self.y = y
|
|
116
|
-
self
|
|
117
|
-
|
|
118
|
-
fun setOrigin(self, ox: Float, oy: Float) -> Actor =
|
|
119
|
-
self.originX = ox
|
|
120
|
-
self.originY = oy
|
|
121
|
-
self
|
|
122
|
-
|
|
123
|
-
fun centerOrigin(self) -> Actor =
|
|
124
|
-
self.originX = self.width / 2.0
|
|
125
|
-
self.originY = self.height / 2.0
|
|
126
|
-
self
|
|
127
|
-
|
|
128
|
-
# The index of `c` among this actor's children, -1 if it is not one.
|
|
129
|
-
fun childIndex(self, c: Actor) -> Int =
|
|
130
|
-
for i := range self.children.length()
|
|
131
|
-
if self.children.at(i) == c
|
|
132
|
-
return i
|
|
133
|
-
-1
|
|
134
|
-
|
|
135
|
-
# Appends a child (taking it from its previous parent) so it draws after its siblings.
|
|
136
|
-
fun add(self, child: Actor) =
|
|
137
|
-
if Some(p) := child.parent
|
|
138
|
-
p.detach(child)
|
|
139
|
-
child.parent = Some(self)
|
|
140
|
-
self.children.push(child)
|
|
141
|
-
|
|
142
|
-
fun detach(self, child: Actor) =
|
|
143
|
-
i := self.childIndex(child)
|
|
144
|
-
if i >= 0
|
|
145
|
-
self.children.removeAt(i)
|
|
146
|
-
|
|
147
|
-
fun remove(self, child: Actor) =
|
|
148
|
-
if Some(p) := child.parent
|
|
149
|
-
if p == self
|
|
150
|
-
self.detach(child)
|
|
151
|
-
child.parent = None
|
|
152
|
-
|
|
153
|
-
fun clear(self) =
|
|
154
|
-
for i := range self.children.length()
|
|
155
|
-
self.children.at(i).parent = None
|
|
156
|
-
self.children.clear()
|
|
157
|
-
|
|
158
|
-
# The actor's index among its siblings (its draw order), -1 without a parent.
|
|
159
|
-
fun zIndex(self) -> Int =
|
|
160
|
-
if Some(p) := self.parent
|
|
161
|
-
return p.childIndex(self)
|
|
162
|
-
-1
|
|
163
|
-
|
|
164
|
-
# Moves the actor to index `i` of its parent's children (clamped to the valid
|
|
165
|
-
# range), shifting the siblings in between. Without a parent it does nothing.
|
|
166
|
-
fun setZIndex(self, i: Int) =
|
|
167
|
-
if Some(p) := self.parent
|
|
168
|
-
src := self.zIndex()
|
|
169
|
-
dst := i
|
|
170
|
-
if dst < 0
|
|
171
|
-
dst = 0
|
|
172
|
-
if dst > p.children.length() - 1
|
|
173
|
-
dst = p.children.length() - 1
|
|
174
|
-
d := p.children.data
|
|
175
|
-
base := p.children.start
|
|
176
|
-
me: Actor := self
|
|
177
|
-
while src < dst
|
|
178
|
-
d.set(base + src, d.get(base + src + 1))
|
|
179
|
-
src = src + 1
|
|
180
|
-
while src > dst
|
|
181
|
-
d.set(base + src, d.get(base + src - 1))
|
|
182
|
-
src = src - 1
|
|
183
|
-
d.set(base + dst, me)
|
|
184
|
-
|
|
185
|
-
# Draws last (on top) and is hit first.
|
|
186
|
-
fun toFront(self) =
|
|
187
|
-
if Some(p) := self.parent
|
|
188
|
-
self.setZIndex(p.children.length() - 1)
|
|
189
|
-
|
|
190
|
-
# Draws first (underneath) and is hit last.
|
|
191
|
-
fun toBack(self) =
|
|
192
|
-
self.setZIndex(0)
|
|
193
|
-
|
|
194
|
-
# Reorders the children in place so that `before(a, b)` puts a ahead of b;
|
|
195
|
-
# equal children (neither before the other) keep their order. A natural merge
|
|
196
|
-
# sort: it finds the ascending runs, then merges neighbouring runs, trimming
|
|
197
|
-
# the part of each merge that is already in place by binary search. A list that
|
|
198
|
-
# is already sorted costs n - 1 comparisons, a nearly sorted one about 2n (one
|
|
199
|
-
# scan to find the runs, one to confirm the merge) plus the distance each
|
|
200
|
-
# displaced child moves; the worst case is O(n log n). The merge buffer
|
|
201
|
-
# is the actor's own `scratch` list, which keeps its capacity between calls, so
|
|
202
|
-
# sorting every frame allocates nothing once it has grown.
|
|
203
|
-
fun sortChildren(self, before: fn(Actor, Actor) -> Bool) =
|
|
204
|
-
n := self.children.length()
|
|
205
|
-
d := self.children.data
|
|
206
|
-
base := self.children.start
|
|
207
|
-
runs := 2
|
|
208
|
-
while runs > 1
|
|
209
|
-
runs = 0
|
|
210
|
-
i := 0
|
|
211
|
-
while i < n
|
|
212
|
-
a := sortRunEnd(d, base, i, n, before)
|
|
213
|
-
runs = runs + 1
|
|
214
|
-
if a >= n
|
|
215
|
-
i = n
|
|
216
|
-
else
|
|
217
|
-
b := sortRunEnd(d, base, a, n, before)
|
|
218
|
-
runs = runs + 1
|
|
219
|
-
sortMerge(self.scratch, d, base, i, a, b, before)
|
|
220
|
-
i = b
|
|
221
|
-
runs = {runs + 1} / 2
|
|
222
|
-
|
|
223
|
-
# Culls this actor's direct children against the rectangle (x, y, w, h), in this
|
|
224
|
-
# actor's own coordinates: a child whose box (position and size, scaled about its
|
|
225
|
-
# origin; rotation ignored) misses it is neither drawn nor hit, with its whole
|
|
226
|
-
# subtree. A child with no size (a plain group) is never culled.
|
|
227
|
-
fun setCullingArea(self, x: Float, y: Float, w: Float, h: Float) =
|
|
228
|
-
self.cull = true
|
|
229
|
-
self.cullX = x
|
|
230
|
-
self.cullY = y
|
|
231
|
-
self.cullW = w
|
|
232
|
-
self.cullH = h
|
|
233
|
-
|
|
234
|
-
fun clearCullingArea(self) =
|
|
235
|
-
self.cull = false
|
|
236
|
-
|
|
237
|
-
# The transform into the parent's coordinates: translate to the position,
|
|
238
|
-
# rotate and scale about the origin.
|
|
239
|
-
fun local(self) -> Affine =
|
|
240
|
-
cs := Math.cos(self.rotation)
|
|
241
|
-
sn := Math.sin(self.rotation)
|
|
242
|
-
a := cs * self.scaleX
|
|
243
|
-
b := sn * self.scaleX
|
|
244
|
-
c := 0.0 - sn * self.scaleY
|
|
245
|
-
d := cs * self.scaleY
|
|
246
|
-
Affine(
|
|
247
|
-
a: a, b: b, c: c, d: d,
|
|
248
|
-
e: self.x + self.originX - {a * self.originX + c * self.originY},
|
|
249
|
-
f: self.y + self.originY - {b * self.originX + d * self.originY})
|
|
250
|
-
|
|
251
|
-
# Whether this actor's box (in its parent's coordinates) lies outside the
|
|
252
|
-
# rectangle (ax, ay, aw, ah). An actor with no size is never outside.
|
|
253
|
-
fun outside(self, ax: Float, ay: Float, aw: Float, ah: Float) -> Bool =
|
|
254
|
-
if self.width == 0.0 && self.height == 0.0
|
|
255
|
-
return false
|
|
256
|
-
w := self.width * self.scaleX
|
|
257
|
-
h := self.height * self.scaleY
|
|
258
|
-
x := self.x + self.originX - self.originX * self.scaleX
|
|
259
|
-
y := self.y + self.originY - self.originY * self.scaleY
|
|
260
|
-
if w < 0.0
|
|
261
|
-
x = x + w
|
|
262
|
-
w = 0.0 - w
|
|
263
|
-
if h < 0.0
|
|
264
|
-
y = y + h
|
|
265
|
-
h = 0.0 - h
|
|
266
|
-
x > ax + aw || x + w < ax || y > ay + ah || y + h < ay
|
|
267
|
-
|
|
268
|
-
# Whether this actor culls its child `c`.
|
|
269
|
-
fun culls(self, c: Actor) -> Bool =
|
|
270
|
-
self.cull && c.outside(self.cullX, self.cullY, self.cullW, self.cullH)
|
|
271
|
-
|
|
272
|
-
# Draws this actor and then its children, given the parent's stage transform.
|
|
273
|
-
fun drawTree(self, canvas: Canvas, parent: Affine, alpha: Float) =
|
|
274
|
-
if !self.visible
|
|
275
|
-
return
|
|
276
|
-
m := parent * self.local()
|
|
277
|
-
a := alpha * self.color.a
|
|
278
|
-
self.draw(canvas, m, a)
|
|
279
|
-
n := self.children.length()
|
|
280
|
-
for i := range n
|
|
281
|
-
kid := self.children.at(i)
|
|
282
|
-
if !self.culls(kid)
|
|
283
|
-
kid.drawTree(canvas, m, a)
|
|
284
|
-
|
|
285
|
-
fun collect(self, parent: Affine, alpha: Float, out: Slice[Drawn]) =
|
|
286
|
-
if !self.visible
|
|
287
|
-
return
|
|
288
|
-
m := parent * self.local()
|
|
289
|
-
a := alpha * self.color.a
|
|
290
|
-
me: Actor := self
|
|
291
|
-
out.push(Drawn(node: me, m: m, alpha: a))
|
|
292
|
-
n := self.children.length()
|
|
293
|
-
for i := range n
|
|
294
|
-
kid := self.children.at(i)
|
|
295
|
-
if !self.culls(kid)
|
|
296
|
-
kid.collect(m, a, out)
|
|
297
|
-
|
|
298
|
-
# The topmost visible actor under `p`, a point in the parent's coordinates:
|
|
299
|
-
# children before the actor itself, later siblings before earlier ones.
|
|
300
|
-
fun hitTree(self, p: Vec2) -> Option[Actor] =
|
|
301
|
-
if !self.visible
|
|
302
|
-
return None
|
|
303
|
-
q := self.local().inverse().apply(p)
|
|
304
|
-
n := self.children.length()
|
|
305
|
-
for i := range n
|
|
306
|
-
child := self.children.at(n - 1 - i)
|
|
307
|
-
if !self.culls(child)
|
|
308
|
-
if Some(found) := child.hitTree(q)
|
|
309
|
-
return Some(found)
|
|
310
|
-
if self.hit(q)
|
|
311
|
-
me: Actor := self
|
|
312
|
-
return Some(me)
|
|
313
|
-
None
|
|
314
|
-
|
|
315
|
-
# A point in this actor's coordinates, in the stage's.
|
|
316
|
-
fun localToStage(self, p: Vec2) -> Vec2 =
|
|
317
|
-
me: Actor := self
|
|
318
|
-
worldMatrix(me).apply(p)
|
|
319
|
-
|
|
320
|
-
# A point in the stage's coordinates, in this actor's.
|
|
321
|
-
fun stageToLocal(self, p: Vec2) -> Vec2 =
|
|
322
|
-
me: Actor := self
|
|
323
|
-
worldMatrix(me).inverse().apply(p)
|
|
324
|
-
|
|
325
|
-
# Whether two actor values are the same actor (`==` is identity on a heap struct).
|
|
326
|
-
fun same(a: Actor, b: Actor) -> Bool =
|
|
327
|
-
a == b
|
|
328
|
-
|
|
329
|
-
# An actor that only holds children.
|
|
330
|
-
struct Group(Actor)
|
|
331
|
-
|
|
332
|
-
# A texture region (srcX, srcY, srcW, srcH in texture pixels) drawn width by height.
|
|
333
|
-
struct Sprite(Actor(width, height), tex: Texture, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
|
|
334
|
-
|
|
335
|
-
fun create(tex: Texture, sx: Float, sy: Float, sw: Float, sh: Float, w: Float, h: Float) -> Sprite =
|
|
336
|
-
Sprite(width: w, height: h, tex: tex, srcX: sx, srcY: sy, srcW: sw, srcH: sh)
|
|
337
|
-
|
|
338
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
339
|
-
drawSpriteFrame(canvas, m, alpha, self.width, self.height, self.color, self.tex, self.srcX, self.srcY, self.srcW, self.srcH)
|
|
340
|
-
|
|
341
|
-
struct Rect(Actor(width, height, color)) =
|
|
342
|
-
|
|
343
|
-
fun create(w: Float, h: Float, c: Color) -> Rect =
|
|
344
|
-
Rect(width: w, height: h, color: c)
|
|
345
|
-
|
|
346
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
347
|
-
drawRectBox(canvas, m, alpha, self.width, self.height, self.color)
|
|
348
|
-
|
|
349
|
-
struct Circle(Actor(width, height, color)) =
|
|
350
|
-
|
|
351
|
-
# A circle of radius r whose bounding box has its top-left at the actor's position.
|
|
352
|
-
fun create(r: Float, c: Color) -> Circle =
|
|
353
|
-
Circle(width: r * 2.0, height: r * 2.0, color: c)
|
|
354
|
-
|
|
355
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
356
|
-
drawDisc(canvas, m, alpha, self.width / 2.0, self.color)
|
|
357
|
-
|
|
358
|
-
struct Text(Actor(width, height, color), mut text: Str, size: Float) =
|
|
359
|
-
|
|
360
|
-
# Text with its top-left at the actor's position; the size is an estimate of the extent.
|
|
361
|
-
fun create(s: Str, size: Float, c: Color) -> Text =
|
|
362
|
-
Text(width: size * 0.6 * Float(s.length()), height: size, color: c, text: s, size: size)
|
|
363
|
-
|
|
364
|
-
override fun draw(self, canvas: Canvas, m: Affine, alpha: Float) =
|
|
365
|
-
drawLabel(canvas, m, alpha, self.text, self.size, self.color)
|
|
366
|
-
|
|
367
|
-
# Draws a `w` by `h` rectangle at the transform `m`, for your own rect-like records.
|
|
368
|
-
fun drawRectBox(canvas: Canvas, m: Affine, alpha: Float, w: Float, h: Float, color: Color) =
|
|
369
|
-
sx := w * m.scaleX()
|
|
370
|
-
sy := h * m.scaleY()
|
|
371
|
-
c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
|
|
372
|
-
canvas.drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, color.withAlpha(alpha))
|
|
373
|
-
|
|
374
|
-
# Draws a circle of radius `r` whose bounding box has its top-left at the transform `m`.
|
|
375
|
-
fun drawDisc(canvas: Canvas, m: Affine, alpha: Float, r: Float, color: Color) =
|
|
376
|
-
c := m.apply(Vec2(x: r, y: r))
|
|
377
|
-
canvas.drawCircle(c.x, c.y, r * m.scaleX(), color.withAlpha(alpha))
|
|
378
|
-
|
|
379
|
-
# Draws text with its top-left at the transform `m`.
|
|
380
|
-
fun drawLabel(canvas: Canvas, m: Affine, alpha: Float, s: Str, size: Float, color: Color) =
|
|
381
|
-
p := m.apply(Vec2(x: 0.0, y: 0.0))
|
|
382
|
-
canvas.drawText(s, p.x, p.y, size * m.scaleY(), color.withAlpha(alpha))
|
|
383
|
-
|
|
384
|
-
# Draws a texture region `w` by `h` at the transform `m`: for your own sprite-like records.
|
|
385
|
-
fun drawSpriteFrame(canvas: Canvas, m: Affine, alpha: Float, w: Float, h: Float, color: Color, tex: Texture, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
|
|
386
|
-
sx := w * m.scaleX()
|
|
387
|
-
sy := h * m.scaleY()
|
|
388
|
-
c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
|
|
389
|
-
canvas.drawTextureEx(tex, srcX, srcY, srcW, srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, color.withAlpha(alpha))
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
# The end of the ascending run of `d` starting at `i` (exclusive), within n.
|
|
393
|
-
fun sortRunEnd(d: Array[Actor], base: Int, i: Int, n: Int, before: fn(Actor, Actor) -> Bool) -> Int =
|
|
394
|
-
j := i + 1
|
|
395
|
-
while j < n && !before(d.get(base + j), d.get(base + j - 1))
|
|
396
|
-
j = j + 1
|
|
397
|
-
j
|
|
398
|
-
|
|
399
|
-
# Merges the sorted runs [lo, a) and [a, b) of `d`, stably. The left elements that
|
|
400
|
-
# already precede the first right one and the right elements that already follow
|
|
401
|
-
# the last left one are found by binary search and left alone.
|
|
402
|
-
fun sortMerge(scratch: Slice[Actor], d: Array[Actor], base: Int, lo: Int, a: Int, b: Int, before: fn(Actor, Actor) -> Bool) =
|
|
403
|
-
first := d.get(base + a)
|
|
404
|
-
l := lo
|
|
405
|
-
h := a
|
|
406
|
-
while l < h
|
|
407
|
-
mid := {l + h} / 2
|
|
408
|
-
if before(first, d.get(base + mid))
|
|
409
|
-
h = mid
|
|
410
|
-
else
|
|
411
|
-
l = mid + 1
|
|
412
|
-
lo2 := l
|
|
413
|
-
if lo2 >= a
|
|
414
|
-
return
|
|
415
|
-
last := d.get(base + a - 1)
|
|
416
|
-
l = a
|
|
417
|
-
h = b
|
|
418
|
-
while l < h
|
|
419
|
-
mid := {l + h} / 2
|
|
420
|
-
if before(d.get(base + mid), last)
|
|
421
|
-
l = mid + 1
|
|
422
|
-
else
|
|
423
|
-
h = mid
|
|
424
|
-
hi := l
|
|
425
|
-
scratch.clear()
|
|
426
|
-
for k := range a - lo2
|
|
427
|
-
scratch.push(d.get(base + lo2 + k))
|
|
428
|
-
ln := a - lo2
|
|
429
|
-
i := 0
|
|
430
|
-
j := a
|
|
431
|
-
o := lo2
|
|
432
|
-
while i < ln && j < hi
|
|
433
|
-
r := d.get(base + j)
|
|
434
|
-
s := scratch.at(i)
|
|
435
|
-
if before(r, s)
|
|
436
|
-
d.set(base + o, r)
|
|
437
|
-
j = j + 1
|
|
438
|
-
else
|
|
439
|
-
d.set(base + o, s)
|
|
440
|
-
i = i + 1
|
|
441
|
-
o = o + 1
|
|
442
|
-
while i < ln
|
|
443
|
-
d.set(base + o, scratch.at(i))
|
|
444
|
-
i = i + 1
|
|
445
|
-
o = o + 1
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
# The actor's transform into its parent's coordinates.
|
|
449
|
-
fun localMatrix(n: Actor) -> Affine =
|
|
450
|
-
n.local()
|
|
451
|
-
|
|
452
|
-
# The actor's transform into stage coordinates.
|
|
453
|
-
fun worldMatrix(n: Actor) -> Affine =
|
|
454
|
-
if Some(p) := n.parent
|
|
455
|
-
worldMatrix(p) * n.local()
|
|
456
|
-
else
|
|
457
|
-
n.local()
|
|
458
|
-
|
|
459
|
-
# One entry of the draw list: a visible actor, its stage transform and its alpha.
|
|
460
|
-
struct Drawn(node: Actor, m: Affine, alpha: Float)
|
|
461
|
-
|
|
462
|
-
# What `draw` would draw, in order: a parent before its children, siblings in
|
|
463
|
-
# list order, invisible and culled subtrees left out.
|
|
464
|
-
fun flatten(root: Actor) -> Slice[Drawn] =
|
|
465
|
-
out := Slice[Drawn]()
|
|
466
|
-
root.collect(Affine.Identity, 1.0, out)
|
|
467
|
-
out
|
|
468
|
-
|
|
469
|
-
fun drawNode(canvas: Canvas, n: Actor, parent: Affine, alpha: Float) =
|
|
470
|
-
n.drawTree(canvas, parent, alpha)
|
|
471
|
-
|
|
472
|
-
fun actNode(n: Actor, dt: Float) =
|
|
473
|
-
n.act(dt)
|
|
474
|
-
|
|
475
|
-
# The topmost visible actor under `p`, a point in the actor's parent's coordinates.
|
|
476
|
-
fun hitNode(n: Actor, p: Vec2) -> Option[Actor] =
|
|
477
|
-
n.hitTree(p)
|
|
478
|
-
|
|
479
|
-
# A root actor plus a camera (the world point at the screen's top-left and the
|
|
480
|
-
# pixels per world unit, as in `setCamera`).
|
|
481
|
-
struct Stage(mut camX: Float, mut camY: Float, mut camScale: Float, mut focus: Option[Actor]) =
|
|
482
|
-
|
|
483
|
-
fun init(self) =
|
|
484
|
-
self.root: Actor = Group()
|
|
485
|
-
|
|
486
|
-
fun create() -> Stage =
|
|
487
|
-
Stage(camX: 0.0, camY: 0.0, camScale: 1.0, focus: None)
|
|
488
|
-
|
|
489
|
-
# Runs every actor's `act`, parents before children, in insertion order.
|
|
490
|
-
fun act(self, dt: Float) =
|
|
491
|
-
self.root.act(dt)
|
|
492
|
-
|
|
493
|
-
fun draw(self, canvas: Canvas) =
|
|
494
|
-
canvas.setCamera(self.camX, self.camY, self.camScale)
|
|
495
|
-
self.root.drawTree(canvas, Affine.Identity, 1.0)
|
|
496
|
-
canvas.resetCamera()
|
|
497
|
-
|
|
498
|
-
# The topmost visible actor at a stage point.
|
|
499
|
-
fun hit(self, x: Float, y: Float) -> Option[Actor] =
|
|
500
|
-
self.root.hitTree(Vec2(x: x, y: y))
|
|
501
|
-
|
|
502
|
-
# Delivers one input and says whether an actor consumed it. A pointer input goes
|
|
503
|
-
# to the topmost actor under it, then each parent in turn until one's `onInput`
|
|
504
|
-
# returns true; a press also gives that actor the keyboard focus. A key goes to the
|
|
505
|
-
# focused actor, then its parents. Unconsumed input is the game's to handle.
|
|
506
|
-
fun input(self, e: InputEvent) -> Bool =
|
|
507
|
-
if Some(p) := e.position()
|
|
508
|
-
sp := self.screenToStage(p.x, p.y)
|
|
509
|
-
target := self.hit(sp.x, sp.y)
|
|
510
|
-
match e
|
|
511
|
-
PointerDown(_, _) =>
|
|
512
|
-
self.focus = target
|
|
513
|
-
_ => 0
|
|
514
|
-
if Some(a) := target
|
|
515
|
-
return Stage.bubble(a, InputEvent.pointerAt(e, sp))
|
|
516
|
-
return false
|
|
517
|
-
if Some(f) := self.focus
|
|
518
|
-
return Stage.bubble(f, e)
|
|
519
|
-
false
|
|
520
|
-
|
|
521
|
-
# Offers `e` (in stage coordinates) to `a`, then each parent, until one consumes it.
|
|
522
|
-
fun bubble(a: Actor, e: InputEvent) -> Bool =
|
|
523
|
-
cur: Option[Actor] := Some(a)
|
|
524
|
-
while Some(c) := cur
|
|
525
|
-
if c.onInput(e.through(worldMatrix(c).inverse()))
|
|
526
|
-
return true
|
|
527
|
-
cur = c.parent
|
|
528
|
-
false
|
|
529
|
-
|
|
530
|
-
# A screen pixel in stage coordinates under this camera.
|
|
531
|
-
fun screenToStage(self, sx: Float, sy: Float) -> Vec2 =
|
|
532
|
-
Vec2(x: sx / self.camScale + self.camX, y: sy / self.camScale + self.camY)
|
|
533
|
-
|
|
534
|
-
# --- tests ---
|
|
535
|
-
|
|
536
|
-
# Counts the inputs it is offered and the last pointer x it saw; consumes them if `eat`.
|
|
537
|
-
struct Catcher(Actor(width, height), mut got: Int, mut lastX: Float, eat: Bool) =
|
|
538
|
-
override fun onInput(self, e: InputEvent) -> Bool =
|
|
539
|
-
self.got = self.got + 1
|
|
540
|
-
if Some(p) := e.position()
|
|
541
|
-
self.lastX = p.x
|
|
542
|
-
self.eat
|
|
543
|
-
|
|
544
|
-
fun sceneNear(a: Float, b: Float) -> Bool =
|
|
545
|
-
{a - b}.abs() < 0.0001
|
|
546
|
-
|
|
547
|
-
fun sceneNearV(p: Vec2, x: Float, y: Float) -> Bool =
|
|
548
|
-
sceneNear(p.x, x) && sceneNear(p.y, y)
|
|
549
|
-
|
|
550
|
-
fun sceneKids(n: Int) -> Group =
|
|
551
|
-
g := Group()
|
|
552
|
-
for i := range n
|
|
553
|
-
g.add(Rect.create(1.0, 1.0, Color.Red).at(Float(i), 0.0))
|
|
554
|
-
g
|
|
555
|
-
|
|
556
|
-
fun sceneOrder(g: Actor) -> Str =
|
|
557
|
-
s := ""
|
|
558
|
-
for i := range g.children.length()
|
|
559
|
-
s = "{s}{Int(g.children.at(i).x)},"
|
|
560
|
-
s
|
|
561
|
-
|
|
562
|
-
test "an actor translates; rotation and scale pivot about the origin"
|
|
563
|
-
n := Rect.create(10.0, 10.0, Color.Red).at(100.0, 50.0)
|
|
564
|
-
assert sceneNearV(localMatrix(n).apply(Vec2(x: 0.0, y: 0.0)), 100.0, 50.0)
|
|
565
|
-
n.originX = 5.0
|
|
566
|
-
n.originY = 5.0
|
|
567
|
-
n.rotation = Float.HalfPi
|
|
568
|
-
assert sceneNearV(localMatrix(n).apply(Vec2(x: 5.0, y: 5.0)), 105.0, 55.0)
|
|
569
|
-
assert sceneNearV(localMatrix(n).apply(Vec2(x: 10.0, y: 5.0)), 105.0, 60.0)
|
|
570
|
-
n.rotation = 0.0
|
|
571
|
-
n.scaleX = 2.0
|
|
572
|
-
assert sceneNearV(localMatrix(n).apply(Vec2(x: 10.0, y: 10.0)), 115.0, 60.0)
|
|
573
|
-
|
|
574
|
-
test "matrices compose, and inverse undoes them"
|
|
575
|
-
g := Group().at(100.0, 0.0)
|
|
576
|
-
g.rotation = Float.HalfPi
|
|
577
|
-
g.scaleX = 2.0
|
|
578
|
-
g.scaleY = 2.0
|
|
579
|
-
k := Rect.create(10.0, 10.0, Color.Red).at(10.0, 0.0)
|
|
580
|
-
g.add(k)
|
|
581
|
-
w := worldMatrix(k)
|
|
582
|
-
assert sceneNearV(w.apply(Vec2(x: 0.0, y: 0.0)), 100.0, 20.0)
|
|
583
|
-
assert sceneNearV(w.apply(Vec2(x: 10.0, y: 10.0)), 80.0, 40.0)
|
|
584
|
-
assert sceneNear(w.scaleX(), 2.0)
|
|
585
|
-
assert sceneNear(w.rotation(), Float.HalfPi)
|
|
586
|
-
back := w.inverse().apply(w.apply(Vec2(x: 3.0, y: 7.0)))
|
|
587
|
-
assert sceneNearV(back, 3.0, 7.0)
|
|
588
|
-
assert sceneNearV(k.localToStage(Vec2(x: 10.0, y: 10.0)), 80.0, 40.0)
|
|
589
|
-
assert sceneNearV(k.stageToLocal(Vec2(x: 80.0, y: 40.0)), 10.0, 10.0)
|
|
590
|
-
|
|
591
|
-
test "add reparents, remove and clear detach"
|
|
592
|
-
a := Group()
|
|
593
|
-
b := Group()
|
|
594
|
-
k := Group()
|
|
595
|
-
a.add(k)
|
|
596
|
-
assert a.children.length() == 1
|
|
597
|
-
b.add(k)
|
|
598
|
-
assert a.children.length() == 0
|
|
599
|
-
assert b.children.length() == 1
|
|
600
|
-
assert same(k.parent.unwrap(), b)
|
|
601
|
-
b.remove(k)
|
|
602
|
-
assert b.children.length() == 0
|
|
603
|
-
assert k.parent.isNone()
|
|
604
|
-
b.add(k)
|
|
605
|
-
extra := Group()
|
|
606
|
-
b.add(extra)
|
|
607
|
-
b.clear()
|
|
608
|
-
assert b.children.length() == 0
|
|
609
|
-
assert k.parent.isNone()
|
|
610
|
-
|
|
611
|
-
test "draw order is parent first, then children in insertion order"
|
|
612
|
-
root := Group()
|
|
613
|
-
a := Group()
|
|
614
|
-
root.add(a)
|
|
615
|
-
b := Group()
|
|
616
|
-
root.add(b)
|
|
617
|
-
a1 := Rect.create(1.0, 1.0, Color.Red)
|
|
618
|
-
a.add(a1)
|
|
619
|
-
a2 := Rect.create(1.0, 1.0, Color.Red)
|
|
620
|
-
a.add(a2)
|
|
621
|
-
b1 := Rect.create(1.0, 1.0, Color.Red)
|
|
622
|
-
b.add(b1)
|
|
623
|
-
list := flatten(root)
|
|
624
|
-
assert list.length() == 6
|
|
625
|
-
assert same(list.at(0).node, root)
|
|
626
|
-
assert same(list.at(1).node, a)
|
|
627
|
-
assert same(list.at(2).node, a1)
|
|
628
|
-
assert same(list.at(3).node, a2)
|
|
629
|
-
assert same(list.at(4).node, b)
|
|
630
|
-
assert same(list.at(5).node, b1)
|
|
631
|
-
|
|
632
|
-
test "invisible subtrees are skipped and alpha multiplies down"
|
|
633
|
-
root := Group()
|
|
634
|
-
root.color = Color.White.withAlpha(0.5)
|
|
635
|
-
g := Group()
|
|
636
|
-
root.add(g)
|
|
637
|
-
g.color = Color.White.withAlpha(0.5)
|
|
638
|
-
k := Rect.create(1.0, 1.0, Color.Red)
|
|
639
|
-
g.add(k)
|
|
640
|
-
hidden := Group()
|
|
641
|
-
root.add(hidden)
|
|
642
|
-
hidden.visible = false
|
|
643
|
-
hidden.add(Rect.create(1.0, 1.0, Color.Red))
|
|
644
|
-
list := flatten(root)
|
|
645
|
-
assert list.length() == 3
|
|
646
|
-
assert sceneNear(list.at(2).alpha, 0.25)
|
|
647
|
-
g.visible = false
|
|
648
|
-
assert flatten(root).length() == 1
|
|
649
|
-
|
|
650
|
-
test "hit finds the topmost actor through rotated and scaled groups"
|
|
651
|
-
root := Group()
|
|
652
|
-
g := Group().at(100.0, 0.0)
|
|
653
|
-
root.add(g)
|
|
654
|
-
g.rotation = Float.HalfPi
|
|
655
|
-
g.scaleX = 2.0
|
|
656
|
-
g.scaleY = 2.0
|
|
657
|
-
low := Rect.create(30.0, 30.0, Color.Red)
|
|
658
|
-
g.add(low)
|
|
659
|
-
top := Rect.create(10.0, 10.0, Color.Blue).at(10.0, 0.0)
|
|
660
|
-
g.add(top)
|
|
661
|
-
stage := Stage.create()
|
|
662
|
-
stage.root.add(g)
|
|
663
|
-
assert same(stage.hit(90.0, 30.0).unwrap(), top)
|
|
664
|
-
assert same(stage.hit(70.0, 10.0).unwrap(), low)
|
|
665
|
-
assert same(stage.hit(60.0, 30.0).unwrap(), low)
|
|
666
|
-
assert stage.hit(10.0, 10.0).isNone()
|
|
667
|
-
assert stage.hit(150.0, 30.0).isNone()
|
|
668
|
-
top.visible = false
|
|
669
|
-
assert same(stage.hit(90.0, 30.0).unwrap(), low)
|
|
670
|
-
g.visible = false
|
|
671
|
-
assert stage.hit(90.0, 30.0).isNone()
|
|
672
|
-
|
|
673
|
-
test "hit prefers later siblings and a child over its parent"
|
|
674
|
-
stage := Stage.create()
|
|
675
|
-
a := Rect.create(50.0, 50.0, Color.Red)
|
|
676
|
-
stage.root.add(a)
|
|
677
|
-
b := Rect.create(50.0, 50.0, Color.Blue).at(25.0, 0.0)
|
|
678
|
-
stage.root.add(b)
|
|
679
|
-
inner := Rect.create(10.0, 10.0, Color.Green)
|
|
680
|
-
b.add(inner)
|
|
681
|
-
assert same(stage.hit(10.0, 10.0).unwrap(), a)
|
|
682
|
-
assert same(stage.hit(30.0, 5.0).unwrap(), inner)
|
|
683
|
-
assert same(stage.hit(60.0, 40.0).unwrap(), b)
|
|
684
|
-
assert same(stage.hit(40.0, 10.0).unwrap(), b)
|
|
685
|
-
|
|
686
|
-
test "the default act runs the children's act, invisible ones included"
|
|
687
|
-
stage := Stage.create()
|
|
688
|
-
g := Group()
|
|
689
|
-
stage.root.add(g)
|
|
690
|
-
g.add(Rect.create(1.0, 1.0, Color.Red))
|
|
691
|
-
g.visible = false
|
|
692
|
-
stage.act(0.5)
|
|
693
|
-
assert stage.root.rotation == 0.0
|
|
694
|
-
assert g.children.length() == 1
|
|
695
|
-
|
|
696
|
-
test "the screen maps to the stage through the camera"
|
|
697
|
-
stage := Stage.create()
|
|
698
|
-
stage.camX = 100.0
|
|
699
|
-
stage.camY = 50.0
|
|
700
|
-
stage.camScale = 2.0
|
|
701
|
-
p := stage.screenToStage(40.0, 20.0)
|
|
702
|
-
assert sceneNearV(p, 120.0, 60.0)
|
|
703
|
-
|
|
704
|
-
test "sizes of sprites, circles and text"
|
|
705
|
-
assert Circle.create(5.0, Color.Red).width == 10.0
|
|
706
|
-
s := Sprite.create(Texture(id: 3), 16.0, 0.0, 16.0, 16.0, 32.0, 32.0)
|
|
707
|
-
assert s.tex.id == 3
|
|
708
|
-
assert s.width == 32.0
|
|
709
|
-
assert Text.create("ab", 10.0, Color.White).height == 10.0
|
|
710
|
-
|
|
711
|
-
test "setZIndex, toFront and toBack reorder the siblings"
|
|
712
|
-
g := sceneKids(4)
|
|
713
|
-
a := g.children.at(0)
|
|
714
|
-
b := g.children.at(1)
|
|
715
|
-
d := g.children.at(3)
|
|
716
|
-
assert a.zIndex() == 0
|
|
717
|
-
assert d.zIndex() == 3
|
|
718
|
-
a.toFront()
|
|
719
|
-
assert sceneOrder(g) == "1,2,3,0,"
|
|
720
|
-
assert a.zIndex() == 3
|
|
721
|
-
d.toBack()
|
|
722
|
-
assert sceneOrder(g) == "3,1,2,0,"
|
|
723
|
-
b.setZIndex(2)
|
|
724
|
-
assert sceneOrder(g) == "3,2,1,0,"
|
|
725
|
-
b.setZIndex(99)
|
|
726
|
-
assert sceneOrder(g) == "3,2,0,1,"
|
|
727
|
-
b.setZIndex(-5)
|
|
728
|
-
assert sceneOrder(g) == "1,3,2,0,"
|
|
729
|
-
assert Group().zIndex() == -1
|
|
730
|
-
Group().toFront()
|
|
731
|
-
assert g.children.length() == 4
|
|
732
|
-
|
|
733
|
-
test "sortChildren is stable and keeps ties in order"
|
|
734
|
-
g := Group()
|
|
735
|
-
for i := range 10
|
|
736
|
-
k := Rect.create(1.0, 1.0, Color.Red).at(Float(i), Float(i * 7 % 3))
|
|
737
|
-
g.add(k)
|
|
738
|
-
g.sortChildren(|a, b| a.y < b.y)
|
|
739
|
-
assert sceneOrder(g) == "0,3,6,9,1,4,7,2,5,8,"
|
|
740
|
-
g.sortChildren(|a, b| a.y < b.y)
|
|
741
|
-
assert sceneOrder(g) == "0,3,6,9,1,4,7,2,5,8,"
|
|
742
|
-
|
|
743
|
-
test "sortChildren sorts 5000 random actors and updates hit order"
|
|
744
|
-
g := Group()
|
|
745
|
-
for i := range 5000
|
|
746
|
-
g.add(Rect.create(1.0, 1.0, Color.Red).at(Float(i), Float({i * 7919 + 13} % 5003)))
|
|
747
|
-
g.sortChildren(|a, b| a.y < b.y)
|
|
748
|
-
assert g.children.length() == 5000
|
|
749
|
-
for i := range 4999
|
|
750
|
-
assert g.children.at(i).y <= g.children.at(i + 1).y
|
|
751
|
-
assert same(g.children.at(10).parent.unwrap(), g)
|
|
752
|
-
|
|
753
|
-
test "sorting a nearly sorted list takes about n comparisons and reuses the scratch buffer"
|
|
754
|
-
g := Group()
|
|
755
|
-
for i := range 5000
|
|
756
|
-
g.add(Rect.create(1.0, 1.0, Color.Red).at(0.0, Float(i)))
|
|
757
|
-
c := [0].toSlice()
|
|
758
|
-
less: fn(Actor, Actor) -> Bool := |a, b|
|
|
759
|
-
_ := c.set(0, c.at(0) + 1)
|
|
760
|
-
a.y < b.y
|
|
761
|
-
g.sortChildren(less)
|
|
762
|
-
assert c.at(0) == 4999
|
|
763
|
-
g.children.at(2500).y = 2498.5
|
|
764
|
-
g.children.at(100).y = 105.5
|
|
765
|
-
_ := c.set(0, 0)
|
|
766
|
-
g.sortChildren(less)
|
|
767
|
-
assert c.at(0) < 10100
|
|
768
|
-
for i := range 4999
|
|
769
|
-
assert g.children.at(i).y <= g.children.at(i + 1).y
|
|
770
|
-
scratch := g.scratch
|
|
771
|
-
cap := scratch.cap
|
|
772
|
-
assert cap > 0
|
|
773
|
-
scratch.clear()
|
|
774
|
-
g.children.at(3000).y = 2990.5
|
|
775
|
-
g.sortChildren(less)
|
|
776
|
-
assert scratch.length() > 0
|
|
777
|
-
assert scratch.cap == cap
|
|
778
|
-
_ := c.set(0, 0)
|
|
779
|
-
g.children.clear()
|
|
780
|
-
for i := range 5000
|
|
781
|
-
g.add(Rect.create(1.0, 1.0, Color.Red).at(0.0, Float({i * 7919 + 13} % 5003)))
|
|
782
|
-
g.sortChildren(less)
|
|
783
|
-
assert c.at(0) < 130000
|
|
784
|
-
|
|
785
|
-
test "culling skips children outside the area in flatten and draw order"
|
|
786
|
-
g := Group()
|
|
787
|
-
inside := Rect.create(10.0, 10.0, Color.Red).at(20.0, 20.0)
|
|
788
|
-
outside := Rect.create(10.0, 10.0, Color.Red).at(500.0, 20.0)
|
|
789
|
-
g.add(inside)
|
|
790
|
-
g.add(outside)
|
|
791
|
-
outside.add(Rect.create(5.0, 5.0, Color.Red))
|
|
792
|
-
assert flatten(g).length() == 4
|
|
793
|
-
g.setCullingArea(0.0, 0.0, 100.0, 100.0)
|
|
794
|
-
list := flatten(g)
|
|
795
|
-
assert list.length() == 2
|
|
796
|
-
assert same(list.at(1).node, inside)
|
|
797
|
-
g.setCullingArea(0.0, 100.0, 100.0, 100.0)
|
|
798
|
-
assert flatten(g).length() == 1
|
|
799
|
-
g.clearCullingArea()
|
|
800
|
-
assert flatten(g).length() == 4
|
|
801
|
-
|
|
802
|
-
test "hit does not return culled children, and a scaled child near the edge is kept"
|
|
803
|
-
stage := Stage.create()
|
|
804
|
-
k := Rect.create(10.0, 10.0, Color.Red).at(105.0, 5.0)
|
|
805
|
-
stage.root.add(k)
|
|
806
|
-
stage.root.setCullingArea(0.0, 0.0, 100.0, 100.0)
|
|
807
|
-
assert stage.hit(107.0, 7.0).isNone()
|
|
808
|
-
k.scaleX = 4.0
|
|
809
|
-
k.originX = 10.0
|
|
810
|
-
assert same(stage.hit(90.0, 7.0).unwrap(), k)
|
|
811
|
-
assert flatten(stage.root).length() == 2
|
|
812
|
-
k.scaleX = 1.0
|
|
813
|
-
k.originX = 0.0
|
|
814
|
-
assert stage.hit(107.0, 7.0).isNone()
|
|
815
|
-
stage.root.clearCullingArea()
|
|
816
|
-
assert same(stage.hit(107.0, 7.0).unwrap(), k)
|
|
817
|
-
|
|
818
|
-
test "a child without a size is never culled"
|
|
819
|
-
g := Group()
|
|
820
|
-
slot := Group().at(900.0, 900.0)
|
|
821
|
-
g.add(slot)
|
|
822
|
-
slot.add(Rect.create(10.0, 10.0, Color.Red).at(-890.0, -890.0))
|
|
823
|
-
g.setCullingArea(0.0, 0.0, 100.0, 100.0)
|
|
824
|
-
assert flatten(g).length() == 3
|
|
825
|
-
|
|
826
|
-
test "input bubbles from the hit actor to its parents until consumed"
|
|
827
|
-
stage := Stage.create()
|
|
828
|
-
outer := Catcher(width: 100.0, height: 100.0, got: 0, lastX: 0.0, eat: true)
|
|
829
|
-
inner := Catcher(width: 10.0, height: 10.0, got: 0, lastX: 0.0, eat: false)
|
|
830
|
-
inner.x = 20.0
|
|
831
|
-
outer.add(inner)
|
|
832
|
-
stage.root.add(outer)
|
|
833
|
-
assert stage.input(PointerDown(x: 25.0, y: 5.0))
|
|
834
|
-
assert inner.got == 1
|
|
835
|
-
assert inner.lastX == 5.0
|
|
836
|
-
assert outer.got == 1
|
|
837
|
-
assert outer.lastX == 25.0
|
|
838
|
-
assert !stage.input(PointerDown(x: 500.0, y: 5.0))
|
|
839
|
-
|
|
840
|
-
test "a press gives the keyboard focus; keys bubble from it"
|
|
841
|
-
stage := Stage.create()
|
|
842
|
-
outer := Catcher(width: 100.0, height: 100.0, got: 0, lastX: 0.0, eat: false)
|
|
843
|
-
inner := Catcher(width: 10.0, height: 10.0, got: 0, lastX: 0.0, eat: false)
|
|
844
|
-
outer.add(inner)
|
|
845
|
-
stage.root.add(outer)
|
|
846
|
-
assert !stage.input(KeyDown(key: Key.KeyA))
|
|
847
|
-
assert inner.got == 0
|
|
848
|
-
_ := stage.input(PointerDown(x: 5.0, y: 5.0))
|
|
849
|
-
assert !stage.input(KeyDown(key: Key.KeyA))
|
|
850
|
-
assert inner.got == 2
|
|
851
|
-
assert outer.got == 2
|
std/game/pure/Actions.plum
DELETED
|
@@ -1,402 +0,0 @@
|
|
|
1
|
-
module std
|
|
2
|
-
|
|
3
|
-
import std/Slice
|
|
4
|
-
import std/Int
|
|
5
|
-
import std/Float
|
|
6
|
-
import std/List
|
|
7
|
-
import std/Option
|
|
8
|
-
|
|
9
|
-
# scene2d-style actions as plain data plus a step method. `Action.step`
|
|
10
|
-
# steps an action IN PLACE on its own record (a running action allocates nothing:
|
|
11
|
-
# the result is a struct, flattened into multi-value returns) and reports the new
|
|
12
|
-
# pose, the part of `dt` it did not use and whether it finished. `stepAll` drops
|
|
13
|
-
# finished actions by compacting the Slice in place. Sequences hand the spare time
|
|
14
|
-
# to the next step, so chained and looping animations do not drift. A `Forever`
|
|
15
|
-
# restarts a lap from a fresh copy of its template (one allocation per lap).
|
|
16
|
-
|
|
17
|
-
# The animated part of an actor's transform.
|
|
18
|
-
struct Pose(x: Float, y: Float, rotation: Float, scaleX: Float, scaleY: Float)
|
|
19
|
-
|
|
20
|
-
# One action. `elapsed` and `current` (the running lap of a `Forever`, None
|
|
21
|
-
# before it starts) are the mutable state; `kind` is the immutable description.
|
|
22
|
-
# A `Forever`'s `template` is the pristine action restarted each lap.
|
|
23
|
-
struct Action(mut elapsed: Float, mut current: Option[Action], kind: ActionKind) =
|
|
24
|
-
fun moveBy(dx: Float, dy: Float, secs: Float) -> Action =
|
|
25
|
-
Action(elapsed: 0.0, current: None, kind: MoveBy(dx: dx, dy: dy, duration: secs))
|
|
26
|
-
|
|
27
|
-
fun rotateBy(radians: Float, secs: Float) -> Action =
|
|
28
|
-
Action(elapsed: 0.0, current: None, kind: RotateBy(radians: radians, duration: secs))
|
|
29
|
-
|
|
30
|
-
fun delay(secs: Float) -> Action =
|
|
31
|
-
Action(elapsed: 0.0, current: None, kind: Delay(duration: secs))
|
|
32
|
-
|
|
33
|
-
fun sequence(steps: Slice[Action]) -> Action =
|
|
34
|
-
Action(elapsed: 0.0, current: None, kind: Sequence(steps: steps))
|
|
35
|
-
|
|
36
|
-
fun parallel(steps: Slice[Action]) -> Action =
|
|
37
|
-
Action(elapsed: 0.0, current: None, kind: Parallel(steps: steps))
|
|
38
|
-
|
|
39
|
-
fun forever(a: Action) -> Action =
|
|
40
|
-
Action(elapsed: 0.0, current: Some(a), kind: Forever(template: a.fresh()))
|
|
41
|
-
|
|
42
|
-
# An unstarted copy (elapsed 0, all steps present).
|
|
43
|
-
fun fresh(self) -> Action =
|
|
44
|
-
match self.kind
|
|
45
|
-
MoveBy(dx, dy, duration) => Action.moveBy(dx, dy, duration)
|
|
46
|
-
RotateBy(radians, duration) => Action.rotateBy(radians, duration)
|
|
47
|
-
Delay(duration) => Action.delay(duration)
|
|
48
|
-
Sequence(steps) =>
|
|
49
|
-
out := Slice[Action]()
|
|
50
|
-
for i := range steps.length()
|
|
51
|
-
out.push(steps.at(i).fresh())
|
|
52
|
-
Action.sequence(out)
|
|
53
|
-
Parallel(steps) =>
|
|
54
|
-
out := Slice[Action]()
|
|
55
|
-
for i := range steps.length()
|
|
56
|
-
out.push(steps.at(i).fresh())
|
|
57
|
-
Action.parallel(out)
|
|
58
|
-
Forever(template) => Action(elapsed: 0.0, current: Some(template.fresh()), kind: Forever(template: template))
|
|
59
|
-
|
|
60
|
-
# Steps this action by `dt` in place, from pose `p`.
|
|
61
|
-
fun step(self, p: Pose, dt: Float) -> Step =
|
|
62
|
-
a := self
|
|
63
|
-
match a.kind
|
|
64
|
-
MoveBy(dx, dy, duration) =>
|
|
65
|
-
k := Tick.of(duration, a.elapsed, dt)
|
|
66
|
-
a.elapsed = a.elapsed + k.used
|
|
67
|
-
q := Pose(x: p.x + dx * k.frac, y: p.y + dy * k.frac, rotation: p.rotation, scaleX: p.scaleX, scaleY: p.scaleY)
|
|
68
|
-
Step(pose: q, spare: if k.done then dt - k.used else 0.0, done: k.done)
|
|
69
|
-
RotateBy(radians, duration) =>
|
|
70
|
-
k := Tick.of(duration, a.elapsed, dt)
|
|
71
|
-
a.elapsed = a.elapsed + k.used
|
|
72
|
-
q := Pose(x: p.x, y: p.y, rotation: p.rotation + radians * k.frac, scaleX: p.scaleX, scaleY: p.scaleY)
|
|
73
|
-
Step(pose: q, spare: if k.done then dt - k.used else 0.0, done: k.done)
|
|
74
|
-
Delay(duration) =>
|
|
75
|
-
k := Tick.of(duration, a.elapsed, dt)
|
|
76
|
-
a.elapsed = a.elapsed + k.used
|
|
77
|
-
Step(pose: p, spare: if k.done then dt - k.used else 0.0, done: k.done)
|
|
78
|
-
Sequence(steps) =>
|
|
79
|
-
if steps.length() == 0
|
|
80
|
-
return Step(pose: p, spare: dt, done: true)
|
|
81
|
-
s := steps.at(0).step(p, dt)
|
|
82
|
-
if !s.done
|
|
83
|
-
return Step(pose: s.pose, spare: 0.0, done: false)
|
|
84
|
-
# The rest starts in this same frame with the time left over; the list
|
|
85
|
-
# is one shorter on every call, so this terminates.
|
|
86
|
-
steps.removeAt(0)
|
|
87
|
-
a.step(s.pose, s.spare)
|
|
88
|
-
Parallel(steps) => Action.stepAll(p, steps, dt)
|
|
89
|
-
Forever(template) =>
|
|
90
|
-
# Restarts at most once per step, with the spare time, so a zero-length
|
|
91
|
-
# template cannot spin forever inside one step.
|
|
92
|
-
if a.current.isNone()
|
|
93
|
-
a.current = Some(template.fresh())
|
|
94
|
-
lap := template
|
|
95
|
-
if Some(c) := a.current
|
|
96
|
-
lap = c
|
|
97
|
-
s := lap.step(p, dt)
|
|
98
|
-
if !s.done
|
|
99
|
-
return Step(pose: s.pose, spare: 0.0, done: false)
|
|
100
|
-
next := template.fresh()
|
|
101
|
-
r := next.step(s.pose, s.spare)
|
|
102
|
-
if r.done
|
|
103
|
-
a.current = Some(template.fresh())
|
|
104
|
-
else
|
|
105
|
-
a.current = Some(next)
|
|
106
|
-
Step(pose: r.pose, spare: 0.0, done: false)
|
|
107
|
-
|
|
108
|
-
# Steps every action side by side in place and drops the finished ones by
|
|
109
|
-
# compacting `acts`. `spare` is the smallest spare time among the finished
|
|
110
|
-
# ones, 0 while any action is left.
|
|
111
|
-
fun stepAll(p: Pose, acts: Slice[Action], dt: Float) -> Step =
|
|
112
|
-
pose := p
|
|
113
|
-
spare := dt
|
|
114
|
-
w := 0
|
|
115
|
-
for i := range acts.length()
|
|
116
|
-
s := acts.at(i).step(pose, dt)
|
|
117
|
-
pose = s.pose
|
|
118
|
-
if !s.done
|
|
119
|
-
if w != i
|
|
120
|
-
acts.putAt(w, acts.takeAt(i))
|
|
121
|
-
w = w + 1
|
|
122
|
-
else
|
|
123
|
-
spare = spare.min(s.spare)
|
|
124
|
-
while acts.length() > w
|
|
125
|
-
acts.removeAt(acts.length() - 1)
|
|
126
|
-
if w > 0
|
|
127
|
-
spare = 0.0
|
|
128
|
-
Step(pose: pose, spare: spare, done: w == 0)
|
|
129
|
-
|
|
130
|
-
enum ActionKind =
|
|
131
|
-
| MoveBy(dx: Float, dy: Float, duration: Float)
|
|
132
|
-
| RotateBy(radians: Float, duration: Float)
|
|
133
|
-
| Delay(duration: Float)
|
|
134
|
-
| Sequence(steps: Slice[Action])
|
|
135
|
-
| Parallel(steps: Slice[Action])
|
|
136
|
-
| Forever(template: Action)
|
|
137
|
-
|
|
138
|
-
# How much of a timed action one step covers. A zero-length action finishes at
|
|
139
|
-
# once and applies all of itself (no 0/0).
|
|
140
|
-
struct Tick(used: Float, frac: Float, done: Bool) =
|
|
141
|
-
# One step of `dt` on an action `duration` long that has run for `elapsed`.
|
|
142
|
-
fun of(duration: Float, elapsed: Float, dt: Float) -> Tick =
|
|
143
|
-
if duration <= 0.0
|
|
144
|
-
return Tick(used: 0.0, frac: 1.0, done: true)
|
|
145
|
-
used := dt.min(duration - elapsed)
|
|
146
|
-
Tick(used: used, frac: used / duration, done: elapsed + used >= duration)
|
|
147
|
-
|
|
148
|
-
# The result of one step: the pose, the unused part of dt (0 while running) and
|
|
149
|
-
# whether the action(s) finished. For `stepAll`, `done` means none are left.
|
|
150
|
-
struct Step(pose: Pose, spare: Float, done: Bool)
|
|
151
|
-
|
|
152
|
-
fun actNear(a: Float, b: Float) -> Bool =
|
|
153
|
-
{a - b}.abs() < 0.000001
|
|
154
|
-
|
|
155
|
-
fun actSeqLen(a: Action) -> Int =
|
|
156
|
-
match a.kind
|
|
157
|
-
Sequence(steps) => steps.length()
|
|
158
|
-
_ => 0 - 1
|
|
159
|
-
|
|
160
|
-
fun actElapsed(a: Action) -> Float =
|
|
161
|
-
match a.kind
|
|
162
|
-
MoveBy(_, _, _) => a.elapsed
|
|
163
|
-
_ => 0.0 - 1.0
|
|
164
|
-
|
|
165
|
-
fun actPose0() -> Pose =
|
|
166
|
-
Pose(x: 0.0, y: 0.0, rotation: 0.0, scaleX: 1.0, scaleY: 1.0)
|
|
167
|
-
|
|
168
|
-
# Golden values for actions, taken from the previous (pure, allocating) implementation
|
|
169
|
-
# (`git show e04834f:std/game/Actions.plum`) run on the same inputs: pose, spare
|
|
170
|
-
# time and done flag at fixed checkpoints, stepping a fresh action with dt 0.016 x N
|
|
171
|
-
# and some large dts that span several sub-actions.
|
|
172
|
-
struct GoldRun(a: Action, p: Pose, spare: Float, done: Bool)
|
|
173
|
-
|
|
174
|
-
fun goldRun(a: Action) -> GoldRun =
|
|
175
|
-
GoldRun(a: a, p: actPose0(), spare: 0.0, done: false)
|
|
176
|
-
|
|
177
|
-
fun goldAdv(r0: GoldRun, n: Int, dt: Float) -> GoldRun =
|
|
178
|
-
r := r0
|
|
179
|
-
for i := range n
|
|
180
|
-
if !r.done
|
|
181
|
-
s := r.a.step(r.p, dt)
|
|
182
|
-
r = GoldRun(..r, p: s.pose, spare: s.spare, done: s.done)
|
|
183
|
-
r
|
|
184
|
-
|
|
185
|
-
fun goldNear(a: Float, b: Float) -> Bool =
|
|
186
|
-
{a - b}.abs() < 0.00001
|
|
187
|
-
|
|
188
|
-
fun goldWant(r: GoldRun, x: Float, y: Float, rotation: Float, spare: Float, done: Bool) =
|
|
189
|
-
assert goldNear(r.p.x, x)
|
|
190
|
-
assert goldNear(r.p.y, y)
|
|
191
|
-
assert goldNear(r.p.rotation, rotation)
|
|
192
|
-
assert goldNear(r.spare, spare)
|
|
193
|
-
assert r.done == done
|
|
194
|
-
|
|
195
|
-
fun goldMove() =
|
|
196
|
-
r := goldRun(Action.moveBy(10.0, 5.0, 1.0))
|
|
197
|
-
r = goldAdv(r, 1, 0.016)
|
|
198
|
-
goldWant(r, 0.16, 0.08, 0.0, 0.0, false)
|
|
199
|
-
r = goldAdv(r, 29, 0.016)
|
|
200
|
-
goldWant(r, 4.8, 2.4, 0.0, 0.0, false)
|
|
201
|
-
r = goldAdv(r, 32, 0.016)
|
|
202
|
-
goldWant(r, 9.92, 4.96, 0.0, 0.0, false)
|
|
203
|
-
r = goldAdv(r, 1, 0.016)
|
|
204
|
-
goldWant(r, 10.0, 5.0, 0.0, 0.008, true)
|
|
205
|
-
|
|
206
|
-
fun goldRotate() =
|
|
207
|
-
r := goldRun(Action.rotateBy(2.0, 0.5))
|
|
208
|
-
r = goldAdv(r, 10, 0.016)
|
|
209
|
-
goldWant(r, 0.0, 0.0, 0.64, 0.0, false)
|
|
210
|
-
r = goldAdv(r, 21, 0.016)
|
|
211
|
-
goldWant(r, 0.0, 0.0, 1.984, 0.0, false)
|
|
212
|
-
r = goldAdv(r, 1, 0.016)
|
|
213
|
-
goldWant(r, 0.0, 0.0, 2.0, 0.012, true)
|
|
214
|
-
|
|
215
|
-
fun goldDelay() =
|
|
216
|
-
r := goldRun(Action.delay(0.1))
|
|
217
|
-
r = goldAdv(r, 6, 0.016)
|
|
218
|
-
goldWant(r, 0.0, 0.0, 0.0, 0.0, false)
|
|
219
|
-
r = goldAdv(r, 1, 0.016)
|
|
220
|
-
goldWant(r, 0.0, 0.0, 0.0, 0.012, true)
|
|
221
|
-
|
|
222
|
-
fun goldSequence() =
|
|
223
|
-
r := goldRun(Action.sequence([Action.moveBy(10.0, 0.0, 0.2), Action.delay(0.1), Action.rotateBy(1.0, 0.2)].toSlice()))
|
|
224
|
-
r = goldAdv(r, 12, 0.016)
|
|
225
|
-
goldWant(r, 9.6, 0.0, 0.0, 0.0, false)
|
|
226
|
-
r = goldAdv(r, 1, 0.016)
|
|
227
|
-
goldWant(r, 10.0, 0.0, 0.0, 0.0, false)
|
|
228
|
-
r = goldAdv(r, 6, 0.016)
|
|
229
|
-
goldWant(r, 10.0, 0.0, 0.02, 0.0, false)
|
|
230
|
-
r = goldAdv(r, 1, 0.016)
|
|
231
|
-
goldWant(r, 10.0, 0.0, 0.1, 0.0, false)
|
|
232
|
-
r = goldAdv(r, 5, 0.016)
|
|
233
|
-
goldWant(r, 10.0, 0.0, 0.5, 0.0, false)
|
|
234
|
-
r = goldAdv(r, 10, 0.016)
|
|
235
|
-
goldWant(r, 10.0, 0.0, 1.0, 0.012, true)
|
|
236
|
-
r = goldAdv(r, 5, 0.016)
|
|
237
|
-
goldWant(r, 10.0, 0.0, 1.0, 0.012, true)
|
|
238
|
-
|
|
239
|
-
fun goldParallel() =
|
|
240
|
-
r := goldRun(Action.parallel([Action.moveBy(10.0, 0.0, 0.3), Action.rotateBy(1.0, 0.1), Action.delay(0.2)].toSlice()))
|
|
241
|
-
r = goldAdv(r, 5, 0.016)
|
|
242
|
-
goldWant(r, 2.666667, 0.0, 0.8, 0.0, false)
|
|
243
|
-
r = goldAdv(r, 2, 0.016)
|
|
244
|
-
goldWant(r, 3.733333, 0.0, 1.0, 0.0, false)
|
|
245
|
-
r = goldAdv(r, 5, 0.016)
|
|
246
|
-
goldWant(r, 6.4, 0.0, 1.0, 0.0, false)
|
|
247
|
-
r = goldAdv(r, 7, 0.016)
|
|
248
|
-
goldWant(r, 10.0, 0.0, 1.0, 0.004, true)
|
|
249
|
-
r = goldAdv(r, 1, 0.016)
|
|
250
|
-
goldWant(r, 10.0, 0.0, 1.0, 0.004, true)
|
|
251
|
-
|
|
252
|
-
fun goldForever() =
|
|
253
|
-
r := goldRun(Action.forever(Action.sequence([Action.moveBy(1.0, 0.0, 0.1), Action.delay(0.05)].toSlice())))
|
|
254
|
-
r = goldAdv(r, 9, 0.016)
|
|
255
|
-
goldWant(r, 1.0, 0.0, 0.0, 0.0, false)
|
|
256
|
-
r = goldAdv(r, 91, 0.016)
|
|
257
|
-
goldWant(r, 11.0, 0.0, 0.0, 0.0, false)
|
|
258
|
-
r = goldAdv(r, 900, 0.016)
|
|
259
|
-
goldWant(r, 107.0, 0.0, 0.0, 0.0, false)
|
|
260
|
-
|
|
261
|
-
fun goldNested() =
|
|
262
|
-
inner := Action.parallel([Action.moveBy(4.0, 0.0, 0.1), Action.delay(0.2)].toSlice())
|
|
263
|
-
loop := Action.forever(Action.parallel([Action.rotateBy(1.0, 0.1), Action.moveBy(0.0, 1.0, 0.3)].toSlice()))
|
|
264
|
-
r := goldRun(Action.sequence([inner, loop].toSlice()))
|
|
265
|
-
r = goldAdv(r, 6, 0.016)
|
|
266
|
-
goldWant(r, 3.84, 0.0, 0.0, 0.0, false)
|
|
267
|
-
r = goldAdv(r, 7, 0.016)
|
|
268
|
-
goldWant(r, 4.0, 0.026667, 0.08, 0.0, false)
|
|
269
|
-
r = goldAdv(r, 20, 0.016)
|
|
270
|
-
goldWant(r, 4.0, 1.093333, 1.28, 0.0, false)
|
|
271
|
-
r = goldAdv(r, 200, 0.016)
|
|
272
|
-
goldWant(r, 4.0, 11.76, 12.0, 0.0, false)
|
|
273
|
-
|
|
274
|
-
fun goldLargeDt() =
|
|
275
|
-
r := goldRun(Action.sequence([Action.moveBy(1.0, 0.0, 0.1), Action.moveBy(0.0, 1.0, 0.1), Action.rotateBy(1.0, 0.1), Action.delay(0.1)].toSlice()))
|
|
276
|
-
r = goldAdv(r, 1, 0.25)
|
|
277
|
-
goldWant(r, 1.0, 1.0, 0.5, 0.0, false)
|
|
278
|
-
r = goldAdv(r, 1, 0.05)
|
|
279
|
-
goldWant(r, 1.0, 1.0, 1.0, 0.0, false)
|
|
280
|
-
r = goldAdv(r, 1, 0.5)
|
|
281
|
-
goldWant(r, 1.0, 1.0, 1.0, 0.4, true)
|
|
282
|
-
|
|
283
|
-
fun goldLargeDtParallel() =
|
|
284
|
-
r := goldRun(Action.parallel([Action.moveBy(2.0, 0.0, 0.1), Action.sequence([Action.delay(0.1), Action.rotateBy(1.0, 0.1)].toSlice()), Action.delay(0.3)].toSlice()))
|
|
285
|
-
r = goldAdv(r, 1, 0.15)
|
|
286
|
-
goldWant(r, 2.0, 0.0, 0.5, 0.0, false)
|
|
287
|
-
r = goldAdv(r, 1, 0.1)
|
|
288
|
-
goldWant(r, 2.0, 0.0, 1.0, 0.0, false)
|
|
289
|
-
r = goldAdv(r, 1, 0.4)
|
|
290
|
-
goldWant(r, 2.0, 0.0, 1.0, 0.35, true)
|
|
291
|
-
|
|
292
|
-
fun goldLargeDtForever() =
|
|
293
|
-
r := goldRun(Action.forever(Action.sequence([Action.moveBy(1.0, 0.0, 0.1), Action.rotateBy(1.0, 0.1)].toSlice())))
|
|
294
|
-
r = goldAdv(r, 1, 0.25)
|
|
295
|
-
goldWant(r, 1.5, 0.0, 1.0, 0.0, false)
|
|
296
|
-
r = goldAdv(r, 1, 0.5)
|
|
297
|
-
goldWant(r, 3.0, 0.0, 3.0, 0.0, false)
|
|
298
|
-
r = goldAdv(r, 1, 0.016)
|
|
299
|
-
goldWant(r, 3.16, 0.0, 3.0, 0.0, false)
|
|
300
|
-
|
|
301
|
-
test "stepAll moves partway and keeps the action"
|
|
302
|
-
acts := [Action.moveBy(10.0, 0.0, 1.0)].toSlice()
|
|
303
|
-
r := Action.stepAll(actPose0(), acts, 0.5)
|
|
304
|
-
assert actNear(r.pose.x, 5.0)
|
|
305
|
-
assert acts.length() == 1
|
|
306
|
-
assert actNear(r.spare, 0.0)
|
|
307
|
-
|
|
308
|
-
test "stepAll finishes every action and reports no spare"
|
|
309
|
-
acts := [Action.moveBy(10.0, 0.0, 1.0), Action.rotateBy(1.0, 0.5)].toSlice()
|
|
310
|
-
r := Action.stepAll(actPose0(), acts, 1.0)
|
|
311
|
-
assert actNear(r.pose.x, 10.0)
|
|
312
|
-
assert actNear(r.pose.rotation, 1.0)
|
|
313
|
-
assert acts.length() == 0
|
|
314
|
-
assert actNear(r.spare, 0.0)
|
|
315
|
-
|
|
316
|
-
test "stepAll with no actions is a no-op"
|
|
317
|
-
acts := Slice[Action]()
|
|
318
|
-
r := Action.stepAll(actPose0(), acts, 0.016)
|
|
319
|
-
assert acts.length() == 0
|
|
320
|
-
assert actNear(r.spare, 0.016)
|
|
321
|
-
assert actNear(r.pose.x, 0.0)
|
|
322
|
-
|
|
323
|
-
test "zero_length_move_applies_at_once"
|
|
324
|
-
r := Action.moveBy(10.0, 0.0, 0.0).step(actPose0(), 0.016)
|
|
325
|
-
assert actNear(r.pose.x, 10.0)
|
|
326
|
-
assert r.done
|
|
327
|
-
assert actNear(r.spare, 0.016)
|
|
328
|
-
|
|
329
|
-
test "forever_rotate_no_drift"
|
|
330
|
-
a := Action.forever(Action.rotateBy(1.0, 1.0))
|
|
331
|
-
p := actPose0()
|
|
332
|
-
for i := range 625
|
|
333
|
-
r := a.step(p, 0.016)
|
|
334
|
-
p = r.pose
|
|
335
|
-
assert !r.done
|
|
336
|
-
assert actNear(p.rotation, 10.0)
|
|
337
|
-
|
|
338
|
-
test "sequence_spills_spare_time"
|
|
339
|
-
a := Action.sequence([Action.moveBy(10.0, 0.0, 0.5), Action.moveBy(0.0, 10.0, 0.5)].toSlice())
|
|
340
|
-
r := a.step(actPose0(), 0.75)
|
|
341
|
-
assert actNear(r.pose.x, 10.0)
|
|
342
|
-
assert actNear(r.pose.y, 5.0)
|
|
343
|
-
assert actNear(r.spare, 0.0)
|
|
344
|
-
assert !r.done
|
|
345
|
-
assert actSeqLen(a) == 1
|
|
346
|
-
|
|
347
|
-
test "empty_sequence_finishes_with_full_spare"
|
|
348
|
-
r := Action.sequence(Slice[Action]()).step(actPose0(), 0.3)
|
|
349
|
-
assert r.done
|
|
350
|
-
assert actNear(r.spare, 0.3)
|
|
351
|
-
|
|
352
|
-
test "parallel_spare_is_min_of_finished"
|
|
353
|
-
r := Action.parallel([Action.delay(0.2), Action.delay(0.5)].toSlice()).step(actPose0(), 1.0)
|
|
354
|
-
assert r.done
|
|
355
|
-
assert actNear(r.spare, 0.5)
|
|
356
|
-
|
|
357
|
-
test "forever_zero_length_template_terminates"
|
|
358
|
-
r := Action.forever(Action.delay(0.0)).step(actPose0(), 1.0)
|
|
359
|
-
assert !r.done
|
|
360
|
-
|
|
361
|
-
test "forever of a nested sequence keeps looping"
|
|
362
|
-
a := Action.forever(Action.sequence([Action.moveBy(1.0, 0.0, 0.1), Action.delay(0.1)].toSlice()))
|
|
363
|
-
p := actPose0()
|
|
364
|
-
for i := range 100
|
|
365
|
-
r := a.step(p, 0.05)
|
|
366
|
-
p = r.pose
|
|
367
|
-
assert actNear(p.x, 25.0)
|
|
368
|
-
|
|
369
|
-
test "step_moves_the_action_in_place"
|
|
370
|
-
a := Action.moveBy(10.0, 0.0, 1.0)
|
|
371
|
-
r := a.step(actPose0(), 0.5)
|
|
372
|
-
assert actNear(r.pose.x, 5.0)
|
|
373
|
-
assert actElapsed(a) == 0.5
|
|
374
|
-
|
|
375
|
-
# Steady state: the held MoveBy and RotateBy are the very records stepped (their
|
|
376
|
-
# elapsed moves, no replacement lists or actions), and the slice keeps its length.
|
|
377
|
-
# The wasm of this path has no struct.new (checked with wasm-tools print).
|
|
378
|
-
test "step_all_no_alloc_steady_state"
|
|
379
|
-
m := Action.moveBy(100.0, 0.0, 100.0)
|
|
380
|
-
r := Action.rotateBy(1.0, 100.0)
|
|
381
|
-
acts := [m, r].toSlice()
|
|
382
|
-
p := actPose0()
|
|
383
|
-
for i := range 10
|
|
384
|
-
s := Action.stepAll(p, acts, 0.5)
|
|
385
|
-
p = s.pose
|
|
386
|
-
assert !s.done
|
|
387
|
-
assert acts.length() == 2
|
|
388
|
-
assert actNear(actElapsed(m), 5.0)
|
|
389
|
-
assert actNear(p.x, 5.0)
|
|
390
|
-
assert actNear(p.rotation, 0.05)
|
|
391
|
-
|
|
392
|
-
test "actions_golden_values"
|
|
393
|
-
goldMove()
|
|
394
|
-
goldRotate()
|
|
395
|
-
goldDelay()
|
|
396
|
-
goldSequence()
|
|
397
|
-
goldParallel()
|
|
398
|
-
goldForever()
|
|
399
|
-
goldNested()
|
|
400
|
-
goldLargeDt()
|
|
401
|
-
goldLargeDtParallel()
|
|
402
|
-
goldLargeDtForever()
|
std/game/pure/PureScene.plum
DELETED
|
@@ -1,689 +0,0 @@
|
|
|
1
|
-
module std
|
|
2
|
-
|
|
3
|
-
import std/Int
|
|
4
|
-
import std/Float
|
|
5
|
-
import std/Str
|
|
6
|
-
import std/Slice
|
|
7
|
-
import std/Option
|
|
8
|
-
import std/Math
|
|
9
|
-
import std/game/Vec2
|
|
10
|
-
import std/game/Color
|
|
11
|
-
import std/game/Affine
|
|
12
|
-
import std/game/Game
|
|
13
|
-
import std/game/pure/Actions
|
|
14
|
-
import std/game/Input
|
|
15
|
-
import std/List
|
|
16
|
-
|
|
17
|
-
# A pure scene graph: a node is a record (`Node`) holding a `Transform` and
|
|
18
|
-
# an `enum Kind`. Group, Sprite, Label and Button are built in; games add their
|
|
19
|
-
# own behaviour with `Custom(actor: Actor)`, a base struct their structs
|
|
20
|
-
# embed (like Rust's `Box<dyn Actor>`). A node is a unique value: `update`,
|
|
21
|
-
# `onInput`, `modify` and `route` take it, change it in place and hand it back;
|
|
22
|
-
# readers borrow. Only `drawTree` talks to the host, through `std/game/Game`.
|
|
23
|
-
# InputEvent is the shared `InputEvent` of std/game/Input; `route` delivers it along a hit
|
|
24
|
-
# path, child first, then each ancestor, until a node consumes it.
|
|
25
|
-
|
|
26
|
-
enum Touchable =
|
|
27
|
-
| Enabled
|
|
28
|
-
| Disabled
|
|
29
|
-
| ChildrenOnly
|
|
30
|
-
|
|
31
|
-
# The node's position, size, origin, scale and rotation.
|
|
32
|
-
struct Transform(x: Float, y: Float, width: Float, height: Float, originX: Float, originY: Float, scaleX: Float, scaleY: Float, rotation: Float) =
|
|
33
|
-
|
|
34
|
-
# The transform into the parent's coordinates: translate to the position,
|
|
35
|
-
# rotate and scale about the origin.
|
|
36
|
-
fun localMatrix(self) -> Affine =
|
|
37
|
-
cs := Math.cos(self.rotation)
|
|
38
|
-
sn := Math.sin(self.rotation)
|
|
39
|
-
ma := cs * self.scaleX
|
|
40
|
-
mb := sn * self.scaleX
|
|
41
|
-
mc := 0.0 - sn * self.scaleY
|
|
42
|
-
md := cs * self.scaleY
|
|
43
|
-
Affine(
|
|
44
|
-
a: ma,
|
|
45
|
-
b: mb,
|
|
46
|
-
c: mc,
|
|
47
|
-
d: md,
|
|
48
|
-
e: self.x + self.originX - {ma * self.originX + mc * self.originY},
|
|
49
|
-
f: self.y + self.originY - {mb * self.originX + md * self.originY}
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
# Whether `p`, in the node's own coordinates, is inside its box.
|
|
53
|
-
fun contains(self, p: Vec2) -> Bool =
|
|
54
|
-
p.x >= 0.0 && p.x <= self.width && p.y >= 0.0 && p.y <= self.height
|
|
55
|
-
|
|
56
|
-
struct Node(
|
|
57
|
-
Transform(x, y, width, height, originX, originY, scaleX, scaleY, rotation),
|
|
58
|
-
id: Int,
|
|
59
|
-
color: Color,
|
|
60
|
-
visible: Bool,
|
|
61
|
-
touchable: Touchable,
|
|
62
|
-
actions: Slice[Action],
|
|
63
|
-
kind: Kind
|
|
64
|
-
) =
|
|
65
|
-
|
|
66
|
-
# --- building ---
|
|
67
|
-
|
|
68
|
-
fun group(id: Int, x: Float, y: Float, w: Float, h: Float, children: Slice[Node]) -> Node =
|
|
69
|
-
Node.make(id, x, y, w, h, Enabled, Group(children: children))
|
|
70
|
-
|
|
71
|
-
fun sprite(id: Int, tex: Texture, x: Float, y: Float, w: Float, h: Float) -> Node =
|
|
72
|
-
Node.make(id, x, y, w, h, Enabled, Sprite(tex: tex, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0))
|
|
73
|
-
|
|
74
|
-
fun label(id: Int, text: Str, x: Float, y: Float, size: Float) -> Node =
|
|
75
|
-
Node.make(id, x, y, size * 0.6 * Float(text.length()), size, Disabled, Label(text: text, size: size))
|
|
76
|
-
|
|
77
|
-
fun button(id: Int, text: Str, upTex: Texture, downTex: Texture, x: Float, y: Float, w: Float, h: Float) -> Node =
|
|
78
|
-
Node.make(id, x, y, w, h, Enabled, Button(text: text, upTex: upTex, downTex: downTex, pressed: false, over: false))
|
|
79
|
-
|
|
80
|
-
fun custom(id: Int, x: Float, y: Float, w: Float, h: Float, actor: Actor) -> Node =
|
|
81
|
-
Node.make(id, x, y, w, h, Disabled, Custom(actor: actor))
|
|
82
|
-
|
|
83
|
-
fun make(id: Int, x: Float, y: Float, w: Float, h: Float, touchable: Touchable, kind: Kind) -> Node =
|
|
84
|
-
Node(
|
|
85
|
-
x: x,
|
|
86
|
-
y: y,
|
|
87
|
-
width: w,
|
|
88
|
-
height: h,
|
|
89
|
-
originX: 0.0,
|
|
90
|
-
originY: 0.0,
|
|
91
|
-
scaleX: 1.0,
|
|
92
|
-
scaleY: 1.0,
|
|
93
|
-
rotation: 0.0,
|
|
94
|
-
id: id,
|
|
95
|
-
color: Color.White,
|
|
96
|
-
visible: true,
|
|
97
|
-
touchable: touchable,
|
|
98
|
-
actions: Slice[Action](),
|
|
99
|
-
kind: kind
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
# --- reading ---
|
|
103
|
-
|
|
104
|
-
# The children of a group, or None for anything else.
|
|
105
|
-
fun children(self) -> Option[Slice[Node]] =
|
|
106
|
-
match self.kind
|
|
107
|
-
Group(children) => Some(children)
|
|
108
|
-
_ => None
|
|
109
|
-
|
|
110
|
-
# The actor of a Custom node, or None for anything else.
|
|
111
|
-
fun actor(self) -> Option[Actor] =
|
|
112
|
-
match self.kind
|
|
113
|
-
Custom(actor) => Some(actor)
|
|
114
|
-
_ => None
|
|
115
|
-
|
|
116
|
-
# Whether `p`, in the node's own coordinates, is on it.
|
|
117
|
-
fun hit(self, p: Vec2) -> Bool =
|
|
118
|
-
self.touchable == Enabled && self.contains(p)
|
|
119
|
-
|
|
120
|
-
# The path (child indexes from this node) to the topmost actor under `p`, which
|
|
121
|
-
# is in the coordinates of the parent; `parent` is that parent's stage matrix.
|
|
122
|
-
fun findHit(self, parent: Affine, p: Vec2) -> Option[Slice[Int]] =
|
|
123
|
-
if !self.visible || self.touchable == Disabled
|
|
124
|
-
return None
|
|
125
|
-
m := parent * self.localMatrix()
|
|
126
|
-
inv := m.invert()
|
|
127
|
-
if inv.isNone()
|
|
128
|
-
return None
|
|
129
|
-
lp := Vec2(x: 0.0, y: 0.0)
|
|
130
|
-
if Some(q) := inv
|
|
131
|
-
lp = q.apply(p)
|
|
132
|
-
match self.kind
|
|
133
|
-
Group(ks) =>
|
|
134
|
-
i := ks.length() - 1
|
|
135
|
-
while i >= 0
|
|
136
|
-
sub := ks.at(i).findHit(m, p)
|
|
137
|
-
if Some(s) := sub
|
|
138
|
-
return Some(Node.prepend(i, s))
|
|
139
|
-
i = i - 1
|
|
140
|
-
_ => 0
|
|
141
|
-
if self.hit(lp)
|
|
142
|
-
return Some(Slice[Int]())
|
|
143
|
-
None
|
|
144
|
-
|
|
145
|
-
# The id of the actor at `path` below this node, or -1 if the path leads nowhere.
|
|
146
|
-
fun idAt(self, path: Slice[Int], depth: Int) -> Int =
|
|
147
|
-
if depth >= path.length()
|
|
148
|
-
return self.id
|
|
149
|
-
match self.kind
|
|
150
|
-
Group(ks) =>
|
|
151
|
-
i := path.at(depth)
|
|
152
|
-
if i >= 0 && i < ks.length()
|
|
153
|
-
return ks.at(i).idAt(path, depth + 1)
|
|
154
|
-
_ => 0
|
|
155
|
-
-1
|
|
156
|
-
|
|
157
|
-
# The path to the first actor with `id`, depth first; None if none.
|
|
158
|
-
fun findPath(self, id: Int) -> Option[Slice[Int]] =
|
|
159
|
-
if self.id == id
|
|
160
|
-
return Some(Slice[Int]())
|
|
161
|
-
match self.kind
|
|
162
|
-
Group(ks) =>
|
|
163
|
-
for i := range ks.length()
|
|
164
|
-
sub := ks.at(i).findPath(id)
|
|
165
|
-
if Some(s) := sub
|
|
166
|
-
return Some(Node.prepend(i, s))
|
|
167
|
-
_ => 0
|
|
168
|
-
None
|
|
169
|
-
|
|
170
|
-
# `i` followed by the path `rest`.
|
|
171
|
-
fun prepend(i: Int, rest: Slice[Int]) -> Slice[Int] =
|
|
172
|
-
out := [i].toSlice()
|
|
173
|
-
for j := range rest.length()
|
|
174
|
-
out.push(rest.at(j))
|
|
175
|
-
out
|
|
176
|
-
|
|
177
|
-
# --- drawing ---
|
|
178
|
-
|
|
179
|
-
# Draws the node and its children given the parent's matrix and tint.
|
|
180
|
-
fun drawTree(self, canvas: Canvas, parent: Affine, tint: Color) =
|
|
181
|
-
if !self.visible
|
|
182
|
-
return
|
|
183
|
-
m := parent * self.localMatrix()
|
|
184
|
-
t := tint.times(self.color)
|
|
185
|
-
match self.kind
|
|
186
|
-
Group(children) =>
|
|
187
|
-
for i := range children.length()
|
|
188
|
-
children.at(i).drawTree(canvas, m, t)
|
|
189
|
-
Sprite(tex, srcX, srcY, srcW, srcH) => Node.drawSprite(canvas, m, t, self.width, self.height, tex, srcX, srcY, srcW, srcH)
|
|
190
|
-
Label(text, size) => Node.drawText(canvas, m, t, text, size)
|
|
191
|
-
Button(text, upTex, downTex, pressed, over) => Node.drawButton(canvas, self, m, t, text, upTex, downTex, pressed, over)
|
|
192
|
-
Custom(actor) => actor.draw(canvas, self, m, t)
|
|
193
|
-
|
|
194
|
-
fun drawSprite(canvas: Canvas, m: Affine, tint: Color, w: Float, h: Float, tex: Texture, srcX: Float, srcY: Float, srcW: Float, srcH: Float) =
|
|
195
|
-
sx := w * m.scaleX()
|
|
196
|
-
sy := h * m.scaleY()
|
|
197
|
-
c := m.apply(Vec2(x: w / 2.0, y: h / 2.0))
|
|
198
|
-
canvas.drawTextureEx(tex, srcX, srcY, srcW, srcH, c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, m.rotation(), false, tint)
|
|
199
|
-
|
|
200
|
-
fun drawText(canvas: Canvas, m: Affine, tint: Color, s: Str, size: Float) =
|
|
201
|
-
p := m.apply(Vec2(x: 0.0, y: 0.0))
|
|
202
|
-
canvas.drawText(s, p.x, p.y, size * m.scaleY(), tint)
|
|
203
|
-
|
|
204
|
-
fun drawButton(canvas: Canvas, n: Node, m: Affine, tint: Color, text: Str, upTex: Texture, downTex: Texture, pressed: Bool, over: Bool) =
|
|
205
|
-
t := upTex
|
|
206
|
-
if pressed && over
|
|
207
|
-
t = downTex
|
|
208
|
-
Node.drawSprite(canvas, m, tint, n.width, n.height, t, 0.0, 0.0, 0.0, 0.0)
|
|
209
|
-
size := n.height * 0.5
|
|
210
|
-
ox := {n.width - size * 0.6 * Float(text.length())} / 2.0
|
|
211
|
-
oy := {n.height - size} / 2.0
|
|
212
|
-
Node.drawText(canvas, m.mul(Affine(a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: ox, f: oy)), tint, text, size)
|
|
213
|
-
|
|
214
|
-
# --- updating ---
|
|
215
|
-
|
|
216
|
-
# A Custom node's actor acts on it; every other node is itself.
|
|
217
|
-
fun acted(self, dt: Float) -> Node =
|
|
218
|
-
if Some(actor) := self.actor()
|
|
219
|
-
return actor.act(self, dt)
|
|
220
|
-
self
|
|
221
|
-
|
|
222
|
-
# The node after `dt` seconds: a Custom actor's own `act`, then its actions on its pose,
|
|
223
|
-
# then every child. The tree is changed in place and handed back.
|
|
224
|
-
fun update(self, dt: Float) -> Node =
|
|
225
|
-
b := self.acted(dt)
|
|
226
|
-
r := Action.stepAll(Pose(x: b.x, y: b.y, rotation: b.rotation, scaleX: b.scaleX, scaleY: b.scaleY), b.actions, dt)
|
|
227
|
-
b = Node(..b, x: r.pose.x, y: r.pose.y, rotation: r.pose.rotation, scaleX: r.pose.scaleX, scaleY: r.pose.scaleY)
|
|
228
|
-
match b.kind
|
|
229
|
-
Group(ks) =>
|
|
230
|
-
_ := ks.mapInPlace(|k| k.update(dt))
|
|
231
|
-
_ => 0
|
|
232
|
-
b
|
|
233
|
-
|
|
234
|
-
# The tree where the first actor with `id` is replaced by `f` of it.
|
|
235
|
-
fun modify(self, id: Int, f: fn(Node) -> Node) -> Node =
|
|
236
|
-
path := self.findPath(id)
|
|
237
|
-
if Some(p) := path
|
|
238
|
-
return self.modifyAt(p, 0, f)
|
|
239
|
-
self
|
|
240
|
-
|
|
241
|
-
fun modifyAt(self, path: Slice[Int], depth: Int, f: fn(Node) -> Node) -> Node =
|
|
242
|
-
if depth >= path.length()
|
|
243
|
-
return f(self)
|
|
244
|
-
match self.kind
|
|
245
|
-
Group(ks) =>
|
|
246
|
-
i := path.at(depth)
|
|
247
|
-
if i >= 0 && i < ks.length()
|
|
248
|
-
c := ks.takeAt(i)
|
|
249
|
-
ks.putAt(i, c.modifyAt(path, depth + 1, f))
|
|
250
|
-
_ => 0
|
|
251
|
-
self
|
|
252
|
-
|
|
253
|
-
# --- input ---
|
|
254
|
-
|
|
255
|
-
# Handles `e` (in the node's own coordinates; `over` says the pointer is on the
|
|
256
|
-
# node) and returns the new node, the events it raised and whether it consumed `e`.
|
|
257
|
-
# A Button reacts to the pointer; a Custom node asks its actor's `onInput`.
|
|
258
|
-
fun onInput(self, e: InputEvent, over: Bool) -> Handled =
|
|
259
|
-
n := self
|
|
260
|
-
ev := Slice[UiEvent]()
|
|
261
|
-
match n.kind
|
|
262
|
-
Button(text, upTex, downTex, wasPressed, wasOver) =>
|
|
263
|
-
pressed := wasPressed
|
|
264
|
-
now := wasOver
|
|
265
|
-
match e
|
|
266
|
-
PointerDown(_, _) =>
|
|
267
|
-
pressed = true
|
|
268
|
-
now = over
|
|
269
|
-
ev.push(UiEvent(id: n.id, kind: Press))
|
|
270
|
-
PointerMove(_, _) =>
|
|
271
|
-
now = over
|
|
272
|
-
if over && !wasOver
|
|
273
|
-
ev.push(UiEvent(id: n.id, kind: Enter))
|
|
274
|
-
if !over && wasOver
|
|
275
|
-
ev.push(UiEvent(id: n.id, kind: Exit))
|
|
276
|
-
PointerUp(_, _) =>
|
|
277
|
-
now = over
|
|
278
|
-
if wasPressed
|
|
279
|
-
pressed = false
|
|
280
|
-
ev.push(UiEvent(id: n.id, kind: Release))
|
|
281
|
-
if over
|
|
282
|
-
ev.push(UiEvent(id: n.id, kind: Click))
|
|
283
|
-
_ => 0
|
|
284
|
-
n = Node(..n, kind: Button(text: text, upTex: upTex, downTex: downTex, pressed: pressed, over: now))
|
|
285
|
-
Custom(actor) =>
|
|
286
|
-
return actor.onInput(n, e, over)
|
|
287
|
-
_ => 0
|
|
288
|
-
Handled(node: n, events: ev, consumed: false)
|
|
289
|
-
|
|
290
|
-
# Delivers `e` (in the coordinates of the parent) along `path`: the child on the
|
|
291
|
-
# path first, then this node, unless a child consumed it. A path that runs out
|
|
292
|
-
# reaches the deepest actor it can.
|
|
293
|
-
fun route(self, path: Slice[Int], depth: Int, e: InputEvent, over: Bool) -> Handled =
|
|
294
|
-
events := Slice[UiEvent]()
|
|
295
|
-
self.routeInto(path, depth, e, over, events)
|
|
296
|
-
|
|
297
|
-
# `route`, appending the raised events to `events` (the returned Handled holds it).
|
|
298
|
-
fun routeInto(self, path: Slice[Int], depth: Int, e: InputEvent, over: Bool, events: Slice[UiEvent]) -> Handled =
|
|
299
|
-
local := e
|
|
300
|
-
if Some(q) := self.localMatrix().invert()
|
|
301
|
-
local = e.through(q)
|
|
302
|
-
consumed := false
|
|
303
|
-
if depth < path.length()
|
|
304
|
-
match self.kind
|
|
305
|
-
Group(ks) =>
|
|
306
|
-
i := path.at(depth)
|
|
307
|
-
if i >= 0 && i < ks.length()
|
|
308
|
-
c := ks.takeAt(i)
|
|
309
|
-
h := c.routeInto(path, depth + 1, local, over, events)
|
|
310
|
-
consumed = h.consumed
|
|
311
|
-
ks.putAt(i, h.intoNode())
|
|
312
|
-
_ => 0
|
|
313
|
-
if consumed
|
|
314
|
-
return Handled(node: self, events: events, consumed: true)
|
|
315
|
-
h := self.onInput(local, over)
|
|
316
|
-
for j := range h.events.length()
|
|
317
|
-
events.push(h.events.at(j))
|
|
318
|
-
used := h.consumed
|
|
319
|
-
Handled(node: h.intoNode(), events: events, consumed: used)
|
|
320
|
-
|
|
321
|
-
enum Kind =
|
|
322
|
-
| Group(children: Slice[Node])
|
|
323
|
-
| Sprite(tex: Texture, srcX: Float, srcY: Float, srcW: Float, srcH: Float)
|
|
324
|
-
| Label(text: Str, size: Float)
|
|
325
|
-
| Button(text: Str, upTex: Texture, downTex: Texture, pressed: Bool, over: Bool)
|
|
326
|
-
| Custom(actor: Actor)
|
|
327
|
-
|
|
328
|
-
# Game behaviour for a `Custom` node. The defaults do nothing.
|
|
329
|
-
struct Actor() =
|
|
330
|
-
|
|
331
|
-
# The node after `dt` seconds of this actor's behaviour; the default is itself.
|
|
332
|
-
fun act(self, n: Node, dt: Float) -> Node = n
|
|
333
|
-
|
|
334
|
-
# Paints the node at its stage transform `m` with the combined `tint`.
|
|
335
|
-
fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
336
|
-
return
|
|
337
|
-
|
|
338
|
-
# Handles `e` (in the node's own coordinates; `over` says the pointer is on the
|
|
339
|
-
# node). Return `consumed: true` to keep it from the node's ancestors. The
|
|
340
|
-
# default ignores it.
|
|
341
|
-
fun onInput(self, n: Node, e: InputEvent, over: Bool) -> Handled =
|
|
342
|
-
Handled(node: n, events: Slice[UiEvent](), consumed: false)
|
|
343
|
-
|
|
344
|
-
# The node after handling an input, the events it raised, and whether it consumed
|
|
345
|
-
# the input (its ancestors then do not see it).
|
|
346
|
-
struct Handled(node: Node, events: Slice[UiEvent], consumed: Bool) =
|
|
347
|
-
|
|
348
|
-
# The node, moved out; read `events` and `consumed` first, they stay behind.
|
|
349
|
-
fun intoNode(self) -> Node =
|
|
350
|
-
match self
|
|
351
|
-
Handled(n, _, _) => n
|
|
352
|
-
|
|
353
|
-
fun psKinds(h: Handled) -> Slice[UiKind] =
|
|
354
|
-
out := Slice[UiKind]()
|
|
355
|
-
for i := range h.events.length()
|
|
356
|
-
out.push(h.events.at(i).kind)
|
|
357
|
-
out
|
|
358
|
-
|
|
359
|
-
fun psIsPressed(n: Node) -> Bool =
|
|
360
|
-
match n.kind
|
|
361
|
-
Button(_, _, _, pressed, _) => pressed
|
|
362
|
-
_ => false
|
|
363
|
-
|
|
364
|
-
fun psClicks(h: Handled) -> Int =
|
|
365
|
-
n := 0
|
|
366
|
-
for i := range h.events.length()
|
|
367
|
-
if h.events.at(i).kind == Click
|
|
368
|
-
n = n + 1
|
|
369
|
-
n
|
|
370
|
-
|
|
371
|
-
fun psDown() -> InputEvent = PointerDown(x: 1.0, y: 1.0)
|
|
372
|
-
|
|
373
|
-
fun psUp() -> InputEvent = PointerUp(x: 1.0, y: 1.0)
|
|
374
|
-
|
|
375
|
-
fun psMove() -> InputEvent = PointerMove(x: 1.0, y: 1.0)
|
|
376
|
-
|
|
377
|
-
# A game actor: counts its draws and moves its node right by 1 on each act.
|
|
378
|
-
struct Beacon(Actor, mut draws: Int) =
|
|
379
|
-
override fun act(self, n: Node, dt: Float) -> Node =
|
|
380
|
-
Node(..n, x: n.x + 1.0)
|
|
381
|
-
override fun draw(self, canvas: Canvas, n: Node, m: Affine, tint: Color) =
|
|
382
|
-
self.draws = self.draws + 1
|
|
383
|
-
|
|
384
|
-
fun ttFixture() -> Node =
|
|
385
|
-
inner := [Node.sprite(4, Texture(id: 0), 0.0, 0.0, 5.0, 5.0)].toSlice()
|
|
386
|
-
kids := [Node.sprite(2, Texture(id: 0), 0.0, 0.0, 10.0, 10.0), Node.group(3, 20.0, 0.0, 10.0, 10.0, inner)].toSlice()
|
|
387
|
-
root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
|
|
388
|
-
me: Node := root
|
|
389
|
-
me
|
|
390
|
-
|
|
391
|
-
fun ttNoPath(r: Option[Slice[Int]]) -> Bool =
|
|
392
|
-
r.isNone()
|
|
393
|
-
|
|
394
|
-
fun ttPathEq(r: Option[Slice[Int]], want: Slice[Int]) -> Bool =
|
|
395
|
-
if Some(p) := r
|
|
396
|
-
if p.length() != want.length()
|
|
397
|
-
return false
|
|
398
|
-
for i := range p.length()
|
|
399
|
-
if p.at(i) != want.at(i)
|
|
400
|
-
return false
|
|
401
|
-
return true
|
|
402
|
-
false
|
|
403
|
-
|
|
404
|
-
fun ttInts(a: Int, b: Int) -> Slice[Int] =
|
|
405
|
-
out := [a, b].toSlice()
|
|
406
|
-
out
|
|
407
|
-
|
|
408
|
-
fun ttAt(x: Float, y: Float) -> Vec2 =
|
|
409
|
-
Vec2(x: x, y: y)
|
|
410
|
-
|
|
411
|
-
fun ttMovingTree() -> Node =
|
|
412
|
-
s := Node.sprite(2, Texture(id: 0), 0.0, 0.0, 10.0, 10.0)
|
|
413
|
-
s2 := Node(..s, actions: [Action.moveBy(10.0, 0.0, 1.0)].toSlice())
|
|
414
|
-
kids := [s2].toSlice()
|
|
415
|
-
root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
|
|
416
|
-
me: Node := root
|
|
417
|
-
me
|
|
418
|
-
|
|
419
|
-
fun ttFirstKidX(a: Node) -> Float =
|
|
420
|
-
match a.kind
|
|
421
|
-
Group(k) =>
|
|
422
|
-
return k.at(0).x
|
|
423
|
-
_ => 0
|
|
424
|
-
0.0 - 1.0
|
|
425
|
-
|
|
426
|
-
fun ttFirstKidActions(a: Node) -> Int =
|
|
427
|
-
match a.kind
|
|
428
|
-
Group(k) =>
|
|
429
|
-
return k.at(0).actions.length()
|
|
430
|
-
_ => 0
|
|
431
|
-
0 - 1
|
|
432
|
-
|
|
433
|
-
fun ttSetTouch(t: Node, id: Int, tc: Touchable) -> Node =
|
|
434
|
-
t.modify(id, |a| ttSetTouchOf(a, tc))
|
|
435
|
-
|
|
436
|
-
fun ttSetTouchOf(a: Node, tc: Touchable) -> Node =
|
|
437
|
-
Node(..a, touchable: tc)
|
|
438
|
-
|
|
439
|
-
fun ttSetScaleX(a: Node) -> Node =
|
|
440
|
-
Node(..a, scaleX: 0.0)
|
|
441
|
-
|
|
442
|
-
fun ttSetX9(a: Node) -> Node =
|
|
443
|
-
Node(..a, x: 9.0)
|
|
444
|
-
|
|
445
|
-
fun ttKidX(a: Node, i: Int) -> Float =
|
|
446
|
-
match a.kind
|
|
447
|
-
Group(k) =>
|
|
448
|
-
return k.at(i).x
|
|
449
|
-
_ => 0
|
|
450
|
-
0.0 - 1.0
|
|
451
|
-
|
|
452
|
-
fun ttButtonRoot() -> Node =
|
|
453
|
-
kids := [Node.button(5, "Go", Texture(id: 0), Texture(id: 1), 10.0, 10.0, 100.0, 40.0)].toSlice()
|
|
454
|
-
root := Node.group(1, 0.0, 0.0, 800.0, 600.0, kids)
|
|
455
|
-
me: Node := root
|
|
456
|
-
me
|
|
457
|
-
|
|
458
|
-
fun ttZero() -> Slice[Int] =
|
|
459
|
-
out := [0].toSlice()
|
|
460
|
-
out
|
|
461
|
-
|
|
462
|
-
# Five actions on one sprite: the 1st, 3rd and 5th finish in the first 0.1s step,
|
|
463
|
-
# the 2nd (MoveBy) and 4th (RotateBy) keep running.
|
|
464
|
-
fun ttCompactTree() -> Node =
|
|
465
|
-
s := Node.sprite(2, Texture(id: 0), 0.0, 0.0, 10.0, 10.0)
|
|
466
|
-
s2 := Node(..s, actions: [Action.moveBy(1.0, 0.0, 0.1), Action.moveBy(0.0, 10.0, 10.0), Action.rotateBy(1.0, 0.1), Action.rotateBy(2.0, 10.0), Action.delay(0.1)].toSlice())
|
|
467
|
-
kids := [s2].toSlice()
|
|
468
|
-
root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
|
|
469
|
-
me: Node := root
|
|
470
|
-
me
|
|
471
|
-
|
|
472
|
-
fun ttKid(a: Node) -> Node =
|
|
473
|
-
match a.kind
|
|
474
|
-
Group(k) =>
|
|
475
|
-
return k.at(0)
|
|
476
|
-
_ => 0
|
|
477
|
-
none: Node := Node.sprite(0, Texture(id: 0), 0.0, 0.0, 0.0, 0.0)
|
|
478
|
-
none
|
|
479
|
-
|
|
480
|
-
# 1 MoveBy, 2 RotateBy, 3 Delay, 0 anything else.
|
|
481
|
-
fun ttActKind(a: Action) -> Int =
|
|
482
|
-
match a.kind
|
|
483
|
-
MoveBy(_, _, _) => 1
|
|
484
|
-
RotateBy(_, _) => 2
|
|
485
|
-
Delay(_) => 3
|
|
486
|
-
_ => 0
|
|
487
|
-
|
|
488
|
-
# Adds 1.0 to its node's x on every act.
|
|
489
|
-
struct TtStepper(Actor, ticks: Int) =
|
|
490
|
-
override fun act(self, n: Node, dt: Float) -> Node =
|
|
491
|
-
Node(..n, x: n.x + 1.0)
|
|
492
|
-
|
|
493
|
-
test "local_matrix_translates"
|
|
494
|
-
s := Node.sprite(1, Texture(id: 0), 10.0, 0.0, 2.0, 2.0)
|
|
495
|
-
p := s.localMatrix().apply(Vec2(x: 0.0, y: 0.0))
|
|
496
|
-
assert {p.x - 10.0}.abs() < 1e-6
|
|
497
|
-
assert {p.y}.abs() < 1e-6
|
|
498
|
-
|
|
499
|
-
test "button_click_needs_press_and_over"
|
|
500
|
-
b := Node.button(7, "go", Texture(id: 1), Texture(id: 2), 0.0, 0.0, 10.0, 10.0)
|
|
501
|
-
h1 := b.onInput(psDown(), true)
|
|
502
|
-
ks := psKinds(h1)
|
|
503
|
-
n1 := h1.events.length()
|
|
504
|
-
id1 := h1.events.at(0).id
|
|
505
|
-
h2 := h1.intoNode().onInput(psUp(), true)
|
|
506
|
-
ks.push(h2.events.at(0).kind)
|
|
507
|
-
ks.push(h2.events.at(1).kind)
|
|
508
|
-
assert n1 == 1
|
|
509
|
-
assert h2.events.length() == 2
|
|
510
|
-
assert ks.at(0) == Press
|
|
511
|
-
assert ks.at(1) == Release
|
|
512
|
-
assert ks.at(2) == Click
|
|
513
|
-
assert id1 == 7
|
|
514
|
-
assert h2.events.at(1).id == 7
|
|
515
|
-
|
|
516
|
-
test "button_release_elsewhere_no_click"
|
|
517
|
-
b := Node.button(7, "go", Texture(id: 1), Texture(id: 2), 0.0, 0.0, 10.0, 10.0)
|
|
518
|
-
h1 := b.onInput(psDown(), true)
|
|
519
|
-
h2 := h1.intoNode().onInput(psUp(), false)
|
|
520
|
-
assert h2.events.length() == 1
|
|
521
|
-
assert h2.events.at(0).kind == Release
|
|
522
|
-
assert !psIsPressed(h2.intoNode())
|
|
523
|
-
|
|
524
|
-
test "button_drag_off_and_back_one_click"
|
|
525
|
-
b := Node.button(7, "go", Texture(id: 1), Texture(id: 2), 0.0, 0.0, 10.0, 10.0)
|
|
526
|
-
a1 := b.onInput(psDown(), true).intoNode()
|
|
527
|
-
h2 := a1.onInput(psMove(), false)
|
|
528
|
-
c2 := psClicks(h2)
|
|
529
|
-
h3 := h2.intoNode().onInput(psMove(), true)
|
|
530
|
-
c3 := psClicks(h3)
|
|
531
|
-
h4 := h3.intoNode().onInput(psUp(), true)
|
|
532
|
-
assert c2 + c3 + psClicks(h4) == 1
|
|
533
|
-
|
|
534
|
-
test "on_pointer_hands_back_changed_actor"
|
|
535
|
-
b := Node.button(7, "go", Texture(id: 1), Texture(id: 2), 0.0, 0.0, 10.0, 10.0)
|
|
536
|
-
h := b.onInput(psDown(), true)
|
|
537
|
-
assert psIsPressed(h.intoNode())
|
|
538
|
-
|
|
539
|
-
test "custom_actor_act_and_draw_dispatch"
|
|
540
|
-
beacon := Beacon(draws: 0)
|
|
541
|
-
n := Node.custom(7, 2.0, 0.0, 8.0, 8.0, beacon)
|
|
542
|
-
canvas := Canvas(io: io)
|
|
543
|
-
n.drawTree(canvas, Affine.Identity, Color.White)
|
|
544
|
-
assert beacon.draws == 1
|
|
545
|
-
moved := n.acted(0.016)
|
|
546
|
-
assert moved.x == 3.0
|
|
547
|
-
|
|
548
|
-
test "draw_tree_nested_groups_reaches_custom"
|
|
549
|
-
beacon := Beacon(draws: 0)
|
|
550
|
-
inner := Node.group(2, 0.0, 0.0, 10.0, 10.0, [Node.custom(3, 1.0, 1.0, 4.0, 4.0, beacon)].toSlice())
|
|
551
|
-
root := Node.group(1, 0.0, 0.0, 10.0, 10.0, [inner].toSlice())
|
|
552
|
-
canvas := Canvas(io: io)
|
|
553
|
-
root.drawTree(canvas, Affine.Identity, Color.White)
|
|
554
|
-
assert beacon.draws == 1
|
|
555
|
-
|
|
556
|
-
test "hit_box_and_children_of"
|
|
557
|
-
s := Node.sprite(1, Texture(id: 0), 0.0, 0.0, 10.0, 10.0)
|
|
558
|
-
assert s.hit(Vec2(x: 5.0, y: 5.0))
|
|
559
|
-
assert !s.hit(Vec2(x: 11.0, y: 5.0))
|
|
560
|
-
assert s.children().isNone()
|
|
561
|
-
assert Node.group(2, 0.0, 0.0, 1.0, 1.0, [s].toSlice()).children().isSome()
|
|
562
|
-
|
|
563
|
-
test "button_move_raises_enter_then_exit"
|
|
564
|
-
b := Node.button(7, "go", Texture(id: 1), Texture(id: 2), 0.0, 0.0, 10.0, 10.0)
|
|
565
|
-
h1 := b.onInput(psMove(), true)
|
|
566
|
-
assert h1.events.length() == 1
|
|
567
|
-
assert h1.events.at(0).kind == Enter
|
|
568
|
-
h2 := h1.intoNode().onInput(psMove(), false)
|
|
569
|
-
assert h2.events.length() == 1
|
|
570
|
-
assert h2.events.at(0).kind == Exit
|
|
571
|
-
assert !psIsPressed(h2.intoNode())
|
|
572
|
-
|
|
573
|
-
test "on_pointer_non_button_raises_nothing"
|
|
574
|
-
s := Node.sprite(1, Texture(id: 0), 0.0, 0.0, 10.0, 10.0)
|
|
575
|
-
h := s.onInput(psDown(), true)
|
|
576
|
-
assert h.events.length() == 0
|
|
577
|
-
assert h.intoNode().id == 1
|
|
578
|
-
|
|
579
|
-
test "draw_tree_skips_invisible_custom"
|
|
580
|
-
beacon := Beacon(draws: 0)
|
|
581
|
-
n := Node.custom(7, 0.0, 0.0, 8.0, 8.0, beacon)
|
|
582
|
-
n2 := Node(..n, visible: false)
|
|
583
|
-
canvas := Canvas(io: io)
|
|
584
|
-
n2.drawTree(canvas, Affine.Identity, Color.White)
|
|
585
|
-
assert beacon.draws == 0
|
|
586
|
-
|
|
587
|
-
test "find_hit_nested"
|
|
588
|
-
r := ttFixture().findHit(Affine.Identity, ttAt(21.0, 1.0))
|
|
589
|
-
assert ttPathEq(r, ttInts(1, 0))
|
|
590
|
-
|
|
591
|
-
test "find_hit_miss"
|
|
592
|
-
assert ttNoPath(ttFixture().findHit(Affine.Identity, ttAt(200.0, 200.0)))
|
|
593
|
-
|
|
594
|
-
test "id_at_paths"
|
|
595
|
-
t := ttFixture()
|
|
596
|
-
assert t.idAt(ttInts(1, 0), 0) == 4
|
|
597
|
-
five := [5].toSlice()
|
|
598
|
-
assert t.idAt(five, 0) == -1
|
|
599
|
-
assert t.idAt(Slice[Int](), 0) == 1
|
|
600
|
-
|
|
601
|
-
test "find_path_ids"
|
|
602
|
-
t := ttFixture()
|
|
603
|
-
assert ttPathEq(t.findPath(4), ttInts(1, 0))
|
|
604
|
-
assert ttNoPath(t.findPath(99))
|
|
605
|
-
|
|
606
|
-
test "update_runs_actions_and_children"
|
|
607
|
-
n := ttMovingTree().update(0.5)
|
|
608
|
-
assert {ttFirstKidX(n) - 5.0}.abs() < 1e-6
|
|
609
|
-
assert ttFirstKidActions(n) == 1
|
|
610
|
-
|
|
611
|
-
test "update_changes_tree_in_place"
|
|
612
|
-
t := ttMovingTree()
|
|
613
|
-
n := t.update(0.5)
|
|
614
|
-
assert ttFirstKidX(n) == 5.0
|
|
615
|
-
assert ttFirstKidActions(n) == 1
|
|
616
|
-
|
|
617
|
-
test "disabled_group_hides_subtree"
|
|
618
|
-
t := ttSetTouch(ttFixture(), 3, Disabled)
|
|
619
|
-
r := t.findHit(Affine.Identity, ttAt(21.0, 1.0))
|
|
620
|
-
assert ttPathEq(r, Slice[Int]())
|
|
621
|
-
|
|
622
|
-
test "children_only_group_not_hit_itself"
|
|
623
|
-
t := ttSetTouch(ttFixture(), 3, ChildrenOnly)
|
|
624
|
-
r := t.findHit(Affine.Identity, ttAt(28.0, 8.0))
|
|
625
|
-
assert ttPathEq(r, Slice[Int]())
|
|
626
|
-
|
|
627
|
-
test "scale_zero_not_hit"
|
|
628
|
-
t := ttFixture().modify(2, |a| ttSetScaleX(a))
|
|
629
|
-
r := t.findHit(Affine.Identity, ttAt(1.0, 1.0))
|
|
630
|
-
assert ttPathEq(r, Slice[Int]())
|
|
631
|
-
|
|
632
|
-
test "topmost_child_wins"
|
|
633
|
-
kids := [Node.sprite(2, Texture(id: 0), 0.0, 0.0, 10.0, 10.0), Node.sprite(3, Texture(id: 0), 0.0, 0.0, 10.0, 10.0)].toSlice()
|
|
634
|
-
root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
|
|
635
|
-
me: Node := root
|
|
636
|
-
r := me.findHit(Affine.Identity, ttAt(1.0, 1.0))
|
|
637
|
-
want := [1].toSlice()
|
|
638
|
-
assert ttPathEq(r, want)
|
|
639
|
-
|
|
640
|
-
test "modify_changes_first_match_only"
|
|
641
|
-
kids := [Node.sprite(7, Texture(id: 0), 0.0, 0.0, 10.0, 10.0), Node.sprite(7, Texture(id: 0), 0.0, 0.0, 10.0, 10.0)].toSlice()
|
|
642
|
-
root := Node.group(1, 0.0, 0.0, 100.0, 100.0, kids)
|
|
643
|
-
me: Node := root
|
|
644
|
-
n := me.modify(7, |a| ttSetX9(a))
|
|
645
|
-
assert ttKidX(n, 0) == 9.0
|
|
646
|
-
assert ttKidX(n, 1) == 0.0
|
|
647
|
-
|
|
648
|
-
test "route_child_first_then_group"
|
|
649
|
-
g := ttButtonRoot()
|
|
650
|
-
h1 := g.route(ttZero(), 0, PointerDown(x: 20.0, y: 20.0), true)
|
|
651
|
-
n1 := h1.events.length()
|
|
652
|
-
k1 := h1.events.at(0).kind
|
|
653
|
-
h2 := h1.intoNode().route(ttZero(), 0, PointerUp(x: 20.0, y: 20.0), true)
|
|
654
|
-
assert n1 == 1
|
|
655
|
-
assert k1 == Press
|
|
656
|
-
assert h2.events.length() == 2
|
|
657
|
-
assert h2.events.at(0).kind == Release
|
|
658
|
-
assert h2.events.at(1).kind == Click
|
|
659
|
-
assert h2.events.at(1).id == 5
|
|
660
|
-
|
|
661
|
-
test "stale_path_reaches_group_only"
|
|
662
|
-
nine := [9].toSlice()
|
|
663
|
-
h := ttButtonRoot().route(nine, 0, PointerDown(x: 20.0, y: 20.0), true)
|
|
664
|
-
assert h.events.length() == 0
|
|
665
|
-
|
|
666
|
-
test "compaction_keeps_order"
|
|
667
|
-
n := ttCompactTree().update(0.1)
|
|
668
|
-
acts := ttKid(n).actions
|
|
669
|
-
assert acts.length() == 2
|
|
670
|
-
assert ttActKind(acts.at(0)) == 1
|
|
671
|
-
assert ttActKind(acts.at(1)) == 2
|
|
672
|
-
assert {ttKid(n).x - 1.0}.abs() < 1e-6
|
|
673
|
-
assert {ttKid(n).y - 0.1}.abs() < 1e-6
|
|
674
|
-
assert {ttKid(n).rotation - 1.02}.abs() < 1e-6
|
|
675
|
-
# The next step advances the right survivors: MoveBy 0.1 -> 0.6, RotateBy 0.1 -> 0.6.
|
|
676
|
-
m := n.update(0.5)
|
|
677
|
-
acts2 := ttKid(m).actions
|
|
678
|
-
assert acts2.length() == 2
|
|
679
|
-
assert ttActKind(acts2.at(0)) == 1
|
|
680
|
-
assert ttActKind(acts2.at(1)) == 2
|
|
681
|
-
assert actElapsed(acts2.at(0)) == 0.6
|
|
682
|
-
assert {ttKid(m).y - 0.6}.abs() < 1e-6
|
|
683
|
-
assert {ttKid(m).rotation - 1.12}.abs() < 1e-6
|
|
684
|
-
assert {ttKid(m).x - 1.0}.abs() < 1e-6
|
|
685
|
-
|
|
686
|
-
test "update_runs_custom_actor_act"
|
|
687
|
-
c := Node.custom(9, 1.0, 0.0, 4.0, 4.0, TtStepper(ticks: 0))
|
|
688
|
-
n := Node.group(1, 0.0, 0.0, 10.0, 10.0, [c].toSlice()).update(0.016)
|
|
689
|
-
assert ttFirstKidX(n) == 2.0
|
std/game/pure/SceneInput.plum
DELETED
|
@@ -1,313 +0,0 @@
|
|
|
1
|
-
module std
|
|
2
|
-
|
|
3
|
-
import std/Int
|
|
4
|
-
import std/Float
|
|
5
|
-
import std/Str
|
|
6
|
-
import std/Slice
|
|
7
|
-
import std/game/Vec2
|
|
8
|
-
import std/game/Color
|
|
9
|
-
import std/game/Affine
|
|
10
|
-
import std/game/Game
|
|
11
|
-
import std/game/pure/Actions
|
|
12
|
-
import std/game/pure/PureScene
|
|
13
|
-
import std/game/Input
|
|
14
|
-
import std/List
|
|
15
|
-
import std/Option
|
|
16
|
-
|
|
17
|
-
# The root of a pure scene tree, its camera, pointer capture and keyboard focus. A
|
|
18
|
-
# Stage is an unique value: `input` and `frame` take it, change it in place and hand
|
|
19
|
-
# it back inside a StageHandled (`intoStage` moves it out). A press captures the
|
|
20
|
-
# actor it landed on: moves and the release go to that actor until the release,
|
|
21
|
-
# even when the pointer leaves it (scene2d's touch focus). The pressed actor also
|
|
22
|
-
# takes the keyboard focus: keys go to it first (then its ancestors), and keys no
|
|
23
|
-
# node consumes come back in `StageHandled.keys` for the game. The id stored with a
|
|
24
|
-
# path guards against the tree changing under it.
|
|
25
|
-
|
|
26
|
-
struct Capture(path: Slice[Int], id: Int) =
|
|
27
|
-
|
|
28
|
-
# Whether the captured path is `p` or leads into it (the pointer is over the actor).
|
|
29
|
-
fun within(self, p: Slice[Int]) -> Bool =
|
|
30
|
-
a := self.path
|
|
31
|
-
if a.length() > p.length()
|
|
32
|
-
return false
|
|
33
|
-
for i := range a.length()
|
|
34
|
-
if a.at(i) != p.at(i)
|
|
35
|
-
return false
|
|
36
|
-
true
|
|
37
|
-
|
|
38
|
-
# The stage after some inputs, the UI events raised and the keys no node consumed.
|
|
39
|
-
struct StageHandled(stage: Stage, events: Slice[UiEvent], keys: Slice[InputEvent]) =
|
|
40
|
-
|
|
41
|
-
# The stage, moved out; read `events` and `keys` first, they stay behind.
|
|
42
|
-
fun intoStage(self) -> Stage =
|
|
43
|
-
match self
|
|
44
|
-
StageHandled(s, _, _) => s
|
|
45
|
-
|
|
46
|
-
struct Stage(root: Node, camera: Affine, captured: Option[Capture], focus: Option[Int]) =
|
|
47
|
-
|
|
48
|
-
# A stage over `root` with no camera transform, nothing captured and no focus.
|
|
49
|
-
fun create(root: Node) -> Stage =
|
|
50
|
-
Stage(root: root, camera: Affine.Identity, captured: None, focus: None)
|
|
51
|
-
|
|
52
|
-
# Handles one input: routes it, returns the new stage and the events raised.
|
|
53
|
-
fun input(self, e: InputEvent) -> StageHandled =
|
|
54
|
-
if !e.isPointer()
|
|
55
|
-
return self.key(e)
|
|
56
|
-
none := Slice[UiEvent]()
|
|
57
|
-
inv := self.camera.invert()
|
|
58
|
-
if inv.isNone()
|
|
59
|
-
return StageHandled(stage: self, events: none, keys: Slice[InputEvent]())
|
|
60
|
-
q := inv.unwrap()
|
|
61
|
-
world := e.through(q)
|
|
62
|
-
at := world.position().unwrapOr(Vec2(x: 0.0, y: 0.0))
|
|
63
|
-
hit := self.root.findHit(Affine.Identity, at)
|
|
64
|
-
# A capture is valid only while its path still leads to its actor.
|
|
65
|
-
cap: Option[Capture] := None
|
|
66
|
-
if Some(c) := self.captured
|
|
67
|
-
if self.root.idAt(c.path, 0) == c.id
|
|
68
|
-
cap = Some(c)
|
|
69
|
-
match world
|
|
70
|
-
PointerDown(_, _) =>
|
|
71
|
-
if Some(p) := hit
|
|
72
|
-
id := self.root.idAt(p, 0)
|
|
73
|
-
return Stage(..self, focus: Some(id)).deliver(p, world, true, Some(Capture(path: p, id: id)))
|
|
74
|
-
return StageHandled(stage: Stage(..self, captured: None, focus: None), events: none, keys: Slice[InputEvent]())
|
|
75
|
-
_ => 0
|
|
76
|
-
if Some(c) := cap
|
|
77
|
-
over := false
|
|
78
|
-
if Some(p) := hit
|
|
79
|
-
over = c.within(p)
|
|
80
|
-
match world
|
|
81
|
-
PointerUp(_, _) =>
|
|
82
|
-
return self.deliver(c.path, world, over, None)
|
|
83
|
-
_ => 0
|
|
84
|
-
return self.deliver(c.path, world, over, Some(c))
|
|
85
|
-
# Hover without a press: route to whatever is under the pointer.
|
|
86
|
-
match world
|
|
87
|
-
PointerMove(_, _) =>
|
|
88
|
-
if Some(p) := hit
|
|
89
|
-
return self.deliver(p, world, true, None)
|
|
90
|
-
_ => 0
|
|
91
|
-
StageHandled(stage: self.withCapture(None), events: none, keys: Slice[InputEvent]())
|
|
92
|
-
|
|
93
|
-
# A key goes to the focused actor (then its ancestors); unconsumed, it is returned.
|
|
94
|
-
fun key(self, e: InputEvent) -> StageHandled =
|
|
95
|
-
left := [e].toSlice()
|
|
96
|
-
if Some(f) := self.focus
|
|
97
|
-
if Some(p) := self.root.findPath(f)
|
|
98
|
-
events := Slice[UiEvent]()
|
|
99
|
-
match self
|
|
100
|
-
Stage(root, camera, captured, focus) =>
|
|
101
|
-
h := root.routeInto(p, 0, e, true, events)
|
|
102
|
-
if h.consumed
|
|
103
|
-
left = Slice[InputEvent]()
|
|
104
|
-
return StageHandled(stage: Stage(root: h.intoNode(), camera: camera, captured: captured, focus: focus), events: events, keys: left)
|
|
105
|
-
StageHandled(stage: self, events: Slice[UiEvent](), keys: left)
|
|
106
|
-
|
|
107
|
-
fun withCapture(self, c: Option[Capture]) -> Stage =
|
|
108
|
-
Stage(..self, captured: c)
|
|
109
|
-
|
|
110
|
-
fun deliver(self, path: Slice[Int], e: InputEvent, over: Bool, c: Option[Capture]) -> StageHandled =
|
|
111
|
-
events := Slice[UiEvent]()
|
|
112
|
-
match self
|
|
113
|
-
Stage(root, camera, _, focus) =>
|
|
114
|
-
h := root.routeInto(path, 0, e, over, events)
|
|
115
|
-
StageHandled(stage: Stage(root: h.intoNode(), camera: camera, captured: c, focus: focus), events: events, keys: Slice[InputEvent]())
|
|
116
|
-
|
|
117
|
-
# Handles the inputs in order, then advances the tree by `dt`.
|
|
118
|
-
fun frame(self, dt: Float, inputs: Slice[InputEvent]) -> StageHandled =
|
|
119
|
-
cur := self
|
|
120
|
-
out := Slice[UiEvent]()
|
|
121
|
-
keys := Slice[InputEvent]()
|
|
122
|
-
for i := range inputs.length()
|
|
123
|
-
h := cur.input(inputs.at(i))
|
|
124
|
-
for j := range h.events.length()
|
|
125
|
-
out.push(h.events.at(j))
|
|
126
|
-
for j := range h.keys.length()
|
|
127
|
-
keys.push(h.keys.at(j))
|
|
128
|
-
cur = h.intoStage()
|
|
129
|
-
match cur
|
|
130
|
-
Stage(root, camera, captured, focus) => StageHandled(stage: Stage(root: root.update(dt), camera: camera, captured: captured, focus: focus), events: out, keys: keys)
|
|
131
|
-
|
|
132
|
-
# Paints the tree through the camera.
|
|
133
|
-
fun draw(self, canvas: Canvas) =
|
|
134
|
-
self.root.drawTree(canvas, self.camera, Color.White)
|
|
135
|
-
|
|
136
|
-
fun inBtnTree() -> Node =
|
|
137
|
-
b: Node := Node.button(5, "Go", Texture(id: 0), Texture(id: 1), 10.0, 10.0, 100.0, 40.0)
|
|
138
|
-
kids := [b].toSlice()
|
|
139
|
-
g: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, kids)
|
|
140
|
-
g
|
|
141
|
-
|
|
142
|
-
fun inBtnPressed(s: Stage) -> Bool =
|
|
143
|
-
if Some(k) := s.root.children()
|
|
144
|
-
return psIsPressed(k.at(0))
|
|
145
|
-
false
|
|
146
|
-
|
|
147
|
-
fun inBtnOver(s: Stage) -> Bool =
|
|
148
|
-
if Some(k) := s.root.children()
|
|
149
|
-
return inOverOf(k.at(0))
|
|
150
|
-
false
|
|
151
|
-
|
|
152
|
-
fun inOverOf(a: Node) -> Bool =
|
|
153
|
-
match a.kind
|
|
154
|
-
Button(_, _, _, _, over) => over
|
|
155
|
-
_ => false
|
|
156
|
-
|
|
157
|
-
fun inDown(x: Float, y: Float) -> InputEvent =
|
|
158
|
-
PointerDown(x: x, y: y)
|
|
159
|
-
|
|
160
|
-
fun inUp(x: Float, y: Float) -> InputEvent =
|
|
161
|
-
PointerUp(x: x, y: y)
|
|
162
|
-
|
|
163
|
-
fun inMove(x: Float, y: Float) -> InputEvent =
|
|
164
|
-
PointerMove(x: x, y: y)
|
|
165
|
-
|
|
166
|
-
fun inClicks(events: Slice[UiEvent]) -> Int =
|
|
167
|
-
n := 0
|
|
168
|
-
for i := range events.length()
|
|
169
|
-
if events.at(i).kind == Click
|
|
170
|
-
n = n + 1
|
|
171
|
-
n
|
|
172
|
-
|
|
173
|
-
fun inXformTree() -> Node =
|
|
174
|
-
b: Node := Node.button(5, "Go", Texture(id: 0), Texture(id: 1), 10.0, 10.0, 100.0, 40.0)
|
|
175
|
-
kids := [b].toSlice()
|
|
176
|
-
g := Node.group(1, 100.0, 0.0, 800.0, 600.0, kids)
|
|
177
|
-
g2 := Node(..g, rotation: 1.5707963267948966, scaleX: 2.0, scaleY: 2.0)
|
|
178
|
-
me: Node := g2
|
|
179
|
-
me
|
|
180
|
-
|
|
181
|
-
# A custom actor that moves its node right on its key and reports presses.
|
|
182
|
-
struct KeyMover(Actor, key: Int) =
|
|
183
|
-
override fun onInput(self, n: Node, e: InputEvent, over: Bool) -> Handled =
|
|
184
|
-
match e
|
|
185
|
-
KeyDown(k) =>
|
|
186
|
-
if k == self.key
|
|
187
|
-
return Handled(node: Node(..n, x: n.x + 1.0), events: Slice[UiEvent](), consumed: true)
|
|
188
|
-
PointerDown(_, _) =>
|
|
189
|
-
return Handled(node: n, events: [UiEvent(id: n.id, kind: Press)].toSlice(), consumed: true)
|
|
190
|
-
_ => 0
|
|
191
|
-
Handled(node: n, events: Slice[UiEvent](), consumed: false)
|
|
192
|
-
|
|
193
|
-
fun inMoverTree() -> Node =
|
|
194
|
-
m: Node := Node.custom(8, 10.0, 10.0, 50.0, 50.0, KeyMover(key: Key.KeyA))
|
|
195
|
-
m2 := Node(..m, touchable: Enabled)
|
|
196
|
-
g: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, [m2].toSlice())
|
|
197
|
-
g
|
|
198
|
-
|
|
199
|
-
fun inFirstX(s: Stage) -> Float =
|
|
200
|
-
if Some(k) := s.root.children()
|
|
201
|
-
return k.at(0).x
|
|
202
|
-
0.0 - 1.0
|
|
203
|
-
|
|
204
|
-
test "press_sets_capture"
|
|
205
|
-
h := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
|
|
206
|
-
assert h.events.length() == 1
|
|
207
|
-
assert h.events.at(0).kind == Press
|
|
208
|
-
assert h.events.at(0).id == 5
|
|
209
|
-
if Some(c) := h.stage.captured
|
|
210
|
-
assert c.id == 5
|
|
211
|
-
else
|
|
212
|
-
assert false
|
|
213
|
-
|
|
214
|
-
test "release_elsewhere_no_click"
|
|
215
|
-
h1 := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
|
|
216
|
-
h2 := h1.intoStage().input(inUp(500.0, 500.0))
|
|
217
|
-
assert h2.events.length() == 1
|
|
218
|
-
assert h2.events.at(0).kind == Release
|
|
219
|
-
assert h2.events.at(0).id == 5
|
|
220
|
-
assert h2.stage.captured.isNone()
|
|
221
|
-
assert !inBtnPressed(h2.stage)
|
|
222
|
-
|
|
223
|
-
test "drag_off_and_back_one_click"
|
|
224
|
-
h1 := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
|
|
225
|
-
c1 := inClicks(h1.events)
|
|
226
|
-
h2 := h1.intoStage().input(inMove(500.0, 500.0))
|
|
227
|
-
c2 := inClicks(h2.events)
|
|
228
|
-
h3 := h2.intoStage().input(inMove(20.0, 20.0))
|
|
229
|
-
c3 := inClicks(h3.events)
|
|
230
|
-
h4 := h3.intoStage().input(inUp(20.0, 20.0))
|
|
231
|
-
assert c1 + c2 + c3 + inClicks(h4.events) == 1
|
|
232
|
-
|
|
233
|
-
test "stale_capture_dropped"
|
|
234
|
-
h1 := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
|
|
235
|
-
empty: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, Slice[Node]())
|
|
236
|
-
swapped := Stage(root: empty, camera: h1.stage.camera, captured: h1.stage.captured, focus: None)
|
|
237
|
-
h2 := swapped.input(inUp(20.0, 20.0))
|
|
238
|
-
assert h2.events.length() == 0
|
|
239
|
-
assert h2.stage.captured.isNone()
|
|
240
|
-
|
|
241
|
-
test "hover_without_press_routes_move"
|
|
242
|
-
h := Stage.create(inBtnTree()).input(inMove(20.0, 20.0))
|
|
243
|
-
assert inBtnOver(h.stage)
|
|
244
|
-
assert h.stage.captured.isNone()
|
|
245
|
-
|
|
246
|
-
test "camera_maps_screen_to_world"
|
|
247
|
-
cam := Affine(a: 2.0, b: 0.0, c: 0.0, d: 2.0, e: 0.0, f: 0.0)
|
|
248
|
-
s := Stage(root: inBtnTree(), camera: cam, captured: None, focus: None)
|
|
249
|
-
h := s.input(inDown(40.0, 40.0))
|
|
250
|
-
assert h.events.length() == 1
|
|
251
|
-
assert h.events.at(0).kind == Press
|
|
252
|
-
assert h.events.at(0).id == 5
|
|
253
|
-
|
|
254
|
-
test "frame_handles_inputs_then_updates"
|
|
255
|
-
spr := Node.sprite(6, Texture(id: 0), 300.0, 300.0, 10.0, 10.0)
|
|
256
|
-
acts := [Action.moveBy(10.0, 0.0, 1.0)].toSlice()
|
|
257
|
-
spr2 := Node(..spr, actions: acts)
|
|
258
|
-
b: Node := Node.button(5, "Go", Texture(id: 0), Texture(id: 1), 10.0, 10.0, 100.0, 40.0)
|
|
259
|
-
sp: Node := spr2
|
|
260
|
-
kids := [b, sp].toSlice()
|
|
261
|
-
root: Node := Node.group(1, 0.0, 0.0, 800.0, 600.0, kids)
|
|
262
|
-
ins := [inDown(20.0, 20.0), inUp(20.0, 20.0)].toSlice()
|
|
263
|
-
h := Stage.create(root).frame(0.5, ins)
|
|
264
|
-
assert h.events.length() == 3
|
|
265
|
-
assert h.events.at(0).kind == Press
|
|
266
|
-
assert h.events.at(1).kind == Release
|
|
267
|
-
assert h.events.at(2).kind == Click
|
|
268
|
-
if Some(k) := h.stage.root.children()
|
|
269
|
-
assert {k.at(1).x - 305.0}.abs() < 0.000001
|
|
270
|
-
else
|
|
271
|
-
assert false
|
|
272
|
-
|
|
273
|
-
test "hit_and_route_agree_for_rotated_scaled_group"
|
|
274
|
-
# local (20,20) in the button's parent maps to world (100 - 2*20, 2*20) = (60,40)
|
|
275
|
-
t := inXformTree()
|
|
276
|
-
hit := t.findHit(Affine.Identity, Vec2(x: 60.0, y: 40.0))
|
|
277
|
-
assert hit.isSome()
|
|
278
|
-
h := Stage(root: t, camera: Affine.Identity, captured: None, focus: None).input(inDown(60.0, 40.0))
|
|
279
|
-
assert h.events.length() == 1
|
|
280
|
-
assert h.events.at(0).id == 5
|
|
281
|
-
miss := Stage(root: inXformTree(), camera: Affine.Identity, captured: None, focus: None).input(inDown(20.0, 20.0))
|
|
282
|
-
assert miss.events.length() == 0
|
|
283
|
-
|
|
284
|
-
test "input_changes_stage_in_place"
|
|
285
|
-
h := Stage.create(inBtnTree()).input(inDown(20.0, 20.0))
|
|
286
|
-
assert h.stage.captured.isSome()
|
|
287
|
-
assert inBtnPressed(h.stage)
|
|
288
|
-
|
|
289
|
-
test "custom_actor_gets_pointer_through_on_input"
|
|
290
|
-
h := Stage.create(inMoverTree()).input(inDown(20.0, 20.0))
|
|
291
|
-
assert h.events.length() == 1
|
|
292
|
-
assert h.events.at(0).id == 8
|
|
293
|
-
assert h.stage.focus.unwrapOr(-1) == 8
|
|
294
|
-
|
|
295
|
-
test "focused_actor_consumes_its_key"
|
|
296
|
-
h1 := Stage.create(inMoverTree()).input(inDown(20.0, 20.0))
|
|
297
|
-
h2 := h1.intoStage().input(KeyDown(key: Key.KeyA))
|
|
298
|
-
assert h2.keys.length() == 0
|
|
299
|
-
assert inFirstX(h2.stage) == 11.0
|
|
300
|
-
|
|
301
|
-
test "unconsumed_and_unfocused_keys_go_to_the_game"
|
|
302
|
-
h1 := Stage.create(inMoverTree()).input(KeyDown(key: Key.KeyA))
|
|
303
|
-
assert h1.keys.length() == 1
|
|
304
|
-
assert inFirstX(h1.stage) == 10.0
|
|
305
|
-
h2 := Stage.create(inMoverTree()).input(inDown(20.0, 20.0))
|
|
306
|
-
h3 := h2.intoStage().input(KeyDown(key: Key.KeyB))
|
|
307
|
-
assert h3.keys.length() == 1
|
|
308
|
-
|
|
309
|
-
test "frame_collects_unconsumed_keys"
|
|
310
|
-
ins := [inDown(20.0, 20.0), KeyDown(key: Key.KeyA), KeyDown(key: Key.Space)].toSlice()
|
|
311
|
-
h := Stage.create(inMoverTree()).frame(0.0, ins)
|
|
312
|
-
assert h.keys.length() == 1
|
|
313
|
-
assert inFirstX(h.stage) == 11.0
|