@pi-in-go/pigpen-angry-pigs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CREDITS.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -0
  4. package/extensions/angrypigs/agentplay_test.go +147 -0
  5. package/extensions/angrypigs/art.go +164 -0
  6. package/extensions/angrypigs/component.go +254 -0
  7. package/extensions/angrypigs/export_test.go +19 -0
  8. package/extensions/angrypigs/extension.go +69 -0
  9. package/extensions/angrypigs/fakeclassifier_test.go +40 -0
  10. package/extensions/angrypigs/fakehost_test.go +548 -0
  11. package/extensions/angrypigs/game.go +632 -0
  12. package/extensions/angrypigs/game_test.go +750 -0
  13. package/extensions/angrypigs/go.mod +9 -0
  14. package/extensions/angrypigs/go.sum +2 -0
  15. package/extensions/angrypigs/go.work +10 -0
  16. package/extensions/angrypigs/host_test.go +206 -0
  17. package/extensions/angrypigs/mcp.go +273 -0
  18. package/extensions/angrypigs/mcp_host_test.go +449 -0
  19. package/extensions/angrypigs/mcp_test.go +419 -0
  20. package/extensions/angrypigs/particles.go +115 -0
  21. package/extensions/angrypigs/render.go +463 -0
  22. package/extensions/angrypigs/state.go +84 -0
  23. package/extensions/angrypigs/values_test.go +55 -0
  24. package/libs/pig-play/CREDITS.md +34 -0
  25. package/libs/pig-play/LICENSE +21 -0
  26. package/libs/pig-play/README.md +86 -0
  27. package/libs/pig-play/assets/pig/website-art.txt +29 -0
  28. package/libs/pig-play/go.mod +6 -0
  29. package/libs/pig-play/go.sum +2 -0
  30. package/libs/pig-play/libraries/arcade/arcade.go +185 -0
  31. package/libs/pig-play/libraries/arcade/arcade_test.go +119 -0
  32. package/libs/pig-play/libraries/gamemcp/agent.go +98 -0
  33. package/libs/pig-play/libraries/gamemcp/controller.go +170 -0
  34. package/libs/pig-play/libraries/gamemcp/doc.go +15 -0
  35. package/libs/pig-play/libraries/gamemcp/game.go +328 -0
  36. package/libs/pig-play/libraries/gamemcp/gamemcp_test.go +518 -0
  37. package/libs/pig-play/libraries/gamemcp/gamemcptest/client.go +117 -0
  38. package/libs/pig-play/libraries/gamemcp/server.go +345 -0
  39. package/libs/pig-play/libraries/pixel/pixel.go +245 -0
  40. package/libs/pig-play/libraries/pixel/pixel_test.go +106 -0
  41. package/libs/pig-play/libraries/pixel/text.go +152 -0
  42. package/libs/pig-play/libraries/sprite/art.go +130 -0
  43. package/libs/pig-play/libraries/sprite/doc.go +8 -0
  44. package/libs/pig-play/libraries/sprite/hero.go +64 -0
  45. package/libs/pig-play/libraries/sprite/sprite_test.go +273 -0
  46. package/libs/pig-play/libraries/sprite/state.go +49 -0
  47. package/libs/pig-play/libraries/sprite/variants.go +239 -0
  48. package/libs/pig-play/libraries/termgame/termgame.go +147 -0
  49. package/libs/pig-play/libraries/termgame/termgame_test.go +63 -0
  50. package/libs/pig-play/package.json +38 -0
  51. package/libs/pig-play/provenance.json +18 -0
  52. package/package.json +40 -0
  53. package/port/PORT.md +102 -0
  54. package/port/golden/angry-pigs-command.jsonl +4 -0
  55. package/port/mutation-report.txt +75 -0
  56. package/port/mutations.json +104 -0
  57. package/port/relocation.json +7 -0
  58. package/port/scenarios/angry-pigs-command.json +7 -0
  59. package/provenance.json +18 -0
package/CREDITS.md ADDED
@@ -0,0 +1,18 @@
1
+ # Credits
2
+
3
+ `extensions/angrypigs` is the `angrypigs` extension of **PiG**,
4
+ https://github.com/MichaelKinsy/PiG, written by **Michael Kinsy**
5
+ (Copyright (c) 2026 Michael Kinsy, MIT; see [LICENSE](LICENSE)), moved into
6
+ Pigpen as a Package at the owner's request.
7
+
8
+ - Original: `piglets/standard/extensions/angrypigs` at commit
9
+ `d86eb93f217e64b655e9ee6f48c93a9afd107697` (game, art, render, particles, component, state and all tests).
10
+ - The pig, the pixel rasterizer, the arcade scenery and the terminal plumbing come from the
11
+ shared Package [`pig-play`](../pig-play/CREDITS.md) (formerly `piglets/standard/internal/*` and
12
+ the sprite catalogue of `piglogin`).
13
+ - Angry Pigs wears PiG Runner's look (night sky, turf, HUD lines, title cards) through the shared `arcade` library.
14
+
15
+ What changed: the sprite type and palette come from `pig-play`'s `sprite` library, and the pixel,
16
+ arcade and termgame imports point at `pig-play` instead of PiG Standard's `internal/`. Game rules,
17
+ art, rendering, key bindings, messages, the saved high score path and every test are unchanged.
18
+ [`port/PORT.md`](port/PORT.md) lists the differences and the tests.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Kinsy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # angry-pigs
2
+
3
+ Angry Pigs, from PiG Standard's `angrypigs`: a pixel-art slingshot game drawn over the whole
4
+ terminal. Your pig (the sprite chosen with `/sprite`) flies at bird-topped towers of wood, stone
5
+ and ice that crack, shatter and collapse; a gauge, a trajectory preview and particles show the
6
+ shot. Physics runs at a fixed 120 Hz step; a camera pans over the fixed-size world and a minimap
7
+ shows the whole field when the terminal is narrower than the world.
8
+
9
+ **It does not start by itself.** The extension registers one command and no event handlers (only with
10
+ `PIG_GAMES_MCP=1` set does it add one `session_start` handler, for the opt-in agent server below), so
11
+ bundling it into a Piglet (as `pig-with-batteries` does) does nothing until you type:
12
+
13
+ ```text
14
+ /angry-pigs
15
+ ```
16
+
17
+ Keys: Up / Down pull the band back for power, Left / Right aim, Space start and fire, P pause,
18
+ R restart, Esc / Q leave; a mouse drag aims when the terminal reports the mouse. The high score
19
+ is kept in `$PIG_HOME/state/pig-standard/angrypigs.json`. The game draws a modal overlay with its
20
+ own ticker and never waits on the agent; Esc closes only the overlay.
21
+
22
+ ## Let an agent play (opt-in)
23
+
24
+ Off by default: nothing listens until you ask.
25
+
26
+ ```text
27
+ /angry-pigs mcp on serve Angry Pigs to an agent on 127.0.0.1 for this session
28
+ /angry-pigs mcp off stop it
29
+ /angry-pigs mcp status
30
+ ```
31
+
32
+ or start pig with `PIG_GAMES_MCP=1`. The server is `games-angry-pigs`: five tools, found from a codemode script with
33
+ `searchTools("game")` (`mcp__games_angry_pigs__...`), behind a random bearer token that only the registered config holds.
34
+ The mechanism is the shared library [`gamemcp`](../pig-play/README.md#let-an-agent-play-gamemcp).
35
+
36
+ | Tool | Result |
37
+ |---|---|
38
+ | `games_list()` | `[{name: "angry-pigs", description, instructions, actions}]`; `instructions` say what each action does to the shot |
39
+ | `game_start({name: "angry-pigs"})` | opens the same overlay `/angry-pigs` opens, past the title screen: `{ok, game}` |
40
+ | `game_state()` | `{shots_left, score, level, level_done, game_over, flying, targets: [{x, y, material}], pig: {power, angle}, aim_landing: {x, y, hit} or null, landing_vs_target, landing_vs_target_px, last_shot_result: {outcome, birds_knocked}}` |
41
+ | `game_act({action, confidence?})` | `aim_up`, `aim_down` (the angle, one degree: the Left and Right keys), `power_up`, `power_down` (the pull, three percent: the Up and Down keys), `fire`, `none`, after a result `next_level` or `restart`, and `stop`; queued, applied on the next frame as the key press: `{ok, decisions}` |
42
+ | `game_score({wait_closed_ms?})` | `{score, high_score, decisions, game_over, open, stopped}`, also after the game closed; the high score is saved in `<config home>/state/pig-standard/angrypigs.json` |
43
+
44
+ `x` and `y` are world pixels: x grows right from the left edge, y grows up from the ground, and the slingshot rests at
45
+ (44, 15). `targets` lists every bird (material `bird`) and the top block of each tower column (`wood`, `stone`, `ice`) at the
46
+ centre of its 6-pixel cell. `aim_landing` is where the aimed shot ends, with what stops it (`ground`, a block material,
47
+ `bird`, or `""` when it leaves the field): the preview's flight carried on to its end, with no randomness in it. It reaches
48
+ farther than the screen does. The 24 preview dots show the first 1.2 s of the flight, so a person sees where the dots head
49
+ and judges the rest, while `aim_landing` gives the agent the end point (on level 1 most shots that reach the bird reach it
50
+ after the last dot). A shot it says reaches a bird can still miss, since the pig is a disc and the flight a point. `landing_vs_target` says it
51
+ in words against the nearest bird: `on target` (the shot ends at a bird), `short by N px` or `long by N px`
52
+ (`landing_vs_target_px` is the signed difference, negative when short; `no shot to aim` while a pig flies or after a
53
+ result). `last_shot_result.outcome` is `none`, `landed`, `hit_structure` or `off_field`.
54
+
55
+ **What the actions do** (also in `instructions`): `power_up` pulls the band further, so the shot flies faster and lands
56
+ further away; it is the main way to reach a far bird (on level 1 the start aim, 45 degrees at power 70, lands about 37 px
57
+ short, and a few `power_up` reach the bird). `power_down` brings the landing closer. `aim_up` raises the arc (over a tower
58
+ in the way; above 45 degrees it also lands a little closer), `aim_down` flattens it; an angle step moves the landing far
59
+ less than a power step. Fire when `landing_vs_target` is `on target`: short means `power_up` (at full power `aim_up`),
60
+ long means `power_down`.
61
+
62
+ The overlay shows "AI playing: last decision power_up p=0.92 · decisions 12" while an agent plays. `game_act` `stop` ends
63
+ the play: the game freezes, the high score is saved, and the HUD shows "AI stopped · final score N · high score M ·
64
+ decisions K · any key closes"; the next key closes the overlay and reports the score. (A Go extension cannot close its own
65
+ overlay yet: the SDK has no done callback, so the closing key is the user's.) Esc closes the overlay at any time and ends
66
+ the game for the agent too. The demo that has Jev play it is
67
+ [`demo/jev-plays.js`](../../demo/README.md).
68
+
69
+ Needs the sibling Package [`pig-play`](../pig-play/README.md) (`../pig-play`) when built from
70
+ source, and a Go toolchain unless fused into a Piglet Binary.
71
+
72
+ ```sh
73
+ pig package validate ./components/angry-pigs
74
+ pig install ./components/angry-pigs/extensions/angrypigs --validate-only --json
75
+ ```
76
+
77
+ Credits and licence: [CREDITS.md](CREDITS.md), [LICENSE](LICENSE) (MIT).
@@ -0,0 +1,147 @@
1
+ package angrypigs
2
+
3
+ import (
4
+ "strconv"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/MichaelKinsy/pigpen/components/pig-play/libraries/gamemcp"
9
+ )
10
+
11
+ // classifierLatency is 0.7 s of decision time, what a Jev decision takes in the demo, in
12
+ // physics steps.
13
+ const classifierLatency = 84
14
+
15
+ // playWithLatency plays through the agent path (game_state, then game_act on the component's
16
+ // queue) with one decision in flight that lands latency steps after its state was read, while
17
+ // the game keeps running. It stops when the level is done, the game is over, or after
18
+ // maxSeconds of game time.
19
+ func playWithLatency(c *component, latency int, maxSeconds float64, decide func(map[string]any) string) (steps, decisions int) {
20
+ pending, due := "", -1
21
+ for ; float64(steps)*stepSeconds < maxSeconds; steps++ {
22
+ if due == steps {
23
+ _ = c.queueAction(pending)
24
+ decisions++
25
+ due = -1
26
+ }
27
+ c.mu.Lock()
28
+ c.applyQueuedLocked()
29
+ c.game.step()
30
+ done := c.game.levelDone || c.game.gameOver
31
+ c.mu.Unlock()
32
+ if done {
33
+ return steps, decisions
34
+ }
35
+ if due < 0 {
36
+ pending, due = decide(c.agentState()), steps+latency
37
+ }
38
+ }
39
+ return steps, decisions
40
+ }
41
+
42
+ // The rule-based player, deciding from the state's words with 0.7 s per decision, clears
43
+ // level 1: it knocks out the bird before it runs out of pigs.
44
+ func TestRulePlayerWithSlowDecisionsClearsLevelOne(t *testing.T) {
45
+ c := idleComponent(t)
46
+ c.attachAgent(&gamemcp.Agent{})
47
+ steps, decisions := playWithLatency(c, classifierLatency, 120, func(state map[string]any) string {
48
+ action, _ := fakeJev(state)
49
+ return action
50
+ })
51
+ g := c.game
52
+ if !g.levelDone || g.gameOver {
53
+ t.Fatalf("level 1 not cleared after %.1f s, %d decisions: score %d, pigs left %d, %s", float64(steps)*stepSeconds, decisions, g.score, g.pigsLeft, g.lastEvent)
54
+ }
55
+ t.Logf("level 1 cleared in %.1f s with %d decisions, %d pigs used, score %d", float64(steps)*stepSeconds, decisions, levelPigs[0]-g.pigsLeft, g.score)
56
+ }
57
+
58
+ // Firing whenever a shot can be aimed, at the start aim, never reaches the bird: the rule's
59
+ // adjustments are what clear the level.
60
+ func TestFiringAtTheStartAimDoesNotClearLevelOne(t *testing.T) {
61
+ c := idleComponent(t)
62
+ c.attachAgent(&gamemcp.Agent{})
63
+ playWithLatency(c, classifierLatency, 120, func(state map[string]any) string {
64
+ if state["aim_landing"] != nil {
65
+ return "fire"
66
+ }
67
+ return "none"
68
+ })
69
+ if c.game.levelDone {
70
+ t.Fatal("level 1 cleared without aiming")
71
+ }
72
+ }
73
+
74
+ // landing_vs_target says, in words, where the aimed shot ends against the nearest bird.
75
+ func TestLandingVersusTargetInWords(t *testing.T) {
76
+ g := newGame(80, 0)
77
+ state := g.agentFacts()
78
+ landing := state["aim_landing"].(map[string]any)
79
+ bird := state["targets"].([]map[string]any)[0]
80
+ if bird["material"] != "bird" {
81
+ t.Fatalf("first target %v", bird)
82
+ }
83
+ short := bird["x"].(int) - landing["x"].(int)
84
+ if want := "short by " + itoa(short) + " px"; short <= 0 || state["landing_vs_target"] != want || state["landing_vs_target_px"] != -short {
85
+ t.Fatalf("start aim: landing %v, bird %v, landing_vs_target %q (%v), want %q", landing, bird, state["landing_vs_target"], state["landing_vs_target_px"], want)
86
+ }
87
+ // power_up moves the landing further, as the instructions say.
88
+ g.aim(0, powerStep)
89
+ further := g.agentFacts()["aim_landing"].(map[string]any)["x"].(int)
90
+ if further <= landing["x"].(int) {
91
+ t.Fatalf("power_up moved the landing from %v to %d", landing["x"], further)
92
+ }
93
+ for _, c := range []struct {
94
+ x int
95
+ hit string
96
+ want string
97
+ px int
98
+ }{
99
+ {189, "bird", "on target", 0},
100
+ {150, "ground", "short by 39 px", -39},
101
+ {200, "ground", "long by 11 px", 11},
102
+ {180, "wood", "short by 9 px", -9},
103
+ } {
104
+ got, px := landingVersusTarget(c.x, c.hit, []map[string]any{{"x": 189}})
105
+ if got != c.want || px != c.px {
106
+ t.Errorf("landing %d hit %s: %q %d, want %q %d", c.x, c.hit, got, px, c.want, c.px)
107
+ }
108
+ }
109
+ if got, _ := landingVersusTarget(100, "ground", nil); got != "no bird left" {
110
+ t.Errorf("no bird: %q", got)
111
+ }
112
+ }
113
+
114
+ func itoa(n int) string { return strconv.Itoa(n) }
115
+
116
+ // Stop freezes the game, shows the final score on the HUD, and the next key closes the overlay
117
+ // with the score.
118
+ func TestStopFreezesTheGameAndAnyKeyCloses(t *testing.T) {
119
+ c := idleComponent(t)
120
+ agent := &gamemcp.Agent{}
121
+ agent.Begin()
122
+ c.attachAgent(agent)
123
+ c.game.score = 550
124
+ saved, redraws := -1, 0
125
+ c.SetInvalidate(func() {
126
+ redraws++
127
+ if !agent.Stopped() {
128
+ t.Error("the redraw came before the HUD knew the final score")
129
+ }
130
+ })
131
+ p := &player{c: c, done: make(chan struct{}), save: func(high int) error { saved = high; return nil }}
132
+ final := p.Stop()
133
+ if redraws != 1 {
134
+ t.Fatalf("stop asked for %d redraws, want one for the final-score HUD", redraws)
135
+ }
136
+ if final.Score != 550 || final.HighScore != 550 || saved != 550 {
137
+ t.Fatalf("final %+v, saved %d", final, saved)
138
+ }
139
+ hud := strings.Join(c.Render(100), "\n")
140
+ if !strings.Contains(hud, "AI stopped") || !strings.Contains(hud, "final score 550") {
141
+ t.Fatalf("HUD after stop:\n%s", hud)
142
+ }
143
+ result, err := c.HandleInput("x")
144
+ if err != nil || !result.Done || result.Value.(gameState).Score != 550 {
145
+ t.Fatalf("key after stop: %+v %v", result, err)
146
+ }
147
+ }
@@ -0,0 +1,164 @@
1
+ package angrypigs
2
+
3
+ import (
4
+ "image/color"
5
+
6
+ "github.com/MichaelKinsy/pigpen/components/pig-play/libraries/pixel"
7
+ )
8
+
9
+ func rgb(r, g, b uint8) color.RGBA { return color.RGBA{R: r, G: g, B: b, A: 0xFF} }
10
+
11
+ // Scene colors.
12
+ var (
13
+ bandColor = rgb(0x3B, 0x22, 0x14)
14
+ dustColor = rgb(0xE9, 0xDF, 0xC9)
15
+ puffColor = rgb(0xFF, 0xFF, 0xFF)
16
+ trailColor = rgb(0xFF, 0xFF, 0xFF)
17
+ previewDot = rgb(0xFF, 0xFF, 0xFF)
18
+ hudInk = rgb(0xFF, 0xFF, 0xFF)
19
+ hudShadow = rgb(0x1B, 0x3A, 0x55)
20
+ hudGold = rgb(0xFF, 0xD8, 0x4D)
21
+ featherColor = rgb(0xF4, 0xC4, 0x30)
22
+ meterFrame = rgb(0x1B, 0x14, 0x10)
23
+ meterEmpty = rgb(0x3A, 0x30, 0x2A)
24
+ meterLow = rgb(0x5E, 0xD0, 0x6B)
25
+ meterMid = rgb(0xFF, 0xD8, 0x4D)
26
+ meterHigh = rgb(0xF0, 0x4E, 0x3A)
27
+ )
28
+
29
+ // Block materials: a 6x6 texture per material and its palette. 'h' is the lit
30
+ // top edge, 'd' the shaded border that separates neighboring blocks.
31
+ var (
32
+ woodTexture = []string{
33
+ "hhhhhd",
34
+ "wwwwwd",
35
+ "gggwwd",
36
+ "wwwwwd",
37
+ "wwgggd",
38
+ "dddddd",
39
+ }
40
+ stoneTexture = []string{
41
+ "hhhhhd",
42
+ "ssmssd",
43
+ "ssmssd",
44
+ "mmmmmd",
45
+ "sssmsd",
46
+ "dddddd",
47
+ }
48
+ iceTexture = []string{
49
+ "hhhhhd",
50
+ "hiiicd",
51
+ "ihiicd",
52
+ "iihicd",
53
+ "ccccid",
54
+ "dddddd",
55
+ }
56
+ woodPalette = pixel.NewPalette(map[byte]color.RGBA{
57
+ 'h': rgb(0xE8, 0xAE, 0x68), 'w': rgb(0xC9, 0x8A, 0x4B), 'g': rgb(0xA1, 0x68, 0x35), 'd': rgb(0x6B, 0x42, 0x22),
58
+ })
59
+ stonePalette = pixel.NewPalette(map[byte]color.RGBA{
60
+ 'h': rgb(0xC2, 0xC8, 0xD0), 's': rgb(0x9A, 0xA0, 0xA9), 'm': rgb(0x74, 0x7A, 0x83), 'd': rgb(0x52, 0x57, 0x5E),
61
+ })
62
+ icePalette = pixel.NewPalette(map[byte]color.RGBA{
63
+ 'h': rgb(0xFA, 0xFE, 0xFF), 'i': rgb(0xBF, 0xEC, 0xFB), 'c': rgb(0x96, 0xD7, 0xF0), 'd': rgb(0x5E, 0xB0, 0xD6),
64
+ })
65
+ )
66
+
67
+ // Crack overlays for blocks that took one and two hits of damage.
68
+ var cracks = [...][]string{
69
+ nil,
70
+ {
71
+ "......",
72
+ "...x..",
73
+ "..x...",
74
+ "..xx..",
75
+ "......",
76
+ "......",
77
+ },
78
+ {
79
+ "....x.",
80
+ ".x.x..",
81
+ "..x...",
82
+ ".xx.x.",
83
+ "x...x.",
84
+ "......",
85
+ },
86
+ }
87
+
88
+ func materialColor(kind cellKind) color.RGBA {
89
+ switch kind {
90
+ case cellWood:
91
+ return woodPalette['w']
92
+ case cellStone:
93
+ return stonePalette['s']
94
+ case cellIce:
95
+ return icePalette['i']
96
+ }
97
+ return featherColor
98
+ }
99
+
100
+ // birdFrames are a 6x6 bird facing the slingshot, eyes open and blinking.
101
+ // 'Y' body, 'y' belly, 'O' beak, 'W' eye white, 'K' pupil and brow, 'k'
102
+ // crest and tail.
103
+ var birdFrames = [2][]string{
104
+ {
105
+ "..kk..",
106
+ ".KKYYk",
107
+ "YWKYYY",
108
+ "OOYYyY",
109
+ ".YyyYY",
110
+ "..YYY.",
111
+ },
112
+ {
113
+ "..kk..",
114
+ ".KKYYk",
115
+ "YKKYYY",
116
+ "OOYYyY",
117
+ ".YyyYY",
118
+ "..YYY.",
119
+ },
120
+ }
121
+
122
+ var birdPalette = pixel.NewPalette(map[byte]color.RGBA{
123
+ 'Y': rgb(0xF4, 0xC4, 0x30), 'y': rgb(0xFF, 0xE9, 0x9A), 'O': rgb(0xF0, 0x7A, 0x1E),
124
+ 'W': rgb(0xFF, 0xFF, 0xFF), 'K': rgb(0x1F, 0x17, 0x14), 'k': rgb(0xC9, 0x8F, 0x12),
125
+ })
126
+
127
+ // pigBall is the 8x8 launched pig, drawn with the login sprite's palette so
128
+ // it matches the selected pig.
129
+ var pigBall = []string{
130
+ ".OO..OO.",
131
+ "OeeOOeeO",
132
+ "OPPPPPPO",
133
+ "OWKPPKWO",
134
+ "ObPssPbO",
135
+ "OPsKKsPO",
136
+ ".OPPPPO.",
137
+ "..OOOO..",
138
+ }
139
+
140
+ // slingshot is the 9x17 wooden fork. 'T' is lit wood, 't' shaded wood, 'b'
141
+ // the rubber band's anchor.
142
+ var slingshot = []string{
143
+ "bTT...TTb",
144
+ ".Tt...Tt.",
145
+ ".TT...TT.",
146
+ ".tT...Tt.",
147
+ ".TT...TT.",
148
+ "..TT.TT..",
149
+ "..tTTTt..",
150
+ "...TTT...",
151
+ "...tTt...",
152
+ "...TTT...",
153
+ "...TTt...",
154
+ "...TTT...",
155
+ "...tTT...",
156
+ "...TTT...",
157
+ "...TTt...",
158
+ "..tTTTt..",
159
+ ".tttttt..",
160
+ }
161
+
162
+ var slingPalette = pixel.NewPalette(map[byte]color.RGBA{
163
+ 'T': rgb(0xB0, 0x74, 0x3C), 't': rgb(0x80, 0x50, 0x28), 'b': bandColor,
164
+ })
@@ -0,0 +1,254 @@
1
+ package angrypigs
2
+
3
+ import (
4
+ "sync"
5
+ "time"
6
+
7
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
8
+ "github.com/MichaelKinsy/pigpen/components/pig-play/libraries/gamemcp"
9
+ "github.com/MichaelKinsy/pigpen/components/pig-play/libraries/pixel"
10
+ "github.com/MichaelKinsy/pigpen/components/pig-play/libraries/sprite"
11
+ "github.com/MichaelKinsy/pigpen/components/pig-play/libraries/termgame"
12
+ )
13
+
14
+ const (
15
+ // frameInterval matches the SDK's render coalescing interval.
16
+ frameInterval = 16 * time.Millisecond
17
+ // idleInterval redraws the drifting clouds when nothing else moves.
18
+ idleInterval = 250 * time.Millisecond
19
+ // maxCatchUp bounds the simulated time after a stall.
20
+ maxCatchUp = 100 * time.Millisecond
21
+ // stepDuration is stepSeconds as a duration.
22
+ stepDuration = time.Second / 120
23
+ // powerStep is the power change per Up or Down press, in percent.
24
+ powerStep = 3
25
+ )
26
+
27
+ type gameState struct {
28
+ Score int `json:"score"`
29
+ HighScore int `json:"highScore"`
30
+ Level int `json:"level"`
31
+ GameOver bool `json:"gameOver"`
32
+ }
33
+
34
+ // component is the full-terminal remote component. Its loop advances the
35
+ // physics at a fixed step off the TUI loop, ticking every frame while the
36
+ // scene moves and a few times a second while it idles. The SDK coalesces the
37
+ // resulting invalidations to its frame interval.
38
+ type component struct {
39
+ mu sync.Mutex
40
+ game *game
41
+ scene *scene
42
+ height func() int
43
+ lastHeight int
44
+ lastFrame time.Time
45
+ wasActive bool
46
+ invalidate func()
47
+ stop chan struct{}
48
+ stopped chan struct{}
49
+ stopOnce sync.Once
50
+ wake chan struct{}
51
+ dragging bool
52
+ // waiting holds the title screen until the player presses space.
53
+ waiting bool
54
+ // agent and queue serve an AI player (see mcp.go): the queue holds the key presses it
55
+ // asked for, which the next frame applies.
56
+ agent *gamemcp.Agent
57
+ queue []string
58
+ // stopped freezes the game once the agent stopped; saveErr is a failure to save the high
59
+ // score then, shown in the HUD.
60
+ agentStopped bool
61
+ saveErr error
62
+ }
63
+
64
+ func newComponent(highScore int, variant sprite.Variant, height func() int) *component {
65
+ c := &component{
66
+ game: newGame(80, highScore),
67
+ scene: newScene(pixel.NewPalette(sprite.MascotPalette(variant))),
68
+ waiting: true,
69
+ height: height,
70
+ stop: make(chan struct{}),
71
+ stopped: make(chan struct{}),
72
+ wake: make(chan struct{}, 1),
73
+ }
74
+ go c.run()
75
+ return c
76
+ }
77
+
78
+ func (c *component) run() {
79
+ defer close(c.stopped)
80
+ ticker := time.NewTicker(frameInterval)
81
+ defer ticker.Stop()
82
+ interval := frameInterval
83
+ last := time.Now()
84
+ var carry time.Duration
85
+ for {
86
+ select {
87
+ case <-ticker.C:
88
+ case <-c.wake:
89
+ case <-c.stop:
90
+ return
91
+ }
92
+ now := time.Now()
93
+ carry += min(now.Sub(last), maxCatchUp)
94
+ last = now
95
+ height := c.height()
96
+
97
+ c.mu.Lock()
98
+ queued := c.applyQueuedLocked()
99
+ stepped := false
100
+ if c.waiting || c.agentStopped {
101
+ carry = 0
102
+ }
103
+ for ; carry >= stepDuration; carry -= stepDuration {
104
+ c.game.step()
105
+ stepped = true
106
+ }
107
+ active := c.game.animating()
108
+ redraw := queued || stepped && (active || c.wasActive || now.Sub(c.lastFrame) >= idleInterval) || height != c.lastHeight
109
+ c.wasActive = active
110
+ if redraw {
111
+ c.lastFrame = now
112
+ }
113
+ c.lastHeight = height
114
+ invalidate := c.invalidate
115
+ c.mu.Unlock()
116
+
117
+ if redraw && invalidate != nil {
118
+ invalidate()
119
+ }
120
+ want := idleInterval
121
+ if active {
122
+ want = frameInterval
123
+ }
124
+ if want != interval {
125
+ interval = want
126
+ ticker.Reset(interval)
127
+ }
128
+ }
129
+ }
130
+
131
+ // Render draws the game to fill the overlay.
132
+ func (c *component) Render(width int) []string {
133
+ c.mu.Lock()
134
+ defer c.mu.Unlock()
135
+ w, h := termgame.Viewport(width, c.height())
136
+ c.game.resize(w)
137
+ c.scene.waiting = c.waiting
138
+ c.scene.agentLine = ""
139
+ if c.agent != nil {
140
+ c.scene.agentLine = c.agent.Line()
141
+ if c.saveErr != nil {
142
+ c.scene.agentLine += " · high score not saved: " + c.saveErr.Error()
143
+ }
144
+ }
145
+ return c.scene.render(c.game, w, h, make([]string, 0, h))
146
+ }
147
+
148
+ func (c *component) HandleInput(data string) (sdk.RemoteComponentResult, error) {
149
+ c.mu.Lock()
150
+ defer c.mu.Unlock()
151
+ return c.inputLocked(data), nil
152
+ }
153
+
154
+ // inputLocked applies one input. The keyboard and an AI player's actions both come here.
155
+ func (c *component) inputLocked(data string) sdk.RemoteComponentResult {
156
+ if c.agentStopped {
157
+ // The agent stopped: the final score is on the HUD, and any key closes.
158
+ return sdk.RemoteComponentResult{Done: true, Value: c.stateLocked()}
159
+ }
160
+ if c.waiting {
161
+ key, text := termgame.ParseKey(data)
162
+ switch {
163
+ case key == termgame.KeyEscape || key == termgame.KeyText && (text == 'q' || text == 'Q'):
164
+ return sdk.RemoteComponentResult{Done: true, Value: c.stateLocked()}
165
+ case key == termgame.KeyEnter || key == termgame.KeyText && text == ' ':
166
+ c.waiting = false
167
+ c.wakeLoop()
168
+ }
169
+ return sdk.RemoteComponentResult{}
170
+ }
171
+ if mouse, ok := termgame.ParseMouse(data); ok {
172
+ c.handleMouse(mouse)
173
+ return sdk.RemoteComponentResult{}
174
+ }
175
+ key, text := termgame.ParseKey(data)
176
+ switch {
177
+ case key == termgame.KeyEscape || key == termgame.KeyText && (text == 'q' || text == 'Q'):
178
+ return sdk.RemoteComponentResult{Done: true, Value: c.stateLocked()}
179
+ case key == termgame.KeyUp:
180
+ c.game.aim(0, powerStep)
181
+ case key == termgame.KeyDown:
182
+ c.game.aim(0, -powerStep)
183
+ case key == termgame.KeyLeft:
184
+ c.game.aim(1, 0)
185
+ case key == termgame.KeyRight:
186
+ c.game.aim(-1, 0)
187
+ case key == termgame.KeyEnter || key == termgame.KeyText && text == ' ':
188
+ c.game.launch()
189
+ case key == termgame.KeyText && (text == 'n' || text == 'N'):
190
+ c.game.nextLevel()
191
+ case key == termgame.KeyText && (text == 'r' || text == 'R'):
192
+ c.game.restart()
193
+ default:
194
+ return sdk.RemoteComponentResult{}
195
+ }
196
+ // Input can start motion while the loop idles; wake it for smooth frames.
197
+ c.wakeLoop()
198
+ return sdk.RemoteComponentResult{}
199
+ }
200
+
201
+ // handleMouse aims by dragging, as in the real game: press on the view,
202
+ // drag to pull the band back, and release to launch. Terminals report mouse
203
+ // input only when the host enables mouse tracking.
204
+ func (c *component) handleMouse(m termgame.Mouse) {
205
+ if m.Button != 0 {
206
+ return
207
+ }
208
+ cx, cy := termgame.ViewCell(m)
209
+ x, y := c.scene.world(cx, cy*2+1)
210
+ switch {
211
+ case m.Release:
212
+ if c.dragging {
213
+ c.dragging = false
214
+ c.game.launch()
215
+ c.wakeLoop()
216
+ }
217
+ case m.Motion && !c.dragging:
218
+ default:
219
+ c.dragging = c.game.drag(x, y)
220
+ }
221
+ }
222
+
223
+ func (c *component) wakeLoop() {
224
+ select {
225
+ case c.wake <- struct{}{}:
226
+ default:
227
+ }
228
+ }
229
+
230
+ func (c *component) SetInvalidate(invalidate func()) {
231
+ c.mu.Lock()
232
+ c.invalidate = invalidate
233
+ c.mu.Unlock()
234
+ }
235
+
236
+ func (c *component) Dispose() {
237
+ c.stopOnce.Do(func() { close(c.stop) })
238
+ <-c.stopped
239
+ }
240
+
241
+ func (c *component) State() gameState {
242
+ c.mu.Lock()
243
+ defer c.mu.Unlock()
244
+ return c.stateLocked()
245
+ }
246
+
247
+ func (c *component) stateLocked() gameState {
248
+ return gameState{
249
+ Score: c.game.score,
250
+ HighScore: max(c.game.highScore, c.game.score),
251
+ Level: c.game.level + 1,
252
+ GameOver: c.game.gameOver,
253
+ }
254
+ }