diff --git a/godot-jev/README.md b/godot-jev/README.md index f5ca733..0c7939e 100644 --- a/godot-jev/README.md +++ b/godot-jev/README.md @@ -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. | @@ -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 @@ -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: diff --git a/godot-jev/demo/adventure.gd b/godot-jev/demo/adventure.gd index 5201cb3..2a6f2f3 100644 --- a/godot-jev/demo/adventure.gd +++ b/godot-jev/demo/adventure.gd @@ -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 := [ @@ -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 @@ -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: @@ -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 diff --git a/godot-jev/demo/art/antechamber.png b/godot-jev/demo/art/antechamber.png index 65e049a..006251f 100644 Binary files a/godot-jev/demo/art/antechamber.png and b/godot-jev/demo/art/antechamber.png differ diff --git a/godot-jev/demo/art/armory.png b/godot-jev/demo/art/armory.png index 8371c83..b658b79 100644 Binary files a/godot-jev/demo/art/armory.png and b/godot-jev/demo/art/armory.png differ diff --git a/godot-jev/demo/art/hall.png b/godot-jev/demo/art/hall.png index 1ca7873..dba3bfe 100644 Binary files a/godot-jev/demo/art/hall.png and b/godot-jev/demo/art/hall.png differ diff --git a/godot-jev/demo/art/hall_cleared.png b/godot-jev/demo/art/hall_cleared.png index 3927c83..4901407 100644 Binary files a/godot-jev/demo/art/hall_cleared.png and b/godot-jev/demo/art/hall_cleared.png differ diff --git a/godot-jev/demo/art/ossuary.png b/godot-jev/demo/art/ossuary.png index ce3721b..57a2d02 100644 Binary files a/godot-jev/demo/art/ossuary.png and b/godot-jev/demo/art/ossuary.png differ diff --git a/godot-jev/demo/art/stairs.png b/godot-jev/demo/art/stairs.png index 9f9f2cb..ad1698a 100644 Binary files a/godot-jev/demo/art/stairs.png and b/godot-jev/demo/art/stairs.png differ diff --git a/godot-jev/demo/art/vault.png b/godot-jev/demo/art/vault.png index 8138eea..db8cb05 100644 Binary files a/godot-jev/demo/art/vault.png and b/godot-jev/demo/art/vault.png differ diff --git a/godot-jev/demo/room_fx.gd b/godot-jev/demo/room_fx.gd new file mode 100644 index 0000000..cac5abe --- /dev/null +++ b/godot-jev/demo/room_fx.gd @@ -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 diff --git a/godot-jev/demo/room_fx.gd.uid b/godot-jev/demo/room_fx.gd.uid new file mode 100644 index 0000000..a1d0156 --- /dev/null +++ b/godot-jev/demo/room_fx.gd.uid @@ -0,0 +1 @@ +uid://c82fkff1dvrrt diff --git a/godot-jev/demo/start_menu.gd b/godot-jev/demo/start_menu.gd index 2195e8d..f209269 100644 --- a/godot-jev/demo/start_menu.gd +++ b/godot-jev/demo/start_menu.gd @@ -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." @@ -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() @@ -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 diff --git a/godot-jev/demo/style.gd b/godot-jev/demo/style.gd index 2ec61b0..950ded5 100644 --- a/godot-jev/demo/style.gd +++ b/godot-jev/demo/style.gd @@ -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. @@ -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 @@ -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()) diff --git a/godot-jev/docs/screenshot.png b/godot-jev/docs/screenshot.png index e1b52bb..8cd4b14 100644 Binary files a/godot-jev/docs/screenshot.png and b/godot-jev/docs/screenshot.png differ diff --git a/godot-jev/tests/run_tests.gd b/godot-jev/tests/run_tests.gd index aac2546..4812760 100644 --- a/godot-jev/tests/run_tests.gd +++ b/godot-jev/tests/run_tests.gd @@ -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 diff --git a/godot-jev/tests/test_room_fx.gd b/godot-jev/tests/test_room_fx.gd new file mode 100644 index 0000000..5cb65d5 --- /dev/null +++ b/godot-jev/tests/test_room_fx.gd @@ -0,0 +1,59 @@ +extends "res://tests/suite.gd" + +const RoomFx := preload("res://demo/room_fx.gd") +const Style := preload("res://demo/style.gd") + + +func _image_with(pixels: Dictionary) -> Image: + var image := Image.create(8, 6, false, Image.FORMAT_RGB8) + image.fill(Color("3a3347")) + for at: Vector2i in pixels: + image.set_pixelv(at, Color(pixels[at])) + return image + + +func test_embers_rise_only_from_the_brightest_flame_pixels() -> void: + var image := _image_with({ + Vector2i(2, 1): RoomFx.FLAME[5], Vector2i(5, 4): RoomFx.FLAME[4], + Vector2i(6, 4): RoomFx.FLAME[1], Vector2i(0, 0): "efc13a", # the crown's gold + }) + var sources := RoomFx.ember_sources(image) + eq(Array(sources), [Vector2(2, 1), Vector2(5, 4)], "ember sources") + + +func test_art_without_fire_has_no_embers() -> void: + var embers := RoomFx.embers(RoomFx.ember_sources(_image_with({})), 4.0) + check(not embers.emitting, "nothing emits") + embers.free() + + +func test_ember_sources_are_capped_and_spread_through_the_fire() -> void: + var image := Image.create(40, 2, false, Image.FORMAT_RGB8) + image.fill(Color(RoomFx.FLAME[5])) + var sources := RoomFx.ember_sources(image) + eq(sources.size(), RoomFx.MAX_EMBERS, "capped") + check(sources[0].y == 0 and sources[sources.size() - 1].y == 1, "spread across both rows") + + +func test_embers_are_placed_in_screen_pixels() -> void: + var embers := RoomFx.embers(PackedVector2Array([Vector2(10, 5)]), 4.0) + eq(Array(embers.emission_points), [Vector2(42, 22)], "centre of art pixel (10, 5) at 4x") + check(embers.emitting, "emits") + embers.free() + + +func test_the_room_material_carries_the_flame_ramp() -> void: + var mat := RoomFx.material() + var flame: Array = mat.get_shader_parameter("flame") + eq(flame.size(), RoomFx.FLAME.size(), "every step") + eq(flame[0], Color(RoomFx.FLAME[0]), "darkest first") + + +func test_framed_panels_get_brass_corners_over_their_content() -> void: + var content := Label.new() + var frame := Style.framed(content) + var corners := frame.get_child(frame.get_child_count() - 1) + check(corners is Style.Corners, "corners are drawn last, over the panel") + eq((corners as Control).mouse_filter, Control.MOUSE_FILTER_IGNORE, "corners never take clicks") + check(content.get_parent().get_parent() == frame, "content still sits in the inner panel") + frame.free() diff --git a/godot-jev/tests/test_room_fx.gd.uid b/godot-jev/tests/test_room_fx.gd.uid new file mode 100644 index 0000000..4bcda88 --- /dev/null +++ b/godot-jev/tests/test_room_fx.gd.uid @@ -0,0 +1 @@ +uid://dpmcq1k5jojt2 diff --git a/godot-jev/tools/paint_rooms.gd b/godot-jev/tools/paint_rooms.gd index e2cde9e..b0e8597 100644 --- a/godot-jev/tools/paint_rooms.gd +++ b/godot-jev/tools/paint_rooms.gd @@ -9,6 +9,7 @@ extends SceneTree ## Replace any PNG in demo/art/ with hand-made art of the same name and size. const World := preload("res://demo/world.gd") +const RoomFx := preload("res://demo/room_fx.gd") const W := 160 const H := 90 # The back wall's rectangle; everything else is perspective toward it. @@ -27,10 +28,20 @@ const RAMPS := { "bone": ["14100c", "3b3228", "6b5d4a", "9c8b6e", "cbbd9c", "efe6cc"], "rust": ["140806", "3a160d", "662814", "8f3f1c", "b8622e", "d98f4e"], "gold": ["1a1004", "4a2f06", "86570b", "c08a17", "efc13a", "fff27a"], - "flame": ["3a0e04", "8a2a06", "d65a0e", "f59a1c", "ffd24a", "fff6c0"], + "flame": RoomFx.FLAME, # the game flickers pixels in exactly these colours "ruby": ["1a0308", "4a0a16", "8a1628", "c82d3c", "f06a6a", "ffc0b0"], "moss": ["0a1208", "1a2a14", "2e4422", "4a6a30", "739444", "a8c060"], + "cloth": ["0c0610", "261028", "44183e", "6a2450", "94385e", "c05a6a"], + "stone_warm": ["120b0c", "2b1c1c", "4d3229", "7a5036", "ad7a4c", "e0b273"], + "floor_warm": ["0e0908", "22160f", "3c2717", "5e3f22", "8c6232", "c09050"], + "wood_warm": ["160a06", "3a1a0c", "652f14", "96501f", "c47a30", "f0aa50"], } +## Ramps that swap to a warm twin where firelight dominates. The swap is +## dithered, so pools of torchlight get a warm edge instead of a hard line. +const WARM := {"stone": "stone_warm", "floor": "floor_warm", "wood": "wood_warm"} +## How far Bayer dithering reaches around each step: 1.0 dithers everywhere, +## lower values leave flat bands with dithered edges, which reads cleaner. +const DITHER := 0.6 const BAYER := [ [0.0, 8.0, 2.0, 10.0], [12.0, 4.0, 14.0, 6.0], [3.0, 11.0, 1.0, 9.0], [15.0, 7.0, 13.0, 5.0], @@ -46,12 +57,13 @@ const INK := { "e": ["ruby", 0.6, false], "E": ["ruby", 0.95, true], "f": ["flame", 0.55, true], "F": ["flame", 0.8, true], "X": ["flame", 1.0, true], "m": ["moss", 0.5, false], + "c": ["cloth", 0.55, false], "C": ["cloth", 0.8, false], } const SKELETON := [ "....BBBBB....", "...BwwwwwB...", - "...wkkwkkw...", + "...wkEwkEw...", "...BwwkwwB...", "....BwwwB....", ".....bkb.....", @@ -78,7 +90,9 @@ const BONES := [ ".BbrRrRrbw.......", ] const BRAZIER := [ - "...f.X..f....", + ".....f.......", + "...f.F..f....", + "...FfXf.F....", "..fFXXFXFf...", ".fFXXXXXXFf..", "IiiiiiiiiiiiI", @@ -91,12 +105,15 @@ const BRAZIER := [ "...iiiiii....", ] const SCONCE := [ - ".X.", - "XFX", - "fFf", - ".O.", - "iIi", - ".i.", + "..f..", + ".fXf.", + ".FXF.", + "fFXFf", + ".fFf.", + ".OoO.", + "iIIIi", + "..i..", + "..i..", ] const SKULL := [ ".bBb.", @@ -104,23 +121,85 @@ const SKULL := [ "kwkwk", ".bwb.", ] +const BANNER := [ + "iIIIIIIIi", + ".CcccccC.", + ".ccccccc.", + ".cccgccc.", + ".ccgGgcc.", + ".cgGYGgc.", + ".ccgGgcc.", + ".cccgccc.", + ".ccccccc.", + ".ccccccc.", + ".ccccccc.", + ".ccccccc.", + ".cc.ccc..", + ".c...cc..", + "......c..", +] +const RACK := [ + "oO.................Oo", + "oOOOOOOOOOOOOOOOOOOOo", + "oO.I.....iii......gOo", + "oO.I....iIIi......oOo", + "oO.I....iIIi.o...iiiO", + "oO.o.....io..o...IiOo", + "oO.o......o..o...IiOo", + "oO.o......o..o...IiOo", + "oO.o......o..o...IiOo", + "oO.o......o..o...IiOo", + "oO.o......o..o...IiOo", + "oO.o......o..o...IiOo", + "oOOOOOOOOOOOOOOOOOOOo", + "oO.o......o..o...IiOo", + "oO.o......o..o...IiOo", + "oO.o......o..o...IiOo", + "oO.o......o..o....iOo", + "oO.o......o..o.....Oo", + "oO.o......o..o.....Oo", + "oOOOOOOOOOOOOOOOOOOOo", + "oO.................Oo", + "oO.................Oo", +] +const CRATE := [ + "OOOOOOOOOOOO", + "OooooooooooO", + "OoOooooooOoO", + "OooOooooOooO", + "OoooOooOoooO", + "OooooOOooooO", + "OooooOOooooO", + "OoooOooOoooO", + "OooOooooOooO", + "OoOooooooOoO", + "OooooooooooO", + "OOOOOOOOOOOO", +] const CHEST := [ - ".oOOOOOOOOOOOo.", - "oOoooooooooooOo", - "IiiiiiiGiiiiiiI", - "oOooooGYGooooOo", - "oOoooooGoooooOo", - "oOoooooooooooOo", - "IiiiiiiiiiiiiiI", - "oooooooooooooo.", + "..oOOOOOOOOOOOOOOo..", + ".oOoooooooooooooOOo.", + "oOooooooooooooooooOo", + "IiiiiiiiiiiiiiiiiiiI", + "GgYgGgGYgGgYgGgGYgGg", + "gYGgYGgGGYgGgYGgGgYG", + "IiiiiiiiiGGiiiiiiiiI", + "oOoooooooGYgoooooOoo", + "oOooooooooGooooooOoo", + "oOoooooooooooooooOoo", + "oOoooooooooooooooOoo", + "IiiiiiiiiiiiiiiiiiiI", + "ooooooooooooooooooo.", ] const CROWN := [ - ".Y...Y...Y.", - ".G..GYG..G.", - ".GG.GGG.GG.", - ".GGGGGGGGG.", - ".GEGGEGGEG.", - ".ggggggggg.", + "..Y....Y....Y..", + "..G...GYG...G..", + "..GG..GGG..GG..", + "..GGG.GGG.GGG..", + "..GGGGGGGGGGG..", + "..GYGGEGGGEGYG.", + "..GEGGGEGGGEGG.", + "..ggggggggggg..", ] const SHIELD := [ "..rrr..", @@ -135,6 +214,7 @@ const SHIELD := [ var ramp: Array = [] # per pixel: ramp name var tone: Array = [] # per pixel: 0..1 before lighting var glow: Array = [] # per pixel: ignores lighting +var ao: Array = [] # per pixel: occlusion, multiplies the tone var lights: Array = [] # [x, y, radius, strength] var ambient := 0.2 @@ -163,67 +243,85 @@ func _paint(art_name: String, exits: Dictionary) -> Image: _shell("stone", "floor") _doorways(exits, {"north": "gate"}) _rubble() - _sprite(BRAZIER, 30, 62) - _light(36, 62, 90, 1.2) - _light(115, 105, 110, 0.5) + _shade(36, 73, 9, 2, 0.6) + _sprite(BRAZIER, 30, 60) + _light(36, 60, 90, 1.2) + _light(125, 100, 90, 0.9, false) _steps() "hall", "hall_cleared": ambient = 0.15 _shell("stone", "floor") _doorways(exits, {}) - _sprite(SCONCE, 56, 28) - _sprite(SCONCE, 102, 28) - _light(57, 28, 55, 0.9) - _light(103, 28, 55, 0.9) + _banner(47, T + 3) + _banner(W - 57, T + 3) + _sprite(SCONCE, 58, 25) + _sprite(SCONCE, 97, 25) + _light(60, 26, 55, 0.9) + _light(99, 26, 55, 0.9) + _light(4, 45, 40, 0.45, false) + _light(W - 4, 45, 40, 0.45, false) if art_name == "hall": + _shade(80, 61, 7, 1.5, 0.7) _sprite(SKELETON, 74, 42) - _light(80, 70, 40, 0.3) + _light(80, 70, 40, 0.3, false) else: + _shade(80, 58, 9, 2, 0.5) _sprite(BONES, 72, 55) - _light(80, 100, 90, 0.35) - _pillar(-3) - _pillar(W - 9) + _light(80, 100, 90, 0.35, false) + _pillar(-3, 1) + _pillar(W - 9, -1) "armory": _shell("stone", "floor") _doorways(exits, {}) - _rack(60, 24) - _rack(84, 24) - _sprite(SHIELD, 50, 36) - _sprite(SHIELD, 103, 36) - _sprite(SCONCE, 78, 16) - _light(79, 16, 90, 1.0) - _light(80, 100, 90, 0.35) - _barrel(126, 58) + _shade(80, 60, 26, 2, 0.5) + _sprite(RACK, 58, 26) + _sprite(RACK, 81, 26) + _sprite(SHIELD, 49, 34) + _sprite(SHIELD, 104, 34) + _sprite(SCONCE, 78, 13) + _light(80, 15, 90, 1.0) + _light(80, 100, 90, 0.35, false) + _shade(137, 82, 13, 3, 0.6) + _sprite(CRATE, 128, 70) + _barrel(123, 60) "ossuary": ambient = 0.03 _shell("stone", "floor") _doorways(exits, {}) for row in 5: for col in 11: - _sprite(SKULL, L + 2 + col * 6 + (row % 2) * 3, T + 2 + row * 6) + var x := L + 2 + col * 6 + (row % 2) * 3 + var y := T + 2 + row * 6 + for dy in range(-1, 5): + for dx in range(-1, 6): + _put(x + dx, y + dy, "stone", 0.12) + _sprite(SKULL, x, y) _altar() _light(80, 78, 115, 1.2) "antechamber": ambient = 0.12 _shell("stone", "floor", 0.8) _doorways(exits, {"north": "black_door"}) - _sprite(SCONCE, 22, 30) - _sprite(SCONCE, W - 25, 30) - _light(23, 30, 70, 1.0) - _light(W - 24, 30, 70, 1.0) - _light(80, 90, 80, 0.45) + _sprite(SKULL, 78, T + 1) + _sprite(SCONCE, 21, 27) + _sprite(SCONCE, W - 26, 27) + _light(23, 28, 70, 1.0) + _light(W - 24, 28, 70, 1.0) + _light(80, 90, 80, 0.45, false) "vault": ambient = 0.25 _shell("stone", "floor") _doorways(exits, {}) _coins() - _sprite(CHEST, 44, 64) - _sprite(CHEST, W - 59, 64) + _shade(52, 77, 13, 2, 0.6) + _shade(W - 52, 77, 13, 2, 0.6) + _sprite(CHEST, 42, 64) + _sprite(CHEST, W - 62, 64) _plinth() - _sprite(CROWN, 75, 35) - _light(80, 40, 70, 1.0) - _light(50, 70, 45, 0.6) - _light(W - 50, 70, 45, 0.6) + _sprite(CROWN, 73, 34) + _light(80, 38, 60, 0.8) + _light(52, 64, 40, 0.6) + _light(W - 52, 64, 40, 0.6) _: push_error("no painter for %s" % art_name) _shell("stone", "floor") @@ -237,6 +335,8 @@ func _reset() -> void: ramp.resize(W * H) tone.resize(W * H) glow.resize(W * H) + ao.resize(W * H) + ao.fill(1.0) ramp.fill("stone") tone.fill(0.0) glow.fill(false) @@ -250,10 +350,25 @@ func _put(x: int, y: int, ramp_name: String, t: float, glows := false) -> void: ramp[y * W + x] = ramp_name tone[y * W + x] = clampf(t, 0.0, 1.0) glow[y * W + x] = glows + ao[y * W + x] = 1.0 + + +## A light at (x, y). Firelight is warm and tints the ramps in WARM; cool +## light (daylight, the dark's own dim glow) keeps them cold. +func _light(x: float, y: float, radius: float, strength: float, warm := true) -> void: + lights.append([x, y, radius, strength, warm]) -func _light(x: float, y: float, radius: float, strength: float) -> void: - lights.append([x, y, radius, strength]) +## Darkens what's already painted around (x, y): contact shadows under props +## and the corners where walls meet. +func _shade(cx: float, cy: float, rx: float, ry: float, depth: float) -> void: + for y in range(floori(cy - ry), ceili(cy + ry) + 1): + for x in range(floori(cx - rx), ceili(cx + rx) + 1): + if x < 0 or y < 0 or x >= W or y >= H: + continue + var d := Vector2((x - cx) / rx, (y - cy) / ry).length() + if d < 1.0: + ao[y * W + x] *= 1.0 - depth * (1.0 - d * d) func _noise(x: int, y: int) -> float: @@ -294,6 +409,42 @@ func _shell(wall: String, floor_ramp: String, wall_tone := 1.0) -> void: var cell := _ceiling_cell(x, y) var seam := cell != _ceiling_cell(x, y + 1) or cell != _ceiling_cell(x - 1, y) _put(x, y, wall, 0.12 if seam else 0.32) + ao[y * W + x] = _corner_shadow(x, y, side_x) + _ribs(wall, wall_tone) + + +## Transverse ribs across the vault, each carried down the side walls as a +## pilaster, so the room reads as a run of bays rather than a box. +func _ribs(wall: String, wall_tone: float) -> void: + for side_x: int in [4, 36]: + var width := 2 + (L - side_x) / 12 + for i in width: + for x: int in [side_x + i, W - 1 - side_x - i]: + var edge := i == 0 or i == width - 1 + for y in range(0, ceili(_bottom_edge(side_x + i))): + var t := (0.62 if edge else 0.78) + 0.08 * _noise(x, y / 4) + if y < _top_edge(side_x + i): + t -= 0.1 + _put(x, y, wall, t * wall_tone) + var top := _top_edge(side_x) + for y in range(floori(top - width / 2.0), ceili(top + 1)): + for x in range(side_x + width, W - side_x - width): + _put(x, y, wall, (0.5 if y == floori(top - width / 2.0) else 0.66) * wall_tone) + for y in range(ceili(top + 1), ceili(top + 2)): + for x in range(side_x + width, W - side_x - width): + ao[y * W + x] *= 0.6 + + +## Light falls off into the seams where walls, floor and ceiling meet. +func _corner_shadow(x: int, y: int, side_x: int) -> float: + var seam := 99.0 + if side_x < 0: + seam = minf(absf(y - (B - 0.5)), absf(y - (T - 0.5))) + if y >= T and y < B: + seam = minf(seam, minf(x - L + 0.5, R - x + 0.5)) + else: + seam = minf(absf(y - _bottom_edge(side_x)), absf(y - _top_edge(side_x))) + return 1.0 - 0.4 * clampf(1.0 - seam / 4.0, 0.0, 1.0) func _back_cell(x: int, y: int) -> Vector2i: @@ -368,9 +519,12 @@ func _arch(style: String) -> void: _put(x, int(spring) + 1, "iron", 0.9) if style == "black_door": _sprite(["IIII", "IkkI", "IIkI", ".IkI", ".II."], int(VX) + 3, 44) - _light(VX + 5, 46, 10, 0.6) + _light(VX + 5, 46, 10, 0.6, false) +## A passage through a side wall: a stone frame, the far jamb's face (the +## wall's thickness, turned toward the viewer), and a floor running into the +## dark. func _side_opening(right: bool) -> void: for side_x in range(12, 30): var top := _top_edge(side_x) @@ -379,7 +533,19 @@ func _side_opening(right: bool) -> void: for y in range(floori(open_top) - 2, ceili(bottom)): var x := W - 1 - side_x if right else side_x var frame := side_x < 14 or side_x > 27 or y < open_top - _put(x, y, "stone", 0.8 + 0.1 * _noise(side_x, y / 3) if frame else 0.03) + if frame: + _put(x, y, "stone", 0.8 + 0.1 * _noise(side_x, y / 3)) + elif side_x > 23: + var course := (y - floori(open_top)) % 4 == 0 + _put(x, y, "stone", 0.35 if course else 0.62 + 0.08 * _noise(side_x, y / 4)) + elif y > bottom - 3: + _put(x, y, "floor", 0.18 - 0.05 * (bottom - y)) + else: + var depth := (side_x - 14) / 10.0 + _put(x, y, "stone", 0.02 + 0.05 * depth * depth) + if side_x == 14 or side_x == 23: + for y in range(floori(open_top), ceili(bottom)): + ao[y * W + (W - 1 - side_x if right else side_x)] *= 0.7 # --- props -------------------------------------------------------------------- @@ -394,85 +560,148 @@ func _sprite(rows: Array, x: int, y: int) -> void: _put(x + dx, y + dy, ink[0], ink[1], ink[2]) -func _pillar(x0: int) -> void: - for y in range(0, 86): - for dx in range(-1, 13): - var core := dx >= 1 and dx <= 10 - var cap := y < 5 or y > 79 - if not core and not cap: +## A round pillar in the foreground, darker than the room behind it with a +## bright rim on the side facing the light: +1 when light comes from its +## right, -1 from its left. +func _pillar(x0: int, lit_side: int) -> void: + for y in range(0, H): + var cap := y < 6 or y > 80 + for dx in range(-1, 14 if cap else 12): + if not cap and (dx < 1 or dx > 10): continue - var round := 1.0 - absf(dx - 5.0) / 7.0 - var crack := dx == 4 + (y / 7) % 3 and y > 20 and y < 55 - _put(x0 + dx, y, "stone", 0.25 if crack else 0.35 + 0.55 * round) + var u := (dx - 5.5) / 5.5 * lit_side + var t := 0.3 + 0.25 * u + if u > 0.8: + t = 0.95 + elif absf(u - 0.35) < 0.1: + t = 0.55 + if cap: + t = 0.9 if y == 5 or y == 81 else t + 0.1 + elif dx == 4 + (y / 7) % 3 and y > 20 and y < 55: + t = 0.1 + _put(x0 + dx, y, "stone", t) -func _rack(x0: int, y0: int) -> void: - for dx in 18: - _put(x0 + dx, y0 + 3, "wood", 0.7) - _put(x0 + dx, y0 + 14, "wood", 0.6) - for post in [0, 17]: - for dy in 26: - _put(x0 + post, y0 + dy, "wood", 0.5) - for peg in [4, 8, 12]: - for dy in range(4, 13): - _put(x0 + peg, y0 + dy, "rust" if peg != 8 else "iron", 0.5 if dy % 4 else 0.7) +func _banner(x: int, y: int) -> void: + _sprite(BANNER, x, y) + _shade(x + 4.5, y + 8, 6, 9, 0.25) func _barrel(x0: int, y0: int) -> void: + _shade(x0 + 7, y0 + 18, 11, 3, 0.6) for dy in 18: var bulge := 1 if dy > 3 and dy < 14 else 0 for dx in range(-bulge, 14 + bulge): - var band := dy == 3 or dy == 14 - _put(x0 + dx, y0 + dy, "iron" if band else "wood", 0.6 if band else 0.35 + 0.35 * (1.0 - absf(dx - 6.5) / 8.0)) + var band := dy == 2 or dy == 3 or dy == 14 or dy == 15 + var u := (dx - 6.5) / 8.0 + var shade := 0.3 + 0.55 * clampf(1.0 - absf(u + 0.25), 0.0, 1.0) + if band: + _put(x0 + dx, y0 + dy, "iron", shade + 0.15) + elif (dx + 1) % 4 == 0: + _put(x0 + dx, y0 + dy, "wood", shade - 0.15) + else: + _put(x0 + dx, y0 + dy, "wood", shade) + for dx in range(1, 13): + _put(x0 + dx, y0, "wood", 0.3) func _rubble() -> void: - for i in 70: - var x := W - 45 + floori(_noise(i, 3) * 40) - var y := 70 + floori(_noise(i, 9) * 18) - var size := 1 + floori(_noise(i, 5) * 3) - for dy in size: - for dx in size + 1: - _put(x + dx, y + dy, "stone", 0.6 + 0.35 * _noise(i, dy) - 0.1 * dy) - for i in 12: - _put(W - 40 + floori(_noise(i, 11) * 34), 68 + floori(_noise(i, 13) * 20), "moss", 0.6) + _shade(W - 26, 80, 30, 10, 0.5) + for i in 18: + var r := 2.5 + _noise(i, 5) * 4.5 + var cx := W - 48 + _noise(i, 3) * 44 + var cy := 70 + _noise(i, 9) * 16 - r * 0.3 + _boulder(cx, cy, r, 0.55 + 0.3 * _noise(i, 7)) + for i in 16: + _put(W - 44 + floori(_noise(i, 11) * 40), 66 + floori(_noise(i, 13) * 22), "moss", 0.5 + 0.3 * _noise(i, 17)) + + +## A rough round stone lit from the upper left. +func _boulder(cx: float, cy: float, r: float, t: float) -> void: + for y in range(floori(cy - r), ceili(cy + r) + 1): + for x in range(floori(cx - r), ceili(cx + r) + 1): + var d := Vector2(x - cx, (y - cy) * 1.2) + if d.length() > r + 0.3 * _noise(x, y): + continue + var facing := -(d.x + d.y) / (r * 1.4) + var edge := d.length() > r - 1.0 + _put(x, y, "stone", t + 0.3 * facing - (0.15 if edge else 0.0)) func _steps() -> void: for step in 2: var y0 := H - 6 + step * 3 for x in W: + var worn := 1.0 - absf(x - VX) / VX for dy in 3: - _put(x, y0 + dy, "stone", 0.9 if dy == 0 else 0.4 - 0.1 * step) + var t := 0.85 + 0.1 * worn if dy == 0 else 0.35 - 0.1 * step + 0.05 * _noise(x / 3, step) + _put(x, y0 + dy, "stone", t) + if dy == 2: + ao[(y0 + dy) * W + x] *= 0.6 func _altar() -> void: - for y in range(46, 62): - for x in range(64, 97): - var top := y < 49 - _put(x, y, "stone", 0.9 if top else 0.55 + 0.1 * _noise(x / 4, y / 4)) + _shade(80, 62, 22, 3, 0.7) + for y in range(44, 62): + for x in range(62, 99): + var slab := y < 48 + if slab: + var t := 0.95 if y == 44 else (0.6 if y == 47 else 0.8) + _put(x, y, "stone", t) + elif x >= 64 and x < 97: + var u := absf(x - 80.5) + var carved := (u < 5 and (y == 51 or y == 57)) or (absf(u - 5) < 0.6 and y > 51 and y < 57) + var cross := u < 1 and y > 49 and y < 59 or (y == 52 and u < 3) + var t := 0.35 if cross or carved else 0.55 + 0.1 * _noise(x / 4, y / 4) + if x == 64 or x == 96: + t -= 0.15 + _put(x, y, "stone", t) + for bone in [[68, 42], [88, 42]]: + _sprite(SKULL, bone[0], bone[1]) func _plinth() -> void: - for y in range(41, 63): - var inset := 0 if y < 44 or y > 58 else 2 - for x in range(71 + inset, 89 - inset): - _put(x, y, "stone", 0.85 if y < 44 else 0.6 + 0.2 * (1.0 - absf(x - 79.5) / 9.0)) + _shade(80, 63, 14, 3, 0.7) + for y in range(42, 63): + var cap := y < 45 or y > 59 + var inset := 0 if cap else 2 + for x in range(70 + inset, 91 - inset): + var u := (x - 80.0) / 10.0 + var t := 0.7 if y == 42 else (0.55 if cap else 0.3 + 0.3 * (1.0 - absf(u + 0.3))) + if not cap and (x - 70) % 5 == 0: + t -= 0.1 + _put(x, y, "stone", t) func _coins() -> void: - for pile in [[22, 76, 22], [W - 22, 76, 22]]: - for y in range(pile[1] - 9, H): + for pile in [[20, 78, 24], [W - 20, 78, 24]]: + _shade(pile[0], H - 2, pile[2] + 4, 4, 0.5) + for y in range(pile[1] - 11, H): for x in range(pile[0] - pile[2], pile[0] + pile[2]): var height: float = 1.0 - absf(x - pile[0]) / pile[2] - if y > pile[1] - height * 9.0 + _noise(x, 1) * 2.0: - var coin := _noise(x, y) > 0.8 - _put(x, y, "gold", 0.95 if coin else 0.35 + 0.35 * height - 0.15 * _noise(y, x)) - for i in 60: - var x := 50 + floori(_noise(i, 21) * 60) - var y := B + 2 + floori(_noise(i, 23) * (H - B - 4)) - _put(x, y, "gold", 0.9) - _put(x + 1, y, "gold", 0.55) + var surface: float = pile[1] - height * 11.0 + _noise(x, 1) * 1.5 + if y < surface: + continue + var t := 0.3 + 0.35 * height - 0.2 * clampf((y - surface) / 12.0, 0.0, 1.0) + if y < surface + 1.0: + t += 0.25 + var n := _noise(x / 2, y) + if n > 0.86: + t = 0.95 + elif n > 0.8: + t += 0.2 + _put(x, y, "gold", t) + for i in 5: + var gx: int = pile[0] - 12 + floori(_noise(i, pile[0]) * 24) + var gy: int = pile[1] - 4 + floori(_noise(pile[0], i) * 8) + _put(gx, gy, "ruby", 0.95, true) + _put(gx + 1, gy, "ruby", 0.6) + for i in 50: + var x := 44 + floori(_noise(i, 21) * 72) + var y := B + 3 + floori(_noise(i, 23) * (H - B - 6)) + _put(x, y, "gold", 0.95) + _put(x + 1, y, "gold", 0.6) + _put(x, y + 1, "gold", 0.3) # --- lighting ------------------------------------------------------------------- @@ -482,20 +711,33 @@ func _render() -> Image: for y in H: for x in W: var i := y * W + x - var colors: Array = RAMPS[ramp[i]] - var brightness: float = tone[i] + var ramp_name: String = ramp[i] + var brightness: float = tone[i] * ao[i] + var threshold: float = (BAYER[y % 4][x % 4] + 0.5) / 16.0 if not glow[i]: - brightness *= _lit(x, y) - var step: float = brightness * (colors.size() - 1) + (BAYER[y % 4][x % 4] + 0.5) / 16.0 - 0.5 + var lit := _lit(x, y) + brightness *= lit.x + if WARM.has(ramp_name) and lit.y > threshold: + ramp_name = WARM[ramp_name] + var colors: Array = RAMPS[ramp_name] + var step: float = brightness * (colors.size() - 1) + (threshold - 0.5) * DITHER var index := clampi(roundi(step), 0, colors.size() - 1) image.set_pixel(x, y, Color(colors[index])) return image -func _lit(x: int, y: int) -> float: +## Returns (light, warmth): how lit the pixel is, and how much of that light +## is firelight, eased so warmth fades out before the light does. +func _lit(x: int, y: int) -> Vector2: var light := ambient + var warm := 0.0 for l in lights: var d := Vector2(x - l[0], (y - l[1]) * 1.3).length() / float(l[2]) - light += l[3] * pow(maxf(0.0, 1.0 - d), 1.5) + var add: float = l[3] * pow(maxf(0.0, 1.0 - d), 1.5) + light += add + if l[4]: + warm += add var edge := Vector2((x - W / 2.0) / (W / 2.0), (y - H / 2.0) / (H / 2.0)).length() - return minf(light, 1.25) * (1.0 - 0.35 * clampf(edge - 0.6, 0.0, 1.0)) + var vignette := 1.0 - 0.35 * clampf(edge - 0.6, 0.0, 1.0) + var warmth := clampf((warm / light - 0.25) * 2.2, 0.0, 1.0) * clampf(warm * 3.0, 0.0, 1.0) + return Vector2(minf(light, 1.25) * vignette, warmth)