plum

#treesitter#compiler#wasm

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
Files changed (46) hide show
  1. compiler/cli/src/program.rs +4 -4
  2. compiler/cli/tests/cli/game_entry_test.rs +51 -2
  3. compiler/cli/tests/cli/game_web_test.rs +3 -3
  4. compiler/core/src/game_entry.rs +97 -0
  5. compiler/core/src/lib.rs +1 -0
  6. compiler/core/src/main_result.rs +1 -1
  7. compiler/game/src/host.rs +16 -6
  8. compiler/game/tests/fixtures/assets_fixture.plum +17 -19
  9. compiler/game/tests/fixtures/host_fixture.plum +13 -13
  10. compiler/game/tests/fixtures/main_game_fixture.plum +11 -12
  11. compiler/game/tests/fixtures/main_missing_fixture.plum +7 -8
  12. compiler/game/tests/fixtures/target_fixture.plum +15 -17
  13. compiler/game/tests/host_test.rs +12 -63
  14. compiler/game/web-test/README.md +3 -3
  15. compiler/game/web-test/fixtures/interleave.plum +11 -12
  16. compiler/game/web-test/fixtures/main_game.plum +9 -9
  17. compiler/game/web-test/fixtures/rendertarget.plum +34 -36
  18. compiler/game/web-test/fixtures/stress.plum +15 -16
  19. compiler/game/web-test/fixtures/stress_pure_scene.plum +0 -144
  20. compiler/game/web-test/fixtures/stress_scene.plum +0 -117
  21. compiler/game/web-test/fixtures/stress_state_view.plum +18 -18
  22. compiler/game/web-test/fixtures/stress_state_view_deep.plum +14 -15
  23. compiler/game/web-test/fixtures/stress_world.plum +66 -56
  24. compiler/game/web-test/fixtures/text_sizes.plum +20 -20
  25. compiler/game/web-test/tests/web_check.rs +6 -16
  26. compiler/game/web/plum_bridge.js +3 -3
  27. compiler/lsp/src/main.rs +2 -2
  28. compiler/wasm-codegen/src/lib.rs +13 -25
  29. compiler/wasm-codegen/tests/wasm_codegen/fusion_alloc_test.rs +1 -1
  30. docs/plans/2026-11-06-simpler-language.benchmark.md +4 -0
  31. examples/games/{imperative/game_loop.plum → game_loop.plum} +0 -0
  32. examples/games/imperative/game.plum +0 -435
  33. examples/games/imperative/game_scene.plum +0 -663
  34. examples/games/imperative/scene.plum +0 -433
  35. examples/games/{functional/kids → kids}/balloon_pop.plum +21 -23
  36. examples/games/{functional/game_state_view.plum → orb_run.plum} +45 -47
  37. examples/games/pure/game_pure_scene.plum +0 -880
  38. std/game/{functional/Anim.plum → Anim.plum} +0 -0
  39. std/game/Game.plum +54 -184
  40. std/game/Input.plum +46 -76
  41. std/game/{functional/FunctionalScene.plum → Scene.plum} +27 -26
  42. std/game/{functional/Ui.plum → Ui.plum} +2 -2
  43. std/game/imperative/Scene.plum +0 -851
  44. std/game/pure/Actions.plum +0 -402
  45. std/game/pure/PureScene.plum +0 -689
  46. 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 `Game` (or a subtype).
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 struct embedding `Game` with `override fun update(self, dt)` and `override fun draw(self)`, started by `fun main(io: Io) -> Game`", file.display()) });
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 `Game`; restart `plum run` to run it as a program", file.display()));
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 `Game`"));
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` returning a struct called `Game` of the user's own compiles to: not a game.
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 aUserStructNamedGameIsNotAGame() {
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 with `fun main(io: Io) -> Game`, with host calls recorded in a fixed order.
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 `fun main(io: Io) -> Game`.
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) -> Game"), "{cmd}: {err}");
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) -> Game`)"
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 `Assets` would crash),
12
+ # Loads through the raw `io` (a missing file answers -1 or empty, where `Texture.load`
13
- # and draws through the `Canvas`.
13
+ # would crash) and draws through it.
14
- struct State(Game(engine), io: Io, mut frames: Int) =
14
+ struct State(frames: Int) =
15
- override fun update(self, dt: Float) =
15
+ fun update(self, io: Io, dt: Float) -> State =
16
- self.frames = self.frames + 1
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
- override fun draw(self) =
19
+ fun draw(self, io: Io) =
20
- io := self.io
21
- c := self.canvas
22
20
  t := Texture(id: io.textureLoad("sprites.png"))
23
- c.drawText("size {c.textureWidth(t)}x{c.textureHeight(t)}", 0.0, 0.0, 8.0, Color.White)
21
+ io.drawText("size {io.textureWidth(t.id)}x{io.textureHeight(t.id)}", 0.0, 0.0, 8.0, Color.White)
24
- c.setSmooth(t, true)
22
+ t.setSmooth(io, true)
25
- c.drawTexture(t, 1.0, 2.0, Color.White)
23
+ t.draw(io, 1.0, 2.0, Color.White)
26
- c.drawTextureEx(t, 16.0, 0.0, 16.0, 16.0, 10.0, 20.0, 32.0, 48.0, 0.5, true, Color.Blue)
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
- c.drawText("bad {c.textureWidth(Texture(id: 99))} {io.textureLoad("missing.png")}", 0.0, 0.0, 8.0, Color.White)
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
- c.drawText("file [{io.fileLoad("hello.txt")}]", 0.0, 0.0, 8.0, Color.White)
27
+ io.drawText("file [{io.fileLoad("hello.txt")}]", 0.0, 0.0, 8.0, Color.White)
30
- c.drawText("missing [{io.fileLoad("nope.txt")}] {io.fileExists("nope.txt")}", 0.0, 0.0, 8.0, Color.White)
28
+ io.drawText("missing [{io.fileLoad("nope.txt")}] {io.fileExists("nope.txt")}", 0.0, 0.0, 8.0, Color.White)
31
- c.drawText("escape {io.textureLoad("../sprites.png")} {io.fileExists("../hello.txt")} [{io.fileLoad("../hello.txt")}]", 0.0, 0.0, 8.0, Color.White)
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
- c.drawText("abs {io.textureLoad("/etc/hosts")} {io.fileExists("/etc/hosts")}", 0.0, 0.0, 8.0, Color.White)
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) -> Game =
32
+ fun main(io: Io) -> State =
35
- State(engine: Engine.create(io), io: io, frames: 0)
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
- # Keeps `io` for the one raw host read (`mousePos`), so the recorded call order stays the same.
12
+ # One raw host read in `draw` (`mousePos`), so the recorded call order is fixed.
13
- struct State(Game(engine), io: Io, mut frames: Int) =
13
+ struct State(frames: Int) =
14
- override fun update(self, dt: Float) =
14
+ fun update(self, io: Io, dt: Float) -> State =
15
- self.frames = self.frames + 1
15
+ State(frames: self.frames + 1)
16
16
 
17
- override fun draw(self) =
17
+ fun draw(self, io: Io) =
18
- self.canvas.clear(Color.Black)
18
+ io.clear(Color.Black)
19
- self.canvas.drawRect(1.0, 2.0, 3.0, 4.0, Color.Red)
19
+ io.drawRect(1.0, 2.0, 3.0, 4.0, Color.Red)
20
- self.canvas.drawCircle(5.0, 6.0, 7.0, Color.Green)
20
+ io.drawCircle(5.0, 6.0, 7.0, Color.Green)
21
- p := self.io.mousePos()
21
+ p := io.mousePos()
22
- self.canvas.drawText("hi", p.x, p.y, 16.0, Color.White)
22
+ io.drawText("hi", p.x, p.y, 16.0, Color.White)
23
23
  if self.frames > 0
24
- self.canvas.drawText("frames {self.frames}", 0.0, 0.0, 8.0, Color.White)
24
+ io.drawText("frames {self.frames}", 0.0, 0.0, 8.0, Color.White)
25
25
 
26
- fun main(io: Io) -> Game =
26
+ fun main(io: Io) -> State =
27
- State(engine: Engine.create(io), io: io, frames: 0)
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 the subtype `Orbs`.
14
+ # A game whose entry is `main`, declared to return `Orbs`, which has `update` and `draw`.
15
- struct Orbs(Game(engine), mut x: Float, ship: Texture) =
15
+ struct Orbs(x: Float, ship: Texture, input: Input, frame: Int) =
16
- override fun update(self, dt: Float) =
16
+ fun update(self, io: Io, dt: Float) -> Orbs =
17
- if self.input.isKeyDown(Key.Right)
17
+ input := self.input.poll(io)
18
- self.x = self.x + 100.0 * dt
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
- override fun draw(self) =
21
+ fun draw(self, io: Io) =
21
- first := self.input.events().length()
22
+ first := self.input.events.length()
22
- second := self.input.events().length()
23
+ second := self.input.events.length()
23
- again := self.assets.texture("sprites.png")
24
- self.canvas.drawText("clock {self.clock.frame} {self.engine.clock.frame} ev {first} {second} x {self.x} tex {self.ship.id} {again.id} w {self.canvas.width()}", 0.0, 0.0, 8.0, Color.White)
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(engine: e, x: 0.0, ship: e.assets.texture("sprites.png"))
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(Game(engine)) =
10
+ struct Broken(tex: Texture) =
10
- override fun update(self, dt: Float) =
11
+ fun update(self, io: Io, dt: Float) -> Broken =
11
- return
12
+ self
12
13
 
13
- override fun draw(self) =
14
+ fun draw(self, io: Io) =
14
- return
15
+ io.clear(Color.Black)
15
16
 
16
17
  fun main(io: Io) -> Broken =
17
- e := Engine.create(io)
18
- _ := e.assets.texture("nope.png")
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
- # `Canvas.createTexture` would crash.
12
+ # `Texture.create` would crash.
13
- struct State(Game(engine), target: Texture, bad: Int) =
13
+ struct State(target: Texture, bad: Int) =
14
- override fun update(self, dt: Float) =
14
+ fun update(self, io: Io, dt: Float) -> State =
15
- return
15
+ self
16
16
 
17
- override fun draw(self) =
17
+ fun draw(self, io: Io) =
18
- c := self.canvas
19
- c.renderTarget(self.target)
18
+ self.target.renderTarget(io)
20
- c.clear(Color.Transparent)
19
+ io.clear(Color.Transparent)
21
- c.drawRect(0.0, 0.0, 10.0, 10.0, Color.Red)
20
+ io.drawRect(0.0, 0.0, 10.0, 10.0, Color.Red)
22
- c.screenTarget()
21
+ Texture.screen(io)
23
- c.blendMode(Blend.Add)
22
+ io.blendMode(Blend.Add)
24
- c.drawTexture(self.target, 20.0, 20.0, Color.White)
23
+ self.target.draw(io, 20.0, 20.0, Color.White)
25
- c.blendMode(Blend.Normal)
24
+ io.blendMode(Blend.Normal)
26
- c.drawText("size {c.textureWidth(self.target)}x{c.textureHeight(self.target)} bad {self.bad}", 0.0, 0.0, 8.0, Color.White)
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) -> Game =
27
+ fun main(io: Io) -> State =
29
- e := Engine.create(io)
30
- State(engine: e, target: e.canvas.createTexture(64, 32), bad: io.textureCreate(0, 5))
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) -> Game"), "{err}");
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(Game(engine), io: Io) =\n override fun update(self, dt: Float) =\n return\n override fun draw(self) =\n x := self.io.rawExists(\"a\")\n\nfun main(io: Io) -> Game =\n State(engine: Engine.create(io), io: io)\n",
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/imperative/game.plum"));
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 sceneExampleRunsFrames() {
273
+ fn balloonExampleRunsFrames() {
274
- let wasm = compile(&root().join("examples/games/imperative/scene.plum"));
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(Game(engine), seed: Int) =\n override fun update(self, dt: Float) =\n return\n override fun draw(self) =\n self.canvas.drawText(\"seed {self.seed}\", 0.0, 0.0, 8.0, Color.White)\n\nfun main(io: Io) -> Game =\n State(engine: Engine.create(io), seed: io.randomSeed())\n",
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 mainReturningGameSubtypeIsAGame() {
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 clockTicksOncePerFrameAndIsShared() {
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 tick again; Game.clock is Engine.clock.
368
+ // Drawing twice per frame does not advance the state; each update does.
420
- assert!(last.contains("clock 3 3 "), "{last}");
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 gameUpdateDispatchesAndAssetsCache() {
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 0"), "{last}");
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/imperative/game.plum --screenshot /tmp/orb-run.png
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/stress_scene.plum` are the scenes behind [BENCHMARK.md](BENCHMARK.md).
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/imperative/game.plum`, `examples/games/imperative/game_scene.plum` and `examples/games/imperative/scene.plum`, plus WebKit runs; each skips with a note if its browser is not installed.
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(Game(engine), tex: Array[Texture], mut t: Int) =
20
+ struct Interleave(tex: Array[Texture], t: Int) =
21
- override fun update(self, dt: Float) =
21
+ fun update(self, io: Io, dt: Float) -> Interleave =
22
- self.t = self.t + 1
22
+ Interleave(..self, t: self.t + 1)
23
23
 
24
- override fun draw(self) =
24
+ fun draw(self, io: Io) =
25
- drawSprites(self.canvas, self.tex, self.t)
25
+ drawSprites(io, self.tex, self.t)
26
26
 
27
- fun main(io: Io) -> Game =
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, e.assets.texture("tex{i}.png"))
30
+ tex.set(i, Texture.load(io, "tex{i}.png"))
32
- Interleave(engine: e, tex: tex, t: 0)
31
+ Interleave(tex: tex, t: 0)
33
32
 
34
- fun drawSprites(canvas: Canvas, tex: Array[Texture], t: Int) =
33
+ fun drawSprites(io: Io, tex: Array[Texture], t: Int) =
35
- canvas.clear(Color.rgb(0.05, 0.06, 0.1))
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
- canvas.drawTexture(tex.get(i % Scene.Textures), Float(x), Float(y), Color.White)
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 Game subtype; draws a square that moves with the clock.
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(Game(engine)) =
12
+ struct Mover(frame: Int) =
13
- override fun update(self, dt: Float) =
13
+ fun update(self, io: Io, dt: Float) -> Mover =
14
- return
14
+ Mover(frame: self.frame + 1)
15
15
 
16
- override fun draw(self) =
16
+ fun draw(self, io: Io) =
17
- self.canvas.clear(Color.rgb(0.2, 0.2, 0.2))
17
+ io.clear(Color.rgb(0.2, 0.2, 0.2))
18
- self.canvas.drawRect(Float(self.clock.frame % 600), 100.0, 40.0, 40.0, Color.Yellow)
18
+ io.drawRect(Float(self.frame % 600), 100.0, 40.0, 40.0, Color.Yellow)
19
- self.canvas.drawText("frame {self.clock.frame}", 20.0, 20.0, 16.0, Color.White)
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(engine: Engine.create(io))
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(Game(engine), big: Texture, small: Texture, glass: Texture, mut t: Int) =
22
+ struct Targets(big: Texture, small: Texture, glass: Texture, t: Int) =
23
- override fun update(self, dt: Float) =
23
+ fun update(self, io: Io, dt: Float) -> Targets =
24
- self.t = self.t + 1
24
+ Targets(..self, t: self.t + 1)
25
25
 
26
- override fun draw(self) =
26
+ fun draw(self, io: Io) =
27
- drawTargets(self.canvas, self)
27
+ drawTargets(io, self)
28
28
 
29
- fun main(io: Io) -> Game =
29
+ fun main(io: Io) -> Targets =
30
- e := Engine.create(io)
31
- c := e.canvas
32
- Targets(engine: e, big: c.createTexture(64, 64), small: c.createTexture(16, 16), glass: c.createTexture(16, 16), t: 0)
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(canvas: Canvas, g: Targets) =
32
+ fun drawTargets(io: Io, g: Targets) =
35
- canvas.setCamera(10.0, 0.0, 2.0)
33
+ io.setCamera(10.0, 0.0, 2.0)
36
- canvas.renderTarget(g.big)
34
+ g.big.renderTarget(io)
37
- canvas.clear(Color.Transparent)
35
+ io.clear(Color.Transparent)
38
- canvas.drawRect(0.0, 0.0, 32.0, 32.0, Color.rgb(1.0, 0.0, 0.0))
36
+ io.drawRect(0.0, 0.0, 32.0, 32.0, Color.rgb(1.0, 0.0, 0.0))
39
- canvas.drawRect(32.0, 32.0, 32.0, 32.0, Color.rgb(0.0, 1.0, 0.0))
37
+ io.drawRect(32.0, 32.0, 32.0, 32.0, Color.rgb(0.0, 1.0, 0.0))
40
- canvas.renderTarget(g.small)
38
+ g.small.renderTarget(io)
41
- canvas.clear(Color.rgb(0.0, 0.0, 1.0))
39
+ io.clear(Color.rgb(0.0, 0.0, 1.0))
42
- canvas.renderTarget(g.glass)
40
+ g.glass.renderTarget(io)
43
- canvas.clear(Color.Transparent)
41
+ io.clear(Color.Transparent)
44
- canvas.drawRect(0.0, 0.0, 16.0, 16.0, Color(r: 1.0, g: 0.0, b: 0.0, a: 0.5))
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
- canvas.screenTarget()
43
+ Texture.screen(io)
46
- canvas.clear(Color.rgb(0.2, 0.2, 0.2))
44
+ io.clear(Color.rgb(0.2, 0.2, 0.2))
47
- canvas.drawTexture(g.big, 60.0, 50.0, Color.White)
45
+ g.big.draw(io, 60.0, 50.0, Color.White)
48
- canvas.resetCamera()
46
+ io.resetCamera()
49
- canvas.blendMode(Blend.Add)
47
+ io.blendMode(Blend.Add)
50
- canvas.drawRect(400.0, 100.0, 100.0, 100.0, Color.rgb(0.2, 0.4, 0.6))
48
+ io.drawRect(400.0, 100.0, 100.0, 100.0, Color.rgb(0.2, 0.4, 0.6))
51
- canvas.blendMode(Blend.Multiply)
49
+ io.blendMode(Blend.Multiply)
52
- canvas.drawRect(550.0, 100.0, 100.0, 100.0, Color.rgb(0.5, 1.0, 0.5))
50
+ io.drawRect(550.0, 100.0, 100.0, 100.0, Color.rgb(0.5, 1.0, 0.5))
53
- canvas.blendMode(Blend.Screen)
51
+ io.blendMode(Blend.Screen)
54
- canvas.drawRect(700.0, 100.0, 100.0, 100.0, Color.rgb(0.5, 0.5, 0.5))
52
+ io.drawRect(700.0, 100.0, 100.0, 100.0, Color.rgb(0.5, 0.5, 0.5))
55
- canvas.blendMode(Blend.Normal)
53
+ io.blendMode(Blend.Normal)
56
- canvas.drawRect(850.0, 100.0, 100.0, 100.0, Color(r: 1.0, g: 0.0, b: 0.0, a: 0.5))
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
- canvas.drawTexture(g.small, 400.0, 300.0, Color.White)
55
+ g.small.draw(io, 400.0, 300.0, Color.White)
58
- canvas.drawTexture(g.glass, 400.0, 400.0, Color.White)
56
+ g.glass.draw(io, 400.0, 400.0, Color.White)
59
- canvas.drawText("frame {g.t}", 20.0, 500.0, 16.0, Color.White)
57
+ io.drawText("frame {g.t}", 20.0, 500.0, 16.0, Color.White)
60
- canvas.drawRect(Float(g.t % 600), 600.0, 40.0, 6.0, Color.Yellow)
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(Game(engine), sheet: Texture, mut t: Int) =
22
+ struct Stress(sheet: Texture, t: Int) =
23
- override fun update(self, dt: Float) =
23
+ fun update(self, io: Io, dt: Float) -> Stress =
24
- self.t = self.t + 1
24
+ Stress(..self, t: self.t + 1)
25
25
 
26
- override fun draw(self) =
26
+ fun draw(self, io: Io) =
27
- drawStress(self.canvas, self.sheet, self.t)
27
+ drawStress(io, self.sheet, self.t)
28
28
 
29
- fun main(io: Io) -> Game =
29
+ fun main(io: Io) -> Stress =
30
- e := Engine.create(io)
31
- Stress(engine: e, sheet: e.assets.texture("sprites.png"), t: 0)
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(canvas: Canvas, sheet: Texture, t: Int) =
35
+ fun drawStress(io: Io, sheet: Texture, t: Int) =
37
- canvas.clear(Color.rgb(0.05, 0.06, 0.1))
36
+ io.clear(Color.rgb(0.05, 0.06, 0.1))
38
- canvas.setCamera(0.0, 0.0, 0.8)
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
- canvas.drawTextureEx(sheet, 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)
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
- canvas.drawRect(Float(x), Float(y), 12.0, 8.0, hue(i))
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
- canvas.drawCircle(Float(x), Float(y), 6.0, hue(i + 3))
50
+ io.drawCircle(Float(x), Float(y), 6.0, hue(i + 3))
52
51
  for i := range Scene.Texts
53
- canvas.drawText("n {t + i}", Float(i * 30 % Scene.WorldW), Float(i * 17 % Scene.WorldH), 14.0, Color.White)
52
+ io.drawText("n {t + i}", Float(i * 30 % Scene.WorldW), Float(i * 17 % Scene.WorldH), 14.0, Color.White)
54
- canvas.resetCamera()
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/functional/FunctionalScene
25
+ import std/game/Scene
26
- import std/game/functional/Anim
26
+ import std/game/Anim
27
- import std/game/functional/Ui
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(canvas: Canvas) -> Texture =
201
+ fun whiteTexture(io: Io) -> Texture =
202
- t := canvas.createTexture(1, 1)
202
+ t := Texture.create(io, 1, 1)
203
- canvas.renderTarget(t)
203
+ t.renderTarget(io)
204
- canvas.clear(Color.White)
204
+ io.clear(Color.White)
205
- canvas.screenTarget()
205
+ Texture.screen(io)
206
206
  t
207
207
 
208
- struct World(Game(engine), mut state: State) =
208
+ struct World(state: State, input: Input) =
209
- override fun update(self, dt: Float) =
209
+ fun update(self, io: Io, dt: Float) -> World =
210
+ input := self.input.poll(io)
210
- inputs := self.input.events()
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
- self.state = s
215
+ World(state: s, input: input)
215
216
 
216
- override fun draw(self) =
217
+ fun draw(self, io: Io) =
217
- self.canvas.clear(Color.rgb(0.05, 0.06, 0.1))
218
+ io.clear(Color.rgb(0.05, 0.06, 0.1))
218
- view(self.state).draw(self.canvas)
219
+ view(self.state).draw(io)
219
220
 
220
- fun main(io: Io) -> Game =
221
+ fun main(io: Io) -> World =
221
- e := Engine.create(io)
222
- World(engine: e, state: newState(e.assets.texture("sprites.png"), whiteTexture(e.canvas)))
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/functional/FunctionalScene
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(canvas: Canvas) -> Texture =
165
+ fun whiteTexture(io: Io) -> Texture =
166
- t := canvas.createTexture(1, 1)
166
+ t := Texture.create(io, 1, 1)
167
- canvas.renderTarget(t)
167
+ t.renderTarget(io)
168
- canvas.clear(Color.White)
168
+ io.clear(Color.White)
169
- canvas.screenTarget()
169
+ Texture.screen(io)
170
170
  t
171
171
 
172
- struct World(Game(engine), mut state: State) =
172
+ struct World(state: State) =
173
- override fun update(self, dt: Float) =
173
+ fun update(self, io: Io, dt: Float) -> World =
174
- self.state = step(self.state, dt)
174
+ World(state: step(self.state, dt))
175
175
 
176
- override fun draw(self) =
176
+ fun draw(self, io: Io) =
177
- self.canvas.clear(Color.rgb(0.05, 0.06, 0.1))
177
+ io.clear(Color.rgb(0.05, 0.06, 0.1))
178
- view(self.state).draw(self.canvas)
178
+ view(self.state).draw(io)
179
179
 
180
- fun main(io: Io) -> Game =
180
+ fun main(io: Io) -> World =
181
- e := Engine.create(io)
182
- World(engine: e, state: newState(e.assets.texture("sprites.png"), whiteTexture(e.canvas)))
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 through the scene graph (std/game/imperative/Scene): 20000 sprites in one
1
+ # A large y-sorted world as state and view: 20000 sprites in one world layer, about 2000
2
- # world layer, about 2000 of them in the 1280x720 view. Every frame a few hundred
2
+ # of them in the 1280x720 view. Every frame a few hundred sprites walk, the whole list is
3
- # sprites walk, the layer is y-sorted with `sortChildren`, and (when `Culling` is
3
+ # sorted by y, and only the sprites in the view become scene nodes. The state is the
4
- # 1) the camera view is set as the layer's culling area so only the visible
4
+ # list, the frame number and the camera; `step` returns the next state and `view` builds
5
- # sprites are drawn. Set `Culling` to 0 to measure the same scene without culling.
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/imperative/Scene
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
- Culling = 1
28
+ struct Spr(x: Float, y: Float, srcX: Float, walker: Int)
26
-
29
+
27
- struct Demo(sheet: Texture, stage: Stage, layer: Group, mut frame: Int)
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) -> Vec2 =
35
+ fun spriteAt(i: Int) -> Spr =
30
- Vec2(x: Float({i * 7919 + 13} % World.Width), y: Float({i * i * 31 / 7 + i * 1013} % World.Height))
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 build(sheet: Texture) -> Demo =
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
- Demo(sheet: sheet, stage: stage, layer: layer, frame: 0)
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 layer is sorted and culled.
50
+ # One frame: the camera drifts, some sprites walk, the list is sorted by y.
42
- fun step(g: Demo) =
51
+ fun step(s: State) -> State =
43
- g.frame = g.frame + 1
52
+ frame := s.frame + 1
44
- t := Float(g.frame) * 0.01
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
- k := g.layer.children.at(i * 37 % World.Sprites)
54
+ moved := s.sprites.map(|k| walk(frame, k))
49
- k.y = k.y + Math.sin(Float(g.frame) * 0.05 + Float(i))
50
- g.layer.sortChildren(|a, b| a.y < b.y)
55
+ sorted := List.fromSlice(moved.toSlice().sort(|a, b| a.y < b.y))
51
- if World.Culling == 1
52
- g.layer.setCullingArea(g.stage.camX, g.stage.camY, World.ViewW, World.ViewH)
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
- # The game around the scene state, so the tests below need no engine.
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 WorldGame(Game(engine), demo: Demo) =
70
+ struct Demo(state: State) =
71
+ fun update(self, io: Io, dt: Float) -> Demo =
72
+ Demo(state: step(self.state))
73
+
56
- override fun update(self, dt: Float) =
74
+ fun draw(self, io: Io) =
57
- step(self.demo)
58
-
59
- override fun draw(self) =
60
- self.canvas.clear(Color.rgb(0.05, 0.06, 0.1))
75
+ io.clear(Color.rgb(0.05, 0.06, 0.1))
61
- self.demo.stage.draw(self.canvas)
76
+ view(self.state).draw(io)
62
-
77
+
63
- fun main(io: Io) -> Game =
78
+ fun main(io: Io) -> Demo =
64
- e := Engine.create(io)
65
- WorldGame(engine: e, demo: build(e.assets.texture("sprites.png")))
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
- g := build(Texture(id: -1))
84
+ s := step(newState(Texture(id: -1)))
71
- step(g)
72
- assert g.layer.children.length() == 20000
85
+ assert s.sprites.length() == 20000
86
+ all := s.sprites.toSlice()
73
87
  for i := range 19999
74
- assert g.layer.children.at(i).y <= g.layer.children.at(i + 1).y
88
+ assert all.at(i).y <= all.at(i + 1).y
75
- shown := flatten(g.stage.root).length() - 1
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(Game(engine), mut t: Int) =
13
+ struct Sizes(t: Int) =
14
- override fun update(self, dt: Float) =
14
+ fun update(self, io: Io, dt: Float) -> Sizes =
15
- self.t = self.t + 1
15
+ Sizes(t: self.t + 1)
16
16
 
17
- override fun draw(self) =
17
+ fun draw(self, io: Io) =
18
- drawSizes(self.canvas, self.t)
18
+ drawSizes(io, self.t)
19
19
 
20
- fun main(io: Io) -> Game =
20
+ fun main(io: Io) -> Sizes =
21
- Sizes(engine: Engine.create(io), t: 0)
21
+ Sizes(t: 0)
22
22
 
23
- fun drawSizes(canvas: Canvas, t: Int) =
23
+ fun drawSizes(io: Io, t: Int) =
24
- canvas.clear(Color.rgb(0.08, 0.09, 0.14))
24
+ io.clear(Color.rgb(0.08, 0.09, 0.14))
25
- canvas.drawText("Size 8: The quick brown fox jumps over the lazy dog 0123456789", 10.0, 6.0, 8.0, Color.White)
25
+ io.drawText("Size 8: The quick brown fox jumps over the lazy dog 0123456789", 10.0, 6.0, 8.0, Color.White)
26
- canvas.drawText("Size 16: The quick brown fox jumps over the lazy dog", 10.0, 24.0, 16.0, Color.White)
26
+ io.drawText("Size 16: The quick brown fox jumps over the lazy dog", 10.0, 24.0, 16.0, Color.White)
27
- canvas.drawText("Size 48: Quick brown fox", 10.0, 52.0, 48.0, Color.Yellow)
27
+ io.drawText("Size 48: Quick brown fox", 10.0, 52.0, 48.0, Color.Yellow)
28
- canvas.drawText("Size 128 Wg", 10.0, 112.0, 128.0, Color.rgb(0.5, 0.9, 1.0))
28
+ io.drawText("Size 128 Wg", 10.0, 112.0, 128.0, Color.rgb(0.5, 0.9, 1.0))
29
- canvas.drawText("Latin-1: café naïve über ½ ©", 10.0, 250.0, 24.0, Color.White)
29
+ io.drawText("Latin-1: café naïve über ½ ©", 10.0, 250.0, 24.0, Color.White)
30
- canvas.drawText("frame {t}", 700.0, 8.0, 16.0, Color.Green)
30
+ io.drawText("frame {t}", 700.0, 8.0, 16.0, Color.Green)
31
- canvas.drawRect(Float(t % 400), 300.0, 60.0, 4.0, Color.Red)
31
+ io.drawRect(Float(t % 400), 300.0, 60.0, 4.0, Color.Red)
32
- canvas.setCamera(0.0, 0.0, 3.0)
32
+ io.setCamera(0.0, 0.0, 3.0)
33
- canvas.drawText("Zoomed x3: size 16", 10.0, 112.0, 16.0, Color.White)
33
+ io.drawText("Zoomed x3: size 16", 10.0, 112.0, 16.0, Color.White)
34
- canvas.drawText("Zoomed x3: size 8", 10.0, 134.0, 8.0, Color.White)
34
+ io.drawText("Zoomed x3: size 8", 10.0, 134.0, 8.0, Color.White)
35
- canvas.resetCamera()
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/imperative/game.plum");
5
+ orb_run("examples/games/orb_run.plum");
6
6
  }
7
7
 
8
8
  #[test]
9
- fn orb_run_scene_renders_and_animates() {
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/pure/game_pure_scene.plum");
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/imperative/game.plum"), &opts)).unwrap_or_else(|e| panic!("{e}"));
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/imperative/game.plum"), &opts)).unwrap_or_else(|e| {
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/imperative/game.plum"), &opts)).unwrap_or_else(|e| panic!("{e}"));
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
- const state = ex.main();
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/imperative/game_loop.plum",
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
- ("Stage", "std/game/imperative/Scene"),
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 `Game` or a struct embedding it first: the module exports
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] = [("gameUpdate", "update"), ("gameDraw", "draw")];
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(source: &ast::Source) -> Vec<(String, String)> {
86
+ fn gameExports(game: bool) -> Vec<(String, String)> {
102
- if !isGame(source) {
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(&prog.source));
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: isGame(&prog.source) })
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(source), typed).map_err(codegenDiag)?;
114
+ let (wasm, fn_keys) = compileSourceInner(source, &gameExports(game), typed).map_err(codegenDiag)?;
127
- Ok(Output { wasm, fn_keys, test_names: Vec::new(), isGame: isGame(source) })
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/functional/game_state_view.plum"));
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/functional/kids/balloon_pop.plum`): a
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/functional/FunctionalScene
25
+ import std/game/Scene
26
- import std/game/functional/Anim
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(Game(engine), mut state: State, popSound: Sound, partySound: Sound, musicSound: Sound) =
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
- override fun update(self, dt: Float) =
314
+ fun update(self, io: Io, dt: Float) -> Balloons =
315
+ input := self.input.poll(io)
315
- s := self.state.update(Resize(w: self.canvas.width(), h: self.canvas.height()))
316
+ s := self.state.update(Resize(w: io.screenWidth(), h: io.screenHeight()))
316
317
  before := s
317
- inputs := self.input.events()
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.audio.play(self.popSound, 0.9)
322
+ self.popSound.play(io, 0.9)
322
323
  if s.partyAt != before.partyAt
323
- self.audio.play(self.partySound, 0.6)
324
+ self.partySound.play(io, 0.6)
324
- self.state = s.update(Tick(dt: dt))
325
+ Balloons(..self, state: s.update(Tick(dt: dt)), input: input)
325
326
 
326
- override fun draw(self) =
327
+ fun draw(self, io: Io) =
327
- s := self.state
328
- canvas := self.canvas
329
- canvas.clear(Color.rgb(0.55, 0.8, 1.0))
328
+ io.clear(Color.rgb(0.55, 0.8, 1.0))
330
- s.render().draw(canvas)
329
+ self.state.render().draw(io)
331
330
 
332
- fun main(io: Io) -> Game =
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: e.assets.sound("pickup.wav"),
335
+ popSound: Sound.load(io, "pickup.wav"),
338
- partySound: e.assets.sound("gameover.wav"),
336
+ partySound: Sound.load(io, "gameover.wav"),
339
- musicSound: e.assets.sound("music.wav")
337
+ musicSound: Sound.load(io, "music.wav")
340
338
  )
341
- e.audio.loop(game.musicSound, 0.2)
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/functional/game_state_view.plum`): the same
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
- # written in the component style of Elm and React but purely functional. Steer the ship
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
- # The `Game` struct below is the host shell: it holds the current `State`, turns the
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
- # frame's input into messages, and plays sounds.
24
+ # messages, and plays sounds.
25
- module game_state_view
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/functional/FunctionalScene
37
+ import std/game/Scene
38
- import std/game/functional/Anim
38
+ import std/game/Anim
39
- import std/game/functional/Ui
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(Game(engine), mut state: State, pickupSound: Sound, hitSound: Sound, overSound: Sound, musicSound: Sound) =
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.audio.stop(self.musicSound)
530
+ self.musicSound.stop(io)
531
- self.audio.loop(self.musicSound, musicVolume())
531
+ self.musicSound.loop(io, musicVolume())
532
-
532
+
533
- # Sends the frame's inputs to the state, then one tick steered by the held keys;
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
- override fun update(self, dt: Float) =
535
+ fun update(self, io: Io, dt: Float) -> OrbGame =
536
+ input := self.input.poll(io)
536
- s := self.state.update(Resize(w: self.canvas.width(), h: self.canvas.height()))
537
+ s := self.state.update(Resize(w: io.screenWidth(), h: io.screenHeight()))
537
538
  clicked := s
538
- inputs := self.input.events()
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
- return
545
+ else
545
- t := clicked.update(Tick(dt: dt, ax: clicked.axisX(), ay: clicked.axisY()))
546
+ t := clicked.update(Tick(dt: dt, ax: clicked.axisX(), ay: clicked.axisY()))
546
- if t.score > clicked.score
547
+ if t.score > clicked.score
547
- self.audio.play(self.pickupSound, 0.8)
548
+ self.pickupSound.play(io, 0.8)
548
- if t.lives < clicked.lives
549
+ if t.lives < clicked.lives
549
- if t.over
550
+ if t.over
550
- self.audio.stop(self.musicSound)
551
+ self.musicSound.stop(io)
551
- self.audio.play(self.overSound, 1.0)
552
+ self.overSound.play(io, 1.0)
552
- else
553
+ else
553
- self.audio.play(self.hitSound, 1.0)
554
+ self.hitSound.play(io, 1.0)
554
- self.state = t
555
+ OrbGame(..self, state: t, input: input)
555
-
556
+
556
- override fun draw(self) =
557
+ fun draw(self, io: Io) =
557
- s := self.state
558
- canvas := self.canvas
559
- canvas.clear(Color.rgb(0.04, 0.05, 0.1))
558
+ io.clear(Color.rgb(0.04, 0.05, 0.1))
560
- s.render().draw(canvas)
559
+ self.state.render().draw(io)
561
560
 
562
561
  fun musicVolume() -> Float =
563
562
  0.3
564
563
 
565
- fun main(io: Io) -> Game =
564
+ fun main(io: Io) -> OrbGame =
566
- e := Engine.create(io)
567
- s := State.start(io.randomSeed(), 0, e.assets.texture("sprites.png"), e.canvas.createTexture(1600, 1200), Rules.ArenaW, Rules.ArenaH)
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: e.assets.sound("pickup.wav"),
569
+ pickupSound: Sound.load(io, "pickup.wav"),
572
- hitSound: e.assets.sound("hit.wav"),
570
+ hitSound: Sound.load(io, "hit.wav"),
573
- overSound: e.assets.sound("gameover.wav"),
571
+ overSound: Sound.load(io, "gameover.wav"),
574
- musicSound: e.assets.sound("music.wav")
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
- # provided by the game host (`plum run`). A game is a struct embedding `Game`; the
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
- # parts below (`Canvas`, `Audio`, `Assets`, `Clock`, `Input`) are what it draws, plays,
13
+ # `draw(self, io: Io)`; the host calls `update` then `draw` every frame, and the state
16
- # loads and reads, so game code never needs `io`. Texture and sound handles are -1 on
14
+ # `update` returns is the next frame's `self`. This file holds the key codes and the
17
- # failure. File, texture and sound paths are relative to the asset root; a path
15
+ # `Texture` and `Sound` handles. A path that escapes the asset root fails like a
18
- # that escapes it fails like a missing file. Coordinates are world units once
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. Made by `Assets.texture` or `Canvas.createTexture`.
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
- # Files, textures and sounds by name, relative to the asset root. A texture or sound is
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 texture(self, name: Str) -> Texture =
86
+ fun load(io: Io, name: Str) -> Texture =
114
- if Some(id) := self.textures.lookup(name)
115
- return Texture(id: id)
116
- id := self.io.textureLoad(name)
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 createTexture(self, w: Int, h: Int) -> Texture =
94
+ fun create(io: Io, w: Int, h: Int) -> Texture =
188
- id := self.io.textureCreate(w, h)
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 play(self, s: Sound, volume: Float = 1.0) =
100
+ fun width(self, io: Io) -> Int = io.textureWidth(self.id)
208
- self.io.soundPlay(s.id, volume)
209
101
 
210
- fun loop(self, s: Sound, volume: Float = 1.0) =
102
+ fun height(self, io: Io) -> Int = io.textureHeight(self.id)
211
- self.io.soundLoop(s.id, volume)
212
103
 
213
- fun stop(self, s: Sound) =
104
+ # Linear (true) or nearest (false, the default) filtering.
214
- self.io.soundStop(s.id)
215
-
216
- fun volume(self, s: Sound, volume: Float) =
105
+ fun setSmooth(self, io: Io, smooth: Bool) =
217
- self.io.soundSetVolume(s.id, volume)
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
- # Everything a game reads and writes, made once from the host `Io`.
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
- struct Engine(input: Input, canvas: Canvas, audio: Audio, assets: Assets, clock: Clock) =
122
+ # Loads a sound relative to the asset root; crashes naming the file if it is missing.
232
- fun create(io: Io) -> Engine =
123
+ fun load(io: Io, name: Str) -> Sound =
124
+ id := io.soundLoad(name)
125
+ if id < 0
233
- Engine(input: Input.create(io), canvas: Canvas(io: io), audio: Audio(io: io), assets: Assets.create(io), clock: Clock.create())
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 beginFrame(self, dt: Float) =
129
+ fun play(self, io: Io, volume: Float = 1.0) =
237
- self.clock.tick(dt)
238
- self.input.beginFrame()
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
- self.input: Input = self.engine.input
132
+ fun loop(self, io: Io, volume: Float = 1.0) =
245
- self.canvas: Canvas = self.engine.canvas
246
- self.audio: Audio = self.engine.audio
133
+ io.soundLoop(self.id, volume)
247
- self.assets: Assets = self.engine.assets
248
- self.clock: Clock = self.engine.clock
249
134
 
250
- abstract fun update(self, dt: Float)
135
+ fun stop(self, io: Io) =
251
- abstract fun draw(self)
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 gameUpdate(g: Game, dt: Float) =
138
+ fun setVolume(self, io: Io, volume: Float) =
257
- g.engine.beginFrame(dt)
139
+ io.soundSetVolume(self.id, volume)
258
- g.update(dt)
259
140
 
141
+ fun missingAsset(kind: Str, name: Str) -> Str =
260
- fun gameDraw(g: Game) =
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
- test "assets remember a name once"
145
+ fun loadFile(io: Io, name: Str) -> Str =
264
- ids := NameIds.create()
146
+ if !io.fileExists(name)
265
- assert ids.lookup("a.png").isNone()
266
- ids.remember("a.png", 7)
147
+ crash missingAsset("file", name)
267
- assert ids.lookup("a.png").unwrapOr(-1) == 7
268
- assert ids.lookup("b.png").isNone()
269
- ids.remember("b.png", 8)
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, read once per frame. The host export calls `beginFrame` before
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
- # `update`; `events()`, `isKeyDown` and the pointer reads then agree for the whole
145
+ # so `events()`, `isKeyDown` and the pointer reads agree for the whole frame however
176
- # frame, however often they are called.
146
+ # often they are called. `poll` is the only host read.
177
- struct Input(io: Io, state: InputState) =
147
+ struct Input(held: Held, events: Slice[InputEvent], pos: Vec2, wheel: Float) =
178
- fun create(io: Io) -> Input =
148
+ fun create() -> Input =
179
- Input(io: io, state: InputState.create())
149
+ Input(held: Held.none(), events: Slice[InputEvent](), pos: Vec2(x: 0.0, y: 0.0), wheel: 0.0)
180
150
 
181
- # Polls the host once and stores the result for this frame.
151
+ # The input of this frame: the host polled once and compared with what was held before.
182
- fun beginFrame(self) =
152
+ fun poll(self, io: Io) -> Input =
183
- p := InputEvent.poll(self.io, self.state.held)
153
+ p := InputEvent.poll(io, self.held)
184
- self.state.accept(p, self.io.mousePos(), self.io.mouseWheel())
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.state.held.isDown(key)
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
- self.state.pressed(key)
163
+ if self.events.at(i).isKeyDownOf(key)
164
+ return true
165
+ false
197
166
 
198
- # A `Mouse` button; the left button is the frame's pointer state.
167
+ # The left mouse button, or a touch.
199
- fun isMouseDown(self, button: Int = Mouse.Left) -> Bool =
168
+ fun isMouseDown(self) -> Bool =
200
- if button == Mouse.Left
201
- return self.state.held.mouse
169
+ self.held.mouse
202
- self.io.isMouseDown(button)
203
170
 
204
- fun isMousePressed(self, button: Int = Mouse.Left) -> Bool =
171
+ fun isMousePressed(self) -> Bool =
205
- if button == Mouse.Left
206
- return self.state.pointerPressed()
172
+ self.pointerPressed()
207
- self.io.isMousePressed(button)
208
173
 
209
174
  fun mousePos(self) -> Vec2 =
210
- self.state.pos
175
+ self.pos
211
176
 
212
177
  fun mouseWheel(self) -> Float =
213
- self.state.wheel
178
+ self.wheel
214
179
 
215
180
  fun pointer(self) -> Pointer =
216
- Pointer(pos: self.state.pos, down: self.state.held.mouse, pressed: self.state.pointerPressed(), released: self.state.pointerReleased())
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 state agrees for the whole frame"
245
+ test "input agrees for the whole frame"
269
- st := InputState.create()
246
+ held := Held(mouse: true, keys: [Key.KeyA].toSlice())
270
- p := Polled(inputs: [PointerDown(x: 3.0, y: 4.0), KeyDown(key: Key.KeyA)].toSlice(), held: Held(mouse: true, keys: [Key.KeyA].toSlice()))
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.pressed(Key.KeyA)
250
+ assert st.isKeyPressed(Key.KeyA)
275
- assert !st.pressed(Key.KeyB)
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.held.mouse
255
+ assert st.isMouseDown()
279
- assert st.wheel == 1.5
256
+ assert st.mouseWheel() == 1.5
280
- assert st.pos.x == 3.0
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 !st.pointerPressed()
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 `textureCreate`; draw
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, canvas: Canvas) =
102
+ fun draw(self, io: Io) =
102
- self.drawWith(canvas, Affine.Identity, 1.0, Texture(id: 0 - 1))
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, canvas: Canvas, m: Affine, alpha: Float, target: Texture) = match 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
- canvas.drawRect(c.x - sx / 2.0, c.y - sy / 2.0, sx, sy, color.withAlpha(color.a * alpha))
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
- canvas.drawCircle(cc.x, cc.y, r * m.scaleX(), ccolor.withAlpha(ccolor.a * alpha))
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
- canvas.drawLine(p.x, p.y, q.x, q.y, th * m.scaleX(), lcolor.withAlpha(lcolor.a * alpha))
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
- canvas.drawTextureEx(tex, 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
+ 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
- canvas.drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
159
+ io.drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
159
- Group(tr, kids) => SceneNode.drawKids(canvas, kids, m.mul(tr.matrix()), alpha, target)
160
+ Group(tr, kids) => SceneNode.drawKids(io, kids, m.mul(tr.matrix()), alpha, target)
160
- Filter(effect, child, tex) => SceneNode.drawFiltered(canvas, effect, child, tex, m, alpha, target)
161
+ Filter(effect, child, tex) => SceneNode.drawFiltered(io, effect, child, tex, m, alpha, target)
161
- Clickable(_, _, child) => child.drawWith(canvas, m, alpha, target)
162
+ Clickable(_, _, child) => child.drawWith(io, m, alpha, target)
162
163
 
163
- fun drawKids(canvas: Canvas, kids: List[SceneNode[M]], m: Affine, alpha: Float, target: Texture) = match kids
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(canvas, m, alpha, target)
167
+ head.drawWith(io, m, alpha, target)
167
- SceneNode.drawKids(canvas, tail, m, alpha, target)
168
+ SceneNode.drawKids(io, tail, m, alpha, target)
168
169
 
169
- fun drawFiltered(canvas: Canvas, effect: Effect, child: SceneNode[M], tex: Texture, m: Affine, alpha: Float, target: Texture) = match effect
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
- canvas.blendMode(mode)
172
+ io.blendMode(mode)
172
- child.drawWith(canvas, m, alpha, target)
173
+ child.drawWith(io, m, alpha, target)
173
- canvas.blendMode(Blend.Normal)
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(canvas.textureWidth(tex))
178
+ w := {Int(b.w) + 1}.min(io.textureWidth(tex.id))
178
- h := {Int(b.h) + 1}.min(canvas.textureHeight(tex))
179
+ h := {Int(b.h) + 1}.min(io.textureHeight(tex.id))
179
- canvas.renderTarget(tex)
180
+ io.renderTarget(tex.id)
180
- canvas.clear(Color.Transparent)
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(canvas, shift.mul(m), 1.0, tex)
183
+ child.drawWith(io, shift.mul(m), 1.0, tex)
183
- canvas.renderTarget(target)
184
+ io.renderTarget(target.id)
184
- canvas.drawTextureEx(tex, 0.0, 0.0, Float(w), Float(h), b.x, b.y, Float(w), Float(h), 0.0, false, effect.tint(alpha))
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/functional/FunctionalScene
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 PureScene: Down captures the pressed id; while captured only
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