more refact

This commit is contained in:
2026-08-30 23:01:48 +02:00
parent 3890edc946
commit 28b1662195
38 changed files with 353 additions and 663 deletions
+84 -32
View File
@@ -49,7 +49,7 @@ All game code lives in one flat package, `inc`. There are no subdirectories: a
file's name carries the structure, in the form `[category].[name].go`.
- The category is always **singular**: `item`, `scene`, `background`,
`character`, `dialog`, `script`, `world`, `widget`, `ui`, `theme`, `names`.
`character`, `tape`, `dialog`, `script`, `world`, `widget`, `theme`, `names`.
- `[category].manager.go` is the file that ties a category together — its
entity type and whatever else the category shares.
- Every other file in a category holds exactly one entity: one scene, one
@@ -66,11 +66,12 @@ inc/theme.realworld.go realworld-93
inc/theme.nokia_punk.go nokia-punk
inc/world.manager.go unsaved runtime state
inc/world.action.go custom actions
inc/widget.manager.go the Widget alias, HUD layout, the visibility gate
inc/widget.manager.go the Widget alias, HUD layout, the shared conditions
inc/widget.*.go one widget per file, registering itself
inc/ui.text.go coloured text on a scratch image
inc/background.*.go one image asset per scene
inc/character.*.go the cast, tapes included
inc/tape.manager.go the Tape entity and the lookups over it
inc/tape.*.go one tape per file
inc/item.*.go inventory
inc/dialog.*.go dialogue trees
inc/script.*.go named action sequences
@@ -85,7 +86,7 @@ Every category that owns a collection of entities has a manager, and they are
all the engine's own `inkwell.Manager[T]` — the same registry type the `*Game`
hangs its content off. The game defines no registry of its own.
All eight are declared together, in `boot.go`, so the list of what the game
All nine are declared together, in `boot.go`, so the list of what the game
holds is one block rather than a line hidden in each category file:
```go
@@ -96,6 +97,7 @@ var (
ItemManager = inkwell.NewManager[Item]()
SceneManager = inkwell.NewManager[Scene]()
ScriptManager = inkwell.NewManager[Script]()
TapeManager = inkwell.NewManager[Tape]()
ThemeManager = inkwell.NewManager[Theme]()
WidgetManager = inkwell.NewManager[Widget]()
)
@@ -117,9 +119,17 @@ construction-time bug, not an update — so rewriting an entity that is already
in the registry goes through `Set`, which keeps its position in the order.
`All` and `Each` both hand back the entities in registration order.
Every entity type is an alias, `Scene` included. It was once a struct of our
own, because inkwell's `Scene` could not carry exits; that gap was closed in the
engine, so there is nothing left for a second type to hold.
Nearly every entity type is an alias, `Scene` included. It was once a struct of
our own, because inkwell's `Scene` could not carry exits; that gap was closed in
the engine, so there is nothing left for a second type to hold.
`Tape` is the exception, and it is a real one: a tape is a cassette that speaks,
a thing inkwell has no notion of. It names the character whose voice it is, the
item that carries it and the dialogue it plays, and `tape.manager.go` holds the
four lookups over the registry — `IsTape`, `TapeOf`, `IsTapeItem`,
`TapeDialogue`. What a tape *sounds* like is not in it: the display name and the
log-only voice are `Character.Label` and `Character.Voice`, because those are
facts about a speaker, not about a cassette.
### Entities register themselves
@@ -176,14 +186,25 @@ func (h *hudFrame) Layer() inkwell.Layer { return inkwell.LayerPanel }
```
A widget that says nothing sits on `LayerHUD`; ours all say it, because the
layer is the one thing about a widget the file cannot show. `gate`, the wrapper
that hides a widget outside `ModePlay`, returns `inkwell.LayerOf` of the widget
it wraps, so wrapping never moves anything.
layer is the one thing about a widget the file cannot show.
Two widgets are worth knowing about because they are not decoration: `pump`
drives `World.PumpTick`, which is what runs every queued action, and
`usewith_guard` sits on `LayerScene` so that it ticks last and can answer a
use-with pair nobody authored.
The other thing a widget declares is **when it is there at all**. The HUD is
hidden for a cutscene, and that is a condition over game state, not a wrapper
and not an `if` at the top of every `Draw`:
```go
WidgetManager.Register(&inkwell.StatusLine{
Name: "status",
When: whenPlaying,
Y: statusY,
})
```
`whenPlaying`, `whenCutscene` and `whenUnpaused` are in `widget.manager.go`, and
they read `VarMode` and `VarNote` out of the engine's own `State`. A widget of
ours says the same thing with a method — `VisibleWhen() inkwell.Condition`
because it has no literal to put a field in. A widget that is switched off
neither ticks nor draws nor blocks a click, so nothing else has to ask.
### Handing a category to the engine
@@ -206,12 +227,16 @@ Themes are the odd one out and stay a copy: `NewGame` puts four preset themes
into its `ThemeManager`, and replacing it would throw them away. Ours are added
to that set instead.
Two consequences of not copying. `prepareScene` has to run **before** the
hand-off, because there is no longer a copy pass to fill in the defaults a
scene leaves out — it writes them back into `SceneManager` with `Set`, and
derives the selector's pins by reading the exit graph backwards. And a manager
swapped in this way must be in place before `inkwell.Run`, which is where the
engine wires up the parts that hold a registry directly.
One consequence of not copying: a manager swapped in this way must be in place
before `inkwell.Run`, which is where the engine wires up the parts that hold a
registry directly. `fillSelectorPins` runs before the hand-off for the same
reason — it writes the derived pins back into `SceneManager` with `Set`.
The defaults a scene leaves out are no longer written into it at all. `g.Player`
names the character a scene with no actors of its own gets, placed at his
`Start`; `g.Walkboxes` is the floor a scene walks on when it declares none. Both
are fields on the `*Game`, so a scene file that says nothing about either is
saying "the usual", and the selector opts out by declaring both empty.
### What runs at boot
@@ -231,14 +256,37 @@ if o.Finale {
opening. Here it is what `-finale` uses to drop into the ending, which is why it
is set from `Opts` rather than always.
The rest of `New` is fields on the `*Game`, and every one of them replaces
something this package used to carry itself:
```go
g.WindowScale = 2
g.Player = Paul
g.Walkboxes = sceneFloor
g.UseWithFail = useWithFail
```
`UseWithFail` is the response to a use-with pair nobody authored — the engine
asks for it the same way it asks for `ExitLook` and `ExitTake`, and the lines
live with the rest of the writing, in `script.usewith_fail.go`.
## The world
There is exactly one game, so there is exactly one world: `World`, a
package-level singleton in `world.manager.go`. Nothing takes a `*world`
parameter and no widget holds a back-reference — `World.Do(…)`,
`World.HUDVisible()`, `World.Slot2()` are reachable from anywhere in the
package. `New` calls `World.attach(g)`, which binds the engine and resets the
runtime state, so building the game twice is clean.
parameter and no widget holds a back-reference — `World.Do(…)`, `World.Mode()`,
`World.Slot2()` are reachable from anywhere in the package. `New` calls
`World.attach(g)`, which binds the engine and resets the runtime state, so
building the game twice is clean.
`World` holds as little as it can get away with. The mode and the top bar's
note are `State` vars (`VarMode`, `VarNote`), because state the engine can see
is state a `Condition` can read — that is what makes a widget's `When` possible
and what puts "— paused" in the top bar without anyone pushing it there.
`World.Do` hands its action to `g.Do`, the engine's queue, so the game runs one
action at a time without a pump of its own. What is left in the struct is the
two things the engine has no notion of: the tape waiting to speak, and the
cassette on its way into slot 2.
This is what lets an entity file be a literal: the tape-insert script closes
over `World`, not over a parameter it would have had to be handed.
@@ -249,7 +297,7 @@ One package means one namespace, so an entity's constructor carries its
category as a prefix:
```go
sceneFloor sceneDefaults prepareScene fillSelectorPins
sceneFloor fillSelectorPins tapeSlots useWithFail
```
Entities themselves need no name at all — they are anonymous literals inside
@@ -257,12 +305,16 @@ their file's `init()`, and the file name says which one it is.
Exported names are the authoring vocabulary — what a content file spells out:
`New` and `Opts`, `World`, the managers and the entity types they hold, the
constants in `names.manager.go`, the colour tokens, and the action constructors
(`TapeSay`, `Paused`, `SetMode`, `EnterScene`, `Back`, `Fn`).
constants in `names.manager.go`, the colour tokens, the tape lookups
(`IsTape`, `TapeOf`, `IsTapeItem`, `TapeDialogue`) and the action constructors
(`Paused`, `SetMode`, `UseTheme`, `TapeOffer`, `Fn`). A tape's line is
`inkwell.Say` like anyone else's — the tape voice is on the character, not on a
second spelling of Say.
Everything else is machinery and stays unexported: the HUD widgets
(`tapeSlots`, `letterbox`, `hudFrame`, …), `gated`, the runners,
`prepareScene`, `sceneDefaults`, `fillSelectorPins`.
(`tapeSlots`, `letterbox`, `hudFrame`, …), the conditions (`whenPlaying`,
`whenCutscene`, `whenUnpaused`), the runners, `useWithFail`,
`fillSelectorPins`.
The word is **scene**, the engine's own. The wiki and the concept-art deck count
*screens*, and this code used to as well, but everything a screen had that a
@@ -275,9 +327,9 @@ Nothing is enforced by the compiler any more, so the layering is a rule kept by
hand:
```
names ← theme ← world ← ui
↑ ↑
content ──┴── boot ← main
names ← theme ← world ← widget
content ──┴── boot ← main
```
`world` knows nothing about the HUD or the content. Both build on it, never the
+41 -47
View File
@@ -162,9 +162,8 @@ inc/boot.go the managers, and New(Opts)
inc/names.manager.go entity names and world-state keys
inc/theme.*.go realworld-93 + nokia-punk
inc/world.*.go unsaved runtime state and custom actions
inc/widget.manager.go HUD layout and the widget registration list
inc/widget.manager.go HUD layout and the shared visibility conditions
inc/widget.*.go one widget per file
inc/ui.text.go coloured text
inc/<kind>.<name>.go one file per registered entity, by kind
```
@@ -173,9 +172,9 @@ code keeps by hand: the world holds no opinion about the HUD or the content, and
both build on it, never the other way round.
```
names ← theme ← world ← ui
↑ ↑
content ──┴── boot ← main
names ← theme ← world ← widget
content ──┴── boot ← main
```
Content is one registered entity per file, and the category prefix groups them
@@ -228,6 +227,19 @@ under the tape slots and the cursor over everything, whatever the file names
happen to be. The layer was added to inkwell for this; the alternative was a
registration list, which is the thing this package spent its life deleting.
The second thing a widget declares is when it is on screen at all: `When` is an
`inkwell.Condition` over game state, and the HUD's is `whenPlaying`, which reads
`VarMode` out of the engine's `State`. Hiding the HUD for a cutscene is
therefore one word per widget rather than a wrapper around each of them and an
`if` at the top of every `Draw`.
Tapes are the one entity type this game invents. `Tape` names the character
whose voice it is, the cassette that carries it and the dialogue it plays, one
file each — what a tape *sounds* like stays on the character, as
`Character.Label` and `Character.Voice`, because that is a fact about a speaker.
A tape's line is `inkwell.Say` like anybody else's; the engine sends it to the
log instead of a speech bubble because the character says so.
So adding an entity is adding a file, and there is no second list to keep in
step. The price is that registration order is file-name order: the arrow keys
walk the scenes alphabetically rather than in the concept-art deck's order.
@@ -243,11 +255,13 @@ share one registry per category instead of keeping two of them in step. Themes
are the exception: `NewGame` seeds its theme manager with four presets, so ours
are added to that set rather than replacing it.
The price of not copying is that the defaults a scene may leave out — Paul's
starting position, the floor walkbox — have to be written back into
`SceneManager` before the hand-off rather than filled in on the way past.
`prepareScene` does that with `Set`, and derives the selector's pins in the same
pass by reading the exit graph backwards.
The defaults a scene may leave out are not written into it at all any more:
`g.Player` names the character a scene with no actors of its own receives, at
his `Start`, and `g.Walkboxes` is the floor a scene without one walks on. Both
are fields on the `*Game`, so a scene file that says nothing about either means
"the usual", and the selector opts out by declaring both empty. What is still
written back before the hand-off is the derived half of the map:
`fillSelectorPins` reads the exit graph backwards and `Set`s the selector.
There is one world, and it is a package-level singleton: `World`. Nothing takes
a `*world` parameter and no widget holds a back-reference, which is what lets a
@@ -300,45 +314,25 @@ The graph stays machine-readable: exit hotspots are named `exit:<target>`, so
the connections can be read straight back out of the registered scenes — and
`Validate` rejects an exit that names a scene nobody registered.
## Engine workarounds
## What this game pushed into the engine
Four inkwell limits turned up during implementation that the engine README does
not mention. All four are worked around on the domain side; each is a candidate
for a small engine change.
Everything below started as a workaround in this package and ended up in
inkwell, because in each case the thing being worked around was a fact an
entity should have carried in the first place. The domain side of each is now a
field in a literal:
Two others have been fixed in the engine since: exits are now
[`inkwell.Exit`](https://git.teletypegames.org/engines/inkwell) on the scene
itself, and `Game.CurrentScene()` / `PreviousScene()` mean the domain no longer
has to shadow where the player is.
1. **`drawText` discards colour.** In `asset.text.go` the colour argument is
`_ = c` and rendering goes through `ebitenutil.DebugPrintAt`, which only
draws white. Every text colour in `Theme` is therefore inert. *Workaround:*
`ui.text.go` renders onto a scratch image and blits it tinted with
`ColorScale`. The custom widgets colour correctly; the built-ins
(`StatusLine`, `DialogBox`, `TopBar`, `InventoryBar`) are still white.
*Fix:* move `drawText` to `text/v2` — no call site would change.
2. **`queueAction` is unexported**, so a domain widget cannot start an action.
*Workaround:* the pump — `widget.action_pump.go` calling `World.PumpTick`
drives its own `Runner` through the exported `inkwell.Ctx`. *Caveat:* it runs alongside the engine's script
runner, not instead of it.
3. **The `"Nem ehhez."` flash is hardcoded** in `core.engine.go` for an
item/hotspot pair with no `OnUseWith`, and cannot be replaced from the
domain. *Workaround:* `UseWithGuard` — widgets tick before the engine's
`handleSceneInput` and can consume the click, so unauthored pairs fail in
character instead, escalating on repeats.
4. **Widgets have no `Visible` field and the `Manager` cannot unregister**, so
the built-in HUD cannot be hidden during a cutscene. *Workaround:* the
`gate` wrapper in `widget.manager.go` forwards `Tick`/`Draw`/`BlocksClickAt` only
while the HUD is visible.
5. **`Run` hardcodes a 4× window** (`core.dsl.go`), which at 640×400 would be
2560×1600 — bigger than most laptop screens. *Workaround:* the `windowSizer`
widget resizes once on the first tick, since Run sets the size before
entering the loop.
| was worked around here | is now |
|---|---|
| a scratch-image text blitter, because `drawText` dropped the colour | `Game.DrawText` renders in colour; `inkwell.TextWidth`, `WrapText`, `ClipText`, `GlyphW/H` are the library's |
| a pump widget driving its own `Runner`, because `queueAction` was unexported | `g.Do(action)` — one queue, in order, engine-side |
| `TapeSay`, a second spelling of `Say` that skipped the speech bubble | `Character.Voice``VoiceLog` sends a character's lines to the log |
| a `Tapes` map of display names | `Character.Label` |
| a `gate` wrapper and `if !World.HUDVisible()` at the top of every `Draw` | `Widget.When`, an `inkwell.Condition` the engine evaluates |
| a `titleBar` widget that pushed the scene title into the top bar | `TopBar` already discovers the title; `NoteVar` adds the "— paused" |
| a `UseWithGuard` widget stealing clicks to answer unauthored use-with pairs | `Game.UseWithFail`, beside `ExitLook` and `ExitTake` |
| a `windowSizer` widget resizing the window on its first tick | `Game.WindowScale` |
| a `sceneNav` widget walking the scene catalogue | `inkwell.SceneNav`, a built-in dev widget |
| `sceneDefaults` rewriting every scene with Paul and a floor | `Game.Player` and `Game.Walkboxes` |
Also: the inkwell README gives the module path as
`git.teletypegames.org/games/inkwell`; the real path per its `go.mod` is
+1 -1
View File
@@ -3,7 +3,7 @@ module git.teletypegames.org/games/realworld
go 1.26.3
require (
git.teletypegames.org/engines/inkwell v0.1.1-0.20260830201917-2429ada0900f
git.teletypegames.org/engines/inkwell v0.1.1-0.20260830205716-92fc36bdf5b8
github.com/hajimehoshi/ebiten/v2 v2.9.9
)
+2
View File
@@ -4,6 +4,8 @@ git.teletypegames.org/engines/inkwell v0.1.1-0.20260830200555-53f4df04669c h1:GO
git.teletypegames.org/engines/inkwell v0.1.1-0.20260830200555-53f4df04669c/go.mod h1:/v6QtismTE8+e7kobbVR5B/CpTSrd4j2DYwJ8DvINhs=
git.teletypegames.org/engines/inkwell v0.1.1-0.20260830201917-2429ada0900f h1:WBIh7svmA2R7tbi1Xrafn5//yl8vnK2qbpBXDbbz6Aw=
git.teletypegames.org/engines/inkwell v0.1.1-0.20260830201917-2429ada0900f/go.mod h1:/v6QtismTE8+e7kobbVR5B/CpTSrd4j2DYwJ8DvINhs=
git.teletypegames.org/engines/inkwell v0.1.1-0.20260830205716-92fc36bdf5b8 h1:t1xAbtOTCUeIbqIHUpRXQMiYDvp8KOt+m39iEZ5Q6Sg=
git.teletypegames.org/engines/inkwell v0.1.1-0.20260830205716-92fc36bdf5b8/go.mod h1:/v6QtismTE8+e7kobbVR5B/CpTSrd4j2DYwJ8DvINhs=
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0=
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI=
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
+6 -1
View File
@@ -11,6 +11,7 @@ var (
ItemManager = inkwell.NewManager[Item]()
SceneManager = inkwell.NewManager[Scene]()
ScriptManager = inkwell.NewManager[Script]()
TapeManager = inkwell.NewManager[Tape]()
ThemeManager = inkwell.NewManager[Theme]()
WidgetManager = inkwell.NewManager[Widget]()
)
@@ -26,17 +27,21 @@ func New(o Opts) *inkwell.Game {
start = SceneAlley
}
prepareScene()
fillSelectorPins()
g := inkwell.NewGame("Real World", ScreenW, ScreenH)
g.MaxLogLines = 64
g.WindowScale = 2
g.SceneRect = inkwell.Rect(0, TopBarH, ScreenW, HUDTop-TopBarH)
g.Player = Paul
g.Walkboxes = sceneFloor
g.ExitLook = func(e inkwell.Exit) inkwell.Action {
return inkwell.Say(Paul, "That way: "+e.Label+".")
}
g.ExitTake = func(inkwell.Exit) inkwell.Action {
return inkwell.Say(Paul, "It's a way out, not a thing.")
}
g.UseWithFail = useWithFail
g.AssetManager = BackgroundManager
g.CharacterManager = CharacterManager
+6
View File
@@ -1,8 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
func init() {
CharacterManager.Register(Character{
Name: Dex,
Label: "DEX",
Voice: inkwell.VoiceLog,
SpeechColor: Amber,
})
}
+6
View File
@@ -1,8 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
func init() {
CharacterManager.Register(Character{
Name: TapeMystery,
Label: "UNKNOWN TAPE",
Voice: inkwell.VoiceLog,
SpeechColor: RGB(0xB9A98A),
})
}
+1 -1
View File
@@ -11,7 +11,7 @@ func init() {
W: 28,
H: 68,
Start: inkwell.Point{
X: 120,
X: 320,
Y: 232,
},
SpeechColor: Ink,
+6
View File
@@ -1,8 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
func init() {
CharacterManager.Register(Character{
Name: TapeSupport,
Label: "TECH SUPPORT",
Voice: inkwell.VoiceLog,
SpeechColor: RGB(0xE8C86A),
})
}
+2 -2
View File
@@ -10,12 +10,12 @@ func init() {
Description: "black-market Armilla",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "Jailbroken. Pushes ads, but it was cheap."),
TapeSay(Dex, "Two slots, and both of them lie about the temperature. Don't trade me in for it."),
inkwell.Say(Dex, "Two slots, and both of them lie about the temperature. Don't trade me in for it."),
),
OnUseWith: map[string]inkwell.Action{
ItemNoodleLetter: inkwell.Seq(
inkwell.Say(Paul, "A letter and a bracelet. Brilliant."),
TapeSay(Dex, "Nothing. Which is what I'd charge for it."),
inkwell.Say(Dex, "Nothing. Which is what I'd charge for it."),
),
},
})
+1 -1
View File
@@ -10,7 +10,7 @@ func init() {
Description: "unmarked Personal Tape",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "Password-locked. Of course."),
TapeSay(Dex, "Put it in the second slot if you care that much. I did warn you."),
inkwell.Say(Dex, "Put it in the second slot if you care that much. I did warn you."),
),
})
}
+1 -1
View File
@@ -10,7 +10,7 @@ func init() {
Description: "Noodle's letter",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "“Come out, Paul. Not a phone conversation.” That's all it says."),
TapeSay(Dex, "And now you're here. And he isn't."),
inkwell.Say(Dex, "And now you're here. And he isn't."),
),
})
}
+4 -29
View File
@@ -34,6 +34,10 @@ const (
VarSlot2 = "armilla.slot2"
VarArmillaStrip = "armilla.strip"
VarDecay = "decay_level"
VarMode = "mode"
VarNote = "note"
NotePaused = "paused"
)
const (
@@ -89,32 +93,3 @@ const (
BgShowroom = "bg_showroom"
BgColumbarium = "bg_columbarium"
)
var Tapes = map[string]string{
Dex: "DEX",
TapeMystery: "UNKNOWN TAPE",
TapeSupport: "TECH SUPPORT",
}
func IsTape(name string) bool { _, ok := Tapes[name]; return ok }
func TapeDisplayName(name string) string {
if d, ok := Tapes[name]; ok {
return d
}
return name
}
var TapeItems = map[string]string{
ItemMysteryTape: TapeMystery,
}
func IsTapeItem(item string) bool { _, ok := TapeItems[item]; return ok }
func TapeDialogue(item string) string {
switch item {
case ItemMysteryTape:
return DlgMysteryTape
}
return DlgDex
}
+4 -4
View File
@@ -47,7 +47,7 @@ func hidingPlace() inkwell.Hotspot {
inkwell.Give(ItemMysteryTape),
inkwell.SetFlag(FlagHasTape),
inkwell.Say(Paul, "A Personal Tape. No label, no seal."),
TapeSay(Dex, "Password-locked. And somebody very much did not want it found."),
inkwell.Say(Dex, "Password-locked. And somebody very much did not want it found."),
),
),
inkwell.Say(Paul, "One loose brick. I'm not taking the alley apart over it."),
@@ -64,13 +64,13 @@ func backDoor() inkwell.Hotspot {
OnLook: inkwell.Say(Paul, "Keypad. Four digits, worn keys."),
OnUse: inkwell.Seq(
inkwell.Say(Paul, "Locked. And I'm not guessing four digits."),
TapeSay(Dex, "The wear is heaviest on the two and the seven. That isn't a code. It's fewer options."),
inkwell.Say(Dex, "The wear is heaviest on the two and the seven. That isn't a code. It's fewer options."),
),
OnTalk: inkwell.Say(Paul, "To the door? I'm not there yet."),
OnUseWith: map[string]inkwell.Action{
ItemBlackArmilla: inkwell.Seq(
inkwell.Say(Paul, "A black-market bracelet doesn't open a keypad."),
TapeSay(Dex, "But you do get an advert out of it."),
inkwell.Say(Dex, "But you do get an advert out of it."),
),
},
}
@@ -84,7 +84,7 @@ func bin() inkwell.Hotspot {
OnLook: inkwell.Say(Paul, "Somebody's been through it. Thoroughly."),
OnUse: inkwell.Seq(
inkwell.Say(Paul, "Already turned out. I'm not going to be the second one."),
TapeSay(Dex, "The police don't go through bins. So who did?"),
inkwell.Say(Dex, "The police don't go through bins. So who did?"),
),
}
}
-25
View File
@@ -6,13 +6,6 @@ import (
type Scene = inkwell.Scene
func prepareScene() {
fillSelectorPins()
for _, entity := range SceneManager.All() {
SceneManager.Set(sceneDefaults(entity))
}
}
var sceneFloor = []inkwell.Polygon{
inkwell.Poly(
inkwell.Point{
@@ -32,24 +25,6 @@ var sceneFloor = []inkwell.Polygon{
),
}
func sceneDefaults(s Scene) Scene {
if s.Actors == nil {
s.Actors = []inkwell.SceneActor{
{
CharacterName: Paul,
At: inkwell.Point{
X: 320,
Y: 232,
},
},
}
}
if s.Walkboxes == nil {
s.Walkboxes = sceneFloor
}
return s
}
func setVarIfEmpty(name string, v any) inkwell.Action {
return Fn(func(ctx *inkwell.Ctx) {
if ctx.Game.State.Var(name) == nil {
+1 -1
View File
@@ -15,7 +15,7 @@ func init() {
UseTheme(NokiaPunk),
inkwell.Say("norman", "No — this isn't what you were supposed to do!"),
inkwell.Wait(0.6),
TapeSay(TapeMystery, "Thank you, Paul. You did exactly what I asked, the whole way through."),
inkwell.Say(TapeMystery, "Thank you, Paul. You did exactly what I asked, the whole way through."),
inkwell.Wait(1.0),
inkwell.ShowEnd("REAL WORLD — end of game two"),
),
+1 -1
View File
@@ -10,7 +10,7 @@ func init() {
Actions: inkwell.Seq(
inkwell.SetFlag(FlagPoliceTip),
inkwell.Say(Paul, "Back alley. Just like the clerk said."),
TapeSay(Dex, "A government office tipped you off to remove something from a scene. That doesn't strike you as odd?"),
inkwell.Say(Dex, "A government office tipped you off to remove something from a scene. That doesn't strike you as odd?"),
),
})
}
+2 -2
View File
@@ -18,8 +18,8 @@ func init() {
ctx.Game.State.SetVar(VarArmillaStrip, "SLOT2: READING")
}),
inkwell.Say(Paul, "Right. Let's see who you are."),
TapeSay(Dex, "Clicked in. Spinning. And now: nothing."),
TapeSay(Dex, "A stranger's tape in your own Armilla, Paul. I hope you know what you're doing."),
inkwell.Say(Dex, "Clicked in. Spinning. And now: nothing."),
inkwell.Say(Dex, "A stranger's tape in your own Armilla, Paul. I hope you know what you're doing."),
),
})
}
+59
View File
@@ -0,0 +1,59 @@
package inc
import (
"math/rand"
inkwell "git.teletypegames.org/engines/inkwell"
)
func useWithFail(item string, h *inkwell.Hotspot) inkwell.Action {
key := "fail." + item + "." + h.Name
World.G.State.NoteTalked(key)
n := World.G.State.Talked(key)
return inkwell.Seq(
inkwell.Say(Paul, pick(paulFails, n)),
inkwell.Say(Dex, dexFail(n)),
)
}
var paulFails = []string{
"No. That's not going to work.",
"Tried that. It didn't improve.",
"All right, I know that one doesn't work.",
"Now it's personal.",
}
var dexDry = []string{
"No. Not like that.",
"I heard it. Nothing happened.",
}
var dexTease = []string{
"Twice the same. The second one rarely goes better.",
"Do it a few more times, maybe physics reconsiders.",
}
func dexFail(n int) string {
switch {
case n <= 1:
return pick(dexDry, n)
case n == 2:
return pick(dexTease, n)
default:
return "Paul. Leave it. Look again at what you're carrying — that's where it is."
}
}
func pick(s []string, n int) string {
switch {
case len(s) == 0:
return ""
case n <= 0:
return s[0]
case n-1 < len(s):
return s[n-1]
default:
return s[rand.Intn(len(s))]
}
}
+8
View File
@@ -0,0 +1,8 @@
package inc
func init() {
TapeManager.Register(Tape{
Name: Dex,
Dialogue: DlgDex,
})
}
+32
View File
@@ -0,0 +1,32 @@
package inc
type Tape struct {
Name string
Item string
Dialogue string
}
func (t Tape) GetName() string { return t.Name }
func IsTape(name string) bool { return TapeManager.Has(name) }
func TapeOf(item string) (Tape, bool) {
for _, t := range TapeManager.All() {
if t.Item != "" && t.Item == item {
return t, true
}
}
return Tape{}, false
}
func IsTapeItem(item string) bool {
_, ok := TapeOf(item)
return ok
}
func TapeDialogue(item string) string {
if t, ok := TapeOf(item); ok && t.Dialogue != "" {
return t.Dialogue
}
return DlgDex
}
+9
View File
@@ -0,0 +1,9 @@
package inc
func init() {
TapeManager.Register(Tape{
Name: TapeMystery,
Item: ItemMysteryTape,
Dialogue: DlgMysteryTape,
})
}
+7
View File
@@ -0,0 +1,7 @@
package inc
func init() {
TapeManager.Register(Tape{
Name: TapeSupport,
})
}
-107
View File
@@ -1,107 +0,0 @@
package inc
import (
"image"
"image/color"
"unicode/utf8"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
)
const (
glyphW = 6
glyphH = 16
)
var scratch *ebiten.Image
func textW(s string) int { return utf8.RuneCountInString(s) * glyphW }
func drawTextC(dst *ebiten.Image, s string, x, y int, c color.Color) {
if s == "" {
return
}
w, h := textW(s)+glyphW, glyphH
if scratch == nil || scratch.Bounds().Dx() < w || scratch.Bounds().Dy() < h {
nw, nh := w, h
if nw < 320 {
nw = 320
}
scratch = ebiten.NewImage(nw, nh)
}
scratch.Clear()
ebitenutil.DebugPrintAt(scratch, s, 0, 0)
if c == nil {
c = color.White
}
r, g, b, a := c.RGBA()
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(x), float64(y))
op.ColorScale.Scale(
float32(r)/0xffff, float32(g)/0xffff, float32(b)/0xffff, float32(a)/0xffff,
)
dst.DrawImage(scratch.SubImage(image.Rect(0, 0, w, h)).(*ebiten.Image), op)
}
func wrap(s string, maxPx int) []string {
if maxPx <= glyphW || textW(s) <= maxPx {
return []string{s}
}
var out []string
line, word := "", ""
flush := func() {
if line != "" {
out = append(out, line)
line = ""
}
}
emit := func() {
if word == "" {
return
}
switch {
case line == "":
line = word
case textW(line+" "+word) <= maxPx:
line += " " + word
default:
flush()
line = word
}
word = ""
}
for _, r := range s {
switch r {
case ' ':
emit()
case '\n':
emit()
flush()
default:
word += string(r)
}
}
emit()
flush()
if len(out) == 0 {
return []string{s}
}
return out
}
func clip(s string, maxPx int) string {
r := []rune(s)
max := maxPx / glyphW
switch {
case max <= 0:
return ""
case len(r) <= max:
return s
case max == 1:
return string(r[:1])
default:
return string(r[:max-1]) + "…"
}
}
-21
View File
@@ -1,21 +0,0 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
)
func init() {
WidgetManager.Register(&actionPump{
Name: "pump",
})
}
type actionPump struct {
Name string
}
func (p *actionPump) GetName() string { return p.Name }
func (p *actionPump) Layer() inkwell.Layer { return inkwell.LayerScene }
func (p *actionPump) Tick(ctx *inkwell.UICtx) { World.PumpTick(ctx.DT) }
func (p *actionPump) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
+5 -9
View File
@@ -16,14 +16,12 @@ type hudFrame struct {
Name string
}
func (h *hudFrame) GetName() string { return h.Name }
func (h *hudFrame) Layer() inkwell.Layer { return inkwell.LayerPanel }
func (h *hudFrame) Tick(ctx *inkwell.UICtx) {}
func (h *hudFrame) GetName() string { return h.Name }
func (h *hudFrame) VisibleWhen() inkwell.Condition { return whenPlaying }
func (h *hudFrame) Layer() inkwell.Layer { return inkwell.LayerPanel }
func (h *hudFrame) Tick(ctx *inkwell.UICtx) {}
func (h *hudFrame) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if !World.HUDVisible() {
return
}
th := ctx.Game.Theme()
vector.DrawFilledRect(dst, 0, HUDTop+1, ScreenW, ScreenH-HUDTop-1, th.PanelBG, false)
@@ -32,6 +30,4 @@ func (h *hudFrame) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
vector.StrokeLine(dst, DividerX, HUDTop+1, DividerX, ScreenH, 1, th.CharacterPanelBorder, false)
}
func (h *hudFrame) BlocksClickAt(p inkwell.Point) bool {
return World.HUDVisible() && p.Y >= HUDTop
}
func (h *hudFrame) BlocksClickAt(p inkwell.Point) bool { return p.Y >= HUDTop }
+3 -2
View File
@@ -5,8 +5,9 @@ import (
)
func init() {
WidgetManager.Register(gated("inventory", &inkwell.InventoryBar{
WidgetManager.Register(&inkwell.InventoryBar{
Name: "inventory",
When: whenPlaying,
Origin: inkwell.Point{
X: Pad + 2,
Y: invY,
@@ -15,5 +16,5 @@ func init() {
Cols: 4,
SlotSize: invSlot,
Gap: invGap,
}))
})
}
+6 -9
View File
@@ -19,23 +19,20 @@ type letterbox struct {
Bar float64
}
func (l *letterbox) GetName() string { return l.Name }
func (l *letterbox) Layer() inkwell.Layer { return inkwell.LayerScene }
func (l *letterbox) GetName() string { return l.Name }
func (l *letterbox) VisibleWhen() inkwell.Condition { return whenCutscene }
func (l *letterbox) Layer() inkwell.Layer { return inkwell.LayerScene }
func (l *letterbox) Tick(ctx *inkwell.UICtx) {
if World.Mode() != ModeCutscene || !World.TapeWaiting() {
if !World.TapeWaiting() {
return
}
if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
World.TakeTapeOffer()
}
}
func (l *letterbox) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if World.Mode() != ModeCutscene {
return
}
g := ctx.Game
th := g.Theme()
bar := l.Bar
@@ -48,7 +45,7 @@ func (l *letterbox) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
vector.DrawFilledRect(dst, 0, h-float32(bar), w, float32(bar), black, false)
if World.TapeWaiting() {
msg := "SPACE — " + TapeDisplayName(Dex) + " has something to say"
drawTextC(dst, msg, g.Width-textW(msg)-4, g.Height-int(bar)+4, th.ChatLogResponse)
msg := "SPACE — " + g.CharacterLabel(Dex) + " has something to say"
g.DrawText(dst, msg, g.Width-inkwell.TextWidth(msg)-4, g.Height-int(bar)+4, th.ChatLogResponse)
}
}
+6 -37
View File
@@ -2,7 +2,6 @@ package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
)
type Widget = inkwell.Widget
@@ -11,7 +10,7 @@ const (
ScreenW = 640
ScreenH = 380
LineH = glyphH + 2
LineH = inkwell.GlyphH + 2
Pad = 6
TopBarH = LineH + 2
@@ -28,38 +27,8 @@ const (
invGap = 4
)
type gate struct {
Name string
Inner Widget
}
func gated(name string, inner Widget) Widget {
return &gate{
Name: name,
Inner: inner,
}
}
func (n *gate) GetName() string { return n.Name }
func (n *gate) Layer() inkwell.Layer { return inkwell.LayerOf(n.Inner) }
func (n *gate) Tick(ctx *inkwell.UICtx) {
if World.HUDVisible() {
n.Inner.Tick(ctx)
}
}
func (n *gate) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if World.HUDVisible() {
n.Inner.Draw(dst, ctx)
}
}
func (n *gate) BlocksClickAt(p inkwell.Point) bool {
if !World.HUDVisible() {
return false
}
b, ok := n.Inner.(interface{ BlocksClickAt(inkwell.Point) bool })
return ok && b.BlocksClickAt(p)
}
var (
whenPlaying = inkwell.VarEq(VarMode, string(ModePlay))
whenCutscene = inkwell.VarEq(VarMode, string(ModeCutscene))
whenUnpaused = inkwell.And(whenPlaying, inkwell.Not(inkwell.VarEq(VarNote, NotePaused)))
)
+2 -43
View File
@@ -2,52 +2,11 @@ package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
func init() {
WidgetManager.Register(&sceneNav{
WidgetManager.Register(&inkwell.SceneNav{
Name: "scene_nav",
When: whenUnpaused,
})
}
type sceneNav struct {
Name string
}
func (n *sceneNav) GetName() string { return n.Name }
func (n *sceneNav) Layer() inkwell.Layer { return inkwell.LayerScene }
func (n *sceneNav) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
func (n *sceneNav) Tick(ctx *inkwell.UICtx) {
if !World.HUDVisible() || World.Paused() {
return
}
step := 0
switch {
case inpututil.IsKeyJustPressed(ebiten.KeyArrowRight):
step = 1
case inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft):
step = -1
default:
return
}
if next := n.neighbour(ctx.Game, step); next != "" {
World.Do(inkwell.GoTo(next))
}
}
func (n *sceneNav) neighbour(g *inkwell.Game, step int) string {
deck := g.SceneManager.Names()
if len(deck) < 2 {
return ""
}
for i, name := range deck {
if name == World.G.CurrentScene() {
return deck[((i+step)%len(deck)+len(deck))%len(deck)]
}
}
return deck[0]
}
+3 -2
View File
@@ -5,10 +5,11 @@ import (
)
func init() {
WidgetManager.Register(gated("status", &inkwell.StatusLine{
WidgetManager.Register(&inkwell.StatusLine{
Name: "status",
When: whenPlaying,
Y: statusY,
Align: inkwell.AlignLeft,
ScreenWidth: DividerX,
}))
})
}
+9 -13
View File
@@ -20,19 +20,15 @@ type tapeChannel struct {
Bounds inkwell.Rectangle
}
func (t *tapeChannel) GetName() string { return t.Name }
func (t *tapeChannel) Layer() inkwell.Layer { return inkwell.LayerHUD }
func (t *tapeChannel) GetName() string { return t.Name }
func (t *tapeChannel) VisibleWhen() inkwell.Condition { return whenPlaying }
func (t *tapeChannel) Layer() inkwell.Layer { return inkwell.LayerHUD }
func (t *tapeChannel) Tick(ctx *inkwell.UICtx) {}
func (t *tapeChannel) BlocksClickAt(p inkwell.Point) bool {
return World.HUDVisible() && t.Bounds.Contains(p)
}
func (t *tapeChannel) BlocksClickAt(p inkwell.Point) bool { return t.Bounds.Contains(p) }
func (t *tapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if !World.HUDVisible() {
return
}
g := ctx.Game
th := g.Theme()
b := t.Bounds
@@ -44,7 +40,7 @@ func (t *tapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.ChatLogBG, false)
speaker, speakerCol := t.lastSpeaker(g, th)
drawTextC(dst, speaker, int(b.X)+pad, int(b.Y)+pad, speakerCol)
g.DrawText(dst, speaker, int(b.X)+pad, int(b.Y)+pad, speakerCol)
hy := float32(b.Y+pad+lh) + 2
vector.StrokeLine(dst, float32(b.X)+pad, hy,
float32(b.X+b.W)-pad, hy, 1, th.CharacterPanelBorder, false)
@@ -70,7 +66,7 @@ func (t *tapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
default:
col, txt = th.ChatLogSystem, m.Text
}
for _, wl := range wrap(txt, wrapW) {
for _, wl := range inkwell.WrapText(txt, wrapW) {
rendered = append(rendered, line{
text: wl,
col: col,
@@ -87,7 +83,7 @@ func (t *tapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
rendered = rendered[start:]
}
for i, ln := range rendered {
drawTextC(dst, ln.text, int(b.X)+pad, top+i*lh, ln.col)
g.DrawText(dst, ln.text, int(b.X)+pad, top+i*lh, ln.col)
}
}
@@ -96,10 +92,10 @@ func (t *tapeChannel) lastSpeaker(g *inkwell.Game, th inkwell.Theme) (string, co
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m.Kind == inkwell.LogResponse && IsTape(m.Speaker) {
return TapeDisplayName(m.Speaker), speechColor(g, m.Speaker, th.ChatLogResponse)
return g.CharacterLabel(m.Speaker), speechColor(g, m.Speaker, th.ChatLogResponse)
}
}
return TapeDisplayName(Dex), speechColor(g, Dex, th.ChatLogResponse)
return g.CharacterLabel(Dex), speechColor(g, Dex, th.ChatLogResponse)
}
func speechColor(g *inkwell.Game, name string, fallback color.Color) color.Color {
+14 -21
View File
@@ -18,12 +18,11 @@ type tapeSlots struct {
Bounds inkwell.Rectangle
}
func (t *tapeSlots) GetName() string { return t.Name }
func (t *tapeSlots) Layer() inkwell.Layer { return inkwell.LayerHUD }
func (t *tapeSlots) GetName() string { return t.Name }
func (t *tapeSlots) VisibleWhen() inkwell.Condition { return whenPlaying }
func (t *tapeSlots) Layer() inkwell.Layer { return inkwell.LayerHUD }
func (t *tapeSlots) BlocksClickAt(p inkwell.Point) bool {
return World.HUDVisible() && t.Bounds.Contains(p)
}
func (t *tapeSlots) BlocksClickAt(p inkwell.Point) bool { return t.Bounds.Contains(p) }
func (t *tapeSlots) slotRects() (inkwell.Rectangle, inkwell.Rectangle) {
b := t.Bounds
@@ -32,16 +31,13 @@ func (t *tapeSlots) slotRects() (inkwell.Rectangle, inkwell.Rectangle) {
}
func (t *tapeSlots) Tick(ctx *inkwell.UICtx) {
if !World.HUDVisible() {
return
}
g := ctx.Game
mp := g.Input.Point()
r1, r2 := t.slotRects()
switch {
case r1.Contains(mp):
g.SetHoverLabel(TapeDisplayName(Dex))
g.SetHoverLabel(g.CharacterLabel(Dex))
case r2.Contains(mp):
if s := World.Slot2(); s != "" {
g.SetHoverLabel(itemLabel(g, s))
@@ -68,12 +64,12 @@ func (t *tapeSlots) Tick(ctx *inkwell.UICtx) {
World.Do(inkwell.RunScript(ScriptTapeInsert))
return
}
World.Do(TapeSay(Dex, "That isn't a tape, Paul. That's an object. There is a difference."))
World.Do(inkwell.Say(Dex, "That isn't a tape, Paul. That's an object. There is a difference."))
return
}
if !slot2 && sel != "" {
g.Inventory.Select("")
World.Do(TapeSay(Dex, "The first slot is mine. I'm not moving."))
World.Do(inkwell.Say(Dex, "The first slot is mine. I'm not moving."))
return
}
@@ -87,24 +83,21 @@ func (t *tapeSlots) Tick(ctx *inkwell.UICtx) {
World.Do(Paused(inkwell.RunDialogue(TapeDialogue(s))))
return
}
World.Do(TapeSay(Dex, "Empty. Nobody to talk to."))
World.Do(inkwell.Say(Dex, "Empty. Nobody to talk to."))
default:
if !slot2 {
World.Do(TapeSay(Dex, "Me. Four kilobytes of a dead man. Be grateful."))
World.Do(inkwell.Say(Dex, "Me. Four kilobytes of a dead man. Be grateful."))
return
}
if s := World.Slot2(); s != "" {
World.Do(TapeSay(Dex, "That is the second slot. Be careful what you let in."))
World.Do(inkwell.Say(Dex, "That is the second slot. Be careful what you let in."))
return
}
World.Do(TapeSay(Dex, "The second slot is empty. That's the rarer state."))
World.Do(inkwell.Say(Dex, "The second slot is empty. That's the rarer state."))
}
}
func (t *tapeSlots) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if !World.HUDVisible() {
return
}
g := ctx.Game
th := g.Theme()
r1, r2 := t.slotRects()
@@ -118,11 +111,11 @@ func (t *tapeSlots) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
vector.StrokeRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, border, false)
x, inner := int(r.X)+Pad, int(r.W)-Pad*2
drawTextC(dst, label, x, int(r.Y)+Pad/2, th.ChatLogSystem)
drawTextC(dst, clip(body, inner), x, int(r.Y)+Pad/2+LineH, col)
g.DrawText(dst, label, x, int(r.Y)+Pad/2, th.ChatLogSystem)
g.DrawText(dst, inkwell.ClipText(body, inner), x, int(r.Y)+Pad/2+LineH, col)
}
drawSlot(r1, "SLOT 1", TapeDisplayName(Dex), true)
drawSlot(r1, "SLOT 1", g.CharacterLabel(Dex), true)
if s := World.Slot2(); s != "" {
drawSlot(r2, "SLOT 2", itemLabel(g, s), true)
} else {
+7 -19
View File
@@ -5,23 +5,11 @@ import (
)
func init() {
WidgetManager.Register(gated("topbar", &titleBar{
TopBar: &inkwell.TopBar{
Name: "topbar",
Height: TopBarH,
TimeVar: VarArmillaStrip,
},
}))
}
type titleBar struct {
*inkwell.TopBar
}
func (t *titleBar) Tick(ctx *inkwell.UICtx) {
t.LeftText = World.SceneTitle()
if World.Paused() {
t.LeftText += " — paused"
}
t.TopBar.Tick(ctx)
WidgetManager.Register(&inkwell.TopBar{
Name: "topbar",
When: whenPlaying,
Height: TopBarH,
TimeVar: VarArmillaStrip,
NoteVar: VarNote,
})
}
-110
View File
@@ -1,110 +0,0 @@
package inc
import (
"math/rand"
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
)
func init() {
WidgetManager.Register(&useWithGuard{
Name: "usewith_guard",
})
}
type useWithGuard struct {
Name string
}
func (u *useWithGuard) GetName() string { return u.Name }
func (u *useWithGuard) Layer() inkwell.Layer { return inkwell.LayerScene }
func (u *useWithGuard) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
func (u *useWithGuard) Tick(ctx *inkwell.UICtx) {
g := ctx.Game
if !World.HUDVisible() || !g.Input.LeftClicked() {
return
}
sel := g.Inventory.Selected()
if sel == "" {
return
}
h := g.HotspotAt(g.Input.Point())
if h == nil || hasAuthoredPair(g, h, sel) {
return
}
g.Input.ConsumeLeft()
g.Inventory.Select("")
label := h.Label
if label == "" {
label = h.Name
}
g.LogAction("> Use " + itemLabel(g, sel) + " on: " + label)
key := "fail." + sel + "." + h.Name
g.State.NoteTalked(key)
n := g.State.Talked(key)
World.Do(inkwell.Seq(
inkwell.Say(Paul, pick(paulFails, n)),
TapeSay(Dex, dexFail(n)),
))
}
func hasAuthoredPair(g *inkwell.Game, h *inkwell.Hotspot, item string) bool {
if h.OnUseWith != nil {
if a, ok := h.OnUseWith[item]; ok && a != nil {
return true
}
}
if it, ok := g.ItemManager.Get(item); ok && it.OnUseWith != nil {
if a, ok := it.OnUseWith[h.Name]; ok && a != nil {
return true
}
}
return false
}
var paulFails = []string{
"No. That's not going to work.",
"Tried that. It didn't improve.",
"All right, I know that one doesn't work.",
"Now it's personal.",
}
var dexDry = []string{
"No. Not like that.",
"I heard it. Nothing happened.",
}
var dexTease = []string{
"Twice the same. The second one rarely goes better.",
"Do it a few more times, maybe physics reconsiders.",
}
func dexFail(n int) string {
switch {
case n <= 1:
return pick(dexDry, n)
case n == 2:
return pick(dexTease, n)
default:
return "Paul. Leave it. Look again at what you're carrying — that's where it is."
}
}
func pick(s []string, n int) string {
switch {
case len(s) == 0:
return ""
case n <= 0:
return s[0]
case n-1 < len(s):
return s[n-1]
default:
return s[rand.Intn(len(s))]
}
}
-35
View File
@@ -1,35 +0,0 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
)
func init() {
WidgetManager.Register(&windowSizer{
Name: "window",
Scale: 2,
})
}
type windowSizer struct {
Name string
Scale int
done bool
}
func (s *windowSizer) GetName() string { return s.Name }
func (s *windowSizer) Layer() inkwell.Layer { return inkwell.LayerScene }
func (s *windowSizer) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
func (s *windowSizer) Tick(ctx *inkwell.UICtx) {
if s.done {
return
}
s.done = true
scale := s.Scale
if scale <= 0 {
scale = 2
}
ebiten.SetWindowSize(ctx.Game.Width*scale, ctx.Game.Height*scale)
}
+3 -74
View File
@@ -4,42 +4,7 @@ import (
inkwell "git.teletypegames.org/engines/inkwell"
)
func (w *world) Do(a inkwell.Action) {
if a != nil {
w.queue = append(w.queue, a)
}
}
func (w *world) PumpTick(dt float64) {
if w.running == nil {
if len(w.queue) == 0 {
return
}
w.running = w.queue[0].Start()
w.queue = w.queue[1:]
}
ctx := &inkwell.Ctx{
Game: w.G,
DT: dt,
}
if s, ok := w.G.SceneManager.Get(w.G.CurrentScene()); ok {
ctx.Scene = &s
}
if w.running.Tick(ctx) != inkwell.StatusRunning {
w.running = nil
}
}
func (w *world) SceneTitle() string {
s, ok := w.G.SceneManager.Get(w.G.CurrentScene())
if !ok {
return ""
}
if s.Title != "" {
return s.Title
}
return s.Name
}
func (w *world) Do(a inkwell.Action) { w.G.Do(a) }
type fnAction struct{ fn func(*inkwell.Ctx) }
@@ -70,42 +35,6 @@ func SetMode(m Mode) inkwell.Action {
return Fn(func(*inkwell.Ctx) { World.SetMode(m) })
}
func TapeSay(speaker, text string) inkwell.Action {
return &tapeSayAction{
speaker: speaker,
text: text,
}
}
type tapeSayAction struct{ speaker, text string }
func (a *tapeSayAction) Start() inkwell.Runner {
return &tapeSayRunner{
spec: a,
}
}
type tapeSayRunner struct {
spec *tapeSayAction
started bool
elapsed float64
duration float64
}
func (r *tapeSayRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
if !r.started {
r.started = true
r.duration = 1.2 + float64(len([]rune(r.spec.text)))*0.05
ctx.Game.LogResponse(r.spec.speaker, r.spec.text)
}
r.elapsed += ctx.DT
if r.elapsed >= r.duration {
return inkwell.StatusDone
}
return inkwell.StatusRunning
}
func TapeOffer(line inkwell.Action) inkwell.Action {
return Fn(func(*inkwell.Ctx) {
World.tapeLine = line
@@ -149,11 +78,11 @@ type pausedRunner struct {
func (r *pausedRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
if !r.started {
r.started = true
World.paused = true
World.SetNote(NotePaused)
}
s := r.inner.Tick(ctx)
if s != inkwell.StatusRunning {
World.paused = false
World.SetNote("")
}
return s
}
+11 -13
View File
@@ -15,12 +15,7 @@ const (
type world struct {
G *inkwell.Game
mode Mode
paused bool
pending string
queue []inkwell.Action
running inkwell.Runner
tapeWaiting bool
tapeLine inkwell.Action
@@ -30,22 +25,25 @@ var World = &world{}
func (w *world) attach(g *inkwell.Game) {
w.G = g
w.mode = ModePlay
w.paused = false
w.pending = ""
w.queue = nil
w.running = nil
w.tapeWaiting = false
w.tapeLine = nil
w.SetMode(ModePlay)
w.SetNote("")
}
func (w *world) Mode() Mode { return w.mode }
func (w *world) Mode() Mode {
if v, ok := w.G.State.Var(VarMode).(string); ok {
return Mode(v)
}
return ModePlay
}
func (w *world) SetMode(m Mode) { w.mode = m }
func (w *world) SetMode(m Mode) { w.G.State.SetVar(VarMode, string(m)) }
func (w *world) HUDVisible() bool { return w.mode == ModePlay }
func (w *world) SetNote(text string) { w.G.State.SetVar(VarNote, text) }
func (w *world) Paused() bool { return w.paused }
func (w *world) Paused() bool { return w.G.State.Var(VarNote) == NotePaused }
func (w *world) Slot2() string {
if v, ok := w.G.State.Var(VarSlot2).(string); ok {