The three things the greengrocer was still missing, plus the physics bug that
finding them uncovered.
HANGING SCALES. Real PinJoint3D pendulums, not animations: a static dial and
rod, and a scale-pan rigid body pinned at its own origin so it can only rotate
about the pivot. They hang at chest height down the aisles — dial at eye level,
dish below — where a real shop scale hangs and where you keep walking into it.
The dish swings 0.40 m off a light knock and the pin holds the pivot to 1.1 mm.
The pan is steel so it never breaks; what you get is a heavy brass weight loose
in a room full of stacked fruit.
THE BAG YOU CANNOT OPEN. Tearing one off the roll is the easy half — that's the
setup. Then you have to open it, and the bag has two ends, only one of which
opens, and nothing tells you which. Rubbing alternates arrow keys, because
mashing one key is not rubbing. Either the meter climbs or it doesn't, and the
only way to learn which end you're holding is to have already lost several
seconds to the other one. SPACE turns it over. That is the whole solution and
the game never says so.
CARRY AND THROW. E picks up anything under 6 kg that isn't a record; LMB throws
it at 11 m/s, six times any brittle threshold in the game. Thrown fruit bursts
on landing through the same brittle_speed path a collapse uses — no separate
thrown-object code at all.
Then the part that took the longest. Round produce got sphere colliders (a box
on an apple is why nothing ever rolled) and every display in the shop instantly
fell over. Three separate things were wrong:
- the stack radii were TYPED, not measured. The table said a cabbage was
84 mm; the model is 213 mm, so that pyramid was built with every row driven
a third of the way into the row below it. Main._glb_radius() reads the asset.
- the lattice was box geometry. For spheres the rise per row is
sqrt(4r^2 - step^2/2) — the height at which a fruit touches all four
beneath it. Anything else spawns every row above the first in mid-air.
- there was no tray. A pyramid of spheres on a bare flat table cannot stand;
nothing holds the bottom row in, so the weight above wedges it outward and
the display walks itself apart in a second. Main._stack_tray() frames each
pile in four low timber walls sized to its base row, which is what every
greengrocer on earth already does.
All 310 bodies asleep within 3 s.
While chasing that, the spawn guard fired on a cardboard box in the OFFICE. The
guard is a net, not a test: it only catches a pair Jolt happens to resolve
violently on the frames it's watching, and this one had been interpenetrating
in four levels for weeks. dev/probe_overlap.gd now finds them by measurement,
comparing every pair of dynamic colliders across all six sites. It found 15,
including a row of filing cabinets 0.70 m apart that are 0.80 m wide, and a
stapler inside a monitor. The box asset is 1.35 m across, so boxes are now
stacked into piles rather than dotted about, and the Backrooms scatter uses
rejection sampling with a 1.6 m minimum. All six sites read CLEAN.
Also: the rage veins were drawing every branch from its own start point
regardless of how far the trunk had grown, so at low rage you got disconnected
fragments floating mid-screen that read as biro scribble rather than blood. A
branch now can't appear before the trunk carrying it, and trunks are thick and
dark where capillaries are fine and pale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
518 lines
16 KiB
GDScript
518 lines
16 KiB
GDScript
extends Node
|
|
class_name Tasks
|
|
|
|
## The job. Deliberately, precisely annoying.
|
|
##
|
|
## Stations sit in the world (a desk, the photocopier). You walk up, press E, and get
|
|
## locked in place doing something mundane — because you ARE at your desk, and being
|
|
## unable to walk away is part of it. Every station has a designed way of wasting your
|
|
## time, and every wasted second is fuel for Rage.
|
|
##
|
|
## SPREADSHEET — enter a figure in a named cell. The selection silently jumps to a
|
|
## neighbouring cell partway through. Commit without noticing and the
|
|
## figure lands in the wrong cell and you start again. This one is the
|
|
## whole thesis of the game in about forty lines.
|
|
## COPIER ...... run a copy job. It jams. Clearing it needs the RIGHT tray, named in
|
|
## tiny text, and the trays are not numbered in a sensible order.
|
|
## STAPLES ..... de-staple a bundle. Each staple wants SPACE then the right arrow key.
|
|
## Take too long on one and the sheet tears and you redo the bundle.
|
|
## GUILLOTINE .. align the sheet to a mark and cut. The guide reads 2 mm off, always
|
|
## has, and everyone who works here knows and nobody has fixed it.
|
|
## BAGS ........ tear a produce bag off the roll, then open it. The bag has TWO ends
|
|
## and only one of them opens. Nothing tells you which. You find out
|
|
## you picked wrong because the meter isn't moving.
|
|
##
|
|
## Completing work pays WAGES and soothes the meter a little — never quite enough.
|
|
##
|
|
## Godot 4.7 GDScript 2.0.
|
|
|
|
enum Kind { SPREADSHEET, COPIER, STAPLES, GUILLOTINE, BAGS }
|
|
enum Phase { IDLE, SPREADSHEET, COPIER_RUN, COPIER_JAM, STAPLES, GUILLOTINE,
|
|
BAGS, DONE }
|
|
|
|
const REACH := 2.1 ## metres you must be within to work at a station
|
|
const COLS := ["A", "B", "C", "D", "E", "F"]
|
|
const ROWS := 5
|
|
|
|
## Trays are labelled in the order someone at head office thought was obvious.
|
|
const TRAYS := ["1", "2", "2A", "3", "3B"]
|
|
|
|
@export var wage_per_task: int = 120
|
|
@export var soothe_per_task: float = 0.12
|
|
|
|
var rage: Rage
|
|
var _player: Node3D
|
|
var _stations: Array = [] # {kind, pos, label}
|
|
var _near: Dictionary = {} # the station currently in reach, or empty
|
|
|
|
var phase: int = Phase.IDLE
|
|
var wages: int = 0
|
|
var tasks_done: int = 0
|
|
var tasks_fumbled: int = 0
|
|
|
|
# --- spreadsheet state
|
|
var _target_cell := Vector2i.ZERO # where the figure is SUPPOSED to go
|
|
var _sel := Vector2i.ZERO # where the cursor actually is
|
|
var _want_value := ""
|
|
var _typed := ""
|
|
var _jump_at := 0.0 # seconds into the task the selection will drift
|
|
var _jumped := false
|
|
var _t := 0.0
|
|
|
|
# --- staples state
|
|
var _staples_left := 0
|
|
var _staples_total := 0
|
|
var _staple_dir := 0 # which arrow the current staple wants
|
|
var _staple_armed := false # SPACE lifts it; then the arrow pulls it out
|
|
var _staple_clock := 0.0
|
|
|
|
# --- bag state
|
|
var _bag_stage := 0 # 0 = tear one off the roll, 1 = get it open
|
|
var _bag_end := 0 # which end you're currently working
|
|
var _bag_open_end := 0 # the one that opens. Never displayed.
|
|
var _grip := 0.0
|
|
var _last_rub := 0 # rubbing needs alternating keys, like real life
|
|
var _futile := 0.0 # seconds spent on the sealed end
|
|
var _hinted := false
|
|
|
|
# --- guillotine state
|
|
var _cut_target := 0.0 # where the sheet should sit, 0..1
|
|
var _cut_at := 0.5 # where it actually is
|
|
var _cut_bias := 0.0 # the guide's permanent, unacknowledged error
|
|
var _cuts_left := 0
|
|
|
|
# --- copier state
|
|
var _pages_left := 0
|
|
var _job_pages := 0
|
|
var _copy_t := 0.0
|
|
var _jam_tray := ""
|
|
var _jam_in := 0.0
|
|
|
|
signal task_completed()
|
|
signal task_fumbled(reason: String)
|
|
|
|
func setup(player: Node3D, r: Rage) -> void:
|
|
_player = player
|
|
rage = r
|
|
|
|
func register(kind: int, pos: Vector3, label: String) -> void:
|
|
_stations.append({"kind": kind, "pos": pos, "label": label})
|
|
|
|
## Swapping sites invalidates every station position.
|
|
func clear_stations() -> void:
|
|
_stations.clear()
|
|
_near = {}
|
|
phase = Phase.IDLE
|
|
|
|
func is_working() -> bool:
|
|
return phase != Phase.IDLE and phase != Phase.DONE
|
|
|
|
## What the HUD should show as an interaction prompt, or "".
|
|
func prompt() -> String:
|
|
if is_working():
|
|
return ""
|
|
if _near.is_empty():
|
|
return ""
|
|
return "E — %s" % String(_near["label"])
|
|
|
|
func nearest_label() -> String:
|
|
return String(_near["label"]) if not _near.is_empty() else ""
|
|
|
|
# ---------------------------------------------------------------- proximity
|
|
func _process(dt: float) -> void:
|
|
_t += dt
|
|
if not is_working():
|
|
_update_near()
|
|
return
|
|
match phase:
|
|
Phase.SPREADSHEET:
|
|
_tick_spreadsheet(dt)
|
|
Phase.COPIER_RUN:
|
|
_tick_copier(dt)
|
|
Phase.COPIER_JAM:
|
|
pass # waits on input
|
|
# working cleanly is mildly calming, which is what makes the drift so infuriating
|
|
if rage != null and phase != Phase.COPIER_JAM:
|
|
rage.soothe(rage.focus_soothe * dt)
|
|
|
|
func _update_near() -> void:
|
|
_near = {}
|
|
if _player == null:
|
|
return
|
|
var best := REACH * REACH
|
|
for s in _stations:
|
|
var d: float = (_player.global_position - (s["pos"] as Vector3)).length_squared()
|
|
if d < best:
|
|
best = d
|
|
_near = s
|
|
|
|
# ---------------------------------------------------------------- entry / exit
|
|
## Player pressed E. Returns true if it started (so the caller can skip other uses of E).
|
|
func try_start() -> bool:
|
|
if is_working() or _near.is_empty():
|
|
return false
|
|
match int(_near["kind"]):
|
|
Kind.SPREADSHEET:
|
|
_begin_spreadsheet()
|
|
Kind.COPIER:
|
|
_begin_copier()
|
|
Kind.STAPLES:
|
|
_begin_staples()
|
|
Kind.GUILLOTINE:
|
|
_begin_guillotine()
|
|
Kind.BAGS:
|
|
_begin_bags()
|
|
return true
|
|
|
|
func abandon() -> void:
|
|
if not is_working():
|
|
return
|
|
phase = Phase.IDLE
|
|
tasks_fumbled += 1
|
|
task_fumbled.emit("walked away")
|
|
if rage != null:
|
|
rage.provoke(0.05, "you'll have to start that again")
|
|
|
|
## You snapped. You are no longer at your desk — the chair is somewhere behind you.
|
|
## Unlike abandon() this costs nothing: you didn't choose to walk away from the job.
|
|
func eject() -> void:
|
|
if not is_working():
|
|
return
|
|
phase = Phase.IDLE
|
|
|
|
func _finish() -> void:
|
|
phase = Phase.IDLE
|
|
tasks_done += 1
|
|
wages += wage_per_task
|
|
task_completed.emit()
|
|
if rage != null:
|
|
rage.soothe(soothe_per_task)
|
|
|
|
func _fumble(reason: String, amount: float) -> void:
|
|
tasks_fumbled += 1
|
|
task_fumbled.emit(reason)
|
|
if rage != null:
|
|
rage.provoke(amount, reason)
|
|
|
|
# ---------------------------------------------------------------- spreadsheet
|
|
func _begin_spreadsheet() -> void:
|
|
phase = Phase.SPREADSHEET
|
|
_target_cell = Vector2i(randi() % COLS.size(), randi() % ROWS)
|
|
_sel = _target_cell
|
|
_want_value = str(randi_range(1000, 9999))
|
|
_typed = ""
|
|
_jumped = false
|
|
# it drifts once, early enough that you've started typing and are looking at the keys
|
|
_jump_at = _t + randf_range(1.6, 3.4)
|
|
|
|
func _tick_spreadsheet(_dt: float) -> void:
|
|
if _jumped or _t < _jump_at:
|
|
return
|
|
_jumped = true
|
|
# nudge the selection one cell in a random direction. No sound, no animation.
|
|
var dirs := [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
|
|
var d: Vector2i = dirs[randi() % dirs.size()]
|
|
var moved := Vector2i(clampi(_sel.x + d.x, 0, COLS.size() - 1),
|
|
clampi(_sel.y + d.y, 0, ROWS - 1))
|
|
if moved == _sel: # clamped against an edge; go the other way
|
|
moved = Vector2i(clampi(_sel.x - d.x, 0, COLS.size() - 1),
|
|
clampi(_sel.y - d.y, 0, ROWS - 1))
|
|
_sel = moved
|
|
|
|
func _key_spreadsheet(k: int) -> void:
|
|
if k >= KEY_0 and k <= KEY_9:
|
|
if _typed.length() < 6:
|
|
_typed += str(k - KEY_0)
|
|
return
|
|
match k:
|
|
KEY_BACKSPACE:
|
|
_typed = _typed.substr(0, maxi(_typed.length() - 1, 0))
|
|
KEY_LEFT:
|
|
_sel.x = clampi(_sel.x - 1, 0, COLS.size() - 1)
|
|
KEY_RIGHT:
|
|
_sel.x = clampi(_sel.x + 1, 0, COLS.size() - 1)
|
|
KEY_UP:
|
|
_sel.y = clampi(_sel.y - 1, 0, ROWS - 1)
|
|
KEY_DOWN:
|
|
_sel.y = clampi(_sel.y + 1, 0, ROWS - 1)
|
|
KEY_ENTER, KEY_KP_ENTER:
|
|
_commit_cell()
|
|
|
|
func _commit_cell() -> void:
|
|
if _typed != _want_value:
|
|
_fumble("#VALUE! — that isn't the figure", 0.09)
|
|
_typed = ""
|
|
return
|
|
if _sel != _target_cell:
|
|
# the drift got you. The figure is now in the wrong cell and you have to redo it.
|
|
_fumble("it went in %s%d. it was meant to go in %s%d." % [
|
|
COLS[_sel.x], _sel.y + 1, COLS[_target_cell.x], _target_cell.y + 1], 0.17)
|
|
_typed = ""
|
|
_sel = _target_cell
|
|
_jumped = false
|
|
_jump_at = _t + randf_range(2.0, 4.0)
|
|
return
|
|
_finish()
|
|
|
|
# ---------------------------------------------------------------- copier
|
|
func _begin_copier() -> void:
|
|
phase = Phase.COPIER_RUN
|
|
_job_pages = randi_range(8, 18)
|
|
_pages_left = _job_pages
|
|
_copy_t = 0.0
|
|
_jam_in = randf_range(1.2, 2.8)
|
|
|
|
func _tick_copier(dt: float) -> void:
|
|
_copy_t += dt
|
|
if _copy_t >= 0.42:
|
|
_copy_t = 0.0
|
|
_pages_left -= 1
|
|
if _pages_left <= 0:
|
|
_finish()
|
|
return
|
|
_jam_in -= dt
|
|
if _jam_in <= 0.0:
|
|
phase = Phase.COPIER_JAM
|
|
_jam_tray = TRAYS[randi() % TRAYS.size()]
|
|
if rage != null:
|
|
rage.provoke(0.06, "PAPER JAM")
|
|
|
|
func _key_copier(k: int) -> void:
|
|
if phase != Phase.COPIER_JAM:
|
|
return
|
|
var pressed := ""
|
|
if k >= KEY_1 and k <= KEY_5:
|
|
pressed = TRAYS[k - KEY_1]
|
|
if pressed == "":
|
|
return
|
|
if pressed == _jam_tray:
|
|
phase = Phase.COPIER_RUN
|
|
_jam_in = randf_range(2.0, 5.5)
|
|
else:
|
|
_fumble("that's not tray %s" % _jam_tray, 0.08)
|
|
|
|
# ---------------------------------------------------------------- staples
|
|
## A bundle to de-staple. SPACE lifts a staple, then it has to be pulled the way it's
|
|
## bent — and it's bent a different way each time. Dither and the sheet tears.
|
|
const STAPLE_ARROWS := [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN]
|
|
const STAPLE_NAMES := ["<", ">", "^", "v"]
|
|
const STAPLE_PATIENCE := 2.6
|
|
|
|
func _begin_staples() -> void:
|
|
phase = Phase.STAPLES
|
|
_staples_total = randi_range(6, 11)
|
|
_staples_left = _staples_total
|
|
_next_staple()
|
|
|
|
func _next_staple() -> void:
|
|
_staple_dir = randi() % 4
|
|
_staple_armed = false
|
|
_staple_clock = STAPLE_PATIENCE
|
|
|
|
func _tick_staples(dt: float) -> void:
|
|
_staple_clock -= dt
|
|
if _staple_clock > 0.0:
|
|
return
|
|
# too slow: the sheet tears and the whole bundle is reprinted
|
|
_fumble("the sheet tore. reprint the bundle.", 0.14)
|
|
_staples_left = _staples_total
|
|
_next_staple()
|
|
|
|
func _key_staples(k: int) -> void:
|
|
if k == KEY_SPACE:
|
|
_staple_armed = true
|
|
return
|
|
var idx := STAPLE_ARROWS.find(k)
|
|
if idx < 0:
|
|
return
|
|
if not _staple_armed:
|
|
_fumble("lift it first", 0.04)
|
|
return
|
|
if idx != _staple_dir:
|
|
_fumble("bent the staple", 0.07)
|
|
_next_staple()
|
|
return
|
|
_staples_left -= 1
|
|
if _staples_left <= 0:
|
|
_finish()
|
|
return
|
|
_next_staple()
|
|
|
|
# ---------------------------------------------------------------- guillotine
|
|
## Slide the sheet to a mark and cut. `_cut_bias` is the guide's permanent error: the
|
|
## number printed on the rule is not where the blade lands, and never has been. Once you
|
|
## work out the offset the task is easy, which is the joke — you have to learn to
|
|
## distrust the equipment.
|
|
const CUT_TOLERANCE := 0.045
|
|
|
|
func _begin_guillotine() -> void:
|
|
phase = Phase.GUILLOTINE
|
|
_cuts_left = randi_range(3, 5)
|
|
_cut_bias = randf_range(0.05, 0.09) * (1.0 if randf() < 0.5 else -1.0)
|
|
_next_cut()
|
|
|
|
func _next_cut() -> void:
|
|
_cut_target = randf_range(0.22, 0.78)
|
|
_cut_at = 0.5
|
|
|
|
func _key_guillotine(k: int) -> void:
|
|
match k:
|
|
KEY_LEFT:
|
|
_cut_at = clampf(_cut_at - 0.02, 0.0, 1.0)
|
|
KEY_RIGHT:
|
|
_cut_at = clampf(_cut_at + 0.02, 0.0, 1.0)
|
|
KEY_SPACE, KEY_ENTER, KEY_KP_ENTER:
|
|
var landed := _cut_at + _cut_bias # where the blade ACTUALLY comes down
|
|
if absf(landed - _cut_target) <= CUT_TOLERANCE:
|
|
_cuts_left -= 1
|
|
if _cuts_left <= 0:
|
|
_finish()
|
|
else:
|
|
_next_cut()
|
|
else:
|
|
_fumble("crooked. that's the whole ream.", 0.11)
|
|
_next_cut()
|
|
|
|
# ---------------------------------------------------------------- bags
|
|
## The roll of produce bags.
|
|
##
|
|
## Stage one is fine: tear one off. Stage two is the whole joke — the bag is welded shut
|
|
## by static and has two ends, only one of which opens, and NOTHING indicates which. You
|
|
## rub, and either the meter climbs or it doesn't, and the only way to learn is to have
|
|
## already wasted several seconds on the wrong end.
|
|
##
|
|
## Rubbing also has to ALTERNATE (left, right, left) because mashing one key isn't
|
|
## rubbing. After a while on the sealed end it takes pity and tells you, but only after
|
|
## the provoke has landed.
|
|
const RUB_PER_STROKE := 0.075
|
|
const BAG_HINT_AFTER := 4.5
|
|
|
|
func _begin_bags() -> void:
|
|
phase = Phase.BAGS
|
|
_bag_stage = 0
|
|
_bag_end = 0
|
|
_bag_open_end = randi() % 2
|
|
_grip = 0.0
|
|
_last_rub = 0
|
|
_futile = 0.0
|
|
_hinted = false
|
|
|
|
func _tick_bags(dt: float) -> void:
|
|
if _bag_stage != 1 or _bag_end == _bag_open_end:
|
|
return
|
|
_futile += dt
|
|
if _futile > BAG_HINT_AFTER and not _hinted:
|
|
_hinted = true
|
|
_fumble("that is the sealed end", 0.13)
|
|
|
|
func _key_bags(k: int) -> void:
|
|
if _bag_stage == 0:
|
|
if k == KEY_SPACE or k == KEY_ENTER or k == KEY_KP_ENTER:
|
|
# tearing one off is the easy half; it always works, and that's the setup
|
|
_bag_stage = 1
|
|
_grip = 0.0
|
|
_futile = 0.0
|
|
return
|
|
match k:
|
|
KEY_SPACE:
|
|
# turn it over. This is the answer, and the game never says so.
|
|
_bag_end = 1 - _bag_end
|
|
_futile = 0.0
|
|
KEY_LEFT, KEY_RIGHT:
|
|
if k == _last_rub:
|
|
return # mashing one key is not rubbing
|
|
_last_rub = k
|
|
if _bag_end != _bag_open_end:
|
|
return # nothing happens. That is the entire mechanic.
|
|
_grip += RUB_PER_STROKE
|
|
if _grip >= 1.0:
|
|
_finish()
|
|
|
|
# ---------------------------------------------------------------- input
|
|
## Player forwards keys here while a task is open. Returns true if consumed.
|
|
func handle_key(k: int) -> bool:
|
|
if not is_working():
|
|
return false
|
|
if k == KEY_ESCAPE or k == KEY_E:
|
|
abandon()
|
|
return true
|
|
match phase:
|
|
Phase.SPREADSHEET:
|
|
_key_spreadsheet(k)
|
|
Phase.COPIER_RUN, Phase.COPIER_JAM:
|
|
_key_copier(k)
|
|
Phase.STAPLES:
|
|
_key_staples(k)
|
|
Phase.GUILLOTINE:
|
|
_key_guillotine(k)
|
|
Phase.BAGS:
|
|
_key_bags(k)
|
|
return true
|
|
|
|
# ---------------------------------------------------------------- HUD feed
|
|
## Everything the task panel needs to draw itself, as plain data.
|
|
func panel_state() -> Dictionary:
|
|
match phase:
|
|
Phase.SPREADSHEET:
|
|
return {
|
|
"kind": "spreadsheet",
|
|
"title": "QUARTERLY RETURNS.XLS",
|
|
"instruction": "Enter %s in cell %s%d" % [
|
|
_want_value, COLS[_target_cell.x], _target_cell.y + 1],
|
|
"cols": COLS, "rows": ROWS,
|
|
"sel": _sel, "typed": _typed,
|
|
"footer": "digits · arrows move · ENTER commit · E leave",
|
|
}
|
|
Phase.COPIER_RUN:
|
|
return {
|
|
"kind": "copier", "title": "MULTIFUNCTION DEVICE",
|
|
"instruction": "Copying… %d of %d" % [
|
|
_job_pages - _pages_left + 1, _job_pages],
|
|
"progress": 1.0 - float(_pages_left) / float(maxi(_job_pages, 1)),
|
|
"footer": "E leave",
|
|
}
|
|
Phase.COPIER_JAM:
|
|
return {
|
|
"kind": "copier", "title": "MULTIFUNCTION DEVICE",
|
|
"instruction": "PAPER JAM — CLEAR TRAY %s" % _jam_tray,
|
|
"jam": true, "trays": TRAYS,
|
|
"progress": 1.0 - float(_pages_left) / float(maxi(_job_pages, 1)),
|
|
"footer": "press the tray number · E leave",
|
|
}
|
|
Phase.STAPLES:
|
|
var done := _staples_total - _staples_left
|
|
return {
|
|
"kind": "staples", "title": "THE HENDERSON BUNDLE",
|
|
"instruction": ("pull it %s" % STAPLE_NAMES[_staple_dir]) if _staple_armed
|
|
else "staple %d of %d" % [done + 1, _staples_total],
|
|
"armed": _staple_armed,
|
|
"dir": STAPLE_NAMES[_staple_dir],
|
|
"progress": float(done) / float(maxi(_staples_total, 1)),
|
|
"urgency": clampf(1.0 - _staple_clock / STAPLE_PATIENCE, 0.0, 1.0),
|
|
"footer": "SPACE lift · arrow pull · E leave",
|
|
}
|
|
Phase.BAGS:
|
|
if _bag_stage == 0:
|
|
return {
|
|
"kind": "bags", "title": "PRODUCE BAGS",
|
|
"instruction": "tear one off the roll",
|
|
"stage": 0,
|
|
"footer": "SPACE tear · E leave",
|
|
}
|
|
return {
|
|
"kind": "bags", "title": "PRODUCE BAGS",
|
|
"instruction": "open the bag",
|
|
"stage": 1, "grip": _grip, "end": _bag_end,
|
|
"footer": "← → rub (alternate) · SPACE turn it over · E leave",
|
|
}
|
|
Phase.GUILLOTINE:
|
|
return {
|
|
"kind": "guillotine", "title": "ROTATRIM A3",
|
|
"instruction": "cut at %d mm · %d left" % [
|
|
int(round(_cut_target * 300.0)), _cuts_left],
|
|
"target": _cut_target, "at": _cut_at,
|
|
"progress": _cut_at,
|
|
"footer": "arrows slide · SPACE cut · E leave",
|
|
}
|
|
return {}
|