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
14 changes: 12 additions & 2 deletions godot-jev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ in that room at that moment.

## The screen

The game opens on a start menu over the crypt stairs: **Enter the crypt**, **Help** and **Quit**,
with a line saying whether Jev is listening (and which model) or offline for want of
`TYPESAFE_API_KEY`. Help is a scrolling page on what the experiment is about: the problem, how the
Choice over possible moves works, the act and clarify thresholds (read from the parser, not
written into the text), the hypothesis, and how to read the screen below. Back or Esc returns to
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. |
Expand Down Expand Up @@ -118,11 +125,14 @@ don't need separate art; the scene dims the room's image at runtime.
| `demo/world.json` | The authored dungeon: 6 rooms, 5 items, 3 interactions |
| `demo/world.gd` | Rules: possible actions, room variants, and authored responses only |
| `demo/parser.gd` | Player text to one possible action, gated by confidence |
| `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/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 29 tests; a script error inside a test counts as a failure |
| `tests/` | A headless runner and 34 tests; a script error inside a test counts as a failure |

Using the add-on in any game:

Expand Down Expand Up @@ -157,7 +167,7 @@ the game stays fully authored, and it's fast enough to feel like a parser, not a

```bash
export TYPESAFE_API_KEY=sk-... # read by the Jev autoload; local prototyping only
godot --path . # play
godot --path . # play (opens the start menu)
godot --headless --path . --import
godot --headless --path . --script res://tests/run_tests.gd
```
Expand Down
83 changes: 15 additions & 68 deletions godot-jev/demo/adventure.gd
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,20 @@ extends Control
const World := preload("res://demo/world.gd")
const Parser := preload("res://demo/parser.gd")
const Transcript := preload("res://demo/transcript.gd")
const BODY_FONT := preload("res://demo/fonts/VT323-Regular.ttf")
const TITLE_FONT := preload("res://demo/fonts/PixelifySans.ttf")
const Style := preload("res://demo/style.gd")
const TITLE_FONT := Style.TITLE_FONT

const NOT_UNDERSTOOD := [
"You're not sure how to do that here.",
"Nothing here answers to that.",
"You hesitate. That doesn't seem possible right now.",
]
const REVEAL_CHARS_PER_S := 160.0
const BG := Color("0b0910")
const PANEL := Color("16121c")
const EDGE := Color("7a6038")
const EDGE_DARK := Color("2a2130")
const INK := Color("e8dcc0")
const DIM := Color("8d82a3")
const GOLD := Color("f0b85a")
const PANEL := Style.PANEL
const EDGE := Style.EDGE
const EDGE_DARK := Style.EDGE_DARK
const DIM := Style.DIM
const GOLD := Style.GOLD
const DARKNESS := Color(0.05, 0.045, 0.07)

var world: Object
Expand Down Expand Up @@ -50,10 +48,7 @@ var details_text: RichTextLabel


func _ready() -> void:
for font: FontFile in [BODY_FONT, TITLE_FONT]:
font.antialiasing = TextServer.FONT_ANTIALIASING_NONE
font.hinting = TextServer.HINTING_NONE
font.subpixel_positioning = TextServer.SUBPIXEL_POSITIONING_DISABLED
Style.crisp_fonts()
world = World.from_file("res://demo/world.json")
parser = Parser.new(Jev)
_build_ui()
Expand Down Expand Up @@ -248,9 +243,9 @@ func _finish_reveal() -> void:
# --- layout -------------------------------------------------------------------------

func _build_ui() -> void:
theme = _theme()
theme = Style.theme()
var bg := ColorRect.new()
bg.color = BG
bg.color = Style.BG
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(bg)

Expand All @@ -265,11 +260,11 @@ func _build_ui() -> void:
var left := VBoxContainer.new()
left.add_theme_constant_override("separation", 14)
row.add_child(left)
left.add_child(_framed(_art_view()))
var hud := _framed(_hud())
left.add_child(Style.framed(_art_view()))
var hud := Style.framed(_hud())
hud.size_flags_vertical = Control.SIZE_EXPAND_FILL
left.add_child(hud)
var text_box := _framed(_text_panel())
var text_box := Style.framed(_text_panel())
text_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(text_box)
add_child(_details_popup())
Expand All @@ -278,7 +273,7 @@ func _build_ui() -> void:
func _details_popup() -> PopupPanel:
details = PopupPanel.new()
details.theme = theme
details.add_theme_stylebox_override("panel", _box(PANEL, GOLD, 2, Vector4.ONE * 14))
details.add_theme_stylebox_override("panel", Style.box(PANEL, GOLD, 2, Vector4.ONE * 14))
details.popup_hide.connect(func(): input.grab_focus())
details_text = RichTextLabel.new()
details_text.bbcode_enabled = true
Expand All @@ -298,7 +293,7 @@ func _art_view() -> Control:
art.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
holder.add_child(art)
var tag := PanelContainer.new()
tag.add_theme_stylebox_override("panel", _box(Color(PANEL, 0.85), EDGE, 2, Vector4(10, 2, 10, 4)))
tag.add_theme_stylebox_override("panel", Style.box(Color(PANEL, 0.85), EDGE, 2, Vector4(10, 2, 10, 4)))
tag.position = Vector2(12, 12)
plaque = Label.new()
plaque.add_theme_font_override("font", TITLE_FONT)
Expand Down Expand Up @@ -405,54 +400,6 @@ func _caption(text: String) -> Label:
return label


## A double border: a dark outer line around a brass inner line.
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))
inner.size_flags_vertical = Control.SIZE_EXPAND_FILL
outer.add_child(inner)
inner.add_child(content)
return outer


func _box(fill: Color, border: Color, width: int, padding: Vector4) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = fill
style.border_color = border
style.set_border_width_all(width)
style.content_margin_left = padding.x
style.content_margin_top = padding.y
style.content_margin_right = padding.z
style.content_margin_bottom = padding.w
return style


func _theme() -> Theme:
var t := Theme.new()
t.default_font = BODY_FONT
t.default_font_size = 22
t.set_color("font_color", "Label", INK)
t.set_color("default_color", "RichTextLabel", INK)
t.set_color("selection_color", "RichTextLabel", Color(EDGE, 0.5))
t.set_color("font_color", "LineEdit", INK)
t.set_color("font_placeholder_color", "LineEdit", Color(DIM, 0.7))
t.set_color("caret_color", "LineEdit", GOLD)
t.set_constant("caret_width", "LineEdit", 2)
t.set_font_size("normal_font_size", "RichTextLabel", 22)
t.set_font("normal_font", "RichTextLabel", BODY_FONT)
t.set_constant("line_separation", "RichTextLabel", 2)
for state in ["normal", "focus", "read_only"]:
t.set_stylebox(state, "LineEdit", StyleBoxEmpty.new())
var scroll := _box(EDGE_DARK, EDGE_DARK, 0, Vector4.ZERO)
t.set_stylebox("scroll", "VScrollBar", _box(Color(0, 0, 0, 0), EDGE_DARK, 0, Vector4.ONE * 2))
t.set_stylebox("grabber", "VScrollBar", scroll)
t.set_stylebox("grabber_highlight", "VScrollBar", _box(EDGE, EDGE, 0, Vector4.ZERO))
t.set_stylebox("grabber_pressed", "VScrollBar", _box(EDGE, EDGE, 0, Vector4.ZERO))
return t


## The last answer's confidence, with ticks at the clarify and act thresholds.
class ConfidenceMeter:
extends Control
Expand Down
53 changes: 53 additions & 0 deletions godot-jev/demo/help.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
extends RefCounted
## BBCode for the start menu's help page: what this experiment is trying to
## show and how to read the screen while playing. The thresholds are passed
## in from the parser, so the page can't drift from what the game does.

const Transcript := preload("res://demo/transcript.gd")
const HttpDecider := preload("res://addons/jev/http_decider.gd")


static func page(clarify_at: float, act_at: float) -> String:
var k: Dictionary = Transcript.KIND
return "\n".join([
Transcript.heading("What is this?"),
"The Sunken Crypt is an experiment: can a game let you type anything, in your own words, while every room, item and line is still written by its author?",
"",
"Classic parser games failed on vocabulary: [i]\"I don't know the word 'grab'.\"[/i] Games run by a language model fail the other way, inventing doors and treasure nobody wrote.",
"",
Transcript.heading("How it works"),
"Each turn, the game lists the moves possible right now (take the torch, go north, attack the skeleton...) plus [i]none of these[/i]. Jev, a small decision model, picks the one that matches what you typed. It can't choose a door that isn't there, because that door is never one of the options.",
"",
"How sure Jev is decides what happens:",
" [color=%s]act[/color] confidence ≥ %.2f: the move happens" % [k.act, act_at],
" [color=%s]clarify[/color] confidence ≥ %.2f: \"Did you mean...\" with two choices" % [k.clarify, clarify_at],
" [color=%s]unknown[/color] anything lower, or none of these: an authored \"that doesn't seem possible\"" % k.unknown,
"",
Transcript.heading("Hypothesis"),
"Choosing among only the moves that are possible lets players type naturally with few dead ends, keeps the game fully authored, and is fast enough to feel like a parser rather than a chat. The experiment checks that:",
" · paraphrases, typos and indirect requests land on the move you meant",
" · impossible or off-topic requests come back as unknown",
" · a turn answers in well under a second",
" · the options Jev sees are exactly the possible moves, never more",
"",
Transcript.heading("Reading the screen"),
"[color=%s]Jev heard[/color] (bottom left) shows the last outcome, the move it chose, a confidence meter with ticks at %.2f and %.2f, and how long the turn took." % [Transcript.TITLE, clarify_at, act_at],
"Every command you type ends with a score such as [color=%s]act 0.94[/color]. Click it to see what Jev weighed: the top candidates, the options offered, and the latency." % k.act,
"",
Transcript.heading("Playing"),
"Find your way from the collapsed stair to the vault. Say it however you like: \"set the rag on fire\", \"stab the bones\", \"use the little key on the door\".",
"Enter submits, ↑ and ↓ recall earlier commands. When Jev asks which move you meant, type 1 or 2, or click one.",
"",
"[color=%s]Jev needs TYPESAFE_API_KEY in the environment. Without it the game still runs, but every turn reports that the parser is offline.[/color]" % Transcript.QUIET,
])


## One line for the menu: can Jev answer, and with which model?
static func status_line(decider: Object) -> String:
if decider == null:
return "Jev is offline: no decider configured"
if decider.get_script() == HttpDecider:
if decider.api_key.is_empty():
return "Jev is offline: set TYPESAFE_API_KEY to play"
return "Jev is listening · model %s" % decider.model
return "Jev is scripted: answers are canned, not live"
1 change: 1 addition & 0 deletions godot-jev/demo/help.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://c8aalc6t3lkaq
147 changes: 147 additions & 0 deletions godot-jev/demo/start_menu.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
extends Control
## The start menu: enter the crypt, read what the experiment is about, or quit.
## Help swaps the title for a scrolling page; Back or Esc swaps it back.

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 GAME := "res://demo/adventure.tscn"
const BACKDROP := "stairs"
const TAGLINE := "Type anything. Only what the author wrote can happen."

var title_view: Control
var help_view: Control
var help_text: RichTextLabel
var play_button: Button
var help_button: Button
var quit_button: Button
var back_button: Button


func _ready() -> void:
Style.crisp_fonts()
theme = Style.theme()
var bg := ColorRect.new()
bg.color = Style.BG
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(bg)
var art := TextureRect.new()
art.set_anchors_preset(Control.PRESET_FULL_RECT)
art.texture = load(World.art_path(BACKDROP))
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)
add_child(art)
title_view = _title_view()
add_child(title_view)
help_view = _help_view()
add_child(help_view)
_show_help(false)
play_button.grab_focus()


func _unhandled_input(event: InputEvent) -> void:
if help_view.visible and event.is_action_pressed("ui_cancel"):
_show_help(false)
accept_event()


func _show_help(on: bool) -> void:
title_view.visible = not on
help_view.visible = on
if on:
help_text.scroll_to_line(0)
back_button.grab_focus()
elif is_inside_tree():
help_button.grab_focus()


func _play() -> void:
get_tree().change_scene_to_file(GAME)


func _title_view() -> Control:
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 12)
center.add_child(column)
var title := Label.new()
title.text = ProjectSettings.get_setting("application/config/name")
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_override("font", Style.TITLE_FONT)
title.add_theme_font_size_override("font_size", 72)
title.add_theme_color_override("font_color", Style.GOLD)
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)
column.add_child(title)
var tagline := Label.new()
tagline.text = TAGLINE
tagline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
tagline.add_theme_font_size_override("font_size", 26)
column.add_child(tagline)
var gap := Control.new()
gap.custom_minimum_size.y = 28
column.add_child(gap)
play_button = _button(column, "Enter the crypt", _play)
help_button = _button(column, "Help", _show_help.bind(true))
quit_button = _button(column, "Quit", func(): get_tree().quit())
quit_button.visible = OS.get_name() != "Web"
var status := Label.new()
var jev := get_node_or_null("/root/Jev")
status.text = Help.status_line(jev.decider if jev else null)
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
status.add_theme_color_override("font_color", Style.DIM)
var status_gap := Control.new()
status_gap.custom_minimum_size.y = 20
column.add_child(status_gap)
column.add_child(status)
return center


func _button(parent: Control, label: String, action: Callable) -> Button:
var holder := CenterContainer.new()
parent.add_child(holder)
var button := Button.new()
button.text = label
button.custom_minimum_size.x = 300
button.pressed.connect(action)
holder.add_child(button)
return button


func _help_view() -> Control:
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
for side in ["left", "right"]:
margin.add_theme_constant_override("margin_" + side, 160)
for side in ["top", "bottom"]:
margin.add_theme_constant_override("margin_" + side, 40)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 12)
help_text = RichTextLabel.new()
help_text.bbcode_enabled = true
help_text.selection_enabled = true
help_text.size_flags_vertical = Control.SIZE_EXPAND_FILL
var thresholds := Parser.new(null)
help_text.text = Help.page(thresholds.clarify_confidence, thresholds.act_confidence)
column.add_child(help_text)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_END
column.add_child(row)
var hint := Label.new()
hint.text = "Esc to go back"
hint.add_theme_color_override("font_color", Style.DIM)
hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(hint)
back_button = Button.new()
back_button.text = "Back"
back_button.custom_minimum_size.x = 160
back_button.pressed.connect(_show_help.bind(false))
row.add_child(back_button)
var frame := Style.framed(column)
margin.add_child(frame)
return margin
1 change: 1 addition & 0 deletions godot-jev/demo/start_menu.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://4id3d27qcswh
12 changes: 12 additions & 0 deletions godot-jev/demo/start_menu.tscn
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]

[ext_resource type="Script" path="res://demo/start_menu.gd" id="1_menu"]

[node name="StartMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_menu")
Loading
Loading