Skip to content

Latest commit

 

History

History
339 lines (271 loc) · 12.8 KB

input.adoc

File metadata and controls

339 lines (271 loc) · 12.8 KB

Input, time, and the window

Each host cycle, update! receives an App.Input describing what happened since the previous cycle. This chapter covers the keyboard, mouse, gamepads, typed text, time, the window, and dropped files. It assumes [app-model].

Two kinds of information

An input contains two different kinds of information, and the difference matters when you read it:

  • A state sample is the latest value at the moment the cycle began: whether a key is held, where the pointer is, how far a gamepad stick is pushed.

  • An interval event is something that happened since the previous input: a key press, a release, a click, a wheel notch, a typed character.

A key tapped quickly between two cycles is never held at a cycle boundary, but the press and the release still arrive as events. RocRay records keyboard, mouse-button, wheel, and text events as the window system delivers them, so every such event appears in the next input; none is silently lost.

The keyboard

Read keys through the devices snapshot:

devices = input.devices
jump = devices.key_pressed(KeySpace)     # went down this cycle
running = devices.key_down(KeyLeftShift) # held right now
released = devices.key_released(KeyW)    # came up this cycle

Keys are tags such as KeySpace, KeyW, KeyLeft, and KeyEnter; the Keys page of the API reference lists them all.

key_pressed and key_released mean "at least once since the previous input". Several presses of the same key in one interval count as one. When the count or the order matters, read devices.events instead (see The ordered event record). A double tap is one example. Text editing is another, where a key press can come between two typed characters.

By default, Escape is the exit key: it ends the app before update! sees the press, so you do not check for it yourself. An app that wants Escape for something else, such as a pause menu, turns that off with App.default.with_exit_key(NoExitKey). The window’s close button still ends the app; [app-model-closing] shows how to handle it yourself.

Typed text

devices.text_input holds the Unicode codepoints typed since the previous input, in the order typed and according to the user’s keyboard layout. Use it for a name entry box or a chat line, rather than working out characters from key presses. Up to 32 codepoints are delivered per input; if more were typed, the rest are discarded and devices.text_input_overflow is set.

The mouse

mouse = input.devices.mouse
pointer = mouse.position()           # { x, y } in window coordinates
held = mouse.button_down(Left)
clicked = mouse.button_pressed(Left)
scroll = mouse.wheel_delta()         # every notch this interval, summed

Positions use the same logical coordinates as drawing: x grows to the right and y grows downward, starting from the window’s top-left corner. mouse.delta() is the movement since the previous input.

Change the cursor’s shape with Mouse.set_cursor! and its mode with Mouse.set_cursor_mode!(Hidden) or Mouse.set_cursor_mode!(Locked). Both are legal in init!, update!, and tasks.

Gamepads

The host samples up to four gamepads once per cycle. They are named One, Two, Three, and Four, following the operating system’s numbering; a single controller is almost always One. input.devices.gamepad(id) returns Connected(pad), where pad is a view you can query, or Disconnected:

stick = match input.devices.gamepad(One) {
    Connected(pad) => pad.left_stick()
    Disconnected => { x: 0, y: 0 }
}

Buttons use layout-neutral names: FaceDown is the bottom face button, whatever its label says. So pad.button_pressed(FaceDown) is "jump" on any controller.

Gamepad buttons are state samples only. The host compares this cycle’s sample with the previous one to compute button_pressed and button_released, so a press and release that both happen between two samples is not seen. At 60 cycles a second, that is a tap shorter than about 17 milliseconds. This is rare for a deliberate press, but it can happen when a player presses a button very rapidly, or when the frame rate drops. For anything that must never be missed, use a held button, not a single press or release. Gamepads never appear in devices.events.

The ordered event record

devices.events lists every key press and release, click, wheel notch, and typed character since the previous input, in the order they arrived, across all of those sources:

Event : [
    KeyPressed(Keys.Key),
    KeyReleased(Keys.Key),
    ButtonPressed(Mouse.Button, { x : F32, y : F32 }),
    ButtonReleased(Mouse.Button, { x : F32, y : F32 }),
    Wheel({ x : F32, y : F32 }),
    Text(U32),
]

Each click includes the pointer position where it happened. The list holds up to 256 events per input; if more arrive, the rest are discarded and devices.events_overflow is set. The combined values (key_pressed, the wheel sum, text_input) are still updated when the list is full.

Time

input.time describes this cycle:

  • elapsed_seconds is how much time passed since the previous cycle, in seconds. The first cycle reports 0.

  • cycle_count counts cycles from zero.

Move things by speed times elapsed time, and they move at the same speed whatever the frame rate. With plain records:

dt = input.time.elapsed_seconds
position = {
    x: model.position.x + model.velocity.x * dt,
    y: model.position.y + model.velocity.y * dt,
}

The Math module has the same arithmetic ready-made. Make a vector with Math.vec2(x, y) and store it as a Math.Vec2; its methods add, sub, scale, length, and normalize then read as one line:

# With position : Math.Vec2 and velocity : Math.Vec2 in the model:
position = model.position.add(model.velocity.scale(dt))

Time.now!() reads the wall clock as a UTC Time.Timestamp, for things like naming a save file. It does not wait, and it is legal in init!, update!, and tasks. Use input.time, not the wall clock, for animation and movement.

Uneven cycle times

elapsed_seconds is the real time between two cycles, and it varies. A cycle is usually a few milliseconds, but the operating system decides: while the user drags the window, or while you pause the app in a debugger, no cycles run, and the next cycle reports all the time that passed, possibly several seconds. The host reports that value as it is, without limiting it, because only the app knows how it should handle a long pause. So a simple position + velocity * dt can move a player straight through a wall.

The simplest protection is to clamp the elapsed time before you use it, so one cycle never advances the game by more than a limit you choose. Paddle in [first-game] uses a twentieth of a second, and most examples use a quarter:

dt = Math.clamp(input.time.elapsed_seconds, 0, 0.25)

Physics and collision work best with a fixed step: the game always advances by exactly the same small amount, say 1/60 of a second, and runs as many of those steps each cycle as fit in the elapsed time. Because every step is the same size, the result does not depend on how fast the computer is or how uneven the cycles are: the same input always moves things the same way, and no single step is large enough to carry an object through a wall.

Leftover time is kept in the model for the next cycle. Each of those advances is a simulation step, and one host cycle may run zero, one, or several of them:

step_seconds : F32
step_seconds = 1 / 60

## Run as many whole steps as fit in `banked`, the time saved so far,
## and return the time left over.
run_steps : World, F32 -> (World, F32)
run_steps = |world, banked| {
    if banked < step_seconds {
        (world, banked)
    } else {
        run_steps(simulate(world), banked - step_seconds)
    }
}

# In update!:
dt = Math.clamp(input.time.elapsed_seconds, 0, 0.25)
(world, accumulator) = run_steps(model.world, model.accumulator + dt)
Ok({ ..model, world: world, accumulator: accumulator })

Here World is the part of your model that the game logic updates, and simulate is your own pure function that advances it by one step. Snake moves its snake this way. [architecture] explains the difference between a host cycle and a simulation step.

Some runs report a fixed elapsed_seconds rather than a measured one. A headless run (see [testing]) reports exactly 1/60 of a second every cycle, and a recording with FixedStep timing reports exactly one recording frame’s worth (see [capture]). Those are the runs you can repeat and get the same result, provided the input, the random seed, and any task results are the same too.

Randomness

Keep a Random.State in the model and take random values from it with pure generators, so a run can be reproduced from its seed. Seed it once in init!, from io.entropy!() for a run that should vary, or from a constant for one that must reproduce:

# In init!:
rng = Random.seed(U64.to_u32_wrap(io.entropy!()))

Each call to Random.step returns a value and the next state. Keep the state, or the next call returns the same value again:

x = Random.step(model.rng, Random.f32(0, 800))
Ok({ ..model, rng: x.state, star: { x: x.value, y: 0 } })

Random.f32(low, high) gives an F32, ready to use as a position. The integer generators, such as Random.bounded_i32(0, 9), give integers; convert one before mixing it with positions, for example I32.to_f32(column.value) * 60.

The window

input.window reports:

  • size, the window’s { width, height };

  • focused, whether it has keyboard focus;

  • minimized, whether it is minimized. A minimized window still runs cycles, so an app that should pause while minimized checks this.

  • close_requested, whether the user asked to close it this cycle. It is only ever set when the app asked for close requests with with_close_request(Deliver); see [app-model-closing], which shows how to save before the app ends.

The size is in logical units: the same units as mouse coordinates and drawing. On a high-DPI display, such as a Retina screen, the window has more physical pixels than logical units, so that things are not drawn tiny. Window.scale!() returns the factor between the two on each axis, for example { x: 2, y: 2 }. Most apps never need it.

Window effects are legal in init!, update!, and tasks:

  • Window.suggest_size!, Window.suggest_min_size!, Window.suggest_position!, and Window.suggest_monitor! ask the window manager for new geometry. They are named suggest because the window manager may change or ignore the request; the next input’s window field shows the actual result.

  • Window.set_target_fps! changes the frame-rate cap; see below.

  • Window.monitors!() lists the connected monitors.

Frame pacing

Frame pacing decides how often cycles run. Choose it at startup with with_frame_pacing:

  • Capped(fps) runs at most fps cycles a second. App.default uses Capped(240). A value of zero or less means Uncapped.

  • VSync waits for the display’s refresh, usually 60 times a second, which avoids tearing.

  • Uncapped runs as fast as the machine allows.

Window.set_target_fps!(fps) changes the cap while the app runs, for example to slow down while minimized. It sets the same cap as Capped, and a value of zero or less removes it. It does not turn VSync on or off: if the app started with VSync, the display still limits the rate, and the app runs at whichever of the two is slower.

Whatever the pacing, read elapsed_seconds rather than assuming a rate.

Dropped files

When the user drops files on the window, input.dropped lists them for that cycle, each with its path and the pointer position where it landed, and input.dropped_overflow is set if more arrived than the host keeps.

A dropped path is only information; it does not give the app access to the file. To read the file, turn the path into a handle in the same update! that received it; see [files-designated].

Further reading