@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.
- package/CREDITS.md +18 -0
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/extensions/angrypigs/agentplay_test.go +147 -0
- package/extensions/angrypigs/art.go +164 -0
- package/extensions/angrypigs/component.go +254 -0
- package/extensions/angrypigs/export_test.go +19 -0
- package/extensions/angrypigs/extension.go +69 -0
- package/extensions/angrypigs/fakeclassifier_test.go +40 -0
- package/extensions/angrypigs/fakehost_test.go +548 -0
- package/extensions/angrypigs/game.go +632 -0
- package/extensions/angrypigs/game_test.go +750 -0
- package/extensions/angrypigs/go.mod +9 -0
- package/extensions/angrypigs/go.sum +2 -0
- package/extensions/angrypigs/go.work +10 -0
- package/extensions/angrypigs/host_test.go +206 -0
- package/extensions/angrypigs/mcp.go +273 -0
- package/extensions/angrypigs/mcp_host_test.go +449 -0
- package/extensions/angrypigs/mcp_test.go +419 -0
- package/extensions/angrypigs/particles.go +115 -0
- package/extensions/angrypigs/render.go +463 -0
- package/extensions/angrypigs/state.go +84 -0
- package/extensions/angrypigs/values_test.go +55 -0
- package/libs/pig-play/CREDITS.md +34 -0
- package/libs/pig-play/LICENSE +21 -0
- package/libs/pig-play/README.md +86 -0
- package/libs/pig-play/assets/pig/website-art.txt +29 -0
- package/libs/pig-play/go.mod +6 -0
- package/libs/pig-play/go.sum +2 -0
- package/libs/pig-play/libraries/arcade/arcade.go +185 -0
- package/libs/pig-play/libraries/arcade/arcade_test.go +119 -0
- package/libs/pig-play/libraries/gamemcp/agent.go +98 -0
- package/libs/pig-play/libraries/gamemcp/controller.go +170 -0
- package/libs/pig-play/libraries/gamemcp/doc.go +15 -0
- package/libs/pig-play/libraries/gamemcp/game.go +328 -0
- package/libs/pig-play/libraries/gamemcp/gamemcp_test.go +518 -0
- package/libs/pig-play/libraries/gamemcp/gamemcptest/client.go +117 -0
- package/libs/pig-play/libraries/gamemcp/server.go +345 -0
- package/libs/pig-play/libraries/pixel/pixel.go +245 -0
- package/libs/pig-play/libraries/pixel/pixel_test.go +106 -0
- package/libs/pig-play/libraries/pixel/text.go +152 -0
- package/libs/pig-play/libraries/sprite/art.go +130 -0
- package/libs/pig-play/libraries/sprite/doc.go +8 -0
- package/libs/pig-play/libraries/sprite/hero.go +64 -0
- package/libs/pig-play/libraries/sprite/sprite_test.go +273 -0
- package/libs/pig-play/libraries/sprite/state.go +49 -0
- package/libs/pig-play/libraries/sprite/variants.go +239 -0
- package/libs/pig-play/libraries/termgame/termgame.go +147 -0
- package/libs/pig-play/libraries/termgame/termgame_test.go +63 -0
- package/libs/pig-play/package.json +38 -0
- package/libs/pig-play/provenance.json +18 -0
- package/package.json +40 -0
- package/port/PORT.md +102 -0
- package/port/golden/angry-pigs-command.jsonl +4 -0
- package/port/mutation-report.txt +75 -0
- package/port/mutations.json +104 -0
- package/port/relocation.json +7 -0
- package/port/scenarios/angry-pigs-command.json +7 -0
- 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
|
+
}
|