Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions godot-jev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ the menu.

| Area | What it shows |
| --- | --- |
| Room art (top left) | A 160×90 pixel scene for the current room, scaled 4×. It fades between rooms, drops to near-black in a dark room without light, and switches to a room's variant art once the variant's flag is set (the hall after the skeleton falls). The room's name sits on a plaque in the corner. |
| Room art (top left) | A 160×90 pixel scene for the current room, scaled 4×. Fires flicker and shed embers, and rooms change through a dithered dissolve. It drops to near-black in a dark room without light, and switches to a room's variant art once the variant's flag is set (the hall after the skeleton falls). The room's name sits on a plaque in the corner. |
| Status panel (bottom left) | Exits and what you're carrying, plus **Jev heard**: the latest outcome (act, clarify, unknown, or error when the API is unreachable), the action it chose, a confidence meter with ticks at the clarify (0.40) and act (0.75) thresholds, and the turn's latency. It's there to make the experiment visible while playing. |
| Transcript (right) | Room names as headings, your commands echoed in muted text, item lines highlighted, and new text revealed a few characters at a time. Clarify options are links: click one, or type 1 or 2. |
| Turn scores | Each command Jev answered ends with its outcome and confidence (`act 0.94`, `clarify 0.52`, `offline`). Click one to open that turn's card: what you typed, the outcome and move, confidence against the thresholds, latency, how many options were offered, API calls, the model, and the top three candidates with their probabilities. Esc or a click elsewhere closes it. |
Expand Down Expand Up @@ -106,12 +106,22 @@ godot --headless --path . --script res://tools/paint_rooms.gd
```

`tools/paint_rooms.gd` draws each room in one-point perspective (brick walls, a flagstone floor
and a vaulted ceiling), cuts doorways for the room's exits in `world.json` (north is an arch in the
back wall; east and west are openings in the side walls; south is behind the viewer), and adds
props from small ASCII sprites: the skeleton, braziers, sconces, skull niches, chests and the crown.
and a vaulted ceiling with ribs carried down the walls as pilasters), cuts doorways for the room's
exits in `world.json` (north is an arch in the back wall; east and west are passages in the side
walls; south is behind the viewer), and adds props from small ASCII sprites and painters: the
skeleton, braziers, sconces, banners, weapon racks, skull niches, the altar, chests and the crown.
Lighting is computed per pixel from each room's lights and then snapped to fixed colour ramps with
4×4 Bayer dithering, so the output stays a limited-palette pixel image rather than a smooth
gradient.
gradient. Lights are warm (fire) or cool (daylight, bounce): where firelight dominates, stone,
floor and wood swap to warm twins of their ramps, with a dithered edge, so torchlight pools amber
against cold violet shadow. Seams between walls, floor and ceiling, and the floor under props,
are shaded darker.

In the game, `demo/room_fx.gd` animates the still image with a shader. Pixels in exactly the
flame ramp's colours step up and down that ramp, the scene brightens and dims slightly with the
firelight, and embers rise from the brightest flame pixels. Room changes dissolve pixel by pixel
in Bayer order. Hand-made art without those exact colours is shown as it is, with no flicker or
embers.

To use real art, replace any file with a 160×90 PNG of the same name: the room id, or the
variant's `art` name. `test_every_room_and_variant_has_art` fails if one is missing. Dark rooms
Expand All @@ -128,11 +138,12 @@ don't need separate art; the scene dims the room's image at runtime.
| `demo/start_menu.gd` | The start menu (the main scene) and its help page |
| `demo/help.gd` | BBCode for the help page, and the menu's Jev status line |
| `demo/adventure.gd` | The playable scene: art, status panel, transcript and input |
| `demo/style.gd` | Palette, fonts, framed panels and theme shared by the menu and the game |
| `demo/style.gd` | Palette, fonts, brass-cornered panels and theme shared by the menu and the game |
| `demo/room_fx.gd` | Flame flicker, embers and the dissolve between rooms, for the menu and the game |
| `demo/transcript.gd` | BBCode for the transcript; escapes all authored and typed text |
| `demo/art/`, `demo/fonts/` | Room images and the two pixel fonts |
| `tools/paint_rooms.gd` | Regenerates the placeholder room art |
| `tests/` | A headless runner and 34 tests; a script error inside a test counts as a failure |
| `tests/` | A headless runner and 40 tests; a script error inside a test counts as a failure |

Using the add-on in any game:

Expand Down
33 changes: 29 additions & 4 deletions godot-jev/demo/adventure.gd
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const World := preload("res://demo/world.gd")
const Parser := preload("res://demo/parser.gd")
const Transcript := preload("res://demo/transcript.gd")
const Style := preload("res://demo/style.gd")
const RoomFx := preload("res://demo/room_fx.gd")
const TITLE_FONT := Style.TITLE_FONT

const NOT_UNDERSTOOD := [
Expand All @@ -33,6 +34,8 @@ var reveal: Tween
var shown_art := ""

var art: TextureRect
var art_holder: Control
var embers: CPUParticles2D
var plaque: Label
var exits_label: Label
var pack_label: Label
Expand Down Expand Up @@ -194,12 +197,31 @@ func _refresh() -> void:
return
var first := shown_art.is_empty()
shown_art = art_name
var target := DARKNESS if world.is_dark() else Color.WHITE
var fade := create_tween()
if not first:
fade.tween_property(art, "modulate", Color.BLACK, 0.15)
fade.tween_callback(func(): art.texture = load(World.art_path(world.art())))
fade.tween_property(art, "modulate", target, 0.35)
fade.tween_method(_set_reveal, 1.0, 0.0, 0.3)
fade.tween_callback(_show_art.bind(world.art(), world.is_dark()))
fade.tween_method(_set_reveal, 0.0, 1.0, 0.45)


## Swaps the room image while it's fully dissolved. A dark room keeps its
## art but dimmed, and nothing burns in it.
func _show_art(art_name: String, dark: bool) -> void:
var texture: Texture2D = load(World.art_path(art_name))
art.texture = texture
art.modulate = DARKNESS if dark else Color.WHITE
if embers:
embers.queue_free()
var pixel := minf(art_holder.size.x / texture.get_width(), art_holder.size.y / texture.get_height())
var sources := PackedVector2Array() if dark else RoomFx.ember_sources(texture.get_image())
embers = RoomFx.embers(sources, pixel)
embers.position = (art_holder.size - texture.get_size() * pixel) / 2.0
art_holder.add_child(embers)
art_holder.move_child(embers, art.get_index() + 1)


func _set_reveal(amount: float) -> void:
(art.material as ShaderMaterial).set_shader_parameter("reveal", amount)


func _show_decision(result: Dictionary, elapsed_ms: int, texts: Dictionary) -> void:
Expand Down Expand Up @@ -286,7 +308,10 @@ func _details_popup() -> PopupPanel:
func _art_view() -> Control:
var holder := Control.new()
holder.custom_minimum_size = Vector2(640, 360)
holder.clip_contents = true
art_holder = holder
art = TextureRect.new()
art.material = RoomFx.material()
art.set_anchors_preset(Control.PRESET_FULL_RECT)
art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
Expand Down
Binary file modified godot-jev/demo/art/antechamber.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified godot-jev/demo/art/armory.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified godot-jev/demo/art/hall.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified godot-jev/demo/art/hall_cleared.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified godot-jev/demo/art/ossuary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified godot-jev/demo/art/stairs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified godot-jev/demo/art/vault.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
125 changes: 125 additions & 0 deletions godot-jev/demo/room_fx.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
extends RefCounted
## Motion for the still room art: fires flicker, embers rise from them, and
## rooms change through a dithered dissolve. The painter's flame ramp is the
## key. Only pixels in exactly those colours flicker or shed embers, so
## hand-made art in other colours is left alone.

## The flame ramp, dark to light. tools/paint_rooms.gd paints fire with it.
const FLAME := ["3a0e04", "8a2a06", "d65a0e", "f59a1c", "ffd24a", "fff6c0"]
## Art pixels in the brightest steps of FLAME become ember sources.
const EMBER_STEPS := 2
const MAX_EMBERS := 24

const SHADER := """
shader_type canvas_item;

// The flame ramp, dark to light; flame pixels step along it over time.
uniform vec4 flame[6];
uniform vec2 art_size = vec2(160.0, 90.0);
// 1 shows the whole image; below 1, pixels drop out in Bayer order.
uniform float reveal : hint_range(0.0, 1.0) = 1.0;
// How much the whole scene brightens and dims with the firelight.
uniform float breathing = 0.035;

// The node's modulate, kept apart because COLOR in fragment() already
// includes the texture.
varying vec4 tint;

float hash(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}

float bayer4(vec2 p) {
int x = int(mod(p.x, 4.0));
int y = int(mod(p.y, 4.0));
int m[16] = int[](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);
return (float(m[y * 4 + x]) + 0.5) / 16.0;
}

void vertex() {
tint = COLOR;
}

void fragment() {
vec4 c = texture(TEXTURE, UV);
vec2 px = floor(UV * art_size);
for (int i = 0; i < 6; i++) {
if (distance(c.rgb, flame[i].rgb) < 0.02) {
vec2 cell = floor(px / vec2(2.0, 3.0));
float tick = floor(TIME * 9.0 + hash(cell) * 4.0);
float n = hash(cell + tick);
int j = clamp(i + (n > 0.7 ? 1 : (n < 0.3 ? -1 : 0)), 0, 5);
c.rgb = flame[j].rgb;
break;
}
}
float wave = sin(TIME * 7.1) * 0.5 + sin(TIME * 12.7 + 1.3) * 0.3 + sin(TIME * 2.3) * 0.2;
c.rgb *= 1.0 + breathing * wave;
if (bayer4(px) >= reveal) {
c.rgb = vec3(0.0);
}
COLOR = c * tint;
}
"""


static func material(art_size := Vector2(160, 90)) -> ShaderMaterial:
var shader := Shader.new()
shader.code = SHADER
var mat := ShaderMaterial.new()
mat.shader = shader
mat.set_shader_parameter("flame", FLAME.map(func(hex): return Color(hex)))
mat.set_shader_parameter("art_size", art_size)
return mat


## Art-pixel positions of the brightest flame pixels, at most MAX_EMBERS of
## them, spread evenly through the image.
static func ember_sources(image: Image) -> PackedVector2Array:
var hot: Array = FLAME.slice(FLAME.size() - EMBER_STEPS).map(func(hex): return Color(hex))
var found := PackedVector2Array()
for y in image.get_height():
for x in image.get_width():
var c := image.get_pixel(x, y)
for h: Color in hot:
if absf(c.r - h.r) + absf(c.g - h.g) + absf(c.b - h.b) < 0.03:
found.append(Vector2(x, y))
break
if found.size() <= MAX_EMBERS:
return found
var spread := PackedVector2Array()
for i in MAX_EMBERS:
spread.append(found[i * found.size() / MAX_EMBERS])
return spread


## Embers drifting up from `sources` (art pixels), drawn as whole art pixels
## at `pixel` screen pixels each.
static func embers(sources: PackedVector2Array, pixel: float) -> CPUParticles2D:
var p := CPUParticles2D.new()
var dot := Image.create(1, 1, false, Image.FORMAT_RGBA8)
dot.fill(Color.WHITE)
p.texture = ImageTexture.create_from_image(dot)
p.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
p.emission_shape = CPUParticles2D.EMISSION_SHAPE_POINTS
var points := PackedVector2Array()
for s in sources:
points.append((s + Vector2(0.5, 0.5)) * pixel)
p.emission_points = points
p.emitting = not points.is_empty()
p.amount = clampi(points.size() / 2, 3, 12)
p.lifetime = 1.6
p.preprocess = 1.6
p.direction = Vector2.UP
p.spread = 25.0
p.gravity = Vector2(0, -6 * pixel)
p.initial_velocity_min = 2.0 * pixel
p.initial_velocity_max = 5.0 * pixel
p.scale_amount_min = pixel * 0.5
p.scale_amount_max = pixel
var fade := Gradient.new()
fade.set_color(0, Color(FLAME[5]))
fade.set_color(1, Color(Color(FLAME[2]), 0.0))
fade.add_point(0.4, Color(FLAME[4]))
p.color_ramp = fade
return p
1 change: 1 addition & 0 deletions godot-jev/demo/room_fx.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://c82fkff1dvrrt
13 changes: 12 additions & 1 deletion godot-jev/demo/start_menu.gd
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const Style := preload("res://demo/style.gd")
const Help := preload("res://demo/help.gd")
const Parser := preload("res://demo/parser.gd")
const World := preload("res://demo/world.gd")
const RoomFx := preload("res://demo/room_fx.gd")
const GAME := "res://demo/adventure.tscn"
const BACKDROP := "stairs"
const TAGLINE := "Type anything. Only what the author wrote can happen."
Expand All @@ -32,8 +33,16 @@ func _ready() -> void:
art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
art.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
art.modulate = Color(0.35, 0.32, 0.4)
art.modulate = Color(0.5, 0.45, 0.55)
art.material = RoomFx.material()
add_child(art)
var screen := get_viewport_rect().size
var art_size := art.texture.get_size()
var pixel := maxf(screen.x / art_size.x, screen.y / art_size.y) # the art covers the screen
var embers := RoomFx.embers(RoomFx.ember_sources(art.texture.get_image()), pixel)
embers.position = (screen - art_size * pixel) / 2.0
embers.modulate = Color(1, 1, 1, 0.6)
add_child(embers)
title_view = _title_view()
add_child(title_view)
help_view = _help_view()
Expand Down Expand Up @@ -77,6 +86,8 @@ func _title_view() -> Control:
title.add_theme_color_override("font_shadow_color", Style.BG)
title.add_theme_constant_override("shadow_offset_x", 4)
title.add_theme_constant_override("shadow_offset_y", 4)
title.add_theme_color_override("font_outline_color", Style.BG)
title.add_theme_constant_override("outline_size", 12)
column.add_child(title)
var tagline := Label.new()
tagline.text = TAGLINE
Expand Down
46 changes: 44 additions & 2 deletions godot-jev/demo/style.gd
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const EDGE_DARK := Color("2a2130")
const INK := Color("e8dcc0")
const DIM := Color("8d82a3")
const GOLD := Color("f0b85a")
const BRASS := Color("b08a4a")


## Pixel fonts stay sharp only without antialiasing, hinting or subpixel offsets.
Expand All @@ -34,15 +35,20 @@ static func box(fill: Color, border: Color, width: int, padding: Vector4) -> Sty
return style


## A double border: a dark outer line around a brass inner line.
## A double border: a dark outer line around a brass inner line, with brass
## corner pieces riveted over it.
static func framed(content: Control) -> PanelContainer:
var outer := PanelContainer.new()
outer.add_theme_stylebox_override("panel", box(EDGE_DARK, BG, 2, Vector4.ONE * 2))
var inner := PanelContainer.new()
inner.add_theme_stylebox_override("panel", box(PANEL, EDGE, 2, Vector4.ONE * 12))
var fill := box(PANEL, EDGE, 2, Vector4.ONE * 12)
fill.shadow_color = Color(0, 0, 0, 0.45)
fill.shadow_size = 6
inner.add_theme_stylebox_override("panel", fill)
inner.size_flags_vertical = Control.SIZE_EXPAND_FILL
outer.add_child(inner)
inner.add_child(content)
outer.add_child(Corners.new())
return outer


Expand Down Expand Up @@ -79,3 +85,39 @@ static func theme() -> Theme:
t.set_stylebox("focus", "Button", box(Color(0, 0, 0, 0), GOLD, 2, pad))
t.set_stylebox("pressed", "Button", box(GOLD, GOLD, 2, pad))
return t


## Brass L-shaped corner pieces with a rivet, drawn in whole 2px steps so
## they sit on the same grid as the pixel fonts.
class Corners:
extends Control

const ARM := 14
const THICK := 4

func _init() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE

func _draw() -> void:
for corner in [Vector2(0, 0), Vector2(1, 0), Vector2(0, 1), Vector2(1, 1)]:
var flip := Vector2(1 - 2 * corner.x, 1 - 2 * corner.y)
var at := Vector2(corner.x * size.x, corner.y * size.y)
_piece(at, flip)

func _piece(at: Vector2, flip: Vector2) -> void:
for layer in [[BG, 2], [BRASS, 0]]:
var grow: float = layer[1]
var horizontal := Rect2(-grow, -grow, ARM + grow * 2, THICK + grow * 2)
var vertical := Rect2(-grow, -grow, THICK + grow * 2, ARM + grow * 2)
for r: Rect2 in [horizontal, vertical]:
draw_rect(_place(r, at, flip), layer[0])
draw_rect(_place(Rect2(0, 0, ARM, 2), at, flip), GOLD)
draw_rect(_place(Rect2(0, 0, 2, ARM), at, flip), GOLD)
draw_rect(_place(Rect2(THICK + 2, THICK + 2, 4, 4), at, flip), BG)
draw_rect(_place(Rect2(THICK + 2, THICK + 2, 2, 2), at, flip), GOLD)

## Mirrors a rect drawn for the top-left corner into any corner.
func _place(r: Rect2, at: Vector2, flip: Vector2) -> Rect2:
var a := at + r.position * flip
var b := at + r.end * flip
return Rect2(Vector2(minf(a.x, b.x), minf(a.y, b.y)), (b - a).abs())
Binary file modified godot-jev/docs/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions godot-jev/tests/run_tests.gd
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const SUITES := [
"res://tests/test_parser.gd",
"res://tests/test_transcript.gd",
"res://tests/test_menu.gd",
"res://tests/test_room_fx.gd",
]

var failures := 0
Expand Down
Loading
Loading