commit 733b2fb798ac00fd27b7be8f61bfe4940a85bb55 Author: gem Date: Sun Jun 7 22:53:58 2026 +0800 Initial flame_lua_runtime package diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f5f6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Dart / Flutter generated files +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock +build/ +coverage/ + +# IDE / OS +*.iml +.idea/ +.vscode/ +.DS_Store + +# Logs +*.log diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..a4395cd --- /dev/null +++ b/.pubignore @@ -0,0 +1,4 @@ +build/ +.dart_tool/ +.packages +pubspec.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..220f564 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.1.0 + +- Initial extracted package skeleton for `flame_lua_runtime`. +- Added public API barrel with `LuaGameWidget`, `FlameLuaGame`, `RuntimeOptions`, package repositories, and script engine interfaces. +- Added shared Runtime Lua helper assets. +- Added manifest-driven package/resource/module loading support. +- Added Runtime commands, rendering nodes, package activation, and Lua bridge infrastructure. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d757df8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2026, Flame Lua Runtime contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1eee729 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# flame_lua_runtime + +A Flutter + Flame + Lua runtime kit for manifest-driven 2D game packages. + +The package provides a reusable runtime boundary: + +```text +RuntimeEvent -> Lua -> GameDiff / RuntimeCommand -> Flame +``` + +It is designed for Flutter apps that want to host Lua-authored 2D games or interactive showcases while keeping the Flutter/Flame side generic. + +## Features + +- `LuaGameWidget` for embedding a Lua game package in a Flutter app. +- Manifest-driven Lua module, resource, audio, and package loading. +- Controlled Lua modularization through `runtime.import(moduleName)`. +- Generic Runtime nodes for panels, text, sprites, buttons, particles, Spine, and list views. +- Runtime commands for movement, fading, scaling, rotation, sequencing, audio, resources, toast, clipboard, and Spine animation. +- Shared Lua helper modules under `assets/runtime/lua/`. +- Configurable Runtime Lua asset root via `RuntimeOptions.runtimeLuaRoot`. + +## Example + +This package includes a runnable Flutter showcase app under `example/`: + +```bash +cd example +flutter run --dart-define=LUA_GAME_ID=showcase +``` + +Other bundled example packages can be selected with: + +```bash +flutter run --dart-define=LUA_GAME_ID=template +flutter run --dart-define=LUA_GAME_ID=ludo +flutter run --dart-define=LUA_GAME_ID=flight +``` + +## Quick start + +Add the package to your app: + +```yaml +dependencies: + flame_lua_runtime: ^0.1.0 +``` + +Embed a game package: + +```dart +import 'package:flame_lua_runtime/flame_lua_runtime.dart'; + +LuaGameWidget( + gameId: 'template', + runtimeOptions: const RuntimeOptions( + runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua', + ), +) +``` + +Your app should provide game package assets such as: + +```text +assets/games/template/manifest.json +assets/games/template/scripts/main.lua +assets/games/template/scripts/state.lua +assets/games/template/scripts/ui.lua +``` + +The game manifest declares package-local scripts and shared Runtime Lua modules: + +```json +{ + "modules": { + "main": "scripts/main.lua", + "runtime_ui": "runtime:runtime_ui.lua", + "runtime_widgets": "runtime:runtime_widgets.lua", + "runtime_commands": "runtime:runtime_commands.lua", + "layout": "runtime:layout.lua" + } +} +``` + +## Runtime asset path + +When used as a published package, configure: + +```dart +const RuntimeOptions( + runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua', +) +``` + +For source-tree development, the default remains: + +```dart +RuntimeOptions.defaultRuntimeLuaRoot // assets/runtime/lua +``` + +## Status + +This package is in early extraction stage. Public API is intentionally small and centered on `LuaGameWidget`, `FlameLuaGame`, `RuntimeOptions`, package repositories, and script engine interfaces. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/assets/runtime/lua/layout.lua b/assets/runtime/lua/layout.lua new file mode 100644 index 0000000..f3f9900 --- /dev/null +++ b/assets/runtime/lua/layout.lua @@ -0,0 +1,292 @@ +---@alias RuntimeLayoutAlign 'start'|'center'|'end' + +---@class (exact) RuntimeLayoutItem +---@field node RuntimeNode +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLayoutItemOpts +---@field margin? number +---@field mx? number +---@field my? number +---@field ml? number +---@field mr? number +---@field mt? number +---@field mb? number +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLinearLayoutOpts +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field gap? number +---@field align? RuntimeLayoutAlign +---@field padding? number +---@field paddingX? number +---@field paddingY? number +---@field px? number +---@field py? number +---@field paddingLeft? number +---@field paddingTop? number + +---@class RuntimeBoxLayoutOpts: RuntimeLinearLayoutOpts +---@field rows? integer +---@field columns? integer +---@field cols? integer +---@field cellWidth? number +---@field cellHeight? number +---@field cellW? number +---@field cellH? number +---@field gapX? number +---@field gapY? number +---@field valign? RuntimeLayoutAlign + +---@class RuntimeLayout +local layout = {} + +---@param value any +---@param fallback number +---@return number +local function read_number(value, fallback) + if type(value) == "number" then + return value + end + return fallback +end + +---@param opts? table +---@param key string +---@return number +local function spacing(opts, key) + opts = opts or {} + if key == "left" then + return read_number(opts.paddingLeft, read_number(opts.paddingX, read_number(opts.px, read_number(opts.padding, 0)))) + end + if key == "top" then + return read_number(opts.paddingTop, read_number(opts.paddingY, read_number(opts.py, read_number(opts.padding, 0)))) + end + return 0 +end + +---@param node RuntimeNode +---@param parent? string +local function apply_parent(node, parent) + if parent ~= nil and parent ~= "" then + node.parent = parent + elseif parent == "" then + node.parent = nil + end +end + +---@param axis_size number +---@param item_size number +---@param align RuntimeLayoutAlign +---@return number +local function align_cross(axis_size, item_size, align) + if align == "center" then + return (axis_size - item_size) / 2 + end + if align == "end" then + return axis_size - item_size + end + return 0 +end + +---@param value any +---@return RuntimeNode +local function node_of(value) + return value.node or value +end + +---@param value any +---@param key string +---@return number +local function margin_of(value, key) + local all = read_number(value.margin, 0) + if key == "marginLeft" then + return read_number(value.marginLeft, read_number(value.ml, read_number(value.mx, all))) + end + if key == "marginRight" then + return read_number(value.marginRight, read_number(value.mr, read_number(value.mx, all))) + end + if key == "marginTop" then + return read_number(value.marginTop, read_number(value.mt, read_number(value.my, all))) + end + if key == "marginBottom" then + return read_number(value.marginBottom, read_number(value.mb, read_number(value.my, all))) + end + return 0 +end + +---@param node RuntimeNode +---@param opts? RuntimeLayoutItemOpts +---@return RuntimeLayoutItem +function layout.item(node, opts) + opts = opts or {} + return { + node = node, + marginLeft = margin_of(opts, "marginLeft"), + marginRight = margin_of(opts, "marginRight"), + marginTop = margin_of(opts, "marginTop"), + marginBottom = margin_of(opts, "marginBottom") + } +end + +---@param origin RuntimePoint +---@param position RuntimePoint +---@return RuntimePoint +function layout.local_position(origin, position) + return { + x = position.x - origin.x, + y = position.y - origin.y + } +end + +---@param parent? string +---@param items (RuntimeNode|RuntimeLayoutItem)[] +---@param opts? RuntimeLinearLayoutOpts +---@return RuntimeNode[] +function layout.row(parent, items, opts) + opts = opts or {} + local cursor = read_number(opts.x, 0) + spacing(opts, "left") + local base_y = read_number(opts.y, 0) + spacing(opts, "top") + local gap = read_number(opts.gap, 0) + local height = read_number(opts.height, 0) + local align = opts.align or "start" + local nodes = {} + + for index, item in ipairs(items) do + local node = node_of(item) + cursor = cursor + margin_of(item, "marginLeft") + node.x = cursor + + local item_height = read_number(node.height, 0) + node.y = base_y + align_cross(height, item_height, align) + apply_parent(node, parent) + + cursor = cursor + read_number(node.width, 0) + margin_of(item, "marginRight") + gap + nodes[index] = node + end + + return nodes +end + +---@param parent? string +---@param items (RuntimeNode|RuntimeLayoutItem)[] +---@param opts? RuntimeLinearLayoutOpts +---@return RuntimeNode[] +function layout.column(parent, items, opts) + opts = opts or {} + local cursor = read_number(opts.y, 0) + spacing(opts, "top") + local base_x = read_number(opts.x, 0) + spacing(opts, "left") + local gap = read_number(opts.gap, 0) + local width = read_number(opts.width, 0) + local align = opts.align or "start" + local nodes = {} + + for index, item in ipairs(items) do + local node = node_of(item) + cursor = cursor + margin_of(item, "marginTop") + node.y = cursor + + local item_width = read_number(node.width, 0) + node.x = base_x + align_cross(width, item_width, align) + apply_parent(node, parent) + + cursor = cursor + read_number(node.height, 0) + margin_of(item, "marginBottom") + gap + nodes[index] = node + end + + return nodes +end + +---@param parent? string +---@param items (RuntimeNode|RuntimeLayoutItem)[] +---@param opts? RuntimeLinearLayoutOpts +---@return RuntimeNode[] +function layout.stack(parent, items, opts) + opts = opts or {} + local offset_x = read_number(opts.x, 0) + spacing(opts, "left") + local offset_y = read_number(opts.y, 0) + spacing(opts, "top") + local nodes = {} + + for index, item in ipairs(items) do + local node = node_of(item) + node.x = read_number(node.x, 0) + offset_x + node.y = read_number(node.y, 0) + offset_y + apply_parent(node, parent) + nodes[index] = node + end + + return nodes +end + +---@param parent? string +---@param items (RuntimeNode|RuntimeLayoutItem)[] +---@param opts? RuntimeBoxLayoutOpts +---@return RuntimeNode[] +function layout.box(parent, items, opts) + opts = opts or {} + local count = #items + local rows = math.floor(read_number(opts.rows, 0)) + local columns = math.floor(read_number(opts.columns, read_number(opts.cols, 0))) + if columns <= 0 and rows > 0 then + columns = math.ceil(count / rows) + end + if columns <= 0 then + columns = count > 0 and count or 1 + end + if rows <= 0 then + rows = math.ceil(count / columns) + end + + local gap_x = read_number(opts.gapX, read_number(opts.gap, 0)) + local gap_y = read_number(opts.gapY, read_number(opts.gap, 0)) + local origin_x = read_number(opts.x, 0) + spacing(opts, "left") + local origin_y = read_number(opts.y, 0) + spacing(opts, "top") + local cell_w = read_number(opts.cellWidth, read_number(opts.cellW, 0)) + local cell_h = read_number(opts.cellHeight, read_number(opts.cellH, 0)) + local width = read_number(opts.width, 0) + local height = read_number(opts.height, 0) + if cell_w <= 0 and width > 0 then + cell_w = (width - gap_x * (columns - 1)) / columns + end + if cell_h <= 0 and height > 0 then + cell_h = (height - gap_y * (rows - 1)) / rows + end + + local align = opts.align or "start" + local valign = opts.valign or "start" + local nodes = {} + + for index, item in ipairs(items) do + local node = node_of(item) + local item_width = read_number(node.width, cell_w) + local item_height = read_number(node.height, cell_h) + local effective_cell_w = cell_w > 0 and cell_w or item_width + local effective_cell_h = cell_h > 0 and cell_h or item_height + local col = (index - 1) % columns + local row = math.floor((index - 1) / columns) + local margin_left = margin_of(item, "marginLeft") + local margin_right = margin_of(item, "marginRight") + local margin_top = margin_of(item, "marginTop") + local margin_bottom = margin_of(item, "marginBottom") + local inner_w = effective_cell_w - margin_left - margin_right + local inner_h = effective_cell_h - margin_top - margin_bottom + + node.x = origin_x + col * (effective_cell_w + gap_x) + margin_left + align_cross(inner_w, item_width, align) + node.y = origin_y + row * (effective_cell_h + gap_y) + margin_top + align_cross(inner_h, item_height, valign) + apply_parent(node, parent) + nodes[index] = node + end + + return nodes +end + +return layout diff --git a/assets/runtime/lua/runtime_commands.lua b/assets/runtime/lua/runtime_commands.lua new file mode 100644 index 0000000..5c49de4 --- /dev/null +++ b/assets/runtime/lua/runtime_commands.lua @@ -0,0 +1,261 @@ +---@class (exact) RuntimeCommandOpts +---@field id? string +---@field group? string +---@field commandGroup? string +---@field scope? string +---@field onComplete? string +---@field duration? number + +---@class (exact) RuntimeAudioCommandOpts: RuntimeCommandOpts +---@field volume? number + +---@class (exact) RuntimeBgmCommandOpts: RuntimeAudioCommandOpts +---@field channel? string +---@field loop? boolean + +---@class (exact) RuntimeSpineCommandOpts: RuntimeCommandOpts +---@field track? integer +---@field loop? boolean +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeResourceCommandOpts: RuntimeCommandOpts +---@field failOnError? boolean + +---@class RuntimeCommands +local commands = {} + +---@param opts? table +---@return table +local function copy_opts(opts) + local result = {} + if opts ~= nil then + for key, value in pairs(opts) do + result[key] = value + end + end + return result +end + +---@param command_type RuntimeCommandType +---@param opts? table +---@return RuntimeCommand +local function with_type(command_type, opts) + local command = copy_opts(opts) + command.type = command_type + return command +end + +---@param text string +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.toast(text, opts) + local command = with_type("toast", opts) + command.text = text + return command +end + +---@param text string +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.copy_text(text, opts) + local command = with_type("copy_text", opts) + command.text = text + return command +end + +---@param duration number +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.delay(duration, opts) + local command = with_type("delay", opts) + command.duration = duration + return command +end + +---@param items RuntimeCommand[] +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.sequence(items, opts) + local command = with_type("sequence", opts) + command.commands = items or {} + return command +end + +---@param items RuntimeCommand[] +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.parallel(items, opts) + local command = with_type("parallel", opts) + command.commands = items or {} + return command +end + +---@param target string +---@param path RuntimePoint[] +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.move_path(target, path, opts) + local command = with_type("move_path", opts) + command.target = target + command.path = path or {} + return command +end + +---@param target string +---@param x number +---@param y number +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.move_to(target, x, y, opts) + local command = with_type("move_to", opts) + command.target = target + command.x = x + command.y = y + return command +end + +---@param target string +---@param alpha number +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.fade_to(target, alpha, opts) + local command = with_type("fade_to", opts) + command.target = target + command.alpha = alpha + return command +end + +---@param target string +---@param scale number +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.scale_to(target, scale, opts) + local command = with_type("scale_to", opts) + command.target = target + command.scale = scale + return command +end + +---@param target string +---@param angle number +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.rotate_to(target, angle, opts) + local command = with_type("rotate_to", opts) + command.target = target + command.angle = angle + return command +end + +---@param target string +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.remove_node(target, opts) + local command = with_type("remove_node", opts) + command.target = target + return command +end + +---@param target string +---@param animation string +---@param opts? RuntimeSpineCommandOpts +---@return RuntimeCommand +function commands.play_spine_animation(target, animation, opts) + local command = with_type("play_spine_animation", opts) + command.target = target + command.animation = animation + if command.loop == nil then + command.loop = true + end + return command +end + +---@param asset string +---@param opts? RuntimeAudioCommandOpts +---@return RuntimeCommand +function commands.play_sound(asset, opts) + local command = with_type("play_sound", opts) + command.asset = asset + return command +end + +---@param asset string +---@param opts? RuntimeBgmCommandOpts +---@return RuntimeCommand +function commands.play_bgm(asset, opts) + local command = with_type("play_bgm", opts) + command.asset = asset + if command.channel == nil then + command.channel = "bgm" + end + if command.loop == nil then + command.loop = true + end + return command +end + +---@param channel? string +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.pause_bgm(channel, opts) + local command = with_type("pause_bgm", opts) + command.channel = channel or "bgm" + return command +end + +---@param channel? string +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.resume_bgm(channel, opts) + local command = with_type("resume_bgm", opts) + command.channel = channel or "bgm" + return command +end + +---@param channel? string +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.stop_bgm(channel, opts) + local command = with_type("stop_bgm", opts) + command.channel = channel or "bgm" + return command +end + +---@param group string +---@param opts? RuntimeResourceCommandOpts +---@return RuntimeCommand +function commands.preload_group(group, opts) + local command = with_type("preload_resources", opts) + command.group = group + return command +end + +---@param group string +---@param opts? RuntimeCommandOpts +---@return RuntimeCommand +function commands.evict_group(group, opts) + local command = with_type("evict_resources", opts) + command.group = group + return command +end + +---@param id string +---@return RuntimeCommand +function commands.cancel_id(id) + return { type = "cancel_commands", id = id } +end + +---@param group string +---@return RuntimeCommand +function commands.cancel_group(group) + return { type = "cancel_commands", group = group } +end + +---@param scope string +---@return RuntimeCommand +function commands.cancel_scope(scope) + return { type = "cancel_commands", scope = scope } +end + +return commands diff --git a/assets/runtime/lua/runtime_ui.lua b/assets/runtime/lua/runtime_ui.lua new file mode 100644 index 0000000..6c02dd1 --- /dev/null +++ b/assets/runtime/lua/runtime_ui.lua @@ -0,0 +1,427 @@ +---@class RuntimeUi +local runtime_ui = {} + +---@generic T: table +---@param base? T +---@param opts? table +---@return T +local function merge(base, opts) + local result = {} + for key, value in pairs(base or {}) do + result[key] = value + end + + if opts ~= nil then + for key, value in pairs(opts) do + result[key] = value + end + end + + return result +end + +---@param value any +---@return boolean +local function is_table(value) + return type(value) == "table" +end + +---@param opts? table +---@return table +local function normalize_opts(opts) + local source = opts or {} + local result = {} + for key, value in pairs(source) do + if key ~= "w" and key ~= "h" and key ~= "size" and key ~= "handler" and key ~= "onClick" then + result[key] = value + end + end + if result.width == nil then + result.width = source.w or source.size + end + if result.height == nil then + result.height = source.h or source.size + end + if result.onTap == nil then + result.onTap = source.handler or source.onClick + end + return result +end + +---@param base table +---@param opts? table +---@return table +local function node_opts(base, opts) + return merge(base, normalize_opts(opts)) +end + +---@param base? RuntimeNodeProps +---@param opts? RuntimeNodeProps +---@return RuntimeNodeProps +function runtime_ui.style(base, opts) + return merge(base or {}, opts) +end + +---@param parent string +---@param opts? RuntimeNodeProps +---@return RuntimeNodeProps +function runtime_ui.with_parent(parent, opts) + return merge(opts or {}, { parent = parent }) +end + +---@param node_type RuntimeNodeType +---@param id string +---@param opts? RuntimeNodeProps +---@return RuntimeNode +function runtime_ui.node(node_type, id, opts) + return node_opts({ id = id, type = node_type }, opts) +end + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.panel(id, x, y, width, height, opts) + if is_table(x) then + return runtime_ui.node("panel", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x)) + end + return runtime_ui.node("panel", id, node_opts({ + x = x, + y = y, + width = width, + height = height + }, opts)) +end + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.rect(id, x, y, width, height, opts) + if is_table(x) then + return runtime_ui.node("rect", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x)) + end + return runtime_ui.node("rect", id, node_opts({ + x = x, + y = y, + width = width, + height = height + }, opts)) +end + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param size? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.circle(id, x, y, size, opts) + if is_table(x) then + return runtime_ui.node("circle", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x)) + end + return runtime_ui.node("circle", id, node_opts({ + x = x, + y = y, + width = size, + height = size + }, opts)) +end + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.line(id, x, y, width, height, opts) + if is_table(x) then + return runtime_ui.node("line", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x)) + end + return runtime_ui.node("line", id, node_opts({ + x = x, + y = y, + width = width, + height = height + }, opts)) +end + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param width? number +---@param height? number +---@param value? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.progress(id, x, y, width, height, value, opts) + if is_table(x) then + return runtime_ui.node("progress", id, node_opts({ x = 0, y = 0, width = 0, height = 0, value = 0 }, x)) + end + return runtime_ui.node("progress", id, node_opts({ + x = x, + y = y, + width = width, + height = height, + value = value + }, opts)) +end + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.particle(id, x, y, width, height, opts) + if is_table(x) then + return runtime_ui.node("particle", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x)) + end + return runtime_ui.node("particle", id, node_opts({ + x = x, + y = y, + width = width, + height = height + }, opts)) +end + +---@param id string +---@param text string|RuntimeNodeInit +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.text(id, text, x, y, width, height, opts) + if is_table(text) then + return runtime_ui.node("text", id, node_opts({ text = "", x = 0, y = 0, width = 0, height = 0 }, text)) + end + return runtime_ui.node("text", id, node_opts({ + text = text, + x = x, + y = y, + width = width, + height = height + }, opts)) +end + +---@param id string +---@param text string|RuntimeNodeInit +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param handler? string +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.button(id, text, x, y, width, height, handler, opts) + if is_table(text) then + return runtime_ui.node("button", id, node_opts({ + text = "", + x = 0, + y = 0, + width = 0, + height = 0, + interactive = true + }, text)) + end + return runtime_ui.node("button", id, node_opts({ + text = text, + x = x, + y = y, + width = width, + height = height, + interactive = true, + onTap = handler + }, opts)) +end + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.list_view(id, x, y, width, height, opts) + if is_table(x) then + return runtime_ui.node("listView", id, node_opts({ x = 0, y = 0, width = 0, height = 0 }, x)) + end + return runtime_ui.node("listView", id, node_opts({ + x = x, + y = y, + width = width, + height = height + }, opts)) +end + +---@param id string +---@param asset string|RuntimeNodeInit +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.image(id, asset, x, y, width, height, opts) + if is_table(asset) then + return runtime_ui.node("image", id, node_opts({ asset = "", x = 0, y = 0, width = 0, height = 0 }, asset)) + end + return runtime_ui.node("image", id, node_opts({ + asset = asset, + x = x, + y = y, + width = width, + height = height + }, opts)) +end + +---@param id string +---@param asset string|RuntimeNodeInit +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.sprite(id, asset, x, y, width, height, opts) + if is_table(asset) then + return runtime_ui.node("sprite", id, node_opts({ asset = "", x = 0, y = 0, width = 0, height = 0 }, asset)) + end + return runtime_ui.node("sprite", id, node_opts({ + asset = asset, + x = x, + y = y, + width = width, + height = height + }, opts)) +end + +---@param id string +---@param asset string|RuntimeNodeInit +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param animation? string +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function runtime_ui.spine(id, asset, x, y, width, height, animation, opts) + if is_table(asset) then + return runtime_ui.node("spine", id, node_opts({ asset = "", x = 0, y = 0, width = 0, height = 0, loop = true }, asset)) + end + return runtime_ui.node("spine", id, node_opts({ + asset = asset, + x = x, + y = y, + width = width, + height = height, + animation = animation, + loop = true + }, opts)) +end + +---@param id string +---@param props RuntimeNodeInit +---@return RuntimeNodeUpdate +function runtime_ui.update(id, props) + return { id = id, props = normalize_opts(props or {}) } +end + +---@param id string +---@param text string +---@return RuntimeNodeUpdate +function runtime_ui.text_update(id, text) + return runtime_ui.update(id, { text = text }) +end + +---@param id string +---@param visible boolean +---@return RuntimeNodeUpdate +function runtime_ui.visible_update(id, visible) + return runtime_ui.update(id, { visible = visible }) +end + +---@param id string +---@param alpha number +---@return RuntimeNodeUpdate +function runtime_ui.alpha_update(id, alpha) + return runtime_ui.update(id, { alpha = alpha }) +end + +---@param id string +---@param scale number +---@return RuntimeNodeUpdate +function runtime_ui.scale_update(id, scale) + return runtime_ui.update(id, { scale = scale }) +end + +---@param id string +---@param x number +---@param y number +---@return RuntimeNodeUpdate +function runtime_ui.position_update(id, x, y) + return runtime_ui.update(id, { x = x, y = y }) +end + +---@param id string +---@param width number +---@param height number +---@return RuntimeNodeUpdate +function runtime_ui.size_update(id, width, height) + return runtime_ui.update(id, { width = width, height = height }) +end + +---@param id string +---@param x number +---@param y number +---@param scale number +---@param rotation number +---@return RuntimeNodeUpdate +function runtime_ui.transform_update(id, x, y, scale, rotation) + return runtime_ui.update(id, { + x = x, + y = y, + scale = scale, + rotation = rotation + }) +end + +---@param ids string[] +---@param props RuntimeNodeInit +---@return RuntimeNodeUpdate[] +function runtime_ui.batch_update(ids, props) + local updates = {} + for _, id in ipairs(ids) do + table.insert(updates, runtime_ui.update(id, props)) + end + return updates +end + +---@param nodes RuntimeNode[] +---@param node RuntimeNode +---@return RuntimeNode[] +function runtime_ui.append(nodes, node) + table.insert(nodes, node) + return nodes +end + +---@param nodes RuntimeNode[] +---@param extra_nodes RuntimeNode[] +---@return RuntimeNode[] +function runtime_ui.append_all(nodes, extra_nodes) + for _, node in ipairs(extra_nodes) do + table.insert(nodes, node) + end + return nodes +end + +return runtime_ui diff --git a/assets/runtime/lua/runtime_widgets.lua b/assets/runtime/lua/runtime_widgets.lua new file mode 100644 index 0000000..e937ebd --- /dev/null +++ b/assets/runtime/lua/runtime_widgets.lua @@ -0,0 +1,773 @@ +local runtime_ui = runtime.import("runtime_ui") + +---@class (exact) RuntimeDialogButton +---@field id? string +---@field text string +---@field handler string +---@field color? string + +---@class (exact) RuntimeDialogOpts +---@field screenWidth? number +---@field screenHeight? number +---@field overlay? boolean +---@field overlayColor? string +---@field blockInput? boolean +---@field layer? integer +---@field color? string +---@field radius? number +---@field panelStyle? RuntimeNodeProps +---@field titleColor? string +---@field titleSize? number +---@field titleStyle? RuntimeNodeProps +---@field messageColor? string +---@field messageSize? number +---@field messageStyle? RuntimeNodeProps +---@field buttons? RuntimeDialogButton[] +---@field buttonGap? number +---@field buttonStyle? RuntimeNodeProps + +---@class (exact) RuntimeLabeledProgressOpts: RuntimeNodeProps +---@field labelHeight? number +---@field labelStyle? RuntimeNodeProps + +---@class RuntimePillOpts: RuntimeNodeInit +---@field panelStyle? RuntimeNodeProps +---@field textStyle? RuntimeNodeProps + +---@class RuntimeTextButtonOpts: RuntimeNodeInit +---@field variant? 'primary'|'secondary'|'ghost' + +---@class RuntimeListItemOpts: RuntimeTextButtonOpts +---@field selected? boolean +---@field activeColor? string +---@field inactiveColor? string + +---@class RuntimeTabItem +---@field id? string +---@field key? string +---@field text string +---@field handler? string +---@field selected? boolean + +---@class RuntimeTabsOpts: RuntimeNodeInit +---@field tabs RuntimeTabItem[] +---@field selected? string +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field activeColor? string +---@field inactiveColor? string +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeActionItem +---@field id? string +---@field text string +---@field handler? string +---@field visible? boolean +---@field color? string +---@field style? RuntimeNodeProps + +---@class RuntimeActionRowOpts: RuntimeNodeInit +---@field actions RuntimeActionItem[] +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimePanelHeaderOpts: RuntimeNodeInit +---@field eyebrow? string +---@field title string +---@field summary? string +---@field gap? number +---@field eyebrowId? string +---@field titleId? string +---@field summaryId? string +---@field eyebrowHeight? number +---@field titleHeight? number +---@field summaryHeight? number +---@field eyebrowStyle? RuntimeNodeProps +---@field titleStyle? RuntimeNodeProps +---@field summaryStyle? RuntimeNodeProps + +---@class RuntimeWidgetTheme +---@field primary? string +---@field secondary? string +---@field success? string +---@field overlay? string +---@field surface? string +---@field surfaceAlt? string +---@field card? string +---@field text? string +---@field muted? string +---@field progress? string +---@field transparent? string + +---@class RuntimeWidgets +local widgets = {} + +-- ----------------------------------------------------------------------------- +-- Theme/config +-- ----------------------------------------------------------------------------- + +---@type RuntimeWidgetTheme +local widget_theme = { + primary = "#ff2563eb", + secondary = "#ff475569", + success = "#ff22c55e", + overlay = "#99000000", + surface = "#ff1e293b", + surfaceAlt = "#ff334155", + card = "#ee111827", + text = "#ffffffff", + muted = "#ffe5e7eb", + progress = "#ff22c55e", + transparent = "#00000000" +} + +-- ----------------------------------------------------------------------------- +-- Internal helpers +-- ----------------------------------------------------------------------------- + +---@generic T: table +---@param base? T +---@param opts? table +---@return T +local function merge(base, opts) + local result = {} + for key, value in pairs(base or {}) do + result[key] = value + end + for key, value in pairs(opts or {}) do + result[key] = value + end + return result +end + +---@param value any +---@return boolean +local function is_table(value) + return type(value) == "table" +end + +---@param source? table +---@param keys string[] +---@return table +local function without_keys(source, keys) + local result = merge(source or {}, nil) + for _, key in ipairs(keys) do + result[key] = nil + end + return result +end + +---@param opts? RuntimeNodeInit +---@return RuntimeNodeInit +local function normalize_box(opts) + local result = merge(opts or {}, nil) + if result.width == nil then + result.width = result.w or result.size + end + if result.height == nil then + result.height = result.h or result.size + end + result.w = nil + result.h = nil + result.size = nil + return result +end + +---@param opts? RuntimeNodeInit +---@param fallback? table +---@return RuntimeNodeInit +local function with_box(opts, fallback) + return merge(fallback or {}, normalize_box(opts)) +end + +---@param target RuntimeNode[] +---@param source? RuntimeNode[] +---@return RuntimeNode[] +local function append_all(target, source) + for _, node in ipairs(source or {}) do + table.insert(target, node) + end + return target +end + +---@param parent string +---@param opts? RuntimeNodeProps +---@return RuntimeNodeProps +local function child_opts(parent, opts) + return merge(opts or {}, { parent = parent }) +end + +---@param value table +---@param opts? table +---@param key string +---@return table, table +local function collection_args(value, opts, key) + local options = opts or {} + local items = value + if is_table(value) and value[key] ~= nil then + options = value + items = value[key] + end + return items or {}, options +end + +---@param tokens? RuntimeWidgetTheme +---@return RuntimeWidgets +function widgets.configure(tokens) + widget_theme = merge(widget_theme, tokens or {}) + return widgets +end + +---@param name string +---@return string +local function theme_color(name) + return widget_theme[name] or "#ffffffff" +end + +-- ----------------------------------------------------------------------------- +-- Text helpers +-- ----------------------------------------------------------------------------- + +---@param id string +---@param text string|RuntimeNodeInit +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function widgets.label(id, text, x, y, width, height, opts) + local fallback = { text = "", x = 0, y = 0, width = 0, height = 0 } + local options + if is_table(text) then + options = with_box(text, fallback) + else + options = with_box(merge({ text = text, x = x, y = y, width = width, height = height }, opts), fallback) + end + return runtime_ui.text(id, merge({ + color = theme_color("muted"), + fontSize = 14, + textAlign = "left" + }, options)) +end + +---@param id string +---@param text string|RuntimeNodeInit +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function widgets.section_title(id, text, x, y, width, height, opts) + local options + if is_table(text) then + options = text + else + options = merge({ text = text, x = x, y = y, width = width, height = height }, opts) + end + return widgets.label(id, merge({ + color = theme_color("text"), + fontSize = 18, + textAlign = "left" + }, options)) +end + +-- ----------------------------------------------------------------------------- +-- Badges and buttons +-- ----------------------------------------------------------------------------- + +---@param id string +---@param text string|RuntimePillOpts +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimePillOpts +---@return RuntimeNode[] +function widgets.pill(id, text, x, y, width, height, opts) + local options + if is_table(text) then + options = with_box(text, { text = "", x = 0, y = 0, width = 0, height = 0 }) + else + options = with_box(merge({ text = text, x = x, y = y, width = width, height = height }, opts), { + text = "", + x = 0, + y = 0, + width = 0, + height = 0 + }) + end + local panel_opts = merge({ + color = theme_color("surfaceAlt"), + radius = options.height / 2 + }, options.panelStyle) + panel_opts = merge(panel_opts, without_keys(options, { "text", "panelStyle", "textStyle" })) + local text_opts = child_opts(id, merge({ + color = theme_color("text"), + fontSize = 12, + textAlign = "center" + }, options.textStyle)) + return { + runtime_ui.panel(id, panel_opts), + runtime_ui.text(id .. "_text", merge({ + text = options.text or "", + x = 0, + y = 0, + width = options.width, + height = options.height + }, text_opts)) + } +end + +---@param id string +---@param text string|RuntimeTextButtonOpts +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param handler? string +---@param opts? RuntimeTextButtonOpts +---@return RuntimeNode +function widgets.text_button(id, text, x, y, width, height, handler, opts) + local options + if is_table(text) then + options = with_box(text, { text = "", x = 0, y = 0, width = 0, height = 0 }) + else + options = with_box(merge({ text = text, x = x, y = y, width = width, height = height, handler = handler }, opts), { + text = "", + x = 0, + y = 0, + width = 0, + height = 0 + }) + end + local variant = options.variant or "primary" + local color = theme_color("primary") + if variant == "secondary" then + color = theme_color("secondary") + elseif variant == "ghost" then + color = theme_color("transparent") + end + return runtime_ui.button(id, merge({ + color = color, + radius = 10, + fontSize = 13, + textAlign = "center" + }, without_keys(options, { "variant" }))) +end + +---@param id string +---@param text string|RuntimeListItemOpts +---@param x? number +---@param y? number +---@param width? number +---@param height? number +---@param handler? string +---@param opts? RuntimeListItemOpts +---@return RuntimeNode +function widgets.list_item(id, text, x, y, width, height, handler, opts) + local options + if is_table(text) then + options = with_box(text, { text = "", x = 0, y = 0, width = 0, height = 0 }) + else + options = with_box(merge({ text = text, x = x, y = y, width = width, height = height, handler = handler }, opts), { + text = "", + x = 0, + y = 0, + width = 0, + height = 0 + }) + end + local selected = options.selected == true + local color = selected and (options.activeColor or theme_color("primary")) or (options.inactiveColor or theme_color("surface")) + return widgets.text_button(id, merge({ + color = color, + radius = 10, + fontSize = 12 + }, without_keys(options, { "selected", "activeColor", "inactiveColor" }))) +end + +---@param id string +---@param tabs RuntimeTabItem[]|RuntimeTabsOpts +---@param opts? RuntimeTabsOpts +---@return RuntimeNode[] +function widgets.tabs(id, tabs, opts) + local items, options = collection_args(tabs, opts, "tabs") + + local origin_x = options.x or 0 + local origin_y = options.y or 0 + local gap = options.gap or 6 + local item_w = options.itemWidth or options.width or options.w or 72 + local item_h = options.itemHeight or options.height or options.h or 24 + local nodes = {} + + for index, tab in ipairs(items) do + local key = tab.key or tostring(index) + local selected = tab.selected == true or key == options.selected + local node_id = tab.id or (id .. "_" .. key) + local style = merge({ + x = origin_x + (index - 1) * (item_w + gap), + y = origin_y, + w = item_w, + h = item_h, + text = tab.text or key, + handler = tab.handler or key, + selected = selected, + activeColor = options.activeColor, + inactiveColor = options.inactiveColor + }, options.buttonStyle) + style = merge(style, without_keys(options, { + "tabs", + "selected", + "gap", + "itemWidth", + "itemHeight", + "activeColor", + "inactiveColor", + "buttonStyle", + "x", + "y", + "width", + "height", + "w", + "h" + })) + nodes[index] = widgets.list_item(node_id, style) + end + + return nodes +end + +---@param id string +---@param actions RuntimeActionItem[]|RuntimeActionRowOpts +---@param opts? RuntimeActionRowOpts +---@return RuntimeNode[] +function widgets.action_row(id, actions, opts) + local items, options = collection_args(actions, opts, "actions") + + local count = #items + local gap = options.gap or 8 + local origin_x = options.x or 0 + local origin_y = options.y or 0 + local item_h = options.itemHeight or options.height or options.h or 32 + local item_w = options.itemWidth + if item_w == nil and (options.width ~= nil or options.w ~= nil) and count > 0 then + item_w = ((options.width or options.w) - gap * (count - 1)) / count + end + item_w = item_w or 88 + + local nodes = {} + for index, action in ipairs(items) do + local visible = action.visible ~= false + local style = merge({ + x = origin_x + (index - 1) * (item_w + gap), + y = origin_y, + w = item_w, + h = item_h, + text = visible and (action.text or "") or "", + handler = visible and (action.handler or "noop") or "noop", + color = action.color or (index == 1 and theme_color("primary") or theme_color("surfaceAlt")), + visible = visible + }, options.buttonStyle) + style = merge(style, action.style) + style = merge(style, without_keys(options, { + "actions", + "gap", + "itemWidth", + "itemHeight", + "buttonStyle", + "x", + "y", + "width", + "height", + "w", + "h" + })) + nodes[index] = widgets.text_button(action.id or (id .. "_" .. tostring(index)), style) + end + + return nodes +end + +-- ----------------------------------------------------------------------------- +-- Panels and headers +-- ----------------------------------------------------------------------------- + +---@param id string +---@param opts RuntimePanelHeaderOpts +---@return RuntimeNode[] +function widgets.panel_header(id, opts) + local options = with_box(opts or {}, { x = 0, y = 0, width = 0, height = 0, title = "" }) + local nodes = {} + local cursor = options.y + local gap = options.gap or 4 + local layer = options.layer + local parent = options.parent + + if options.eyebrow ~= nil and options.eyebrow ~= "" then + local h = options.eyebrowHeight or 20 + table.insert(nodes, widgets.label(options.eyebrowId or (id .. "_eyebrow"), merge({ + text = options.eyebrow, + x = options.x, + y = cursor, + width = options.width, + height = h, + parent = parent, + layer = layer, + color = theme_color("primary"), + fontSize = 13 + }, options.eyebrowStyle))) + cursor = cursor + h + gap + end + + local title_h = options.titleHeight or 28 + table.insert(nodes, widgets.section_title(options.titleId or (id .. "_title"), merge({ + text = options.title or "", + x = options.x, + y = cursor, + width = options.width, + height = title_h, + parent = parent, + layer = layer, + fontSize = 20 + }, options.titleStyle))) + cursor = cursor + title_h + gap + + if options.summary ~= nil and options.summary ~= "" then + local h = options.summaryHeight or 22 + table.insert(nodes, widgets.label(options.summaryId or (id .. "_summary"), merge({ + text = options.summary, + x = options.x, + y = cursor, + width = options.width, + height = h, + parent = parent, + layer = layer, + color = theme_color("muted"), + fontSize = 12 + }, options.summaryStyle))) + end + + return nodes +end + +---@param id string +---@param width number|RuntimeNodeInit +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function widgets.overlay(id, width, height, opts) + if is_table(width) then + return runtime_ui.rect(id, merge({ + x = 0, + y = 0, + color = theme_color("overlay"), + layer = 900 + }, width)) + end + return runtime_ui.rect(id, 0, 0, width, height, merge({ + color = theme_color("overlay"), + layer = 900 + }, opts)) +end + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param width? number +---@param height? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function widgets.card(id, x, y, width, height, opts) + if is_table(x) then + return runtime_ui.panel(id, merge({ + x = 0, + y = 0, + width = 0, + height = 0, + color = theme_color("card"), + radius = 14, + layer = 910 + }, x)) + end + return runtime_ui.panel(id, x, y, width, height, merge({ + color = theme_color("card"), + radius = 14, + layer = 910 + }, opts)) +end + +-- ----------------------------------------------------------------------------- +-- Progress +-- ----------------------------------------------------------------------------- + +---@param id string +---@param x number|RuntimeNodeInit +---@param y? number +---@param width? number +---@param height? number +---@param value? number +---@param opts? RuntimeNodeInit +---@return RuntimeNode +function widgets.progress_bar(id, x, y, width, height, value, opts) + if is_table(x) then + return runtime_ui.progress(id, merge({ + x = 0, + y = 0, + width = 0, + height = 0, + value = 0, + color = theme_color("progress"), + radius = 6 + }, x)) + end + return runtime_ui.progress(id, x, y, width, height, value, merge({ + color = theme_color("progress"), + radius = 6 + }, opts)) +end + +---@param id string +---@param label string +---@param x number +---@param y number +---@param width number +---@param height number +---@param value number +---@param opts? RuntimeLabeledProgressOpts +---@return RuntimeNode[] +function widgets.labeled_progress(id, label, x, y, width, height, value, opts) + local label_height = opts ~= nil and opts.labelHeight or 24 + local progress_opts = merge(opts or {}, nil) + progress_opts.labelHeight = nil + progress_opts.labelStyle = nil + return { + runtime_ui.text(id .. "_label", label, x, y, width, label_height, merge({ + color = theme_color("text"), + fontSize = 16 + }, opts ~= nil and opts.labelStyle or nil)), + widgets.progress_bar(id, x, y + label_height + 6, width, height, value, progress_opts) + } +end + +-- ----------------------------------------------------------------------------- +-- Dialogs +-- ----------------------------------------------------------------------------- + +---@param parent string +---@param id string +---@param buttons RuntimeDialogButton[] +---@param x number +---@param y number +---@param width number +---@param height number +---@param gap? number +---@param opts? RuntimeNodeProps +---@return RuntimeNode[] +function widgets.button_row(parent, id, buttons, x, y, width, height, gap, opts) + local nodes = {} + local count = #buttons + if count == 0 then + return nodes + end + + local actual_gap = gap or 8 + local button_width = (width - actual_gap * (count - 1)) / count + for index, button in ipairs(buttons) do + local button_id = button.id or (id .. "_button_" .. index) + table.insert(nodes, runtime_ui.button( + button_id, + button.text or "OK", + x + (index - 1) * (button_width + actual_gap), + y, + button_width, + height, + button.handler, + child_opts(parent, merge({ + layer = 920, + color = button.color or theme_color("primary"), + radius = 10 + }, opts)) + )) + end + return nodes +end + +---@param id string +---@param title string +---@param message string +---@param x number +---@param y number +---@param width number +---@param height number +---@param opts? RuntimeDialogOpts +---@return RuntimeNode[] +function widgets.dialog(id, title, message, x, y, width, height, opts) + local options = opts or {} + local nodes = {} + local layer = options.layer or 900 + local screen_width = options.screenWidth or 720 + local screen_height = options.screenHeight or 720 + + if options.overlay ~= false then + table.insert(nodes, widgets.overlay(id .. "_overlay", screen_width, screen_height, { + layer = layer, + color = options.overlayColor or theme_color("overlay"), + interactive = options.blockInput == true + })) + end + + table.insert(nodes, widgets.card(id, x, y, width, height, merge({ + layer = layer + 1, + color = options.color or theme_color("card"), + radius = options.radius or 16 + }, options.panelStyle))) + + table.insert(nodes, runtime_ui.text( + id .. "_title", + title or "", + 20, + 18, + width - 40, + 32, + child_opts(id, merge({ + layer = layer + 2, + color = options.titleColor or theme_color("text"), + fontSize = options.titleSize or 22 + }, options.titleStyle)) + )) + + table.insert(nodes, runtime_ui.text( + id .. "_message", + message or "", + 20, + 60, + width - 40, + height - 116, + child_opts(id, merge({ + layer = layer + 2, + color = options.messageColor or theme_color("muted"), + fontSize = options.messageSize or 16 + }, options.messageStyle)) + )) + + append_all(nodes, widgets.button_row( + id, + id, + options.buttons or {}, + 20, + height - 48, + width - 40, + 34, + options.buttonGap or 8, + options.buttonStyle + )) + + return nodes +end + +return widgets diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/example/.luarc.json b/example/.luarc.json new file mode 100644 index 0000000..f2f5726 --- /dev/null +++ b/example/.luarc.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime.version": "Lua 5.4", + "workspace.checkThirdParty": false, + "diagnostics.globals": ["runtime"], + "diagnostics.neededFileStatus": { + "undefined-field": "Any", + "inject-field": "Any", + "assign-type-mismatch": "Any", + "param-type-mismatch": "Any", + "return-type-mismatch": "Any", + "missing-fields": "Any", + "undefined-global": "Any" + }, + "type.weakUnionCheck": true, + "type.weakNilCheck": true +} diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..5f4336f --- /dev/null +++ b/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: android + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: ios + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: linux + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: macos + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: web + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: windows + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..62a1d0e --- /dev/null +++ b/example/README.md @@ -0,0 +1,16 @@ +# flame_lua_ludo + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..2b50fa5 --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "dev.flame_lua.runtime_showcase" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "dev.flame_lua.runtime_showcase" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ffb7d68 --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/dev/flame_lua/runtime_showcase/MainActivity.kt b/example/android/app/src/main/kotlin/dev/flame_lua/runtime_showcase/MainActivity.kt new file mode 100644 index 0000000..2da27cd --- /dev/null +++ b/example/android/app/src/main/kotlin/dev/flame_lua/runtime_showcase/MainActivity.kt @@ -0,0 +1,5 @@ +package dev.flame_lua.runtime_showcase + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..fb605bc --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/example/assets/games/flight/manifest.json b/example/assets/games/flight/manifest.json new file mode 100644 index 0000000..0cd4c0a --- /dev/null +++ b/example/assets/games/flight/manifest.json @@ -0,0 +1,22 @@ +{ + "gameId": "flight", + "name": "Lua 飞行棋基础版", + "version": "0.1.0", + "runtimeApiVersion": 1, + "entry": "scripts/main.lua", + "assetsBase": "assets", + "modules": { + "runtime_ui": "runtime:runtime_ui.lua", + "runtime_widgets": "runtime:runtime_widgets.lua", + "runtime_commands": "runtime:runtime_commands.lua", + "layout": "runtime:layout.lua", + "theme": "scripts/theme.lua", + "styles": "scripts/styles.lua", + "state": "scripts/state.lua", + "board": "scripts/board.lua", + "rules": "scripts/rules.lua", + "ui": "scripts/ui.lua", + "animation": "scripts/animation.lua" + }, + "resources": {} +} diff --git a/example/assets/games/flight/scripts/animation.lua b/example/assets/games/flight/scripts/animation.lua new file mode 100644 index 0000000..02a6509 --- /dev/null +++ b/example/assets/games/flight/scripts/animation.lua @@ -0,0 +1,17 @@ +---@type RuntimeCommands +local commands = runtime.import("runtime_commands") + +local animation = {} + +function animation.move_piece(piece_id, path) + return commands.move_path(piece_id, path, { + duration = 0.55, + onComplete = "piece_move_done" + }) +end + +function animation.toast(text) + return commands.toast(text) +end + +return animation diff --git a/example/assets/games/flight/scripts/board.lua b/example/assets/games/flight/scripts/board.lua new file mode 100644 index 0000000..7a94b9e --- /dev/null +++ b/example/assets/games/flight/scripts/board.lua @@ -0,0 +1,91 @@ +local board = { + route_length = 40, + finish_progress = 40, + start = { + red = 1, + yellow = 11, + blue = 21, + green = 31 + }, + homes = { + red = { + { x = 92, y = 132 }, + { x = 142, y = 132 } + }, + yellow = { + { x = 518, y = 132 }, + { x = 568, y = 132 } + }, + blue = { + { x = 518, y = 548 }, + { x = 568, y = 548 } + }, + green = { + { x = 92, y = 548 }, + { x = 142, y = 548 } + } + }, + finish = { + red = { + { x = 290, y = 330 }, + { x = 250, y = 330 } + }, + yellow = { + { x = 350, y = 330 }, + { x = 390, y = 330 } + }, + blue = { + { x = 350, y = 390 }, + { x = 390, y = 390 } + }, + green = { + { x = 290, y = 390 }, + { x = 250, y = 390 } + } + }, + route = {} +} + +local function add(x, y) + table.insert(board.route, { x = x, y = y }) +end + +for i = 0, 9 do add(200 + i * 32, 104) end +for i = 0, 9 do add(520, 136 + i * 32) end +for i = 0, 9 do add(488 - i * 32, 456) end +for i = 0, 9 do add(168, 424 - i * 32) end + +function board.home_position(piece) + local index = piece.id:sub(-1) == "2" and 2 or 1 + return board.homes[piece.owner][index] +end + +function board.finish_position(piece) + local index = piece.id:sub(-1) == "2" and 2 or 1 + return board.finish[piece.owner][index] +end + +function board.route_index(owner, progress) + local start = board.start[owner] + local index = start + progress - 1 + while index > board.route_length do + index = index - board.route_length + end + return index +end + +function board.route_position(owner, progress) + return board.route[board.route_index(owner, progress)] +end + +function board.position(piece) + if piece.status == "home" then + return board.home_position(piece) + end + if piece.status == "finished" then + return board.finish_position(piece) + end + return board.route_position(piece.owner, piece.progress) +end + +return board diff --git a/example/assets/games/flight/scripts/main.lua b/example/assets/games/flight/scripts/main.lua new file mode 100644 index 0000000..bef1ba1 --- /dev/null +++ b/example/assets/games/flight/scripts/main.lua @@ -0,0 +1,154 @@ +local state = runtime.import("state") +local rules = runtime.import("rules") +local ui = runtime.import("ui") +local animation = runtime.import("animation") +---@type RuntimeWidgets +local widgets = runtime.import("runtime_widgets") +local theme = runtime.import("theme") + +widgets.configure({ + primary = theme.colors.dice_button, + secondary = theme.colors.board_inner, + success = theme.colors.green, + overlay = "#99000000", + surface = theme.colors.top_bar, + surfaceAlt = theme.colors.board_inner, + card = theme.colors.top_bar, + text = theme.colors.text, + muted = theme.colors.muted_text, + progress = theme.colors.green, + transparent = "#00000000" +}) + +function smoke_test(ctx) + return ctx ~= nil + and ctx.runtimeApiVersion ~= nil + and state.current_player ~= nil + and rules.plan_move ~= nil + and ui.create_board_nodes ~= nil + and animation.move_piece ~= nil + and widgets.dialog ~= nil +end + +function init(ctx) + return { + render = { creates = ui.create_board_nodes() }, + ui = { creates = ui.create_ui_nodes() }, + commands = {} + } +end + +local function handle_roll_dice() + if state.phase == "game_over" then + return { commands = { animation.toast("游戏已结束") } } + end + + if state.phase ~= "waiting_roll" then + return { commands = { animation.toast("请先完成当前移动") } } + end + + state.dice = rules.next_dice() + local movable = rules.movable_pieces() + + if #movable == 0 then + local message = state.current_player .. " 无可移动飞机" + if state.dice ~= 6 then + state.current_player = rules.next_player() + end + state.phase = "waiting_roll" + return { + ui = { updates = ui.dice_and_status_updates() }, + render = { updates = ui.highlight_updates(rules.all_piece_ids(), false) }, + commands = { animation.toast(message) } + } + end + + state.phase = "waiting_piece" + return { + ui = { updates = ui.dice_and_status_updates() }, + render = { updates = ui.highlight_updates(movable, true) } + } +end + +local function handle_piece_tap(piece_id) + if state.phase ~= "waiting_piece" then + return {} + end + + local piece = state.pieces[piece_id] + if piece == nil or piece.owner ~= state.current_player or not rules.can_move(piece, state.dice) then + return { commands = { animation.toast("该飞机不能移动") } } + end + + local plan = rules.plan_move(piece, state.dice) + state.phase = "animating" + state.selected_piece = piece_id + state.pending_move = plan + + return { + ui = { updates = ui.dice_and_status_updates() }, + render = { updates = ui.highlight_updates(rules.all_piece_ids(), false) }, + commands = { animation.move_piece(piece_id, plan.path) } + } +end + +local function handle_move_done() + local plan = state.pending_move + if plan == nil then + state.phase = "waiting_roll" + return { ui = { updates = ui.dice_and_status_updates() } } + end + + rules.commit_move(plan) + local moved_piece = state.pieces[plan.piece_id] + local updates = ui.piece_position_updates(plan.captures) + table.insert(updates, ui.piece_position_update(plan.piece_id)) + + local messages = {} + if #plan.captures > 0 then + table.insert(messages, "撞回 " .. tostring(#plan.captures) .. " 架飞机") + end + + if rules.player_finished(moved_piece.owner) then + state.winner = moved_piece.owner + state.phase = "game_over" + table.insert(messages, moved_piece.owner .. " 获胜") + else + if state.dice ~= 6 then + state.current_player = rules.next_player() + else + table.insert(messages, "掷出 6,额外回合") + end + state.phase = "waiting_roll" + end + + state.selected_piece = nil + state.pending_move = nil + + local commands = {} + if #messages > 0 then + commands = { animation.toast(table.concat(messages, ";")) } + end + + return { + ui = { updates = ui.dice_and_status_updates() }, + render = { updates = updates }, + commands = commands + } +end + +function on_event(event) + if event.handler == "roll_dice" then + return handle_roll_dice() + end + + if event.handler == "piece_tap" then + return handle_piece_tap(event.target) + end + + if event.handler == "piece_move_done" then + return handle_move_done() + end + + return {} +end diff --git a/example/assets/games/flight/scripts/rules.lua b/example/assets/games/flight/scripts/rules.lua new file mode 100644 index 0000000..7bad0e0 --- /dev/null +++ b/example/assets/games/flight/scripts/rules.lua @@ -0,0 +1,153 @@ +local state = runtime.import("state") +local board = runtime.import("board") + +local rules = {} + +local function piece_index(piece_id) + return piece_id:sub(-1) == "2" and 2 or 1 +end + +function rules.next_player() + for index, player in ipairs(state.players) do + if player == state.current_player then + local next_index = index + 1 + if next_index > #state.players then + next_index = 1 + end + return state.players[next_index] + end + end + return state.players[1] +end + +function rules.next_dice() + state.dice_index = state.dice_index + 1 + if state.dice_index > #state.dice_values then + state.dice_index = 1 + end + return state.dice_values[state.dice_index] +end + +function rules.can_move(piece, dice) + if piece == nil or piece.status == "finished" then + return false + end + if piece.status == "home" then + return dice == 6 + end + return true +end + +function rules.movable_pieces() + local result = {} + for id, piece in pairs(state.pieces) do + if piece.owner == state.current_player and rules.can_move(piece, state.dice) then + table.insert(result, id) + end + end + return result +end + +function rules.all_piece_ids() + local result = {} + for id, _ in pairs(state.pieces) do + table.insert(result, id) + end + return result +end + +function rules.piece_position(piece) + return board.position(piece) +end + +function rules.plan_move(piece, dice) + local path = {} + local final_status = "route" + local final_progress = piece.progress + + if piece.status == "home" then + final_progress = 1 + table.insert(path, board.route_position(piece.owner, final_progress)) + else + for step = 1, dice do + local progress = piece.progress + step + if progress >= board.finish_progress then + final_status = "finished" + final_progress = board.finish_progress + table.insert(path, board.finish_position(piece)) + break + end + table.insert(path, board.route_position(piece.owner, progress)) + final_progress = progress + end + end + + local captures = {} + if final_status == "route" then + local landing_index = board.route_index(piece.owner, final_progress) + for id, other in pairs(state.pieces) do + if id ~= piece.id and other.owner ~= piece.owner and other.status == "route" then + if board.route_index(other.owner, other.progress) == landing_index then + table.insert(captures, id) + end + end + end + end + + return { + piece_id = piece.id, + final_status = final_status, + final_progress = final_progress, + path = path, + captures = captures + } +end + +function rules.commit_move(plan) + local piece = state.pieces[plan.piece_id] + piece.status = plan.final_status + piece.progress = plan.final_progress + + for _, captured_id in ipairs(plan.captures) do + local captured = state.pieces[captured_id] + captured.status = "home" + captured.progress = 0 + end +end + +function rules.player_finished(owner) + for _, piece in pairs(state.pieces) do + if piece.owner == owner and piece.status ~= "finished" then + return false + end + end + return true +end + +function rules.status_text() + if state.winner ~= nil then + return "胜利者: " .. state.winner + end + if state.phase == "waiting_roll" then + return "请 " .. state.current_player .. " 掷骰子" + end + if state.phase == "waiting_piece" then + return "请选择 " .. state.current_player .. " 的飞机" + end + if state.phase == "animating" then + return "飞机移动中..." + end + return "准备中" +end + +function rules.piece_label(piece) + if piece.status == "home" then + return "待飞" + end + if piece.status == "finished" then + return "到达" + end + return tostring(piece.progress) +end + +return rules diff --git a/example/assets/games/flight/scripts/runtime_defs.lua b/example/assets/games/flight/scripts/runtime_defs.lua new file mode 100644 index 0000000..1bfce9f --- /dev/null +++ b/example/assets/games/flight/scripts/runtime_defs.lua @@ -0,0 +1,589 @@ +---@meta +--- COMMON RUNTIME TYPES SECTION. +--- Source of truth: tool/lua_runtime_defs_common.lua +--- After editing this common section, run: +--- dart run tool/generate_lua_runtime_defs.dart + + +---@alias RuntimeNodeType +---| 'panel' +---| 'button' +---| 'text' +---| 'circle' +---| 'rect' +---| 'line' +---| 'progress' +---| 'listView' +---| 'sprite' +---| 'image' +---| 'spine' +---| 'particle' + +---@alias RuntimeAnchor +---| 'center' +---| 'topLeft' +---| 'topRight' +---| 'bottomLeft' +---| 'bottomRight' + +---@alias RuntimeTextAlign +---| 'left' +---| 'center' +---| 'right' + +---@alias RuntimeParticlePreset +---| 'burst' +---| 'trail' +---| 'snow' +---| 'confetti' + +---@alias RuntimeCommandType +---| 'move_path' +---| 'move_to' +---| 'fade_to' +---| 'scale_to' +---| 'rotate_to' +---| 'remove_node' +---| 'sequence' +---| 'parallel' +---| 'delay' +---| 'toast' +---| 'play_sound' +---| 'play_bgm' +---| 'pause_bgm' +---| 'resume_bgm' +---| 'stop_bgm' +---| 'preload_resources' +---| 'evict_resources' +---| 'cancel_commands' +---| 'play_spine_animation' +---| 'copy_text' + +---@alias RuntimeEventType +---| 'tap' +---| 'animation_done' +---| 'resize' +---| 'scroll' + +---@alias RuntimeScaleMode +---| 'fit' +---| 'fill' +---| 'stretch' +---| 'none' + +---@alias RuntimeLayoutAlign +---| 'start' +---| 'center' +---| 'end' + +---@alias RuntimeButtonVariant +---| 'primary' +---| 'secondary' +---| 'ghost' + +---@class (exact) RuntimeNode +---@field id string +---@field type RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---@class (exact) RuntimeNodeProps +---@field type? RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---Helper-only fields accepted by runtime_ui/runtime_widgets. They are normalized +---before the node/update crosses the Dart Runtime protocol boundary. +---@class RuntimeNodeInit: RuntimeNodeProps +---@field w? number Alias for width. +---@field h? number Alias for height. +---@field size? number Alias for both width and height. +---@field handler? string Alias for onTap. +---@field onClick? string Alias for onTap. + +---@class (exact) RuntimeNodeUpdate +---@field id string +---@field props RuntimeNodeProps + +---@class (exact) RuntimeNodeRemove +---@field id string + +---@class (exact) RuntimeDiffSection +---@field creates? RuntimeNode[] +---@field updates? RuntimeNodeUpdate[] +---@field removes? (string|RuntimeNodeRemove)[] + +---@class (exact) RuntimeDiff +---@field render? RuntimeDiffSection +---@field ui? RuntimeDiffSection +---@field commands? RuntimeCommand[] + +---@class (exact) RuntimeEvent +---@field type RuntimeEventType|string +---@field target? string +---@field handler? string +---@field x? number +---@field y? number +---@field data? table + +---@class (exact) RuntimeCommand +---@field type RuntimeCommandType +---@field target? string +---@field scope? string +---@field id? string +---@field group? string +---@field commandGroup? string +---@field onComplete? string +---@field duration? number +---@field commands? RuntimeCommand[] +---@field path? RuntimePoint[] +---@field x? number +---@field y? number +---@field alpha? number +---@field scale? number +---@field angle? number +---@field text? string +---@field message? string +---@field asset? string +---@field name? string +---@field volume? number +---@field channel? string +---@field loop? boolean +---@field failOnError? boolean +---@field animation? string +---@field track? integer +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeCommandOpts +---@field id? string +---@field group? string +---@field commandGroup? string +---@field scope? string +---@field onComplete? string +---@field duration? number + +---@class (exact) RuntimeAudioCommandOpts: RuntimeCommandOpts +---@field volume? number +---@field name? string + +---@class (exact) RuntimeBgmCommandOpts: RuntimeAudioCommandOpts +---@field channel? string +---@field loop? boolean + +---@class (exact) RuntimeSpineCommandOpts: RuntimeCommandOpts +---@field track? integer +---@field loop? boolean +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeResourceCommandOpts: RuntimeCommandOpts +---@field failOnError? boolean + +---@class (exact) RuntimePoint +---@field x number +---@field y number + +---@class (exact) RuntimeLocaleContext +---@field requested string +---@field resolved string +---@field default string +---@field supported string[] +---@field languageCode string +---@field scriptCode? string +---@field countryCode? string + +---@class (exact) RuntimeScreenContext +---@field width number +---@field height number + +---@class (exact) RuntimeDesignContext +---@field width number +---@field height number + +---@class (exact) RuntimeViewportContext +---@field x number +---@field y number +---@field width number +---@field height number +---@field scaleX number +---@field scaleY number +---@field scaleMode RuntimeScaleMode|string + +---@class (exact) RuntimeContext +---@field screen RuntimeScreenContext +---@field design RuntimeDesignContext +---@field viewport RuntimeViewportContext +---@field seed integer +---@field runtimeApiVersion integer +---@field gameId string +---@field gameVersion string +---@field locale? RuntimeLocaleContext + +---@class RuntimeUi +---@field style fun(base?: RuntimeNodeProps, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field with_parent fun(parent: string, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field node fun(node_type: RuntimeNodeType, id: string, opts?: RuntimeNodeInit): RuntimeNode +---@field panel fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field rect fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field circle fun(id: string, x: number|RuntimeNodeInit, y?: number, size?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field line fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field particle fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field text fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field button fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field list_view fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field image fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field sprite fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field spine fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, animation?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field update fun(id: string, props: RuntimeNodeInit): RuntimeNodeUpdate +---@field text_update fun(id: string, text: string): RuntimeNodeUpdate +---@field visible_update fun(id: string, visible: boolean): RuntimeNodeUpdate +---@field alpha_update fun(id: string, alpha: number): RuntimeNodeUpdate +---@field scale_update fun(id: string, scale: number): RuntimeNodeUpdate +---@field position_update fun(id: string, x: number, y: number): RuntimeNodeUpdate +---@field size_update fun(id: string, width: number, height: number): RuntimeNodeUpdate +---@field transform_update fun(id: string, x: number, y: number, scale: number, rotation: number): RuntimeNodeUpdate +---@field batch_update fun(ids: string[], props: RuntimeNodeInit): RuntimeNodeUpdate[] +---@field append fun(nodes: RuntimeNode[], node: RuntimeNode): RuntimeNode[] +---@field append_all fun(nodes: RuntimeNode[], extra_nodes: RuntimeNode[]): RuntimeNode[] + +---@class (exact) RuntimeDialogButton +---@field id? string +---@field text string +---@field handler string +---@field color? string + +---@class (exact) RuntimeDialogOpts +---@field screenWidth? number +---@field screenHeight? number +---@field overlay? boolean +---@field overlayColor? string +---@field blockInput? boolean +---@field layer? integer +---@field color? string +---@field radius? number +---@field panelStyle? RuntimeNodeProps +---@field titleColor? string +---@field titleSize? number +---@field titleStyle? RuntimeNodeProps +---@field messageColor? string +---@field messageSize? number +---@field messageStyle? RuntimeNodeProps +---@field buttons? RuntimeDialogButton[] +---@field buttonGap? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeLabeledProgressOpts: RuntimeNodeInit +---@field labelHeight? number +---@field labelStyle? RuntimeNodeProps + +---@class RuntimePillOpts: RuntimeNodeInit +---@field panelStyle? RuntimeNodeProps +---@field textStyle? RuntimeNodeProps + +---@class RuntimeTextButtonOpts: RuntimeNodeInit +---@field variant? RuntimeButtonVariant + +---@class RuntimeListItemOpts: RuntimeTextButtonOpts +---@field selected? boolean +---@field activeColor? string +---@field inactiveColor? string + +---@class RuntimeTabItem +---@field id? string +---@field key? string +---@field text string +---@field handler? string +---@field selected? boolean + +---@class RuntimeTabsOpts: RuntimeNodeInit +---@field tabs? RuntimeTabItem[] +---@field selected? string +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field activeColor? string +---@field inactiveColor? string +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeActionItem +---@field id? string +---@field text string +---@field handler? string +---@field visible? boolean +---@field color? string +---@field style? RuntimeNodeProps + +---@class RuntimeActionRowOpts: RuntimeNodeInit +---@field actions? RuntimeActionItem[] +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimePanelHeaderOpts: RuntimeNodeInit +---@field eyebrow? string +---@field title string +---@field summary? string +---@field gap? number +---@field eyebrowId? string +---@field titleId? string +---@field summaryId? string +---@field eyebrowHeight? number +---@field titleHeight? number +---@field summaryHeight? number +---@field eyebrowStyle? RuntimeNodeProps +---@field titleStyle? RuntimeNodeProps +---@field summaryStyle? RuntimeNodeProps + +---@class RuntimeWidgetTheme +---@field primary? string +---@field secondary? string +---@field success? string +---@field overlay? string +---@field surface? string +---@field surfaceAlt? string +---@field card? string +---@field text? string +---@field muted? string +---@field progress? string +---@field transparent? string + +---@class RuntimeWidgets +---@field configure fun(tokens?: RuntimeWidgetTheme): RuntimeWidgets +---@field label fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field section_title fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field pill fun(id: string, text: string|RuntimePillOpts, x?: number, y?: number, width?: number, height?: number, opts?: RuntimePillOpts): RuntimeNode[] +---@field text_button fun(id: string, text: string|RuntimeTextButtonOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeTextButtonOpts): RuntimeNode +---@field list_item fun(id: string, text: string|RuntimeListItemOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeListItemOpts): RuntimeNode +---@field tabs fun(id: string, tabs: RuntimeTabItem[]|RuntimeTabsOpts, opts?: RuntimeTabsOpts): RuntimeNode[] +---@field action_row fun(id: string, actions: RuntimeActionItem[]|RuntimeActionRowOpts, opts?: RuntimeActionRowOpts): RuntimeNode[] +---@field panel_header fun(id: string, opts: RuntimePanelHeaderOpts): RuntimeNode[] +---@field overlay fun(id: string, width: number|RuntimeNodeInit, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field card fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress_bar fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field labeled_progress fun(id: string, label: string, x: number, y: number, width: number, height: number, value: number, opts?: RuntimeLabeledProgressOpts): RuntimeNode[] +---@field button_row fun(parent: string, id: string, buttons: RuntimeDialogButton[], x: number, y: number, width: number, height: number, gap?: number, opts?: RuntimeNodeProps): RuntimeNode[] +---@field dialog fun(id: string, title: string, message: string, x: number, y: number, width: number, height: number, opts?: RuntimeDialogOpts): RuntimeNode[] + +---@class (exact) RuntimeLayoutItem +---@field node RuntimeNode +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLayoutItemOpts +---@field margin? number +---@field mx? number +---@field my? number +---@field ml? number +---@field mr? number +---@field mt? number +---@field mb? number +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLinearLayoutOpts +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field gap? number +---@field align? RuntimeLayoutAlign +---@field padding? number +---@field paddingX? number +---@field paddingY? number +---@field px? number +---@field py? number +---@field paddingLeft? number +---@field paddingTop? number + +---@class RuntimeBoxLayoutOpts: RuntimeLinearLayoutOpts +---@field rows? integer +---@field columns? integer +---@field cols? integer +---@field cellWidth? number +---@field cellHeight? number +---@field cellW? number +---@field cellH? number +---@field gapX? number +---@field gapY? number +---@field valign? RuntimeLayoutAlign + +---@class RuntimeLayout +---@field item fun(node: RuntimeNode, opts?: RuntimeLayoutItemOpts): RuntimeLayoutItem +---@field local_position fun(origin: RuntimePoint, position: RuntimePoint): RuntimePoint +---@field row fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field column fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field stack fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field box fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeBoxLayoutOpts): RuntimeNode[] + +---@class RuntimeCommands +---@field toast fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field copy_text fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field delay fun(duration: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field sequence fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field parallel fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_path fun(target: string, path: RuntimePoint[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_to fun(target: string, x: number, y: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field fade_to fun(target: string, alpha: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field scale_to fun(target: string, scale: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field rotate_to fun(target: string, angle: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field remove_node fun(target: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field play_spine_animation fun(target: string, animation: string, opts?: RuntimeSpineCommandOpts): RuntimeCommand +---@field play_sound fun(asset: string, opts?: RuntimeAudioCommandOpts): RuntimeCommand +---@field play_bgm fun(asset: string, opts?: RuntimeBgmCommandOpts): RuntimeCommand +---@field pause_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field resume_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field stop_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field preload_group fun(group: string, opts?: RuntimeResourceCommandOpts): RuntimeCommand +---@field evict_group fun(group: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field cancel_id fun(id: string): RuntimeCommand +---@field cancel_group fun(group: string): RuntimeCommand +---@field cancel_scope fun(scope: string): RuntimeCommand + +---@class RuntimeImportApi +---@field import fun(moduleName: string): table + +---@type RuntimeImportApi +runtime = runtime + +---@alias PlayerColor 'red'|'yellow'|'blue'|'green' +---@alias GamePhase 'waiting_roll'|'waiting_piece'|'animating'|'game_over' +---@alias PieceStatus 'home'|'route'|'finished' + +---@class (exact) PieceState +---@field id string +---@field owner PlayerColor +---@field progress integer +---@field status PieceStatus + +---@class (exact) LudoState +---@field current_player PlayerColor +---@field phase GamePhase +---@field dice_index integer +---@field dice_values integer[] +---@field dice? integer +---@field selected_piece? string +---@field pending_move? table +---@field winner? PlayerColor +---@field players PlayerColor[] +---@field pieces table + +---@class (exact) BoardPoint +---@field x number +---@field y number + +---@class (exact) BoardData +---@field red_home BoardPoint[] +---@field blue_home BoardPoint[] +---@field path BoardPoint[] +---@field start table diff --git a/example/assets/games/flight/scripts/state.lua b/example/assets/games/flight/scripts/state.lua new file mode 100644 index 0000000..c5cc323 --- /dev/null +++ b/example/assets/games/flight/scripts/state.lua @@ -0,0 +1,22 @@ +---@type table +return { + players = { "red", "yellow", "blue", "green" }, + current_player = "red", + phase = "waiting_roll", + dice_index = 0, + dice_values = { 6, 3, 4, 6, 2, 5, 1, 6, 4, 2, 6, 5 }, + dice = nil, + selected_piece = nil, + pending_move = nil, + winner = nil, + pieces = { + red_1 = { id = "red_1", owner = "red", status = "home", progress = 0 }, + red_2 = { id = "red_2", owner = "red", status = "home", progress = 0 }, + yellow_1 = { id = "yellow_1", owner = "yellow", status = "home", progress = 0 }, + yellow_2 = { id = "yellow_2", owner = "yellow", status = "home", progress = 0 }, + blue_1 = { id = "blue_1", owner = "blue", status = "home", progress = 0 }, + blue_2 = { id = "blue_2", owner = "blue", status = "home", progress = 0 }, + green_1 = { id = "green_1", owner = "green", status = "home", progress = 0 }, + green_2 = { id = "green_2", owner = "green", status = "home", progress = 0 } + } +} diff --git a/example/assets/games/flight/scripts/styles.lua b/example/assets/games/flight/scripts/styles.lua new file mode 100644 index 0000000..017a608 --- /dev/null +++ b/example/assets/games/flight/scripts/styles.lua @@ -0,0 +1,81 @@ +local theme = runtime.import("theme") +local colors = theme.colors +local styles = {} + +styles.layers = { + board = 1, + cell = 2, + home = 3, + plane = 10, + hud = 100, + hud_content = 101 +} + +styles.board = { + color = colors.board, + layer = styles.layers.board +} + +styles.center = { + color = colors.board_inner, + layer = styles.layers.board +} + +styles.route_cell = { + anchor = "center", + color = colors.route_cell, + layer = styles.layers.cell +} + +styles.home_panel = function(owner) + return { + color = colors[owner .. "_home"], + layer = styles.layers.home + } +end + +styles.hud = { + color = colors.top_bar, + layer = styles.layers.hud +} + +styles.hud_text = { + fontSize = 20, + color = colors.text, + layer = styles.layers.hud_content +} + +styles.small_text = { + fontSize = 14, + color = colors.muted_text, + layer = styles.layers.hud_content +} + +styles.dice_button = { + color = colors.dice_button, + fontSize = 20, + layer = styles.layers.hud_content +} + +styles.plane = function(owner) + return { + anchor = "center", + color = colors[owner], + layer = styles.layers.plane, + interactive = true, + onTap = "piece_tap", + alpha = 0.95 + } +end + +styles.plane_highlight = { + scale = 1.25, + alpha = 1 +} + +styles.plane_normal = { + scale = 1, + alpha = 0.95 +} + +return styles diff --git a/example/assets/games/flight/scripts/theme.lua b/example/assets/games/flight/scripts/theme.lua new file mode 100644 index 0000000..b2190fb --- /dev/null +++ b/example/assets/games/flight/scripts/theme.lua @@ -0,0 +1,22 @@ +return { + title = "Lua 飞行棋", + colors = { + background = "#0f172a", + board = "#1e293b", + board_inner = "#334155", + route_cell = "#f8fafc", + route_text = "#0f172a", + top_bar = "#020617", + dice_button = "#2563eb", + text = "#ffffff", + muted_text = "#cbd5e1", + red = "#ef4444", + yellow = "#eab308", + blue = "#3b82f6", + green = "#22c55e", + red_home = "#7f1d1d", + yellow_home = "#713f12", + blue_home = "#1e3a8a", + green_home = "#14532d" + } +} diff --git a/example/assets/games/flight/scripts/ui.lua b/example/assets/games/flight/scripts/ui.lua new file mode 100644 index 0000000..543f024 --- /dev/null +++ b/example/assets/games/flight/scripts/ui.lua @@ -0,0 +1,130 @@ +---@type RuntimeUi +local runtime_ui = runtime.import("runtime_ui") +---@type RuntimeLayout +local layout = runtime.import("layout") +local theme = runtime.import("theme") +local styles = runtime.import("styles") +local state = runtime.import("state") +local board = runtime.import("board") +local rules = runtime.import("rules") + +local ui = {} + +local function color_name(owner) + if owner == "red" then return "红" end + if owner == "yellow" then return "黄" end + if owner == "blue" then return "蓝" end + if owner == "green" then return "绿" end + return owner +end + +local function piece_size(piece) + if piece.status == "finished" then + return 30 + end + return 34 +end + +function ui.highlight_updates(ids, enabled) + return runtime_ui.batch_update(ids, enabled and styles.plane_highlight or styles.plane_normal) +end + +function ui.piece_position_update(piece_id) + local piece = state.pieces[piece_id] + local pos = rules.piece_position(piece) + return runtime_ui.update(piece_id, { + x = pos.x, + y = pos.y, + width = piece_size(piece), + height = piece_size(piece), + scale = 1, + alpha = 0.95 + }) +end + +function ui.piece_position_updates(ids) + local updates = {} + for _, id in ipairs(ids) do + table.insert(updates, ui.piece_position_update(id)) + end + return updates +end + +function ui.all_piece_position_updates() + return ui.piece_position_updates(rules.all_piece_ids()) +end + +function ui.create_board_nodes() + local nodes = { + runtime_ui.panel("board_panel", 28, 82, 664, 610, styles.board), + runtime_ui.panel("center_airport", 252, 292, 168, 136, styles.center), + runtime_ui.text("board_title", theme.title, 268, 318, 140, 32, styles.hud_text), + runtime_ui.text("board_tip", "掷 6 起飞 · 撞子回家 · 全部到达获胜", 206, 360, 320, 28, styles.small_text), + runtime_ui.panel("home_red", 62, 98, 128, 92, styles.home_panel("red")), + runtime_ui.panel("home_yellow", 498, 98, 128, 92, styles.home_panel("yellow")), + runtime_ui.panel("home_blue", 498, 516, 128, 92, styles.home_panel("blue")), + runtime_ui.panel("home_green", 62, 516, 128, 92, styles.home_panel("green")), + runtime_ui.text("label_red", "红方", 96, 102, 60, 24, styles.small_text), + runtime_ui.text("label_yellow", "黄方", 532, 102, 60, 24, styles.small_text), + runtime_ui.text("label_blue", "蓝方", 532, 520, 60, 24, styles.small_text), + runtime_ui.text("label_green", "绿方", 96, 520, 60, 24, styles.small_text) + } + + for index, cell in ipairs(board.route) do + runtime_ui.append(nodes, runtime_ui.circle("route_" .. tostring(index), cell.x, cell.y, 24, styles.route_cell)) + end + + for _, owner in ipairs(state.players) do + local start = board.route[board.start[owner]] + runtime_ui.append(nodes, runtime_ui.text("start_" .. owner, color_name(owner), start.x - 14, start.y - 14, 36, 24, { + fontSize = 14, + color = theme.colors[owner], + layer = styles.layers.cell + 1 + })) + end + + for id, piece in pairs(state.pieces) do + local pos = rules.piece_position(piece) + runtime_ui.append(nodes, runtime_ui.circle(id, pos.x, pos.y, piece_size(piece), styles.plane(piece.owner))) + end + + return nodes +end + +function ui.create_ui_nodes() + local hud_items = layout.row("top_bar", { + runtime_ui.text("turn_text", "当前: 红方", 0, 0, 120, 32, styles.hud_text), + runtime_ui.text("dice_text", "骰子: -", 0, 0, 100, 32, styles.hud_text), + runtime_ui.text("status_text", rules.status_text(), 0, 0, 260, 32, styles.hud_text), + layout.item( + runtime_ui.button("dice_button", "掷骰子", 0, 0, 120, 48, "roll_dice", styles.dice_button), + { marginLeft = 18 } + ) + }, { + x = 24, + height = 76, + gap = 16, + align = "center" + }) + + local nodes = { runtime_ui.panel("top_bar", 0, 0, 720, 76, styles.hud) } + return runtime_ui.append_all(nodes, hud_items) +end + +function ui.turn_name(owner) + return "当前: " .. color_name(owner) .. "方" +end + +function ui.dice_and_status_updates() + return { + runtime_ui.text_update("dice_text", "骰子: " .. tostring(state.dice or "-")), + runtime_ui.text_update("turn_text", ui.turn_name(state.current_player)), + runtime_ui.text_update("status_text", rules.status_text()) + } +end + +function ui.status_update() + return { runtime_ui.text_update("status_text", rules.status_text()) } +end + +return ui diff --git a/example/assets/games/ludo/assets/board.png b/example/assets/games/ludo/assets/board.png new file mode 100644 index 0000000..e0ccec7 Binary files /dev/null and b/example/assets/games/ludo/assets/board.png differ diff --git a/example/assets/games/ludo/assets/dice.wav b/example/assets/games/ludo/assets/dice.wav new file mode 100644 index 0000000..4230c9d Binary files /dev/null and b/example/assets/games/ludo/assets/dice.wav differ diff --git a/example/assets/games/ludo/assets/piece_blue.png b/example/assets/games/ludo/assets/piece_blue.png new file mode 100644 index 0000000..e0ccec7 Binary files /dev/null and b/example/assets/games/ludo/assets/piece_blue.png differ diff --git a/example/assets/games/ludo/assets/piece_red.png b/example/assets/games/ludo/assets/piece_red.png new file mode 100644 index 0000000..e0ccec7 Binary files /dev/null and b/example/assets/games/ludo/assets/piece_red.png differ diff --git a/example/assets/games/ludo/manifest.json b/example/assets/games/ludo/manifest.json new file mode 100644 index 0000000..62e3e64 --- /dev/null +++ b/example/assets/games/ludo/manifest.json @@ -0,0 +1,55 @@ +{ + "gameId": "ludo", + "name": "Lua Ludo 示例", + "version": "0.1.0", + "runtimeApiVersion": 1, + "entry": "scripts/main.lua", + "assetsBase": "assets", + "defaultLocale": "zh-Hans", + "supportedLocales": [ + "zh-Hans", + "en" + ], + "display": { + "designWidth": 720, + "designHeight": 720, + "scaleMode": "fit" + }, + "modules": { + "runtime_ui": "runtime:runtime_ui.lua", + "runtime_widgets": "runtime:runtime_widgets.lua", + "runtime_commands": "runtime:runtime_commands.lua", + "layout": "runtime:layout.lua", + "i18n": "scripts/i18n.lua", + "theme": "scripts/theme.lua", + "styles": "scripts/styles.lua", + "state": "scripts/state.lua", + "board": "scripts/board.lua", + "rules": "scripts/rules.lua", + "ui": "scripts/ui.lua", + "animation": "scripts/animation.lua" + }, + "resources": { + "board": { + "type": "image", + "path": "assets/board.png", + "group": "board" + }, + "piece_red": { + "type": "image", + "path": "assets/piece_red.png", + "group": "pieces" + }, + "piece_blue": { + "type": "image", + "path": "assets/piece_blue.png", + "group": "pieces" + }, + "dice": { + "type": "audio", + "path": "assets/dice.wav", + "preload": "lazy", + "group": "sfx" + } + } +} diff --git a/example/assets/games/ludo/scripts/animation.lua b/example/assets/games/ludo/scripts/animation.lua new file mode 100644 index 0000000..a8d0d9e --- /dev/null +++ b/example/assets/games/ludo/scripts/animation.lua @@ -0,0 +1,37 @@ +---@type RuntimeCommands +local commands = runtime.import("runtime_commands") + +local animation = {} + +function animation.move_piece(piece_id, path) + return commands.move_path(piece_id, path, { + duration = 0.7, + onComplete = "piece_move_done" + }) +end + +function animation.toast(text) + return commands.toast(text) +end + +function animation.play_sound(name) + return commands.play_sound(name) +end + +function animation.play_bgm(name, channel) + return commands.play_bgm(name, { channel = channel or "bgm" }) +end + +function animation.pause_bgm(channel) + return commands.pause_bgm(channel) +end + +function animation.resume_bgm(channel) + return commands.resume_bgm(channel) +end + +function animation.stop_bgm(channel) + return commands.stop_bgm(channel) +end + +return animation diff --git a/example/assets/games/ludo/scripts/board.lua b/example/assets/games/ludo/scripts/board.lua new file mode 100644 index 0000000..c5de59f --- /dev/null +++ b/example/assets/games/ludo/scripts/board.lua @@ -0,0 +1,20 @@ +---@type BoardData +return { + red_home = { + {x = 80, y = 520}, + {x = 140, y = 520} + }, + blue_home = { + {x = 520, y = 120}, + {x = 580, y = 120} + }, + path = { + {x = 120, y = 420}, {x = 180, y = 420}, {x = 240, y = 420}, + {x = 300, y = 420}, {x = 360, y = 420}, {x = 420, y = 420}, + {x = 480, y = 420}, {x = 480, y = 360}, {x = 480, y = 300}, + {x = 480, y = 240}, {x = 420, y = 240}, {x = 360, y = 240}, + {x = 300, y = 240}, {x = 240, y = 240}, {x = 180, y = 240}, + {x = 120, y = 240}, {x = 120, y = 300}, {x = 120, y = 360} + }, + start = { red = 1, blue = 10 } +} diff --git a/example/assets/games/ludo/scripts/i18n.lua b/example/assets/games/ludo/scripts/i18n.lua new file mode 100644 index 0000000..c0a2b50 --- /dev/null +++ b/example/assets/games/ludo/scripts/i18n.lua @@ -0,0 +1,103 @@ +---@class RuntimeI18n +local i18n = {} + +local default_locale = "zh-Hans" +local current_locale = default_locale + +local messages = { + ["zh-Hans"] = { + title = "Lua 飞行棋", + roll_button = "掷骰子", + dice_empty = "骰子: -", + dice_value = "骰子: {value}", + current_player = "当前玩家: {player}", + ["player.red"] = "红方", + ["player.blue"] = "蓝方", + ["toast.move_first"] = "请先移动棋子", + ["toast.no_movable_piece"] = "无可移动棋子", + ["toast.invalid_piece"] = "该棋子不能移动" + }, + en = { + title = "Lua Ludo", + roll_button = "Roll", + dice_empty = "Dice: -", + dice_value = "Dice: {value}", + current_player = "Current player: {player}", + ["player.red"] = "Red", + ["player.blue"] = "Blue", + ["toast.move_first"] = "Move a piece first", + ["toast.no_movable_piece"] = "No movable pieces", + ["toast.invalid_piece"] = "This piece cannot move" + } +} + +---@param locale? string +---@return string +local function normalize_locale(locale) + if type(locale) ~= "string" or locale == "" then + return default_locale + end + locale = string.gsub(locale, "_", "-") + if messages[locale] ~= nil then + return locale + end + + local language = string.match(locale, "^([A-Za-z]+)") + if language ~= nil and messages[language] ~= nil then + return language + end + return default_locale +end + +---@param value string +---@return string +local function escape_pattern(value) + return string.gsub(value, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +end + +---@param template string +---@param vars? table +---@return string +local function interpolate(template, vars) + if vars == nil then + return template + end + + local result = template + for key, value in pairs(vars) do + result = string.gsub(result, "{" .. escape_pattern(key) .. "}", tostring(value)) + end + return result +end + +---@param ctx? RuntimeContext +function i18n.configure(ctx) + local locale = nil + if ctx ~= nil and ctx.locale ~= nil then + locale = ctx.locale.resolved or ctx.locale.requested or ctx.locale.default + end + current_locale = normalize_locale(locale) +end + +---@return string +function i18n.locale() + return current_locale +end + +---@param key string +---@param vars? table +---@return string +function i18n.t(key, vars) + local bundle = messages[current_locale] or messages[default_locale] + local fallback = messages[default_locale] + local value = bundle[key] or fallback[key] or key + return interpolate(value, vars) +end + +---@param color PlayerColor +---@return string +function i18n.player(color) + return i18n.t("player." .. color) +end + +return i18n diff --git a/example/assets/games/ludo/scripts/main.lua b/example/assets/games/ludo/scripts/main.lua new file mode 100644 index 0000000..44908c0 --- /dev/null +++ b/example/assets/games/ludo/scripts/main.lua @@ -0,0 +1,117 @@ +local state = runtime.import("state") +local rules = runtime.import("rules") +local ui = runtime.import("ui") +local animation = runtime.import("animation") +local i18n = runtime.import("i18n") +---@type RuntimeWidgets +local widgets = runtime.import("runtime_widgets") +local theme = runtime.import("theme") + +widgets.configure({ + primary = theme.colors.dice_button, + secondary = theme.colors.board, + success = theme.colors.blue, + overlay = "#99000000", + surface = theme.colors.top_bar, + surfaceAlt = theme.colors.board, + card = theme.colors.top_bar, + text = theme.colors.text, + muted = "#ffcbd5e1", + progress = theme.colors.blue, + transparent = "#00000000" +}) + +function smoke_test(ctx) + i18n.configure(ctx) + return ctx ~= nil + and ctx.runtimeApiVersion ~= nil + and state.current_player ~= nil + and rules.next_player ~= nil + and ui.create_board_nodes ~= nil + and animation.move_piece ~= nil + and widgets.dialog ~= nil +end + +function init(ctx) + i18n.configure(ctx) + return { + render = { creates = ui.create_board_nodes() }, + ui = { creates = ui.create_ui_nodes() }, + commands = {} + } +end + +local function handle_roll_dice() + if state.phase ~= "waiting_roll" then + return { commands = { animation.toast(i18n.t("toast.move_first")) } } + end + + state.dice = rules.next_dice() + local movable = rules.movable_pieces() + + if #movable == 0 then + state.current_player = rules.next_player() + state.phase = "waiting_roll" + return { + ui = { updates = ui.dice_and_turn_updates(state.dice, state.current_player) }, + render = { updates = ui.highlight_updates(rules.all_piece_ids(), false) }, + commands = { animation.toast(i18n.t("toast.no_movable_piece")) } + } + end + + state.phase = "waiting_piece" + return { + ui = { updates = ui.dice_update(state.dice) }, + render = { updates = ui.highlight_updates(movable, true) }, + commands = { animation.play_sound("dice") } + } +end + +local function handle_piece_tap(piece_id) + if state.phase ~= "waiting_piece" then + return {} + end + + local piece = state.pieces[piece_id] + if piece == nil or piece.owner ~= state.current_player or not rules.can_move(piece, state.dice) then + return { commands = { animation.toast(i18n.t("toast.invalid_piece")) } } + end + + local path = rules.calculate_path(piece, state.dice) + rules.apply_move(piece, state.dice) + state.phase = "animating" + state.selected_piece = piece_id + + return { + render = { updates = ui.highlight_updates(rules.all_piece_ids(), false) }, + commands = { animation.move_piece(piece_id, path) } + } +end + +local function handle_move_done() + if state.dice ~= 6 then + state.current_player = rules.next_player() + end + state.phase = "waiting_roll" + state.selected_piece = nil + + return { + ui = { updates = ui.turn_update(state.current_player) } + } +end + +function on_event(event) + if event.handler == "roll_dice" then + return handle_roll_dice() + end + + if event.handler == "piece_tap" then + return handle_piece_tap(event.target) + end + + if event.handler == "piece_move_done" then + return handle_move_done() + end + + return {} +end diff --git a/example/assets/games/ludo/scripts/rules.lua b/example/assets/games/ludo/scripts/rules.lua new file mode 100644 index 0000000..813722f --- /dev/null +++ b/example/assets/games/ludo/scripts/rules.lua @@ -0,0 +1,88 @@ +local state = runtime.import("state") +local board = runtime.import("board") + +local rules = {} + +function rules.next_player() + if state.current_player == "red" then + return "blue" + end + return "red" +end + +function rules.next_dice() + state.dice_index = state.dice_index + 1 + if state.dice_index > #state.dice_values then + state.dice_index = 1 + end + return state.dice_values[state.dice_index] +end + +function rules.piece_home_position(piece) + if piece.owner == "red" then + if piece.id == "red_1" then return board.red_home[1] end + return board.red_home[2] + end + if piece.id == "blue_1" then return board.blue_home[1] end + return board.blue_home[2] +end + +function rules.can_move(piece, dice) + if piece.status == "home" then + return dice == 6 + end + return true +end + +function rules.movable_pieces() + local result = {} + for id, piece in pairs(state.pieces) do + if piece.owner == state.current_player and rules.can_move(piece, state.dice) then + table.insert(result, id) + end + end + return result +end + +function rules.all_piece_ids() + local result = {} + for id, _ in pairs(state.pieces) do + table.insert(result, id) + end + return result +end + +function rules.calculate_path(piece, dice) + local path = {} + if piece.status == "home" then + local start_index = board.start[piece.owner] + local pos = board.path[start_index] + table.insert(path, {x = pos.x, y = pos.y}) + return path + end + + for i = 1, dice do + local index = piece.path_index + i + while index > #board.path do + index = index - #board.path + end + local pos = board.path[index] + table.insert(path, {x = pos.x, y = pos.y}) + end + return path +end + +function rules.apply_move(piece, dice) + if piece.status == "home" then + piece.status = "path" + piece.path_index = board.start[piece.owner] + return + end + + piece.path_index = piece.path_index + dice + while piece.path_index > #board.path do + piece.path_index = piece.path_index - #board.path + end +end + +return rules diff --git a/example/assets/games/ludo/scripts/runtime_defs.lua b/example/assets/games/ludo/scripts/runtime_defs.lua new file mode 100644 index 0000000..f489a9c --- /dev/null +++ b/example/assets/games/ludo/scripts/runtime_defs.lua @@ -0,0 +1,587 @@ +---@meta +--- COMMON RUNTIME TYPES SECTION. +--- Source of truth: tool/lua_runtime_defs_common.lua +--- After editing this common section, run: +--- dart run tool/generate_lua_runtime_defs.dart + + +---@alias RuntimeNodeType +---| 'panel' +---| 'button' +---| 'text' +---| 'circle' +---| 'rect' +---| 'line' +---| 'progress' +---| 'listView' +---| 'sprite' +---| 'image' +---| 'spine' +---| 'particle' + +---@alias RuntimeAnchor +---| 'center' +---| 'topLeft' +---| 'topRight' +---| 'bottomLeft' +---| 'bottomRight' + +---@alias RuntimeTextAlign +---| 'left' +---| 'center' +---| 'right' + +---@alias RuntimeParticlePreset +---| 'burst' +---| 'trail' +---| 'snow' +---| 'confetti' + +---@alias RuntimeCommandType +---| 'move_path' +---| 'move_to' +---| 'fade_to' +---| 'scale_to' +---| 'rotate_to' +---| 'remove_node' +---| 'sequence' +---| 'parallel' +---| 'delay' +---| 'toast' +---| 'play_sound' +---| 'play_bgm' +---| 'pause_bgm' +---| 'resume_bgm' +---| 'stop_bgm' +---| 'preload_resources' +---| 'evict_resources' +---| 'cancel_commands' +---| 'play_spine_animation' +---| 'copy_text' + +---@alias RuntimeEventType +---| 'tap' +---| 'animation_done' +---| 'resize' +---| 'scroll' + +---@alias RuntimeScaleMode +---| 'fit' +---| 'fill' +---| 'stretch' +---| 'none' + +---@alias RuntimeLayoutAlign +---| 'start' +---| 'center' +---| 'end' + +---@alias RuntimeButtonVariant +---| 'primary' +---| 'secondary' +---| 'ghost' + +---@class (exact) RuntimeNode +---@field id string +---@field type RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---@class (exact) RuntimeNodeProps +---@field type? RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---Helper-only fields accepted by runtime_ui/runtime_widgets. They are normalized +---before the node/update crosses the Dart Runtime protocol boundary. +---@class RuntimeNodeInit: RuntimeNodeProps +---@field w? number Alias for width. +---@field h? number Alias for height. +---@field size? number Alias for both width and height. +---@field handler? string Alias for onTap. +---@field onClick? string Alias for onTap. + +---@class (exact) RuntimeNodeUpdate +---@field id string +---@field props RuntimeNodeProps + +---@class (exact) RuntimeNodeRemove +---@field id string + +---@class (exact) RuntimeDiffSection +---@field creates? RuntimeNode[] +---@field updates? RuntimeNodeUpdate[] +---@field removes? (string|RuntimeNodeRemove)[] + +---@class (exact) RuntimeDiff +---@field render? RuntimeDiffSection +---@field ui? RuntimeDiffSection +---@field commands? RuntimeCommand[] + +---@class (exact) RuntimeEvent +---@field type RuntimeEventType|string +---@field target? string +---@field handler? string +---@field x? number +---@field y? number +---@field data? table + +---@class (exact) RuntimeCommand +---@field type RuntimeCommandType +---@field target? string +---@field scope? string +---@field id? string +---@field group? string +---@field commandGroup? string +---@field onComplete? string +---@field duration? number +---@field commands? RuntimeCommand[] +---@field path? RuntimePoint[] +---@field x? number +---@field y? number +---@field alpha? number +---@field scale? number +---@field angle? number +---@field text? string +---@field message? string +---@field asset? string +---@field name? string +---@field volume? number +---@field channel? string +---@field loop? boolean +---@field failOnError? boolean +---@field animation? string +---@field track? integer +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeCommandOpts +---@field id? string +---@field group? string +---@field commandGroup? string +---@field scope? string +---@field onComplete? string +---@field duration? number + +---@class (exact) RuntimeAudioCommandOpts: RuntimeCommandOpts +---@field volume? number +---@field name? string + +---@class (exact) RuntimeBgmCommandOpts: RuntimeAudioCommandOpts +---@field channel? string +---@field loop? boolean + +---@class (exact) RuntimeSpineCommandOpts: RuntimeCommandOpts +---@field track? integer +---@field loop? boolean +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeResourceCommandOpts: RuntimeCommandOpts +---@field failOnError? boolean + +---@class (exact) RuntimePoint +---@field x number +---@field y number + +---@class (exact) RuntimeLocaleContext +---@field requested string +---@field resolved string +---@field default string +---@field supported string[] +---@field languageCode string +---@field scriptCode? string +---@field countryCode? string + +---@class (exact) RuntimeScreenContext +---@field width number +---@field height number + +---@class (exact) RuntimeDesignContext +---@field width number +---@field height number + +---@class (exact) RuntimeViewportContext +---@field x number +---@field y number +---@field width number +---@field height number +---@field scaleX number +---@field scaleY number +---@field scaleMode RuntimeScaleMode|string + +---@class (exact) RuntimeContext +---@field screen RuntimeScreenContext +---@field design RuntimeDesignContext +---@field viewport RuntimeViewportContext +---@field seed integer +---@field runtimeApiVersion integer +---@field gameId string +---@field gameVersion string +---@field locale? RuntimeLocaleContext + +---@class RuntimeUi +---@field style fun(base?: RuntimeNodeProps, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field with_parent fun(parent: string, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field node fun(node_type: RuntimeNodeType, id: string, opts?: RuntimeNodeInit): RuntimeNode +---@field panel fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field rect fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field circle fun(id: string, x: number|RuntimeNodeInit, y?: number, size?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field line fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field particle fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field text fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field button fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field list_view fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field image fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field sprite fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field spine fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, animation?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field update fun(id: string, props: RuntimeNodeInit): RuntimeNodeUpdate +---@field text_update fun(id: string, text: string): RuntimeNodeUpdate +---@field visible_update fun(id: string, visible: boolean): RuntimeNodeUpdate +---@field alpha_update fun(id: string, alpha: number): RuntimeNodeUpdate +---@field scale_update fun(id: string, scale: number): RuntimeNodeUpdate +---@field position_update fun(id: string, x: number, y: number): RuntimeNodeUpdate +---@field size_update fun(id: string, width: number, height: number): RuntimeNodeUpdate +---@field transform_update fun(id: string, x: number, y: number, scale: number, rotation: number): RuntimeNodeUpdate +---@field batch_update fun(ids: string[], props: RuntimeNodeInit): RuntimeNodeUpdate[] +---@field append fun(nodes: RuntimeNode[], node: RuntimeNode): RuntimeNode[] +---@field append_all fun(nodes: RuntimeNode[], extra_nodes: RuntimeNode[]): RuntimeNode[] + +---@class (exact) RuntimeDialogButton +---@field id? string +---@field text string +---@field handler string +---@field color? string + +---@class (exact) RuntimeDialogOpts +---@field screenWidth? number +---@field screenHeight? number +---@field overlay? boolean +---@field overlayColor? string +---@field blockInput? boolean +---@field layer? integer +---@field color? string +---@field radius? number +---@field panelStyle? RuntimeNodeProps +---@field titleColor? string +---@field titleSize? number +---@field titleStyle? RuntimeNodeProps +---@field messageColor? string +---@field messageSize? number +---@field messageStyle? RuntimeNodeProps +---@field buttons? RuntimeDialogButton[] +---@field buttonGap? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeLabeledProgressOpts: RuntimeNodeInit +---@field labelHeight? number +---@field labelStyle? RuntimeNodeProps + +---@class RuntimePillOpts: RuntimeNodeInit +---@field panelStyle? RuntimeNodeProps +---@field textStyle? RuntimeNodeProps + +---@class RuntimeTextButtonOpts: RuntimeNodeInit +---@field variant? RuntimeButtonVariant + +---@class RuntimeListItemOpts: RuntimeTextButtonOpts +---@field selected? boolean +---@field activeColor? string +---@field inactiveColor? string + +---@class RuntimeTabItem +---@field id? string +---@field key? string +---@field text string +---@field handler? string +---@field selected? boolean + +---@class RuntimeTabsOpts: RuntimeNodeInit +---@field tabs? RuntimeTabItem[] +---@field selected? string +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field activeColor? string +---@field inactiveColor? string +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeActionItem +---@field id? string +---@field text string +---@field handler? string +---@field visible? boolean +---@field color? string +---@field style? RuntimeNodeProps + +---@class RuntimeActionRowOpts: RuntimeNodeInit +---@field actions? RuntimeActionItem[] +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimePanelHeaderOpts: RuntimeNodeInit +---@field eyebrow? string +---@field title string +---@field summary? string +---@field gap? number +---@field eyebrowId? string +---@field titleId? string +---@field summaryId? string +---@field eyebrowHeight? number +---@field titleHeight? number +---@field summaryHeight? number +---@field eyebrowStyle? RuntimeNodeProps +---@field titleStyle? RuntimeNodeProps +---@field summaryStyle? RuntimeNodeProps + +---@class RuntimeWidgetTheme +---@field primary? string +---@field secondary? string +---@field success? string +---@field overlay? string +---@field surface? string +---@field surfaceAlt? string +---@field card? string +---@field text? string +---@field muted? string +---@field progress? string +---@field transparent? string + +---@class RuntimeWidgets +---@field configure fun(tokens?: RuntimeWidgetTheme): RuntimeWidgets +---@field label fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field section_title fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field pill fun(id: string, text: string|RuntimePillOpts, x?: number, y?: number, width?: number, height?: number, opts?: RuntimePillOpts): RuntimeNode[] +---@field text_button fun(id: string, text: string|RuntimeTextButtonOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeTextButtonOpts): RuntimeNode +---@field list_item fun(id: string, text: string|RuntimeListItemOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeListItemOpts): RuntimeNode +---@field tabs fun(id: string, tabs: RuntimeTabItem[]|RuntimeTabsOpts, opts?: RuntimeTabsOpts): RuntimeNode[] +---@field action_row fun(id: string, actions: RuntimeActionItem[]|RuntimeActionRowOpts, opts?: RuntimeActionRowOpts): RuntimeNode[] +---@field panel_header fun(id: string, opts: RuntimePanelHeaderOpts): RuntimeNode[] +---@field overlay fun(id: string, width: number|RuntimeNodeInit, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field card fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress_bar fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field labeled_progress fun(id: string, label: string, x: number, y: number, width: number, height: number, value: number, opts?: RuntimeLabeledProgressOpts): RuntimeNode[] +---@field button_row fun(parent: string, id: string, buttons: RuntimeDialogButton[], x: number, y: number, width: number, height: number, gap?: number, opts?: RuntimeNodeProps): RuntimeNode[] +---@field dialog fun(id: string, title: string, message: string, x: number, y: number, width: number, height: number, opts?: RuntimeDialogOpts): RuntimeNode[] + +---@class (exact) RuntimeLayoutItem +---@field node RuntimeNode +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLayoutItemOpts +---@field margin? number +---@field mx? number +---@field my? number +---@field ml? number +---@field mr? number +---@field mt? number +---@field mb? number +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLinearLayoutOpts +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field gap? number +---@field align? RuntimeLayoutAlign +---@field padding? number +---@field paddingX? number +---@field paddingY? number +---@field px? number +---@field py? number +---@field paddingLeft? number +---@field paddingTop? number + +---@class RuntimeBoxLayoutOpts: RuntimeLinearLayoutOpts +---@field rows? integer +---@field columns? integer +---@field cols? integer +---@field cellWidth? number +---@field cellHeight? number +---@field cellW? number +---@field cellH? number +---@field gapX? number +---@field gapY? number +---@field valign? RuntimeLayoutAlign + +---@class RuntimeLayout +---@field item fun(node: RuntimeNode, opts?: RuntimeLayoutItemOpts): RuntimeLayoutItem +---@field local_position fun(origin: RuntimePoint, position: RuntimePoint): RuntimePoint +---@field row fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field column fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field stack fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field box fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeBoxLayoutOpts): RuntimeNode[] + +---@class RuntimeCommands +---@field toast fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field copy_text fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field delay fun(duration: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field sequence fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field parallel fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_path fun(target: string, path: RuntimePoint[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_to fun(target: string, x: number, y: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field fade_to fun(target: string, alpha: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field scale_to fun(target: string, scale: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field rotate_to fun(target: string, angle: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field remove_node fun(target: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field play_spine_animation fun(target: string, animation: string, opts?: RuntimeSpineCommandOpts): RuntimeCommand +---@field play_sound fun(asset: string, opts?: RuntimeAudioCommandOpts): RuntimeCommand +---@field play_bgm fun(asset: string, opts?: RuntimeBgmCommandOpts): RuntimeCommand +---@field pause_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field resume_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field stop_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field preload_group fun(group: string, opts?: RuntimeResourceCommandOpts): RuntimeCommand +---@field evict_group fun(group: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field cancel_id fun(id: string): RuntimeCommand +---@field cancel_group fun(group: string): RuntimeCommand +---@field cancel_scope fun(scope: string): RuntimeCommand + +---@class RuntimeImportApi +---@field import fun(moduleName: string): table + +---@type RuntimeImportApi +runtime = runtime + +---@alias PlayerColor 'red'|'blue' +---@alias GamePhase 'waiting_roll'|'waiting_piece'|'animating' +---@alias PieceStatus 'home'|'path'|'finished' + +---@class (exact) PieceState +---@field id string +---@field owner PlayerColor +---@field path_index integer +---@field status PieceStatus + +---@class (exact) LudoState +---@field current_player PlayerColor +---@field phase GamePhase +---@field dice_index integer +---@field dice_values integer[] +---@field dice? integer +---@field selected_piece? string +---@field players PlayerColor[] +---@field pieces table + +---@class (exact) BoardPoint +---@field x number +---@field y number + +---@class (exact) BoardData +---@field red_home BoardPoint[] +---@field blue_home BoardPoint[] +---@field path BoardPoint[] +---@field start table diff --git a/example/assets/games/ludo/scripts/state.lua b/example/assets/games/ludo/scripts/state.lua new file mode 100644 index 0000000..e564e19 --- /dev/null +++ b/example/assets/games/ludo/scripts/state.lua @@ -0,0 +1,15 @@ +---@type LudoState +return { + current_player = "red", + phase = "waiting_roll", + dice_index = 0, + dice_values = {6, 3, 5, 2, 6, 4}, + selected_piece = nil, + players = {"red", "blue"}, + pieces = { + red_1 = { id = "red_1", owner = "red", path_index = 0, status = "home" }, + red_2 = { id = "red_2", owner = "red", path_index = 0, status = "home" }, + blue_1 = { id = "blue_1", owner = "blue", path_index = 0, status = "home" }, + blue_2 = { id = "blue_2", owner = "blue", path_index = 0, status = "home" } + } +} diff --git a/example/assets/games/ludo/scripts/styles.lua b/example/assets/games/ludo/scripts/styles.lua new file mode 100644 index 0000000..0298be7 --- /dev/null +++ b/example/assets/games/ludo/scripts/styles.lua @@ -0,0 +1,80 @@ +local theme = runtime.import("theme") + +local colors = theme.colors +local styles = {} + +styles.layers = { + background = 0, + board = 1, + board_content = 2, + piece = 10, + hud = 100, + hud_content = 101 +} + +styles.board_panel = { + color = colors.board, + layer = styles.layers.background +} + +styles.board_title = { + fontSize = 24, + color = colors.text, + layer = styles.layers.board_content +} + +styles.red_home = { + color = colors.red_home, + layer = styles.layers.board +} + +styles.blue_home = { + color = colors.blue_home, + layer = styles.layers.board +} + +styles.path_cell = { + anchor = "center", + color = colors.path_cell, + layer = styles.layers.board_content +} + +styles.top_bar = { + color = colors.top_bar, + layer = styles.layers.hud +} + +styles.hud_text = { + fontSize = 22, + color = colors.text, + layer = styles.layers.hud_content +} + +styles.dice_button = { + color = colors.dice_button, + fontSize = 20, + layer = styles.layers.hud_content +} + +styles.piece_highlight = { + scale = 1.25, + alpha = 1 +} + +styles.piece_normal = { + scale = 1, + alpha = 0.95 +} + +function styles.piece(owner) + return { + anchor = "center", + asset = "piece_" .. owner, + color = colors[owner], + layer = styles.layers.piece, + interactive = true, + onTap = "piece_tap" + } +end + +return styles diff --git a/example/assets/games/ludo/scripts/theme.lua b/example/assets/games/ludo/scripts/theme.lua new file mode 100644 index 0000000..c0fcdc7 --- /dev/null +++ b/example/assets/games/ludo/scripts/theme.lua @@ -0,0 +1,18 @@ +local i18n = runtime.import("i18n") + +return { + title = function() + return i18n.t("title") + end, + colors = { + red = "#ef4444", + blue = "#3b82f6", + top_bar = "#020617", + dice_button = "#2563eb", + board = "#1e293b", + red_home = "#7f1d1d", + blue_home = "#1e3a8a", + path_cell = "#f8fafc", + text = "#ffffff" + } +} diff --git a/example/assets/games/ludo/scripts/ui.lua b/example/assets/games/ludo/scripts/ui.lua new file mode 100644 index 0000000..f2d5d68 --- /dev/null +++ b/example/assets/games/ludo/scripts/ui.lua @@ -0,0 +1,74 @@ +---@type RuntimeUi +local runtime_ui = runtime.import("runtime_ui") +---@type RuntimeLayout +local layout = runtime.import("layout") +local theme = runtime.import("theme") +local i18n = runtime.import("i18n") +local styles = runtime.import("styles") +local state = runtime.import("state") +local board = runtime.import("board") +local rules = runtime.import("rules") + +local ui = {} +local board_origin = { x = 40, y = 90 } + +function ui.highlight_updates(ids, enabled) + return runtime_ui.batch_update(ids, enabled and styles.piece_highlight or styles.piece_normal) +end + +function ui.create_board_nodes() + local nodes = { + runtime_ui.sprite("board_panel", "board", 40, 90, 640, 520, styles.board_panel), + runtime_ui.text("board_title", theme.title(), 240, 15, 180, 40, runtime_ui.with_parent("board_panel", styles.board_title)), + runtime_ui.panel("red_home", 20, 390, 130, 90, runtime_ui.with_parent("board_panel", styles.red_home)), + runtime_ui.panel("blue_home", 460, -10, 130, 90, runtime_ui.with_parent("board_panel", styles.blue_home)) + } + + for index, cell in ipairs(board.path) do + local pos = layout.local_position(board_origin, cell) + runtime_ui.append(nodes, runtime_ui.circle("cell_" .. tostring(index), pos.x, pos.y, 28, runtime_ui.with_parent("board_panel", styles.path_cell))) + end + + for id, piece in pairs(state.pieces) do + local pos = rules.piece_home_position(piece) + runtime_ui.append(nodes, runtime_ui.circle(id, pos.x, pos.y, 44, styles.piece(piece.owner))) + end + + return nodes +end + +function ui.create_ui_nodes() + local hud_items = layout.row("top_bar", { + runtime_ui.text("turn_text", i18n.t("current_player", { player = i18n.player(state.current_player) }), 0, 0, 230, 32, styles.hud_text), + runtime_ui.text("dice_text", i18n.t("dice_empty"), 0, 0, 120, 32, styles.hud_text), + layout.item( + runtime_ui.button("dice_button", i18n.t("roll_button"), 0, 0, 130, 48, "roll_dice", styles.dice_button), + { marginLeft = 134 } + ) + }, { + x = 24, + height = 76, + gap = 16, + align = "center" + }) + + local nodes = { runtime_ui.panel("top_bar", 0, 0, 720, 76, styles.top_bar) } + return runtime_ui.append_all(nodes, hud_items) +end + +function ui.dice_and_turn_updates(dice, current_player) + return { + runtime_ui.text_update("dice_text", i18n.t("dice_value", { value = dice })), + runtime_ui.text_update("turn_text", i18n.t("current_player", { player = i18n.player(current_player) })) + } +end + +function ui.dice_update(dice) + return { runtime_ui.text_update("dice_text", i18n.t("dice_value", { value = dice })) } +end + +function ui.turn_update(current_player) + return { runtime_ui.text_update("turn_text", i18n.t("current_player", { player = i18n.player(current_player) })) } +end + +return ui diff --git a/example/assets/games/showcase/assets/button_disabled.png b/example/assets/games/showcase/assets/button_disabled.png new file mode 100644 index 0000000..496be65 Binary files /dev/null and b/example/assets/games/showcase/assets/button_disabled.png differ diff --git a/example/assets/games/showcase/assets/button_normal.png b/example/assets/games/showcase/assets/button_normal.png new file mode 100644 index 0000000..298a37c Binary files /dev/null and b/example/assets/games/showcase/assets/button_normal.png differ diff --git a/example/assets/games/showcase/assets/button_pressed.png b/example/assets/games/showcase/assets/button_pressed.png new file mode 100644 index 0000000..0e72cd5 Binary files /dev/null and b/example/assets/games/showcase/assets/button_pressed.png differ diff --git a/example/assets/games/showcase/assets/click.wav b/example/assets/games/showcase/assets/click.wav new file mode 100644 index 0000000..4230c9d Binary files /dev/null and b/example/assets/games/showcase/assets/click.wav differ diff --git a/example/assets/games/showcase/assets/sample.png b/example/assets/games/showcase/assets/sample.png new file mode 100644 index 0000000..a4f1019 Binary files /dev/null and b/example/assets/games/showcase/assets/sample.png differ diff --git a/example/assets/games/showcase/manifest.json b/example/assets/games/showcase/manifest.json new file mode 100644 index 0000000..5d418c1 --- /dev/null +++ b/example/assets/games/showcase/manifest.json @@ -0,0 +1,62 @@ +{ + "gameId": "showcase", + "name": "Lua Runtime Showcase", + "version": "0.1.0", + "runtimeApiVersion": 1, + "entry": "scripts/main.lua", + "assetsBase": "assets", + "defaultLocale": "zh-Hans", + "supportedLocales": [ + "zh-Hans", + "en" + ], + "display": { + "designWidth": 720, + "designHeight": 720, + "scaleMode": "fit" + }, + "modules": { + "runtime_ui": "runtime:runtime_ui.lua", + "runtime_widgets": "runtime:runtime_widgets.lua", + "runtime_commands": "runtime:runtime_commands.lua", + "layout": "runtime:layout.lua", + "theme": "scripts/theme.lua", + "styles": "scripts/styles.lua", + "state": "scripts/state.lua", + "i18n": "scripts/i18n.lua", + "examples": "scripts/examples.lua", + "ui": "scripts/ui.lua" + }, + "resources": { + "sample_image": { + "type": "image", + "path": "assets/sample.png", + "preload": "optional", + "group": "media" + }, + "click": { + "type": "audio", + "path": "assets/click.wav", + "preload": "lazy", + "group": "media" + }, + "button_normal": { + "type": "image", + "path": "assets/button_normal.png", + "preload": "optional", + "group": "media" + }, + "button_pressed": { + "type": "image", + "path": "assets/button_pressed.png", + "preload": "optional", + "group": "media" + }, + "button_disabled": { + "type": "image", + "path": "assets/button_disabled.png", + "preload": "optional", + "group": "media" + } + } +} diff --git a/example/assets/games/showcase/scripts/examples.lua b/example/assets/games/showcase/scripts/examples.lua new file mode 100644 index 0000000..e2cf45d --- /dev/null +++ b/example/assets/games/showcase/scripts/examples.lua @@ -0,0 +1,819 @@ +---@class (exact) ShowcaseAction +---@field text string +---@field text_en? string +---@field handler string + +---@class (exact) ShowcaseExample +---@field id string +---@field group string +---@field group_en? string +---@field category string +---@field category_en? string +---@field menu string +---@field menu_en? string +---@field title string +---@field title_en? string +---@field summary string +---@field summary_en? string +---@field code string +---@field params string +---@field params_en? string +---@field actions ShowcaseAction[] + +---@class ShowcaseExamples +local examples = {} + +---@type ShowcaseExample[] +examples.items = { + { + id = "nodes", + group = "基础功能", + group_en = "Basics", + category = "节点", + category_en = "Nodes", + menu = "基础节点", + menu_en = "Nodes", + title = "RuntimeNode 基础组件", + title_en = "RuntimeNode primitives", + summary = "基础节点、交互和属性更新。", + summary_en = "Primitive nodes, interaction and property updates.", + code = [[local runtime_ui = runtime.import("runtime_ui") + +runtime_ui.circle("hero", { + x = 80, + y = 64, + size = 48, + color = "#ff22c55e", + interactive = true, + handler = "demo_anim" +})]], + params = [[参数说明:type/circle/rect/line 定义节点类型; +x/y/width/height 定位尺寸; +w/h 是 width/height 的便利别名; +size 会同时设置 width 和 height; +handler/onClick 是 onTap 的便利别名; +color/alpha/scale/rotation 控制表现; +interactive/onTap 打开点击事件。 + +填写样例: +runtime_ui.circle("hero", { + x = 80, + y = 64, + size = 48, + color = "#ff22c55e", + alpha = 0.9, + scale = 1.0, + interactive = true, + handler = "demo_anim" +})]], + params_en = [[Params: type/circle/rect/line choose node kind; +x/y/width/height place it; +color/alpha/scale/rotation style it; +interactive/onTap enable taps.]], + actions = { + { text = "更新进度", text_en = "Progress", handler = "demo_progress" }, + { text = "显隐/透明", text_en = "Visibility", handler = "demo_visibility" } + } + }, + { + id = "text_demo", + group = "基础功能", + group_en = "Basics", + category = "文本", + category_en = "Text", + menu = "文本", + menu_en = "Text", + title = "Text 文本组件", + title_en = "Text component", + summary = "当前支持纯文本、颜色和字号;暂不支持富文本。", + summary_en = "Plain text, color and font size; rich text is not supported yet.", + code = [[runtime_ui.text("label", "Hello Lua", 20, 40, 220, 28, { + color = "#ffe2e8f0", + fontSize = 18 +}) + +-- 当前协议没有 richText/spans 字段]], + params = [[参数说明:text 是纯文本; +color/fontSize/alpha 控制整体样式; +textAlign 可填 "left" / "center" / "right"; +当前没有 richText/spans,不能做局部文字样式。 + +填写样例: +runtime_ui.text("label", "Hello Lua", 20, 40, 220, 28, { + color = "#ffe2e8f0", + fontSize = 18, + textAlign = "left" +})]], + params_en = [[Params: text is plain copy; +color/fontSize/alpha style the whole node; +richText/spans are not supported yet.]], + actions = { + { text = "改文案", text_en = "Change", handler = "demo_text_change" }, + { text = "样式切换", text_en = "Style", handler = "demo_text_style" } + } + }, + { + id = "buttons", + group = "基础功能", + group_en = "Basics", + category = "按钮", + category_en = "Buttons", + menu = "按钮交互", + menu_en = "Buttons", + title = "基础按钮和点击事件", + title_en = "Buttons and tap events", + summary = "button 节点、onTap handler、状态更新和禁用态。", + summary_en = "Button nodes, onTap handlers, state updates and disabled style.", + code = [[runtime_ui.button("ok", { + text = "确认", + x = 20, + y = 40, + w = 120, + h = 34, + handler = "submit", + color = theme.primary, + radius = 10, + fontSize = 13, + asset = "button_normal", + pressedAsset = "button_pressed", + disabledAsset = "button_disabled" +}) + +-- asset/pressedAsset/disabledAsset 都是 manifest image 资源 key。 +-- event.handler == "submit" 时返回 Diff/Command]], + params = [[参数说明:button = 背景 + 文本 + onTap; +w/h 是 width/height 的便利别名; +handler/onClick 是 onTap 的便利别名; +color/radius/fontSize 控制样式; +asset/pressedAsset/disabledAsset 可配置 normal/pressed/disabled 三态图片; +interactive=false 可禁用命中并使用 disabledAsset。 + +填写样例: +runtime_ui.button("ok", { + text = "确认", + x = 20, + y = 40, + w = 120, + h = 34, + handler = "submit", + color = theme.primary, + radius = 10, + fontSize = 13, + asset = "button_normal", + pressedAsset = "button_pressed", + disabledAsset = "button_disabled", + interactive = true +})]], + params_en = [[Params: button combines background, label and onTap; +color/radius/fontSize style it; +interactive=false disables hit testing.]], + actions = { + { text = "点击按钮", text_en = "Tap", handler = "demo_button_primary" }, + { text = "切换状态", text_en = "Toggle", handler = "demo_button_toggle" } + } + }, + { + id = "button_images", + group = "基础功能", + group_en = "Basics", + category = "按钮", + category_en = "Buttons", + menu = "按钮三态图", + menu_en = "Button skins", + title = "Button 三态图片", + title_en = "Button state images", + summary = "button 使用 normal / pressed / disabled 三态图片资源。", + summary_en = "Buttons can render normal, pressed and disabled image assets.", + code = [[runtime_ui.button("start", { + text = "开始", + x = 20, + y = 40, + w = 132, + h = 40, + handler = "start", + asset = "button_normal", + pressedAsset = "button_pressed", + disabledAsset = "button_disabled" +}) + +runtime_ui.button("locked", { + text = "禁用", + x = 170, + y = 40, + w = 132, + h = 40, + handler = "noop", + asset = "button_normal", + pressedAsset = "button_pressed", + disabledAsset = "button_disabled", + interactive = false +})]], + params = [[参数说明: +asset = normal 状态图片资源 key; +pressedAsset = 按下状态图片资源 key; +disabledAsset = 禁用状态图片资源 key; +interactive=false 时 Runtime 自动选择 disabledAsset; +按住按钮时 Runtime 自动切换 pressedAsset; +未配置对应状态图片时回退到 asset,再回退到 color/radius 背景。 + +manifest 示例: +"button_normal": { "type": "image", "path": "assets/button_normal.png" }, +"button_pressed": { "type": "image", "path": "assets/button_pressed.png" }, +"button_disabled": { "type": "image", "path": "assets/button_disabled.png" }]], + params_en = [[Params: +asset is the normal-state image resource key; +pressedAsset is used while the button is pressed; +disabledAsset is used when interactive=false; +missing state images fall back to asset, then to color/radius background.]], + actions = { + { text = "点击图片按钮", text_en = "Tap", handler = "demo_button_image_tap" }, + { text = "切换禁用", text_en = "Toggle", handler = "demo_button_image_toggle" } + } + }, + { + id = "sprites", + group = "基础功能", + group_en = "Basics", + category = "精灵", + category_en = "Sprites", + menu = "图片精灵", + menu_en = "Sprites", + title = "图片和 Sprite 节点", + title_en = "Image and sprite nodes", + summary = "image/sprite 使用 manifest 资源 key,不暴露真实路径。", + summary_en = "Image/sprite nodes use manifest resource keys, not raw paths.", + code = [[runtime_ui.image("avatar", "sample_image", 24, 48, 56, 56) +runtime_ui.sprite("icon", "sample_image", 104, 48, 56, 56, { + layer = 20 +})]], + params = [[参数说明:asset 必须是 manifest 资源 key; +image/sprite 不暴露真实路径; +layer 控制绘制顺序。 + +填写样例: +-- manifest.resources.sample_image.type = "image" +runtime_ui.image("avatar", "sample_image", 24, 48, 56, 56, { + layer = 20, + alpha = 1 +})]], + params_en = [[Params: asset must be a manifest resource key; +image/sprite never expose raw paths; +layer controls draw order.]], + actions = { + { text = "精灵动画", text_en = "Animate", handler = "demo_sprite_anim" }, + { text = "切换样式", text_en = "Style", handler = "demo_sprite_style" } + } + }, + { + id = "radio_group", + group = "基础功能", + group_en = "Basics", + category = "选择", + category_en = "Selection", + menu = "RadioGroup", + menu_en = "Radio", + title = "RadioGroup 组合模式", + title_en = "RadioGroup composition", + summary = "Lua 用 circle/text/button 组合单选项。", + summary_en = "Lua composes radio options from circle/text/button nodes.", + code = [[-- RadioGroup 不是 Dart 原生控件 +-- Lua 输出普通 RuntimeNode:circle + text + button + +runtime_ui.circle("radio_dot", 24, 52, 14, { color = theme.primary }) +runtime_ui.text("radio_label", "Audio", 46, 49, 120, 20)]], + params = [[参数说明:RadioGroup 是 Lua 组合,不是原生控件; +用 circle/text/button 组合,并由 Lua state 保存选中值。 + +填写样例: +local option = { key = "audio", label = "Audio", y = 72 } +runtime_ui.circle("radio_" .. option.key .. "_dot", 18, option.y, 14, { + color = selected and theme.primary or "#ff475569" +}) +runtime_ui.button("radio_" .. option.key .. "_hit", "", 8, option.y - 4, 180, 24, "demo_radio_audio", { + color = "#00000000", + interactive = true +})]], + params_en = [[Params: RadioGroup is Lua composition, not a native control; +compose circle/text/button and keep selection in Lua state.]], + actions = { + { text = "选 Audio", text_en = "Audio", handler = "demo_radio_audio" }, + { text = "选 Spine", text_en = "Spine", handler = "demo_radio_spine" }, + { text = "选 Lua", text_en = "Lua", handler = "demo_radio_lua" } + } + }, + { + id = "list_view", + group = "基础功能", + group_en = "Basics", + category = "列表", + category_en = "List", + menu = "ListView", + menu_en = "List", + title = "原生 ListView 容器", + title_en = "Native ListView container", + summary = "双轴滚动、惯性、回调、滚动条样式和虚拟化。", + summary_en = "Two-axis scroll, inertia, callbacks, styled scrollbar and culling.", + code = [[runtime_ui.list_view("list", 16, 42, 260, 72, { + contentWidth = 420, + contentHeight = 150, + scrollX = state.list_scroll_x, + scrollY = state.list_scroll_y, + virtualized = true, + cacheExtent = 24, + inertia = true, + onScroll = "demo_list_scrolled" +}) + +runtime_ui.button("row_1", "Lua", 8, 8, 220, 24, "select", { + parent = "list" +})]], + params = [[参数说明:scrollX/scrollY 是双轴偏移; +contentWidth/contentHeight 是内容尺寸; +virtualized/cacheExtent 控制直接子节点裁剪; +inertia 开启拖动惯性; +onScroll 接收滚动回调; +scrollbarVisible=false 可隐藏滚动条; +scrollbarThumbColor/scrollbarTrackColor/scrollbarThickness 控制样式。 + +填写样例: +local opts = { + contentWidth = 420, + contentHeight = 150, + scrollX = 0, + scrollY = 0, + virtualized = true, + cacheExtent = 24, + inertia = true, + onScroll = "demo_list_scrolled", + scrollbarVisible = false +} +runtime_ui.list_view("list", 16, 42, 260, 72, opts) + +-- 子节点必须把 parent 指向 listView +runtime_ui.button("row_1", "Lua", 8, 8, 220, 24, "select", { + parent = "list" +})]], + params_en = [[Params: scrollX/scrollY are two-axis offsets; +contentWidth/contentHeight define content size; +virtualized/cacheExtent cull children; +inertia enables momentum; +onScroll emits callbacks; +scrollbar* styles bars.]], + actions = { + { text = "横/竖排列", text_en = "Axis", handler = "demo_list_horizontal" }, + { text = "下一项", text_en = "Next", handler = "demo_list_next" }, + { text = "重置滚动", text_en = "Reset", handler = "demo_list_reset" } + } + }, + { + id = "particles", + group = "基础功能", + group_en = "Basics", + category = "特效", + category_en = "Effects", + menu = "粒子特效", + menu_en = "Particles", + title = "Particle 粒子特效", + title_en = "Particle effects", + summary = "Lua 描述粒子参数,Dart Runtime 创建 Flame 粒子组件。", + summary_en = "Lua describes particle params; Dart Runtime creates Flame particles.", + code = [[runtime_ui.particle("hit_fx", 220, 140, 160, 160, { + preset = "burst", + count = 40, + duration = 0.6, + color = "#ffffcc33", + colorTo = "#00ffcc33", + radius = 2.4, + radiusTo = 0, + speedMin = 60, + speedMax = 180, + gravityY = 120, + spread = 360, + autoRemove = true, + fadeOut = true, + layer = 80 +})]], + params = [[参数说明:particle 是 Runtime 原生节点,Lua 不持有 Flame 粒子对象; +preset 可填 "burst" / "trail" / "snow" / "confetti"; +count 是粒子数量; +duration 是生命周期秒数; +color/colorTo 控制颜色渐变; +radius/radiusTo 控制尺寸变化; +speedMin/speedMax 控制初速度范围; +gravityX/gravityY 控制加速度; +spread 控制发散角度; +autoRemove=true 表示生命周期结束后自动移除; +fadeOut=true 表示随进度淡出。 + +填写样例: +local opts = { + preset = "confetti", + count = 72, + duration = 1.2, + color = "#ffff4d6d", + colorTo = "#fffacc15", + radius = 2.6, + radiusTo = 0, + speedMin = 120, + speedMax = 260, + gravityY = 240, + spread = 90, + autoRemove = true, + fadeOut = true +} +runtime_ui.particle("win_fx", 280, 72, 220, 160, opts)]], + params_en = [[Params: particle is a native Runtime node; Lua never owns Flame particle objects; +preset: burst/trail/snow/confetti; +count controls particle amount; +duration is lifetime in seconds; +color/colorTo define gradient; +radius/radiusTo define size change; +speedMin/speedMax define initial speed; +gravityX/gravityY define acceleration; +spread defines emission angle; +autoRemove removes after lifetime; +fadeOut fades by progress.]], + actions = { + { text = "爆发", text_en = "Burst", handler = "demo_particle_burst" }, + { text = "彩纸", text_en = "Confetti", handler = "demo_particle_confetti" }, + { text = "雪花", text_en = "Snow", handler = "demo_particle_snow" } + } + }, + { + id = "layout_demo", + group = "基础功能", + group_en = "Basics", + category = "布局", + category_en = "Layout", + menu = "布局 Helper", + menu_en = "Layout", + title = "Lua layout helper", + title_en = "Lua layout helper", + summary = "layout.row/column/box 消费临时 margin,不污染协议。", + summary_en = "layout.row/column/box consume temporary margin without polluting protocol tables.", + code = [[local layout = runtime.import("layout") + +layout.box("panel", { + runtime_ui.rect("a", 0, 0, 42, 28), + runtime_ui.rect("b", 0, 0, 42, 28), + runtime_ui.rect("c", 0, 0, 42, 28), + runtime_ui.rect("d", 0, 0, 42, 28) +}, { + x = 16, + y = 48, + rows = 2, + columns = 2, + cellWidth = 58, + cellHeight = 34, + gapX = 8, + gapY = 8, + align = "center", + valign = "center" +})]], + params = [[参数说明:layout.item 的 margin 只用于 Lua 布局计算,不会进入 RuntimeNode props; +row/column 处理单轴排列; +box 按 rows/columns 把节点排成几排几列; +cellWidth/cellHeight 是单元格尺寸; +gapX/gapY 是列间距和行间距; +align/valign 控制节点在单元格内的水平/垂直对齐。 + +填写样例: +layout.box("panel", { + layout.item(runtime_ui.rect("a", 0, 0, 42, 28), { marginRight = 4 }), + runtime_ui.rect("b", 0, 0, 42, 28), + runtime_ui.rect("c", 0, 0, 42, 28), + runtime_ui.rect("d", 0, 0, 42, 28) +}, { + x = 16, + y = 48, + rows = 2, + columns = 2, + cellWidth = 58, + cellHeight = 34, + gapX = 8, + gapY = 8, + align = "center", + valign = "center" +})]], + params_en = [[Params: layout.item margins are Lua-only layout metadata and never enter RuntimeNode props; +box uses rows/columns to place nodes in a grid; +cellWidth/cellHeight define cells; +gapX/gapY define column and row gaps; +align/valign align each node inside its cell.]], + actions = { + { text = "横向布局", text_en = "Row", handler = "demo_layout_row" }, + { text = "纵向布局", text_en = "Column", handler = "demo_layout_column" }, + { text = "Box 网格", text_en = "Box", handler = "demo_layout_box" } + } + }, + { + id = "diff", + group = "运行协议", + group_en = "Runtime Protocol", + category = "Diff", + category_en = "Diff", + menu = "Diff 更新", + menu_en = "Diff", + title = "GameDiff 创建、更新、移除", + title_en = "GameDiff create/update/remove", + summary = "Diff 创建、更新、移除节点。", + summary_en = "Create, update and remove nodes with GameDiff.", + code = [[return { + render = { + creates = { node }, + updates = { { id = "node", props = { x = 120 } } }, + removes = { { id = "node" } } + } +}]], + params = [[参数说明:creates 创建节点; +updates 只更新 props; +removes 只需要 id。 +Runtime 会白名单校验未知字段。 + +填写样例: +return { + ui = { + creates = { + runtime_ui.text("tip", "Hello", 20, 20, 160, 24, { textAlign = "left" }) + }, + updates = { + { id = "tip", props = { text = "Updated", color = "#ffffa000" } } + }, + removes = { + { id = "tip" } + } + } +}]], + params_en = [[Params: creates add nodes; +updates patch props; +removes only need ids. +Runtime rejects unknown fields by whitelist.]], + actions = { + { text = "创建/删除", text_en = "Create/remove", handler = "demo_temp" }, + { text = "Toast", text_en = "Toast", handler = "demo_toast" } + } + }, + { + id = "commands", + group = "运行协议", + group_en = "Runtime Protocol", + category = "命令", + category_en = "Commands", + menu = "命令动画", + menu_en = "Commands", + title = "RuntimeCommand 动作和组合", + title_en = "RuntimeCommand actions", + summary = "动作、组合、取消和完成回调。", + summary_en = "Actions, composition, cancellation and callbacks.", + code = [[commands.sequence({ + commands.move_to("actor", 220, 104, { duration = 0.55 }), + commands.parallel({ + commands.scale_to("actor", 1.45, { duration = 0.35 }), + commands.fade_to("actor", 0.45, { duration = 0.35 }) + }) +}, { group = "demo_anim", onComplete = "done" })]], + params = [[参数说明:target 指向节点; +duration 控制时长; +sequence 串行; +parallel 并行; +group/id/scope 可用于取消。 + +填写样例: +local move_opts = { + duration = 0.55, + id = "intro_move", + group = "intro", + scope = "dialog_1" +} +commands.move_to("actor", 220, 104, move_opts) + +commands.cancel_group("intro")]], + params_en = [[Params: target points to a node; +duration controls timing; +sequence runs serially; +parallel runs together; +group/id/scope support cancellation.]], + actions = { + { text = "动画序列", text_en = "Animate", handler = "demo_anim" }, + { text = "取消动画", text_en = "Cancel", handler = "demo_cancel" } + } + }, + { + id = "widgets", + group = "Lua 表现层", + group_en = "Lua Layer", + category = "Widget", + category_en = "Widget", + menu = "组合组件", + menu_en = "Widgets", + title = "Lua Widget 组合组件", + title_en = "Lua widget composition", + summary = "Lua 组合组件仍输出普通 RuntimeNode。", + summary_en = "Lua widgets still output plain RuntimeNode tables.", + code = [[local widgets = runtime.import("runtime_widgets") + +widgets.dialog("dialog", "标题", "内容", 150, 190, 420, 230, { + buttons = { + { text = "确定", handler = "close_dialog" } + } +})]], + params = [[参数说明:widgets.dialog 是 Lua helper,输出普通 RuntimeNode; +buttons 数组定义文案和 handler。 + +填写样例: +local dialog_opts = { + buttons = { + { text = "确定", handler = "close_dialog" }, + { text = "取消", handler = "cancel_dialog" } + }, + modal = true +} +widgets.dialog("dialog", "标题", "内容", 150, 190, 420, 230, dialog_opts)]], + params_en = [[Params: widgets.dialog is a Lua helper that emits plain RuntimeNodes; +buttons define labels and handlers.]], + actions = { + { text = "打开 Dialog", text_en = "Open Dialog", handler = "demo_dialog" }, + { text = "关闭 Dialog", text_en = "Close Dialog", handler = "close_dialog" } + } + }, + { + id = "audio", + group = "资源能力", + group_en = "Assets", + category = "资源", + category_en = "Assets", + menu = "资源音频", + menu_en = "Audio", + title = "资源、音效和 BGM", + title_en = "Assets, SFX and BGM", + summary = "资源 key、图片、音效、BGM 和资源组。", + summary_en = "Resource keys, images, SFX, BGM and groups.", + code = [[commands.play_sound("click", { volume = 0.8 }) +commands.play_bgm("click", { channel = "demo", loop = true }) +commands.preload_group("media", { failOnError = true })]], + params = [[参数说明:asset/name 使用 manifest audio key; +volume 范围 0~1; +channel 管理 BGM; +group 管理资源预载/释放。 + +填写样例: +commands.play_sound("click", { + volume = 0.8, + id = "click_sfx" +}) +commands.play_bgm("click", { + channel = "demo", + loop = true, + volume = 0.5 +}) +commands.preload_group("media", { failOnError = true })]], + params_en = [[Params: asset/name use manifest audio keys; +volume is 0..1; +channel manages BGM; +group manages resource preload/evict.]], + actions = { + { text = "音效", text_en = "SFX", handler = "demo_sound" }, + { text = "BGM 循环", text_en = "BGM loop", handler = "demo_bgm" }, + { text = "预载/释放", text_en = "Preload/evict", handler = "demo_resource" } + } + }, + { + id = "spine", + group = "资源能力", + group_en = "Assets", + category = "骨骼", + category_en = "Spine", + menu = "Spine 模板", + menu_en = "Spine", + title = "Spine 接入模板", + title_en = "Spine integration template", + summary = "Spine 资源、节点和动画命令模板。", + summary_en = "Template for Spine resources, nodes and animation commands.", + code = [[-- manifest.resources +"hero_spine": { + "type": "spine", + "atlas": "assets/hero.atlas", + "skeleton": "assets/hero.skel" +} + +runtime_ui.spine("hero", "hero_spine", 100, 120, 160, 220, "idle") +commands.play_spine_animation("hero", "attack", { loop = false })]], + params = [[参数说明:spine 节点使用 manifest spine key; +animation/skin/loop 描述播放状态; +track/queue/delay 控制动画命令。 + +填写样例: +runtime_ui.spine("hero", "hero_spine", 100, 120, 160, 220, "idle", { + skin = "default", + loop = true +}) +commands.play_spine_animation("hero", "attack", { + track = 0, + loop = false, + queue = false, + delay = 0 +})]], + params_en = [[Params: spine nodes use manifest spine keys; +animation/skin/loop describe playback; +track/queue/delay control animation commands.]], + actions = { + { text = "播放音效", text_en = "SFX", handler = "demo_sound" }, + { text = "Toast", text_en = "Toast", handler = "demo_toast" } + } + }, + { + id = "i18n", + group = "平台能力", + group_en = "Platform", + category = "本地化", + category_en = "I18N", + menu = "多语言", + menu_en = "I18N", + title = "Lua 多语言 Showcase", + title_en = "Lua-owned localization", + summary = "Lua 管理文案表、回退和语言切换。", + summary_en = "Lua owns copy tables, fallback and locale switching.", + code = [[local i18n = runtime.import("i18n") + +i18n.apply_context(ctx) +runtime_ui.text("title", i18n.t("app_title"), 24, 18, 360, 34) + +-- 点击按钮:i18n.toggle_locale() 后更新所有文案]], + params = [[参数说明:Runtime 只传 ctx.locale; +Lua 自己管理 messages、fallback、刷新 Diff。 + +填写样例: +local messages = { + ["zh-Hans"] = { app_title = "Lua Showcase" }, + en = { app_title = "Lua Showcase" } +} +local locale = ctx.locale or "zh-Hans" +local text = (messages[locale] or messages["zh-Hans"]).app_title]], + params_en = [[Params: Runtime only passes ctx.locale; +Lua owns messages, fallback and refresh diffs.]], + actions = { + { text = "切换语言", text_en = "Toggle locale", handler = "demo_i18n_toggle" }, + { text = "刷新文案", text_en = "Refresh copy", handler = "demo_i18n_refresh" } + } + }, + { + id = "responsive", + group = "平台能力", + group_en = "Platform", + category = "适配", + category_en = "Layout", + menu = "分辨率适配", + menu_en = "Responsive", + title = "分辨率适配 Showcase", + title_en = "Responsive layout showcase", + summary = "设计分辨率、viewport 和布局模拟。", + summary_en = "Design size, viewport and layout simulation.", + code = [[-- manifest.display +{ + "designWidth": 720, + "designHeight": 720, + "scaleMode": "fit" +} + +-- ctx.screen / ctx.design / ctx.viewport +-- Lua 根据设计宽度选择 compact / desktop 布局]], + params = [[参数说明:manifest.display 声明设计分辨率; +ctx.screen/design/viewport 给 Lua 做布局决策。 + +填写样例: +-- manifest.display +{ + designWidth = 720, + designHeight = 720, + scaleMode = "fit" +} + +local compact = ctx.screen.width < 640 +local menu_w = compact and 220 or 320]], + params_en = [[Params: manifest.display declares design size; +ctx.screen/design/viewport let Lua make layout decisions.]], + actions = { + { text = "手机宽度", text_en = "Phone", handler = "demo_responsive_phone" }, + { text = "平板宽度", text_en = "Tablet", handler = "demo_responsive_tablet" }, + { text = "桌面宽度", text_en = "Desktop", handler = "demo_responsive_desktop" } + } + } +} + +---@return ShowcaseExample[] +function examples.all() + return examples.items +end + +---@param id string +---@return ShowcaseExample +function examples.find(id) + for _, example in ipairs(examples.items) do + if example.id == id then + return example + end + end + return examples.items[1] +end + +return examples diff --git a/example/assets/games/showcase/scripts/i18n.lua b/example/assets/games/showcase/scripts/i18n.lua new file mode 100644 index 0000000..a212934 --- /dev/null +++ b/example/assets/games/showcase/scripts/i18n.lua @@ -0,0 +1,151 @@ +local state = runtime.import("state") + +---@class ShowcaseI18n +local i18n = {} + +local fallback_locale = "zh-Hans" + +local messages = { + ["zh-Hans"] = { + app_title = "Lua Runtime Showcase", + app_subtitle = "仿 Cocos Showcase:左侧管理所有示例,右侧查看说明并直接运行。", + examples_title = "Examples", + preview_title = "Preview / Runtime Stage", + tab_code = "代码", + tab_params = "参数", + copy_code = "复制代码", + copy_params = "复制参数", + selected_prefix = "已选择示例:", + status_ready = "点击按钮查看 RuntimeEvent -> Lua -> Diff / Command 示例", + i18n_current = "当前语言", + i18n_zh = "中文文案:运行时只传 locale,翻译由 Lua 包管理。", + i18n_en = "English copy: Lua owns text resources and fallback policy.", + i18n_switched = "已切换语言:", + responsive_title = "分辨率适配", + responsive_design = "设计分辨率", + responsive_screen = "屏幕", + responsive_viewport = "视口", + responsive_phone = "模拟手机宽度", + responsive_tablet = "模拟平板宽度", + responsive_desktop = "模拟桌面宽度" + }, + en = { + app_title = "Lua Runtime Showcase", + app_subtitle = "Cocos-style showcase: select examples on the left, inspect and run them on the right.", + examples_title = "Examples", + preview_title = "Preview / Runtime Stage", + tab_code = "Code", + tab_params = "Params", + copy_code = "Copy code", + copy_params = "Copy params", + selected_prefix = "Selected example: ", + status_ready = "Tap actions to inspect RuntimeEvent -> Lua -> Diff / Command.", + i18n_current = "Current locale", + i18n_zh = "中文文案:运行时只传 locale,翻译由 Lua 包管理。", + i18n_en = "English copy: Lua owns text resources and fallback policy.", + i18n_switched = "Locale switched: ", + responsive_title = "Responsive Layout", + responsive_design = "Design", + responsive_screen = "Screen", + responsive_viewport = "Viewport", + responsive_phone = "Simulate phone width", + responsive_tablet = "Simulate tablet width", + responsive_desktop = "Simulate desktop width" + } +} + +---@param locale? string +---@return string +local function normalize(locale) + if locale == nil or locale == "" then + return fallback_locale + end + if messages[locale] ~= nil then + return locale + end + + local language = string.match(locale, "^([A-Za-z]+)") + if language ~= nil and messages[language] ~= nil then + return language + end + return fallback_locale +end + +---@param ctx? RuntimeContext +function i18n.apply_context(ctx) + local locale = fallback_locale + if ctx ~= nil and ctx.locale ~= nil and ctx.locale.resolved ~= nil then + locale = ctx.locale.resolved + end + state.locale = normalize(locale) +end + +---@param locale string +function i18n.set_locale(locale) + state.locale = normalize(locale) +end + +---@return string +function i18n.toggle_locale() + if state.locale == "en" then + state.locale = "zh-Hans" + else + state.locale = "en" + end + return state.locale +end + +---@return string +function i18n.current_locale() + return normalize(state.locale) +end + +---@param key string +---@return string +function i18n.t(key) + local locale = normalize(state.locale) + local bundle = messages[locale] or messages[fallback_locale] + return bundle[key] or messages[fallback_locale][key] or key +end + +---@param example ShowcaseExample +---@param field string +---@return string +function i18n.example_field(example, field) + if i18n.current_locale() == "en" then + local en_value = example[field .. "_en"] + if en_value ~= nil and en_value ~= "" then + return en_value + end + end + return example[field] +end + +---@param example ShowcaseExample +---@return string +function i18n.example_label(example) + return i18n.example_field(example, "menu") +end + +---@param example ShowcaseExample +---@return string +function i18n.example_params(example) + return i18n.example_field(example, "params") +end + +---@param example ShowcaseExample +---@return string +function i18n.example_group(example) + return i18n.example_field(example, "group") +end + +---@param action ShowcaseAction +---@return string +function i18n.action_text(action) + if i18n.current_locale() == "en" and action.text_en ~= nil and action.text_en ~= "" then + return action.text_en + end + return action.text +end + +return i18n diff --git a/example/assets/games/showcase/scripts/main.lua b/example/assets/games/showcase/scripts/main.lua new file mode 100644 index 0000000..e3750e1 --- /dev/null +++ b/example/assets/games/showcase/scripts/main.lua @@ -0,0 +1,698 @@ +local state = runtime.import("state") +local ui = runtime.import("ui") +---@type RuntimeUi +local runtime_ui = runtime.import("runtime_ui") +local examples = runtime.import("examples") +local i18n = runtime.import("i18n") +---@type RuntimeCommands +local commands = runtime.import("runtime_commands") +---@type RuntimeWidgets +local widgets = runtime.import("runtime_widgets") +local theme = runtime.import("theme") + +widgets.configure({ + primary = theme.primary, + secondary = "#ff475569", + success = theme.success, + overlay = "#99000000", + surface = "#ff1e293b", + surfaceAlt = theme.border, + card = "#ee111827", + text = theme.text, + muted = theme.muted, + progress = theme.success, + transparent = "#00000000" +}) + +function smoke_test(ctx) + return ctx ~= nil + and ctx.runtimeApiVersion ~= nil + and examples.all ~= nil + and i18n.t ~= nil + and ui.create_nodes ~= nil + and widgets.dialog ~= nil + and commands.sequence ~= nil +end + +local function apply_context(ctx) + if ctx == nil then + return + end + i18n.apply_context(ctx) + if ctx.screen ~= nil then + state.screen_width = ctx.screen.width or state.screen_width + state.screen_height = ctx.screen.height or state.screen_height + end + if ctx.viewport ~= nil then + state.viewport_width = ctx.viewport.width or state.viewport_width + state.viewport_height = ctx.viewport.height or state.viewport_height + state.viewport_scale = ctx.viewport.scaleX or state.viewport_scale + end +end + +function init(ctx) + apply_context(ctx) + state.status = i18n.t("status_ready") + return { + render = { creates = ui.create_nodes() }, + ui = {}, + commands = {} + } +end + +---@param text string +---@return RuntimeDiff +local function status_only(text) + return { + ui = { updates = ui.status_updates(text) } + } +end + +---@return RuntimeDiff +local function handle_anim() + return { + ui = { updates = ui.status_updates("执行 sequence + parallel:移动、缩放、旋转、淡入淡出。") }, + commands = { + commands.sequence({ + commands.move_path("sample_circle", { + { x = 104, y = 42 }, + { x = 180, y = 44 }, + { x = 248, y = 78 } + }, { duration = 0.75, group = "demo_anim" }), + commands.parallel({ + commands.scale_to("sample_circle", 1.45, { duration = 0.35, group = "demo_anim" }), + commands.rotate_to("sample_circle", 6.28, { duration = 0.35, group = "demo_anim" }), + commands.fade_to("sample_circle", 0.45, { duration = 0.35, group = "demo_anim" }) + }, { group = "demo_anim" }), + commands.parallel({ + commands.move_to("sample_circle", 104, 42, { duration = 0.55, group = "demo_anim" }), + commands.scale_to("sample_circle", 1, { duration = 0.55, group = "demo_anim" }), + commands.fade_to("sample_circle", 1, { duration = 0.55, group = "demo_anim" }) + }, { group = "demo_anim" }) + }, { id = "demo_anim_sequence", group = "demo_anim", onComplete = "demo_anim_done" }) + } + } +end + +---@return RuntimeDiff +local function handle_cancel() + return { + ui = { updates = ui.status_updates("已发送 cancel_commands:group = demo_anim。") }, + commands = { commands.cancel_group("demo_anim") } + } +end + +---@return RuntimeDiff +local function handle_progress() + state.progress = state.progress + 0.15 + if state.progress > 1 then + state.progress = 0.05 + end + local updates = ui.progress_updates() + local status_updates = ui.status_updates("通过 NodeDiff.update 更新 progress.value = " .. tostring(state.progress)) + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { render = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_visibility() + state.visible = not state.visible + local updates = ui.visibility_updates() + local status_updates = ui.status_updates("通过 visible / alpha 更新节点显示状态。") + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { render = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_dialog() + if state.dialog_open then + return status_only("Dialog 已经打开;点击关闭按钮移除它。") + end + state.dialog_open = true + return { + ui = { + creates = ui.dialog_nodes(), + updates = ui.status_updates("创建 widgets.dialog:Lua 组合普通 RuntimeNode。") + } + } +end + +---@return RuntimeDiff +local function handle_close_dialog() + state.dialog_open = false + return { + ui = { + removes = ui.dialog_removes(), + updates = ui.status_updates("通过 GameDiff.removes 移除 Dialog 节点。") + } + } +end + +---@return RuntimeDiff +local function handle_temp() + if state.temp_node_visible then + state.temp_node_visible = false + return { + render = { removes = { { id = "temp_node" }, { id = "temp_node_text" } } }, + ui = { updates = ui.status_updates("通过 GameDiff.removes 移除临时节点。") } + } + end + + state.temp_node_visible = true + return { + render = { creates = ui.temp_nodes() }, + ui = { updates = ui.status_updates("创建临时节点,并用 delay + remove_node 自动删除。") }, + commands = { + commands.sequence({ + commands.delay(1.2, { id = "temp_delay", group = "temp" }), + commands.remove_node("temp_node", { group = "temp" }), + commands.remove_node("temp_node_text", { group = "temp", onComplete = "temp_removed" }) + }, { group = "temp" }) + } + } +end + +---@return RuntimeDiff +local function handle_sound() + return { + ui = { updates = ui.status_updates("播放 manifest 资源 click:commands.play_sound('click')。") }, + commands = { commands.play_sound("click", { volume = 0.8, onComplete = "sound_done" }) } + } +end + +---@return RuntimeDiff +local function handle_bgm() + if state.bgm_state == "stopped" then + state.bgm_state = "playing" + return { + ui = { updates = ui.status_updates("启动 BGM channel=demo:play_bgm,可继续点击切换暂停/恢复/停止。") }, + commands = { commands.play_bgm("click", { channel = "demo", loop = true, volume = 0.25 }) } + } + end + + if state.bgm_state == "playing" then + state.bgm_state = "paused" + return { + ui = { updates = ui.status_updates("暂停 BGM:pause_bgm('demo')。") }, + commands = { commands.pause_bgm("demo") } + } + end + + if state.bgm_state == "paused" then + state.bgm_state = "resumed" + return { + ui = { updates = ui.status_updates("恢复 BGM:resume_bgm('demo')。") }, + commands = { commands.resume_bgm("demo") } + } + end + + state.bgm_state = "stopped" + return { + ui = { updates = ui.status_updates("停止 BGM:stop_bgm('demo')。") }, + commands = { commands.stop_bgm("demo") } + } +end + +---@return RuntimeDiff +local function handle_text_change() + state.text_variant = state.text_variant == "plain" and "styled" or "plain" + local updates = ui.text_updates() + local status_updates = ui.status_updates("Text 当前是纯文本协议;富文本 richText/spans 尚未支持。") + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_text_style() + state.text_variant = state.text_variant == "plain" and "styled" or "plain" + local updates = ui.text_updates() + local status_updates = ui.status_updates("已切换 text 的 color/fontSize;不是富文本。") + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_button_primary() + return { + ui = { updates = ui.status_updates("按钮点击:RuntimeEvent.tap -> Lua handler。") }, + commands = { commands.play_sound("click", { volume = 0.55 }) } + } +end + +---@return RuntimeDiff +local function handle_button_toggle() + state.button_active = not state.button_active + local updates = ui.button_updates() + local status_updates = ui.status_updates(state.button_active and "按钮恢复可点击状态。" or "按钮切换为置灰状态。") + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_button_image_tap() + return { + ui = { updates = ui.status_updates("图片按钮点击:按下时显示 pressedAsset,松开回到 asset。") }, + commands = { commands.play_sound("click", { volume = 0.45 }) } + } +end + +---@return RuntimeDiff +local function handle_button_image_toggle() + state.button_image_enabled = not state.button_image_enabled + local updates = ui.button_image_updates() + local status_updates = ui.status_updates(state.button_image_enabled and "图片按钮恢复 interactive=true。" or "图片按钮设为 interactive=false,显示 disabledAsset。") + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_sprite_anim() + return { + ui = { updates = ui.status_updates("精灵节点执行 move_to + rotate_to。") }, + commands = { + commands.parallel({ + commands.move_to("sprite_sprite_demo", 154, 54, { duration = 0.35, group = "sprite_demo" }), + commands.rotate_to("sprite_sprite_demo", 6.28, { duration = 0.35, group = "sprite_demo" }) + }, { group = "sprite_demo", onComplete = "sprite_anim_done" }) + } + } +end + +---@return RuntimeDiff +local function handle_sprite_style() + state.sprite_variant = state.sprite_variant == "image" and "sprite" or "image" + local updates = ui.sprite_updates() + local status_updates = ui.status_updates("切换 sprite 示例样式:" .. state.sprite_variant) + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@param value string +---@return RuntimeDiff +local function handle_radio(value) + state.radio_selected = value + local updates = ui.radio_updates() + local status_updates = ui.status_updates("RadioGroup 选择:" .. value) + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@param index integer +---@return RuntimeDiff +local function handle_list_pick(index) + local items = { "Lua", "Runtime", "Flame", "Diff", "Command" } + if index < 1 then + index = #items + elseif index > #items then + index = 1 + end + state.list_selected = items[index] + if state.list_axis == "horizontal" then + state.list_scroll_x = math.min(math.max((index - 2) * 126, 0), 330) + state.list_scroll_y = 0 + else + state.list_scroll_x = 0 + state.list_scroll_y = math.min(math.max((index - 2) * 28, 0), 78) + end + local updates = ui.list_updates() + local status_updates = ui.status_updates("ListView 选中:" .. state.list_selected .. ",双轴滚动已更新。") + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_list_horizontal() + state.list_axis = state.list_axis == "horizontal" and "vertical" or "horizontal" + state.list_scroll_x = 0 + state.list_scroll_y = 0 + local updates = ui.list_updates() + local status_updates = ui.status_updates("ListView 排列方向:" .. state.list_axis) + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_list_reset() + state.list_scroll_x = 0 + state.list_scroll_y = 0 + local updates = ui.list_updates() + local status_updates = ui.status_updates("ListView 滚动已重置;滚轮/拖拽会触发 onScroll。") + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@param event RuntimeEvent +---@return RuntimeDiff +local function handle_list_scrolled(event) + if event.data ~= nil then + state.list_scroll_x = event.data.scrollX or state.list_scroll_x + state.list_scroll_y = event.data.scrollY or state.list_scroll_y + end + return { + ui = { updates = ui.status_updates("onScroll 回调:" .. tostring(state.list_scroll_x) .. ", " .. tostring(state.list_scroll_y)) } + } +end + +---@param step integer +---@return RuntimeDiff +---@param preset string +---@return RuntimeDiff +local function handle_particle(preset) + state.particle_seed = (state.particle_seed or 0) + 1 + local count = 42 + (state.particle_seed % 5) + local updates = { + runtime_ui.update("particle_burst", { + preset = preset == "confetti" and "confetti" or "burst", + count = count, + duration = preset == "confetti" and 1.2 or 0.85, + color = preset == "confetti" and "#ffff4d6d" or "#ffffcc33", + colorTo = preset == "confetti" and "#fffacc15" or "#00ffcc33", + gravityY = preset == "confetti" and 240 or 90, + spread = preset == "confetti" and 90 or 360, + visible = preset ~= "snow" + }), + runtime_ui.update("particle_trail", { + visible = preset == "burst" + }), + runtime_ui.update("particle_snow", { + count = 56 + (state.particle_seed % 7), + visible = preset == "snow" + }) + } + local status_updates = ui.status_updates("Particle 预设:" .. preset) + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +local function handle_list(step) + local items = { "Lua", "Runtime", "Flame", "Diff", "Command" } + local index = 1 + for i, value in ipairs(items) do + if value == state.list_selected then + index = i + break + end + end + return handle_list_pick(index + step) +end + +---@param mode string +---@return RuntimeDiff +local function handle_layout(mode) + state.layout_mode = mode + local updates = ui.layout_updates(mode) + local status_updates = ui.status_updates("布局示例:" .. mode) + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@return RuntimeDiff +local function handle_resource() + if state.resource_state == "ready" then + state.resource_state = "evicted" + return { + ui = { updates = ui.status_updates("释放资源组 media:evict_resources。再次点击会 preload。") }, + commands = { commands.evict_group("media") } + } + end + + state.resource_state = "ready" + return { + ui = { updates = ui.status_updates("预载资源组 media:preload_resources。") }, + commands = { commands.preload_group("media", { failOnError = true }) } + } +end + +---@return RuntimeDiff +---@return RuntimeDiff +local function handle_i18n_toggle() + local locale = i18n.toggle_locale() + return { + ui = { updates = ui.locale_updates() }, + commands = { commands.toast(i18n.t("i18n_switched") .. locale) } + } +end + +---@return RuntimeDiff +local function handle_i18n_refresh() + return { + ui = { updates = ui.locale_updates() } + } +end + +---@param mode string +---@return RuntimeDiff +local function handle_responsive(mode) + state.responsive_mode = mode + local label = i18n.t("responsive_desktop") + if mode == "phone" then + label = i18n.t("responsive_phone") + elseif mode == "tablet" then + label = i18n.t("responsive_tablet") + end + local updates = ui.responsive_updates() + local status_updates = ui.status_updates(label) + for _, update in ipairs(status_updates) do + table.insert(updates, update) + end + return { ui = { updates = updates } } +end + +---@param event RuntimeEvent +---@return RuntimeDiff +local function handle_resize(event) + if event.data ~= nil then + apply_context(event.data) + end + return { ui = { updates = ui.responsive_updates() } } +end + +---@return RuntimeDiff +local function handle_toast() + return { + ui = { updates = ui.status_updates("发送 toast 命令:Runtime 会显示临时 overlay 并自动移除。") }, + commands = { commands.toast("Hello from Lua showcase") } + } +end + +---@param target? string +---@return RuntimeDiff +local function handle_select_example(target) + local id = string.sub(target or "example_nodes", 9) + local selected = examples.find(id) + state.selected_example = selected.id + state.detail_tab = "code" + return { + ui = { updates = ui.example_updates(selected) } + } +end + +---@param tab string +---@return RuntimeDiff +local function handle_detail_tab(tab) + state.detail_tab = tab + return { ui = { updates = ui.example_updates(examples.find(state.selected_example)) } } +end + +---@return RuntimeDiff +local function handle_copy_detail() + local selected = examples.find(state.selected_example) + local text = selected.code + local label = "代码" + if state.detail_tab == "params" then + text = i18n.example_params(selected) + label = "参数说明" + end + return { + ui = { updates = ui.status_updates("已复制" .. label .. "到剪贴板。") }, + commands = { commands.copy_text(text) } + } +end + +function on_event(event) + if event.handler == "select_example" then + return handle_select_example(event.target) + end + if event.handler == "detail_tab_code" then + return handle_detail_tab("code") + end + if event.handler == "detail_tab_params" then + return handle_detail_tab("params") + end + if event.handler == "copy_detail" then + return handle_copy_detail() + end + if event.handler == "demo_anim" then + return handle_anim() + end + if event.handler == "demo_cancel" then + return handle_cancel() + end + if event.handler == "demo_progress" then + return handle_progress() + end + if event.handler == "demo_visibility" then + return handle_visibility() + end + if event.handler == "demo_dialog" then + return handle_dialog() + end + if event.handler == "close_dialog" then + return handle_close_dialog() + end + if event.handler == "demo_temp" then + return handle_temp() + end + if event.handler == "demo_text_change" then + return handle_text_change() + end + if event.handler == "demo_text_style" then + return handle_text_style() + end + if event.handler == "demo_button_primary" then + return handle_button_primary() + end + if event.handler == "demo_button_toggle" then + return handle_button_toggle() + end + if event.handler == "demo_button_image_tap" then + return handle_button_image_tap() + end + if event.handler == "demo_button_image_toggle" then + return handle_button_image_toggle() + end + if event.handler == "demo_sprite_anim" then + return handle_sprite_anim() + end + if event.handler == "demo_sprite_style" then + return handle_sprite_style() + end + if event.handler == "demo_radio_audio" then + return handle_radio("audio") + end + if event.handler == "demo_radio_spine" then + return handle_radio("spine") + end + if event.handler == "demo_radio_lua" then + return handle_radio("Lua") + end + if event.handler == "demo_list_prev" then + return handle_list(-1) + end + if event.handler == "demo_list_next" then + return handle_list(1) + end + if event.handler == "demo_list_horizontal" then + return handle_list_horizontal() + end + if event.handler == "demo_list_reset" then + return handle_list_reset() + end + if event.handler == "demo_list_scrolled" then + return handle_list_scrolled(event) + end + if event.handler == "demo_list_pick_1" then + return handle_list_pick(1) + end + if event.handler == "demo_list_pick_2" then + return handle_list_pick(2) + end + if event.handler == "demo_list_pick_3" then + return handle_list_pick(3) + end + if event.handler == "demo_list_pick_4" then + return handle_list_pick(4) + end + if event.handler == "demo_list_pick_5" then + return handle_list_pick(5) + end + if event.handler == "demo_particle_burst" then + return handle_particle("burst") + end + if event.handler == "demo_particle_confetti" then + return handle_particle("confetti") + end + if event.handler == "demo_particle_snow" then + return handle_particle("snow") + end + if event.handler == "demo_layout_row" then + return handle_layout("row") + end + if event.handler == "demo_layout_column" then + return handle_layout("column") + end + if event.handler == "demo_layout_box" then + return handle_layout("box") + end + if event.handler == "demo_sound" then + return handle_sound() + end + if event.handler == "demo_bgm" then + return handle_bgm() + end + if event.handler == "demo_resource" then + return handle_resource() + end + if event.handler == "demo_i18n_toggle" then + return handle_i18n_toggle() + end + if event.handler == "demo_i18n_refresh" then + return handle_i18n_refresh() + end + if event.handler == "demo_responsive_phone" then + return handle_responsive("phone") + end + if event.handler == "demo_responsive_tablet" then + return handle_responsive("tablet") + end + if event.handler == "demo_responsive_desktop" then + return handle_responsive("desktop") + end + if event.handler == "demo_toast" then + return handle_toast() + end + if event.type == "resize" then + return handle_resize(event) + end + if event.handler == "demo_anim_done" then + return status_only("动画完成事件:RuntimeCommand.onComplete -> RuntimeEvent -> Lua。") + end + if event.handler == "temp_removed" then + state.temp_node_visible = false + return status_only("临时节点已由 remove_node 命令删除。") + end + if event.handler == "sound_done" then + return status_only("音效播放完成事件已回到 Lua。") + end + if event.handler == "sprite_anim_done" then + return status_only("精灵动画完成。") + end + + return {} +end diff --git a/example/assets/games/showcase/scripts/runtime_defs.lua b/example/assets/games/showcase/scripts/runtime_defs.lua new file mode 100644 index 0000000..ee07392 --- /dev/null +++ b/example/assets/games/showcase/scripts/runtime_defs.lua @@ -0,0 +1,606 @@ +---@meta +--- COMMON RUNTIME TYPES SECTION. +--- Source of truth: tool/lua_runtime_defs_common.lua +--- After editing this common section, run: +--- dart run tool/generate_lua_runtime_defs.dart + + +---@alias RuntimeNodeType +---| 'panel' +---| 'button' +---| 'text' +---| 'circle' +---| 'rect' +---| 'line' +---| 'progress' +---| 'listView' +---| 'sprite' +---| 'image' +---| 'spine' +---| 'particle' + +---@alias RuntimeAnchor +---| 'center' +---| 'topLeft' +---| 'topRight' +---| 'bottomLeft' +---| 'bottomRight' + +---@alias RuntimeTextAlign +---| 'left' +---| 'center' +---| 'right' + +---@alias RuntimeParticlePreset +---| 'burst' +---| 'trail' +---| 'snow' +---| 'confetti' + +---@alias RuntimeCommandType +---| 'move_path' +---| 'move_to' +---| 'fade_to' +---| 'scale_to' +---| 'rotate_to' +---| 'remove_node' +---| 'sequence' +---| 'parallel' +---| 'delay' +---| 'toast' +---| 'play_sound' +---| 'play_bgm' +---| 'pause_bgm' +---| 'resume_bgm' +---| 'stop_bgm' +---| 'preload_resources' +---| 'evict_resources' +---| 'cancel_commands' +---| 'play_spine_animation' +---| 'copy_text' + +---@alias RuntimeEventType +---| 'tap' +---| 'animation_done' +---| 'resize' +---| 'scroll' + +---@alias RuntimeScaleMode +---| 'fit' +---| 'fill' +---| 'stretch' +---| 'none' + +---@alias RuntimeLayoutAlign +---| 'start' +---| 'center' +---| 'end' + +---@alias RuntimeButtonVariant +---| 'primary' +---| 'secondary' +---| 'ghost' + +---@class (exact) RuntimeNode +---@field id string +---@field type RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---@class (exact) RuntimeNodeProps +---@field type? RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---Helper-only fields accepted by runtime_ui/runtime_widgets. They are normalized +---before the node/update crosses the Dart Runtime protocol boundary. +---@class RuntimeNodeInit: RuntimeNodeProps +---@field w? number Alias for width. +---@field h? number Alias for height. +---@field size? number Alias for both width and height. +---@field handler? string Alias for onTap. +---@field onClick? string Alias for onTap. + +---@class (exact) RuntimeNodeUpdate +---@field id string +---@field props RuntimeNodeProps + +---@class (exact) RuntimeNodeRemove +---@field id string + +---@class (exact) RuntimeDiffSection +---@field creates? RuntimeNode[] +---@field updates? RuntimeNodeUpdate[] +---@field removes? (string|RuntimeNodeRemove)[] + +---@class (exact) RuntimeDiff +---@field render? RuntimeDiffSection +---@field ui? RuntimeDiffSection +---@field commands? RuntimeCommand[] + +---@class (exact) RuntimeEvent +---@field type RuntimeEventType|string +---@field target? string +---@field handler? string +---@field x? number +---@field y? number +---@field data? table + +---@class (exact) RuntimeCommand +---@field type RuntimeCommandType +---@field target? string +---@field scope? string +---@field id? string +---@field group? string +---@field commandGroup? string +---@field onComplete? string +---@field duration? number +---@field commands? RuntimeCommand[] +---@field path? RuntimePoint[] +---@field x? number +---@field y? number +---@field alpha? number +---@field scale? number +---@field angle? number +---@field text? string +---@field message? string +---@field asset? string +---@field name? string +---@field volume? number +---@field channel? string +---@field loop? boolean +---@field failOnError? boolean +---@field animation? string +---@field track? integer +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeCommandOpts +---@field id? string +---@field group? string +---@field commandGroup? string +---@field scope? string +---@field onComplete? string +---@field duration? number + +---@class (exact) RuntimeAudioCommandOpts: RuntimeCommandOpts +---@field volume? number +---@field name? string + +---@class (exact) RuntimeBgmCommandOpts: RuntimeAudioCommandOpts +---@field channel? string +---@field loop? boolean + +---@class (exact) RuntimeSpineCommandOpts: RuntimeCommandOpts +---@field track? integer +---@field loop? boolean +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeResourceCommandOpts: RuntimeCommandOpts +---@field failOnError? boolean + +---@class (exact) RuntimePoint +---@field x number +---@field y number + +---@class (exact) RuntimeLocaleContext +---@field requested string +---@field resolved string +---@field default string +---@field supported string[] +---@field languageCode string +---@field scriptCode? string +---@field countryCode? string + +---@class (exact) RuntimeScreenContext +---@field width number +---@field height number + +---@class (exact) RuntimeDesignContext +---@field width number +---@field height number + +---@class (exact) RuntimeViewportContext +---@field x number +---@field y number +---@field width number +---@field height number +---@field scaleX number +---@field scaleY number +---@field scaleMode RuntimeScaleMode|string + +---@class (exact) RuntimeContext +---@field screen RuntimeScreenContext +---@field design RuntimeDesignContext +---@field viewport RuntimeViewportContext +---@field seed integer +---@field runtimeApiVersion integer +---@field gameId string +---@field gameVersion string +---@field locale? RuntimeLocaleContext + +---@class RuntimeUi +---@field style fun(base?: RuntimeNodeProps, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field with_parent fun(parent: string, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field node fun(node_type: RuntimeNodeType, id: string, opts?: RuntimeNodeInit): RuntimeNode +---@field panel fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field rect fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field circle fun(id: string, x: number|RuntimeNodeInit, y?: number, size?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field line fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field particle fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field text fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field button fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field list_view fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field image fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field sprite fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field spine fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, animation?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field update fun(id: string, props: RuntimeNodeInit): RuntimeNodeUpdate +---@field text_update fun(id: string, text: string): RuntimeNodeUpdate +---@field visible_update fun(id: string, visible: boolean): RuntimeNodeUpdate +---@field alpha_update fun(id: string, alpha: number): RuntimeNodeUpdate +---@field scale_update fun(id: string, scale: number): RuntimeNodeUpdate +---@field position_update fun(id: string, x: number, y: number): RuntimeNodeUpdate +---@field size_update fun(id: string, width: number, height: number): RuntimeNodeUpdate +---@field transform_update fun(id: string, x: number, y: number, scale: number, rotation: number): RuntimeNodeUpdate +---@field batch_update fun(ids: string[], props: RuntimeNodeInit): RuntimeNodeUpdate[] +---@field append fun(nodes: RuntimeNode[], node: RuntimeNode): RuntimeNode[] +---@field append_all fun(nodes: RuntimeNode[], extra_nodes: RuntimeNode[]): RuntimeNode[] + +---@class (exact) RuntimeDialogButton +---@field id? string +---@field text string +---@field handler string +---@field color? string + +---@class (exact) RuntimeDialogOpts +---@field screenWidth? number +---@field screenHeight? number +---@field overlay? boolean +---@field overlayColor? string +---@field blockInput? boolean +---@field layer? integer +---@field color? string +---@field radius? number +---@field panelStyle? RuntimeNodeProps +---@field titleColor? string +---@field titleSize? number +---@field titleStyle? RuntimeNodeProps +---@field messageColor? string +---@field messageSize? number +---@field messageStyle? RuntimeNodeProps +---@field buttons? RuntimeDialogButton[] +---@field buttonGap? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeLabeledProgressOpts: RuntimeNodeInit +---@field labelHeight? number +---@field labelStyle? RuntimeNodeProps + +---@class RuntimePillOpts: RuntimeNodeInit +---@field panelStyle? RuntimeNodeProps +---@field textStyle? RuntimeNodeProps + +---@class RuntimeTextButtonOpts: RuntimeNodeInit +---@field variant? RuntimeButtonVariant + +---@class RuntimeListItemOpts: RuntimeTextButtonOpts +---@field selected? boolean +---@field activeColor? string +---@field inactiveColor? string + +---@class RuntimeTabItem +---@field id? string +---@field key? string +---@field text string +---@field handler? string +---@field selected? boolean + +---@class RuntimeTabsOpts: RuntimeNodeInit +---@field tabs? RuntimeTabItem[] +---@field selected? string +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field activeColor? string +---@field inactiveColor? string +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeActionItem +---@field id? string +---@field text string +---@field handler? string +---@field visible? boolean +---@field color? string +---@field style? RuntimeNodeProps + +---@class RuntimeActionRowOpts: RuntimeNodeInit +---@field actions? RuntimeActionItem[] +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimePanelHeaderOpts: RuntimeNodeInit +---@field eyebrow? string +---@field title string +---@field summary? string +---@field gap? number +---@field eyebrowId? string +---@field titleId? string +---@field summaryId? string +---@field eyebrowHeight? number +---@field titleHeight? number +---@field summaryHeight? number +---@field eyebrowStyle? RuntimeNodeProps +---@field titleStyle? RuntimeNodeProps +---@field summaryStyle? RuntimeNodeProps + +---@class RuntimeWidgetTheme +---@field primary? string +---@field secondary? string +---@field success? string +---@field overlay? string +---@field surface? string +---@field surfaceAlt? string +---@field card? string +---@field text? string +---@field muted? string +---@field progress? string +---@field transparent? string + +---@class RuntimeWidgets +---@field configure fun(tokens?: RuntimeWidgetTheme): RuntimeWidgets +---@field label fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field section_title fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field pill fun(id: string, text: string|RuntimePillOpts, x?: number, y?: number, width?: number, height?: number, opts?: RuntimePillOpts): RuntimeNode[] +---@field text_button fun(id: string, text: string|RuntimeTextButtonOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeTextButtonOpts): RuntimeNode +---@field list_item fun(id: string, text: string|RuntimeListItemOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeListItemOpts): RuntimeNode +---@field tabs fun(id: string, tabs: RuntimeTabItem[]|RuntimeTabsOpts, opts?: RuntimeTabsOpts): RuntimeNode[] +---@field action_row fun(id: string, actions: RuntimeActionItem[]|RuntimeActionRowOpts, opts?: RuntimeActionRowOpts): RuntimeNode[] +---@field panel_header fun(id: string, opts: RuntimePanelHeaderOpts): RuntimeNode[] +---@field overlay fun(id: string, width: number|RuntimeNodeInit, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field card fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress_bar fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field labeled_progress fun(id: string, label: string, x: number, y: number, width: number, height: number, value: number, opts?: RuntimeLabeledProgressOpts): RuntimeNode[] +---@field button_row fun(parent: string, id: string, buttons: RuntimeDialogButton[], x: number, y: number, width: number, height: number, gap?: number, opts?: RuntimeNodeProps): RuntimeNode[] +---@field dialog fun(id: string, title: string, message: string, x: number, y: number, width: number, height: number, opts?: RuntimeDialogOpts): RuntimeNode[] + +---@class (exact) RuntimeLayoutItem +---@field node RuntimeNode +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLayoutItemOpts +---@field margin? number +---@field mx? number +---@field my? number +---@field ml? number +---@field mr? number +---@field mt? number +---@field mb? number +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLinearLayoutOpts +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field gap? number +---@field align? RuntimeLayoutAlign +---@field padding? number +---@field paddingX? number +---@field paddingY? number +---@field px? number +---@field py? number +---@field paddingLeft? number +---@field paddingTop? number + +---@class RuntimeBoxLayoutOpts: RuntimeLinearLayoutOpts +---@field rows? integer +---@field columns? integer +---@field cols? integer +---@field cellWidth? number +---@field cellHeight? number +---@field cellW? number +---@field cellH? number +---@field gapX? number +---@field gapY? number +---@field valign? RuntimeLayoutAlign + +---@class RuntimeLayout +---@field item fun(node: RuntimeNode, opts?: RuntimeLayoutItemOpts): RuntimeLayoutItem +---@field local_position fun(origin: RuntimePoint, position: RuntimePoint): RuntimePoint +---@field row fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field column fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field stack fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field box fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeBoxLayoutOpts): RuntimeNode[] + +---@class RuntimeCommands +---@field toast fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field copy_text fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field delay fun(duration: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field sequence fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field parallel fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_path fun(target: string, path: RuntimePoint[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_to fun(target: string, x: number, y: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field fade_to fun(target: string, alpha: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field scale_to fun(target: string, scale: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field rotate_to fun(target: string, angle: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field remove_node fun(target: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field play_spine_animation fun(target: string, animation: string, opts?: RuntimeSpineCommandOpts): RuntimeCommand +---@field play_sound fun(asset: string, opts?: RuntimeAudioCommandOpts): RuntimeCommand +---@field play_bgm fun(asset: string, opts?: RuntimeBgmCommandOpts): RuntimeCommand +---@field pause_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field resume_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field stop_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field preload_group fun(group: string, opts?: RuntimeResourceCommandOpts): RuntimeCommand +---@field evict_group fun(group: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field cancel_id fun(id: string): RuntimeCommand +---@field cancel_group fun(group: string): RuntimeCommand +---@field cancel_scope fun(scope: string): RuntimeCommand + +---@class RuntimeImportApi +---@field import fun(moduleName: string): table + +---@type RuntimeImportApi +runtime = runtime + +---@class (exact) ShowcaseAction +---@field text string +---@field text_en? string +---@field handler string + +---@class (exact) ShowcaseExample +---@field id string +---@field group string +---@field group_en? string +---@field category string +---@field category_en? string +---@field menu string +---@field menu_en? string +---@field title string +---@field title_en? string +---@field summary string +---@field summary_en? string +---@field code string +---@field params string +---@field params_en? string +---@field actions ShowcaseAction[] + +---@class (exact) ShowcaseState +---@field selected_example string +---@field detail_tab string +---@field progress number +---@field visible boolean +---@field dialog_open boolean +---@field temp_node_visible boolean +---@field bgm_state string +---@field resource_state string +---@field text_variant string +---@field button_active boolean +---@field sprite_variant string +---@field layout_mode string +---@field radio_selected string +---@field list_selected string +---@field list_axis string +---@field list_scroll_x number +---@field list_scroll_y number +---@field locale string +---@field screen_width number +---@field screen_height number +---@field viewport_width number +---@field viewport_height number +---@field viewport_scale number +---@field responsive_mode string +---@field status string diff --git a/example/assets/games/showcase/scripts/state.lua b/example/assets/games/showcase/scripts/state.lua new file mode 100644 index 0000000..6916fff --- /dev/null +++ b/example/assets/games/showcase/scripts/state.lua @@ -0,0 +1,32 @@ +---@class ShowcaseState +local state = { + selected_example = "nodes", + detail_tab = "code", + progress = 0.35, + visible = true, + dialog_open = false, + temp_node_visible = false, + bgm_state = "stopped", + resource_state = "ready", + text_variant = "plain", + button_active = true, + button_image_enabled = true, + sprite_variant = "image", + layout_mode = "row", + radio_selected = "audio", + list_selected = "Lua", + list_axis = "vertical", + list_scroll_x = 0, + list_scroll_y = 0, + locale = "zh-Hans", + screen_width = 720, + screen_height = 720, + viewport_width = 720, + viewport_height = 720, + viewport_scale = 1, + responsive_mode = "desktop", + particle_seed = 0, + status = "点击按钮查看 RuntimeEvent -> Lua -> Diff / Command 示例" +} + +return state diff --git a/example/assets/games/showcase/scripts/styles.lua b/example/assets/games/showcase/scripts/styles.lua new file mode 100644 index 0000000..504a267 --- /dev/null +++ b/example/assets/games/showcase/scripts/styles.lua @@ -0,0 +1,44 @@ +local theme = runtime.import("theme") + +---@class ShowcaseStyles +local styles = {} + +styles.title = { + color = theme.text, + fontSize = 24, + layer = 20 +} + +styles.label = { + color = theme.muted, + fontSize = 14, + layer = 20 +} + +styles.value = { + color = theme.text, + fontSize = 16, + layer = 20 +} + +styles.button = { + color = "#ff2563eb", + radius = 10, + fontSize = 14, + layer = 30 +} + +styles.small_button = { + color = "#ff334155", + radius = 8, + fontSize = 12, + layer = 30 +} + +styles.card = { + color = theme.card, + radius = 14, + layer = 10 +} + +return styles diff --git a/example/assets/games/showcase/scripts/theme.lua b/example/assets/games/showcase/scripts/theme.lua new file mode 100644 index 0000000..149d835 --- /dev/null +++ b/example/assets/games/showcase/scripts/theme.lua @@ -0,0 +1,18 @@ +---@class ShowcaseTheme +local theme = { + screen_width = 720, + screen_height = 720, + background = "#ff0f172a", + panel = "#ff111827", + card = "#ff1f2937", + border = "#ff334155", + text = "#fff8fafc", + muted = "#ff94a3b8", + primary = "#ff38bdf8", + success = "#ff22c55e", + warning = "#fff59e0b", + danger = "#ffef4444", + purple = "#ffa855f7" +} + +return theme diff --git a/example/assets/games/showcase/scripts/ui.lua b/example/assets/games/showcase/scripts/ui.lua new file mode 100644 index 0000000..6996edc --- /dev/null +++ b/example/assets/games/showcase/scripts/ui.lua @@ -0,0 +1,1300 @@ +---@type RuntimeUi +local runtime_ui = runtime.import("runtime_ui") +---@type RuntimeWidgets +local widgets = runtime.import("runtime_widgets") +local theme = runtime.import("theme") +local styles = runtime.import("styles") +local state = runtime.import("state") +local examples = runtime.import("examples") +local i18n = runtime.import("i18n") + +---@class ShowcaseUi +local ui = {} + +local base_preview_ids = { + "sample_rect", + "sample_circle", + "sample_line", + "sample_image_node", + "sample_sprite_node", + "sample_progress", + "widget_progress_label", + "widget_progress" +} + +local text_preview_ids = { + "text_plain_title", + "text_plain_body", + "text_style_badge", + "text_rich_note" +} + +local button_preview_ids = { + "button_hint", + "button_primary", + "button_secondary", + "button_disabled", + "button_state_text" +} + +local button_image_preview_ids = { + "image_button_hint", + "image_button_normal", + "image_button_toggle", + "image_button_disabled", + "image_button_state_text" +} + +local sprite_preview_ids = { + "sprite_image_demo", + "sprite_sprite_demo", + "sprite_frame_demo", + "sprite_label_demo" +} + +local layout_preview_ids = { + "layout_canvas", + "layout_chip_1", + "layout_chip_2", + "layout_chip_3", + "layout_chip_4", + "layout_label" +} + +local radio_preview_ids = { + "radio_title", + "radio_audio_dot", + "radio_audio_label", + "radio_spine_dot", + "radio_spine_label", + "radio_lua_dot", + "radio_lua_label", + "radio_value_text" +} + +local list_preview_ids = { + "list_panel", + "list_row_1", + "list_row_2", + "list_row_3", + "list_row_4", + "list_row_5", + "list_row_1_text", + "list_row_2_text", + "list_row_3_text", + "list_row_4_text", + "list_row_5_text", + "list_value_text" +} + +local particle_preview_ids = { + "particle_burst", + "particle_trail", + "particle_snow", + "particle_label" +} + +local responsive_preview_ids = { + "responsive_info", + "responsive_device", + "responsive_sidebar", + "responsive_content" +} + +---@return table +local function metrics() + local screen_w = theme.screen_width + local screen_h = theme.screen_height + local margin = 20 + local gap = 16 + local header_h = 82 + local status_h = 30 + local content_h = screen_h - header_h - status_h - margin + local compact = screen_w < 640 + local menu_w = compact and 188 or 220 + local detail_w = screen_w - margin * 2 - gap - menu_w + local content_y = header_h + local preview_h = 148 + local action_y = content_y + content_h - preview_h - 54 + local preview_y = content_y + content_h - preview_h - 10 + local code_h = action_y - content_y - 150 + if code_h < 128 then + code_h = 128 + end + + return { + screen_w = screen_w, + screen_h = screen_h, + margin = margin, + gap = gap, + compact = compact, + menu_x = margin, + menu_y = content_y, + menu_w = menu_w, + detail_x = margin + menu_w + gap, + detail_y = content_y, + detail_w = detail_w, + content_h = content_h, + action_y = action_y, + preview_y = preview_y, + preview_h = preview_h, + code_h = code_h, + status_y = screen_h - status_h - 8 + } +end + +---@param example ShowcaseExample +---@return boolean +local function preview_mode(example) + if example.id == "text_demo" then + return "text" + end + if example.id == "buttons" then + return "buttons" + end + if example.id == "button_images" then + return "button_images" + end + if example.id == "sprites" then + return "sprites" + end + if example.id == "layout_demo" then + return "layout" + end + if example.id == "radio_group" then + return "radio" + end + if example.id == "list_view" then + return "list" + end + if example.id == "particles" then + return "particle" + end + if example.id == "responsive" then + return "responsive" + end + return "base" +end + +---@param nodes RuntimeNode[] +---@param text string +---@param y number +---@param m table +local function append_menu_group(nodes, text, y, m) + table.insert(nodes, widgets.label("group_" .. tostring(y), { + text = text, + x = 16, + y = y, + w = m.menu_w - 32, + h = 18, + parent = "example_list_panel", + color = theme.muted, + fontSize = 11, + layer = 30 + })) +end + +---@param nodes RuntimeNode[] +---@param example ShowcaseExample +---@param y number +---@param m table +local function append_menu_item(nodes, example, y, m) + local selected = example.id == state.selected_example + table.insert(nodes, widgets.list_item("example_" .. example.id, { + text = i18n.example_label(example), + x = 14, + y = y, + w = m.menu_w - 28, + h = 27, + handler = "select_example", + parent = "example_list_panel", + selected = selected, + activeColor = theme.primary, + inactiveColor = "#ff1e293b", + radius = 10, + fontSize = 11, + layer = 31 + })) +end + +---@param selected ShowcaseExample +---@param m table +---@return RuntimeNode[] +local function detail_action_nodes(selected, m) + local actions = {} + for index = 1, 3 do + local action = selected.actions[index] + actions[index] = { + id = "detail_action_" .. index, + text = action ~= nil and i18n.action_text(action) or "", + handler = action ~= nil and action.handler or "noop", + visible = action ~= nil, + color = index == 1 and "#ff2563eb" or "#ff334155" + } + end + return widgets.action_row("detail_action", actions, { + x = m.detail_x + 20, + y = m.action_y, + width = m.detail_w - 40, + itemHeight = 34, + gap = 12, + radius = 10, + fontSize = 12, + layer = 35 + }) +end + +---@param example ShowcaseExample +---@return string +local function detail_body(example) + if state.detail_tab == "params" then + return i18n.example_params(example) + end + return example.code +end + +---@return string +local function detail_copy_text() + if state.detail_tab == "params" then + return i18n.t("copy_params") + end + return i18n.t("copy_code") +end + +---@param text string +---@return integer +local function line_count(text) + local value = text or "" + local count = 1 + local start = 1 + while true do + local index = string.find(value, "\n", start, true) + if index == nil then + return count + end + count = count + 1 + start = index + 1 + end +end + +local detail_text_y + +local detail_padding_left = 14 +local detail_padding_top = 12 +local detail_padding_right = 14 +local detail_padding_bottom = 12 + +---@param m table +---@return number +local function detail_viewport_width(m) + return m.detail_w - 40 - detail_padding_left - detail_padding_right +end + +---@param m table +---@return number +local function detail_viewport_height(m) + return m.code_h + 18 - detail_padding_top - detail_padding_bottom +end + +---@param example ShowcaseExample +---@return number +local function detail_text_height(example) + return line_count(detail_body(example)) * 16 + 8 +end + +---@param example ShowcaseExample +---@param m table +---@return number +local function detail_content_height(example, m) + local estimated = detail_text_y(example, m) + detail_text_height(example) + 18 + local viewport_h = detail_viewport_height(m) + if estimated < viewport_h then + return viewport_h + end + return estimated +end + +---@param m table +---@return number +local function detail_content_width(m) + local viewport_w = detail_viewport_width(m) + if state.detail_tab == "params" then + return viewport_w + end + if line_count(detail_body(examples.find(state.selected_example))) <= 7 then + return viewport_w + end + return viewport_w * 1.45 +end + +---@param example ShowcaseExample +---@param m table +---@return number +local function detail_text_width(example, m) + local viewport_w = detail_viewport_width(m) + if state.detail_tab == "params" then + return viewport_w * 0.78 + end + local content_w = detail_content_width(m) - 28 + local centered_w = viewport_w * 0.78 + if line_count(detail_body(example)) <= 7 then + return centered_w + end + return content_w +end + +---@param example ShowcaseExample +---@param m table +---@return number +local function detail_text_x(example, m) + local viewport_w = detail_viewport_width(m) + local width = detail_text_width(example, m) + if state.detail_tab == "code" and width > viewport_w then + return 14 + end + local x = (viewport_w - width) / 2 + if x < 0 then + return 0 + end + return x +end + +---@param example ShowcaseExample +---@param m table +---@return number +detail_text_y = function(example, m) + return 0 +end + +---@return string +local function responsive_text() + local scale = state.viewport_scale or 1 + local width = state.screen_width or theme.screen_width + local height = state.screen_height or theme.screen_height + local viewport_w = state.viewport_width or theme.screen_width + local viewport_h = state.viewport_height or theme.screen_height + return i18n.t("responsive_design") .. ": " .. tostring(theme.screen_width) .. "x" .. tostring(theme.screen_height) + .. " | " .. i18n.t("responsive_screen") .. ": " .. tostring(math.floor(width)) .. "x" .. tostring(math.floor(height)) + .. " | " .. i18n.t("responsive_viewport") .. ": " .. tostring(math.floor(viewport_w)) .. "x" .. tostring(math.floor(viewport_h)) + .. " @" .. string.format("%.2f", scale) +end + +---@return number +local function responsive_device_width() + if state.responsive_mode == "phone" then + return 92 + end + if state.responsive_mode == "tablet" then + return 124 + end + return 148 +end + +---@param nodes RuntimeNode[] +---@param selected ShowcaseExample +local function append_preview_nodes(nodes, selected) + local mode = preview_mode(selected) + local base_visible = mode == "base" + local text_visible = mode == "text" + local button_visible = mode == "buttons" + local button_image_visible = mode == "button_images" + local sprite_visible = mode == "sprites" + local layout_visible = mode == "layout" + local radio_visible = mode == "radio" + local list_visible = mode == "list" + local particle_visible = mode == "particle" + local responsive_visible = mode == "responsive" + + table.insert(nodes, runtime_ui.rect("sample_rect", 16, 48, 70, 44, { + parent = "preview_panel", + color = theme.primary, + radius = 8, + layer = 25, + visible = base_visible + })) + table.insert(nodes, runtime_ui.circle("sample_circle", 104, 42, 54, { + parent = "preview_panel", + color = theme.success, + interactive = true, + onTap = "demo_anim", + layer = 25, + visible = base_visible + })) + table.insert(nodes, runtime_ui.line("sample_line", 176, 70, 82, 0, { + parent = "preview_panel", + color = theme.warning, + strokeWidth = 4, + layer = 25, + visible = base_visible + })) + table.insert(nodes, runtime_ui.image("sample_image_node", "sample_image", 278, 42, 48, 48, { + parent = "preview_panel", + layer = 25, + visible = base_visible + })) + table.insert(nodes, runtime_ui.sprite("sample_sprite_node", "sample_image", 340, 42, 48, 48, { + parent = "preview_panel", + layer = 25, + visible = base_visible + })) + table.insert(nodes, runtime_ui.progress("sample_progress", 16, 112, 180, 16, state.progress, { + parent = "preview_panel", + color = theme.success, + radius = 8, + layer = 25, + visible = base_visible + })) + local progress_nodes = widgets.labeled_progress("widget_progress", "Widget Progress", 220, 98, 170, 12, state.progress, { + parent = "preview_panel", + labelStyle = { parent = "preview_panel", color = theme.muted, fontSize = 12, layer = 25, visible = base_visible }, + color = theme.primary, + layer = 25, + visible = base_visible + }) + runtime_ui.append_all(nodes, progress_nodes) + + table.insert(nodes, runtime_ui.text("text_plain_title", state.text_variant == "plain" and "Text: 纯文本组件" or "Text: 样式已切换", 16, 46, 260, 24, { + parent = "preview_panel", + color = state.text_variant == "plain" and theme.text or theme.warning, + fontSize = state.text_variant == "plain" and 18 or 20, + layer = 25, + visible = text_visible + })) + table.insert(nodes, runtime_ui.text("text_plain_body", "字段:text / color / fontSize / alpha", 16, 78, 310, 20, { + parent = "preview_panel", + color = theme.muted, + fontSize = 12, + layer = 25, + visible = text_visible + })) + table.insert(nodes, runtime_ui.rect("text_style_badge", 16, 108, 92, 24, { + parent = "preview_panel", + color = state.text_variant == "plain" and theme.primary or theme.purple, + radius = 8, + layer = 25, + visible = text_visible + })) + table.insert(nodes, runtime_ui.text("text_rich_note", "富文本:当前未支持 richText/spans", 124, 111, 240, 18, { + parent = "preview_panel", + color = theme.warning, + fontSize = 12, + layer = 26, + visible = text_visible + })) + + table.insert(nodes, runtime_ui.text("button_hint", "button: text + background + onTap", 16, 44, 240, 20, { + parent = "preview_panel", + color = theme.muted, + fontSize = 12, + layer = 25, + visible = button_visible + })) + table.insert(nodes, runtime_ui.button("button_primary", "主按钮", 16, 76, 108, 34, "demo_button_primary", { + parent = "preview_panel", + color = state.button_active and theme.primary or "#ff475569", + radius = 10, + fontSize = 13, + layer = 26, + visible = button_visible + })) + table.insert(nodes, runtime_ui.button("button_secondary", "次按钮", 138, 76, 108, 34, "demo_button_primary", { + parent = "preview_panel", + color = "#ff334155", + radius = 10, + fontSize = 13, + layer = 26, + visible = button_visible + })) + table.insert(nodes, runtime_ui.button("button_disabled", "禁用态", 260, 76, 108, 34, "noop", { + parent = "preview_panel", + color = "#ff1f2937", + radius = 10, + fontSize = 13, + alpha = 0.55, + interactive = false, + layer = 26, + visible = button_visible + })) + table.insert(nodes, runtime_ui.text("button_state_text", state.button_active and "状态:可点击" or "状态:已置灰", 16, 118, 220, 18, { + parent = "preview_panel", + color = theme.warning, + fontSize = 12, + layer = 26, + visible = button_visible + })) + + table.insert(nodes, runtime_ui.text("image_button_hint", "button image: normal / pressed / disabled", 16, 44, 300, 20, { + parent = "preview_panel", + color = theme.muted, + fontSize = 12, + layer = 25, + visible = button_image_visible + })) + table.insert(nodes, runtime_ui.button("image_button_normal", { + text = "按住 Pressed", + x = 16, + y = 76, + w = 116, + h = 40, + handler = "demo_button_image_tap", + parent = "preview_panel", + asset = "button_normal", + pressedAsset = "button_pressed", + disabledAsset = "button_disabled", + radius = 10, + fontSize = 12, + layer = 26, + visible = button_image_visible + })) + table.insert(nodes, runtime_ui.button("image_button_toggle", { + text = state.button_image_enabled and "可切换" or "已禁用", + x = 144, + y = 76, + w = 116, + h = 40, + handler = "demo_button_image_tap", + parent = "preview_panel", + asset = "button_normal", + pressedAsset = "button_pressed", + disabledAsset = "button_disabled", + radius = 10, + fontSize = 12, + interactive = state.button_image_enabled, + layer = 26, + visible = button_image_visible + })) + table.insert(nodes, runtime_ui.button("image_button_disabled", { + text = "Disabled", + x = 272, + y = 76, + w = 116, + h = 40, + handler = "noop", + parent = "preview_panel", + asset = "button_normal", + pressedAsset = "button_pressed", + disabledAsset = "button_disabled", + radius = 10, + fontSize = 12, + interactive = false, + layer = 26, + visible = button_image_visible + })) + table.insert(nodes, runtime_ui.text("image_button_state_text", state.button_image_enabled and "中间按钮:interactive=true" or "中间按钮:interactive=false,显示 disabledAsset", 16, 122, 360, 18, { + parent = "preview_panel", + color = theme.warning, + fontSize = 12, + layer = 26, + visible = button_image_visible + })) + + table.insert(nodes, runtime_ui.image("sprite_image_demo", "sample_image", 24, 54, 64, 64, { + parent = "preview_panel", + layer = 25, + visible = sprite_visible + })) + table.insert(nodes, runtime_ui.sprite("sprite_sprite_demo", "sample_image", 118, 54, 64, 64, { + parent = "preview_panel", + layer = 26, + visible = sprite_visible + })) + table.insert(nodes, runtime_ui.rect("sprite_frame_demo", 212, 54, 64, 64, { + parent = "preview_panel", + color = state.sprite_variant == "image" and theme.primary or theme.purple, + radius = 8, + layer = 25, + visible = sprite_visible + })) + table.insert(nodes, runtime_ui.text("sprite_label_demo", "image / sprite 都通过 manifest key 加载", 20, 120, 300, 18, { + parent = "preview_panel", + color = theme.muted, + fontSize = 12, + layer = 26, + visible = sprite_visible + })) + + table.insert(nodes, runtime_ui.panel("layout_canvas", 16, 42, 360, 86, { + parent = "preview_panel", + color = "#ff111827", + radius = 10, + layer = 24, + visible = layout_visible + })) + table.insert(nodes, runtime_ui.rect("layout_chip_1", 34, 70, 58, 28, { + parent = "preview_panel", + color = theme.primary, + radius = 8, + layer = 26, + visible = layout_visible + })) + table.insert(nodes, runtime_ui.rect("layout_chip_2", 108, 70, 58, 28, { + parent = "preview_panel", + color = theme.success, + radius = 8, + layer = 26, + visible = layout_visible + })) + table.insert(nodes, runtime_ui.rect("layout_chip_3", 182, 70, 58, 28, { + parent = "preview_panel", + color = theme.warning, + radius = 8, + layer = 26, + visible = layout_visible + })) + table.insert(nodes, runtime_ui.rect("layout_chip_4", 256, 70, 58, 28, { + parent = "preview_panel", + color = theme.purple, + radius = 8, + layer = 26, + visible = layout_visible + })) + table.insert(nodes, runtime_ui.text("layout_label", "layout.row:gap + align + margin", 28, 112, 280, 18, { + parent = "preview_panel", + color = theme.muted, + fontSize = 12, + layer = 26, + visible = layout_visible + })) + + table.insert(nodes, runtime_ui.text("radio_title", "RadioGroup: Lua 组合单选项", 16, 44, 260, 20, { + parent = "preview_panel", + color = theme.muted, + fontSize = 12, + layer = 25, + visible = radio_visible + })) + local radio_options = { + { key = "audio", label = "Audio", y = 72 }, + { key = "spine", label = "Spine", y = 96 }, + { key = "Lua", label = "Lua", y = 120 } + } + for _, option in ipairs(radio_options) do + local selected_radio = state.radio_selected == option.key + table.insert(nodes, runtime_ui.circle("radio_" .. string.lower(option.key) .. "_dot", 18, option.y, 14, { + parent = "preview_panel", + color = selected_radio and theme.primary or "#ff475569", + layer = 25, + visible = radio_visible + })) + table.insert(nodes, runtime_ui.text("radio_" .. string.lower(option.key) .. "_label", option.label, 42, option.y - 2, 120, 18, { + parent = "preview_panel", + color = selected_radio and theme.text or theme.muted, + fontSize = 12, + layer = 25, + visible = radio_visible + })) + end + table.insert(nodes, runtime_ui.text("radio_value_text", "当前选择:" .. state.radio_selected, 190, 96, 160, 18, { + parent = "preview_panel", + color = theme.warning, + fontSize = 12, + layer = 25, + visible = radio_visible + })) + + local list_horizontal = state.list_axis == "horizontal" + table.insert(nodes, runtime_ui.list_view("list_panel", 16, 42, 320, 72, { + parent = "preview_panel", + color = "#ff111827", + radius = 10, + contentWidth = list_horizontal and 650 or 430, + contentHeight = list_horizontal and 90 or 150, + scrollX = state.list_scroll_x, + scrollY = state.list_scroll_y, + virtualized = true, + cacheExtent = 24, + inertia = true, + onScroll = "demo_list_scrolled", + scrollbarThumbColor = theme.warning, + scrollbarTrackColor = "#33475569", + scrollbarThickness = 6, + layer = 24, + visible = list_visible + })) + local list_items = { + { key = "Lua", label = "Lua 脚本层" }, + { key = "Runtime", label = "Runtime 协议层" }, + { key = "Flame", label = "Flame 渲染层" }, + { key = "Diff", label = "Diff 更新流" }, + { key = "Command", label = "Command 动作流" } + } + for index, item in ipairs(list_items) do + local selected_item = state.list_selected == item.key + local row_x = list_horizontal and (8 + (index - 1) * 126) or 8 + local row_y = list_horizontal and 18 or (8 + (index - 1) * 28) + local row_w = list_horizontal and 116 or 292 + table.insert(nodes, runtime_ui.button("list_row_" .. index, "", row_x, row_y, row_w, 24, "demo_list_pick_" .. index, { + parent = "list_panel", + color = selected_item and theme.primary or "#ff1e293b", + radius = 7, + layer = 25, + visible = list_visible + })) + table.insert(nodes, runtime_ui.text("list_row_" .. index .. "_text", item.label, 12, 4, 180, 16, { + parent = "list_row_" .. index, + color = theme.text, + fontSize = 11, + textAlign = "left", + layer = 26, + visible = list_visible + })) + end + table.insert(nodes, runtime_ui.text("list_value_text", state.list_axis .. " scrollX=" .. tostring(state.list_scroll_x) .. " scrollY=" .. tostring(state.list_scroll_y), 16, 120, 300, 16, { + parent = "preview_panel", + color = theme.warning, + fontSize = 11, + layer = 26, + visible = list_visible + })) + + table.insert(nodes, runtime_ui.particle("particle_burst", 32, 42, 120, 92, { + parent = "preview_panel", + preset = "burst", + count = 42, + duration = 0.85, + color = "#ffffcc33", + colorTo = "#00ffcc33", + radius = 2.8, + speedMin = 60, + speedMax = 180, + gravityY = 90, + spread = 360, + autoRemove = false, + layer = 26, + visible = particle_visible + })) + table.insert(nodes, runtime_ui.particle("particle_trail", 168, 46, 120, 86, { + parent = "preview_panel", + preset = "trail", + count = 24, + duration = 0.7, + color = "#ff38bdf8", + radius = 2.2, + speedMin = 20, + speedMax = 80, + autoRemove = false, + layer = 26, + visible = particle_visible + })) + table.insert(nodes, runtime_ui.particle("particle_snow", 304, 34, 120, 104, { + parent = "preview_panel", + preset = "snow", + count = 56, + duration = 8, + color = "#ccffffff", + radius = 1.5, + autoRemove = false, + fadeOut = false, + layer = 26, + visible = particle_visible + })) + table.insert(nodes, runtime_ui.text("particle_label", "particle: burst / trail / snow", 16, 120, 280, 18, { + parent = "preview_panel", + color = theme.warning, + fontSize = 11, + layer = 27, + visible = particle_visible + })) + + local width = responsive_device_width() + table.insert(nodes, runtime_ui.text("responsive_info", responsive_text(), 16, 112, 390, 18, { + parent = "preview_panel", + color = theme.muted, + fontSize = 10, + layer = 26, + visible = responsive_visible + })) + table.insert(nodes, runtime_ui.rect("responsive_device", 172, 44, width, 62, { + parent = "preview_panel", + color = "#ff1d4ed8", + radius = 10, + layer = 26, + visible = responsive_visible + })) + table.insert(nodes, runtime_ui.rect("responsive_sidebar", 184, 56, width * 0.26, 38, { + parent = "preview_panel", + color = theme.primary, + radius = 6, + layer = 27, + visible = responsive_visible + })) + table.insert(nodes, runtime_ui.rect("responsive_content", 192 + width * 0.26, 56, width * 0.56, 38, { + parent = "preview_panel", + color = theme.success, + radius = 6, + layer = 27, + visible = responsive_visible + })) +end + +---@return RuntimeNode[] +function ui.create_nodes() + local nodes = {} + local selected = examples.find(state.selected_example) + local m = metrics() + + table.insert(nodes, runtime_ui.rect("app_bg", 0, 0, theme.screen_width, theme.screen_height, { + color = theme.background, + layer = 0 + })) + table.insert(nodes, runtime_ui.text("app_title", i18n.t("app_title"), 24, 18, 360, 34, styles.title)) + table.insert(nodes, runtime_ui.text("app_subtitle", i18n.t("app_subtitle"), 24, 52, 660, 24, styles.label)) + + table.insert(nodes, runtime_ui.panel("example_list_panel", m.menu_x, m.menu_y, m.menu_w, m.content_h, styles.card)) + table.insert(nodes, widgets.section_title("example_list_title", { + text = i18n.t("examples_title"), + x = 16, + y = 14, + w = m.menu_w - 32, + h = 24, + parent = "example_list_panel", + color = theme.text, + fontSize = 20, + layer = 25 + })) + local menu_y = 44 + local last_group = nil + for _, example in ipairs(examples.all()) do + local group = i18n.example_group(example) + if group ~= last_group then + append_menu_group(nodes, group, menu_y, m) + menu_y = menu_y + 15 + last_group = group + end + append_menu_item(nodes, example, menu_y, m) + menu_y = menu_y + 32 + end + + table.insert(nodes, runtime_ui.panel("detail_panel", m.detail_x, m.detail_y, m.detail_w, m.content_h, styles.card)) + runtime_ui.append_all(nodes, widgets.panel_header("detail", { + eyebrow = i18n.example_group(selected) .. " / " .. i18n.example_field(selected, "category"), + title = i18n.example_field(selected, "title"), + summary = i18n.example_field(selected, "summary"), + x = 20, + y = 16, + w = m.detail_w - 40, + parent = "detail_panel", + layer = 25, + gap = 4, + eyebrowHeight = 20, + titleHeight = 30, + summaryHeight = 24, + eyebrowId = "detail_category", + titleId = "detail_title", + summaryId = "detail_summary", + eyebrowStyle = { color = theme.primary, fontSize = 13 }, + titleStyle = { color = theme.text, fontSize = 21 }, + summaryStyle = { color = theme.muted, fontSize = 12 } + })) + + runtime_ui.append_all(nodes, widgets.tabs("detail_tab", { + { id = "detail_tab_code", key = "code", text = i18n.t("tab_code"), handler = "detail_tab_code" }, + { id = "detail_tab_params", key = "params", text = i18n.t("tab_params"), handler = "detail_tab_params" } + }, { + x = m.detail_x + 20, + y = m.detail_y + 104, + itemWidth = 72, + itemHeight = 24, + gap = 6, + selected = state.detail_tab, + activeColor = theme.primary, + inactiveColor = "#ff334155", + radius = 8, + fontSize = 11, + interactive = true, + layer = 90 + })) + table.insert(nodes, widgets.text_button("detail_copy", { + text = detail_copy_text(), + x = m.detail_x + m.detail_w - 116, + y = m.detail_y + 104, + w = 96, + h = 24, + handler = "copy_detail", + color = "#ff475569", + radius = 8, + fontSize = 11, + interactive = true, + layer = 90 + })) + + table.insert(nodes, runtime_ui.list_view("code_panel", m.detail_x + 20, m.detail_y + 132, m.detail_w - 40, m.code_h + 18, { + color = "#ff020617", + radius = 12, + contentWidth = detail_content_width(m), + contentHeight = detail_content_height(selected, m), + scrollX = 0, + scrollY = 0, + paddingLeft = detail_padding_left, + paddingTop = detail_padding_top, + paddingRight = detail_padding_right, + paddingBottom = detail_padding_bottom, + virtualized = false, + inertia = true, + scrollbarThumbColor = theme.primary, + scrollbarTrackColor = "#33475569", + scrollbarThickness = 5, + layer = 20 + })) + table.insert(nodes, runtime_ui.text("detail_code", detail_body(selected), detail_text_x(selected, m), detail_text_y(selected, m), detail_text_width(selected, m), detail_text_height(selected), { + parent = "code_panel", + color = "#ffe2e8f0", + fontSize = 11, + textAlign = "left", + layer = 25 + })) + + runtime_ui.append_all(nodes, detail_action_nodes(selected, m)) + + table.insert(nodes, runtime_ui.panel("preview_panel", m.detail_x + 20, m.preview_y, m.detail_w - 40, m.preview_h, { + color = "#ff0b1120", + radius = 12, + layer = 20 + })) + table.insert(nodes, widgets.section_title("preview_title", { + text = i18n.t("preview_title"), + x = 14, + y = 10, + w = 220, + h = 22, + parent = "preview_panel", + color = theme.text, + fontSize = 15, + layer = 25 + })) + append_preview_nodes(nodes, selected) + + table.insert(nodes, runtime_ui.text("status_text", state.status, 24, m.status_y, theme.screen_width - 48, 26, { + color = theme.warning, + fontSize = 14, + layer = 50 + })) + + return nodes +end + +---@param text string +---@return RuntimeNodeUpdate[] +function ui.status_updates(text) + state.status = text + return { runtime_ui.text_update("status_text", text) } +end + +---@param selected ShowcaseExample +---@return RuntimeNodeUpdate[] +local function preview_visibility_updates(selected) + local updates = {} + local mode = preview_mode(selected) + local groups = { + { ids = base_preview_ids, visible = mode == "base" }, + { ids = text_preview_ids, visible = mode == "text" }, + { ids = button_preview_ids, visible = mode == "buttons" }, + { ids = button_image_preview_ids, visible = mode == "button_images" }, + { ids = sprite_preview_ids, visible = mode == "sprites" }, + { ids = layout_preview_ids, visible = mode == "layout" }, + { ids = radio_preview_ids, visible = mode == "radio" }, + { ids = list_preview_ids, visible = mode == "list" }, + { ids = particle_preview_ids, visible = mode == "particle" }, + { ids = responsive_preview_ids, visible = mode == "responsive" } + } + for _, group in ipairs(groups) do + for _, id in ipairs(group.ids) do + table.insert(updates, runtime_ui.visible_update(id, group.visible)) + end + end + return updates +end + +---@param selected ShowcaseExample +---@return RuntimeNodeUpdate[] +function ui.example_updates(selected) + local m = metrics() + local updates = { + runtime_ui.text_update("detail_category", i18n.example_group(selected) .. " / " .. i18n.example_field(selected, "category")), + runtime_ui.text_update("detail_title", i18n.example_field(selected, "title")), + runtime_ui.text_update("detail_summary", i18n.example_field(selected, "summary")), + runtime_ui.text_update("detail_code", detail_body(selected)), + runtime_ui.update("detail_code", { + x = detail_text_x(selected, m), + y = detail_text_y(selected, m), + width = detail_text_width(selected, m), + height = detail_text_height(selected), + textAlign = "left" + }), + runtime_ui.update("code_panel", { + contentWidth = detail_content_width(m), + contentHeight = detail_content_height(selected, m), + scrollX = 0, + scrollY = 0 + }), + runtime_ui.update("detail_tab_code", { + text = i18n.t("tab_code"), + onTap = "detail_tab_code", + interactive = true, + layer = 90, + color = state.detail_tab == "code" and theme.primary or "#ff334155" + }), + runtime_ui.update("detail_tab_params", { + text = i18n.t("tab_params"), + onTap = "detail_tab_params", + interactive = true, + layer = 90, + color = state.detail_tab == "params" and theme.primary or "#ff334155" + }), + runtime_ui.update("detail_copy", { + text = detail_copy_text(), + onTap = "copy_detail", + interactive = true, + layer = 90 + }), + runtime_ui.text_update("status_text", i18n.t("selected_prefix") .. i18n.example_field(selected, "title")) + } + + for _, example in ipairs(examples.all()) do + table.insert(updates, runtime_ui.update("example_" .. example.id, { + text = i18n.example_label(example), + color = example.id == selected.id and theme.primary or "#ff1e293b" + })) + end + + for index = 1, 3 do + local action = selected.actions[index] + if action ~= nil then + table.insert(updates, runtime_ui.update("detail_action_" .. index, { + text = i18n.action_text(action), + onTap = action.handler, + visible = true, + color = index == 1 and "#ff2563eb" or "#ff334155" + })) + else + table.insert(updates, runtime_ui.update("detail_action_" .. index, { + text = "", + onTap = "noop", + visible = false + })) + end + end + + local preview_updates = preview_visibility_updates(selected) + for _, update in ipairs(preview_updates) do + table.insert(updates, update) + end + table.insert(updates, runtime_ui.update("status_text", { width = m.screen_w - 48 })) + return updates +end + +---@return RuntimeNodeUpdate[] +function ui.locale_updates() + local selected = examples.find(state.selected_example) + local updates = { + runtime_ui.text_update("app_title", i18n.t("app_title")), + runtime_ui.text_update("app_subtitle", i18n.t("app_subtitle")), + runtime_ui.text_update("example_list_title", i18n.t("examples_title")), + runtime_ui.text_update("preview_title", i18n.t("preview_title")), + runtime_ui.text_update("responsive_info", responsive_text()) + } + local detail_updates = ui.example_updates(selected) + for _, update in ipairs(detail_updates) do + table.insert(updates, update) + end + return updates +end + +---@return RuntimeNodeUpdate[] +function ui.responsive_updates() + local width = responsive_device_width() + return { + runtime_ui.text_update("responsive_info", responsive_text()), + runtime_ui.update("responsive_device", { width = width }), + runtime_ui.update("responsive_sidebar", { width = width * 0.26 }), + runtime_ui.update("responsive_content", { + x = 192 + width * 0.26, + width = width * 0.56 + }) + } +end + +---@return RuntimeNodeUpdate[] +function ui.text_updates() + return { + runtime_ui.update("text_plain_title", { + text = state.text_variant == "plain" and "Text: 纯文本组件" or "Text: 样式已切换", + color = state.text_variant == "plain" and theme.text or theme.warning, + fontSize = state.text_variant == "plain" and 18 or 20 + }), + runtime_ui.update("text_style_badge", { + color = state.text_variant == "plain" and theme.primary or theme.purple + }) + } +end + +---@return RuntimeNodeUpdate[] +function ui.radio_updates() + local updates = { + runtime_ui.text_update("radio_value_text", "当前选择:" .. state.radio_selected) + } + local options = { "audio", "spine", "lua" } + for _, key in ipairs(options) do + local value = key == "lua" and "Lua" or key + local selected = state.radio_selected == value + table.insert(updates, runtime_ui.update("radio_" .. key .. "_dot", { + color = selected and theme.primary or "#ff475569" + })) + table.insert(updates, runtime_ui.update("radio_" .. key .. "_label", { + color = selected and theme.text or theme.muted + })) + end + return updates +end + +---@return RuntimeNodeUpdate[] +function ui.list_updates() + local horizontal = state.list_axis == "horizontal" + local updates = { + runtime_ui.update("list_panel", { + contentWidth = horizontal and 650 or 430, + contentHeight = horizontal and 90 or 150, + scrollX = state.list_scroll_x, + scrollY = state.list_scroll_y + }), + runtime_ui.text_update("list_value_text", state.list_axis .. " scrollX=" .. tostring(state.list_scroll_x) .. " scrollY=" .. tostring(state.list_scroll_y)) + } + local items = { "Lua", "Runtime", "Flame", "Diff", "Command" } + for index, key in ipairs(items) do + local row_x = horizontal and (8 + (index - 1) * 126) or 8 + local row_y = horizontal and 18 or (8 + (index - 1) * 28) + local row_w = horizontal and 116 or 292 + table.insert(updates, runtime_ui.update("list_row_" .. index, { + x = row_x, + y = row_y, + width = row_w, + color = state.list_selected == key and theme.primary or "#ff1e293b" + })) + table.insert(updates, runtime_ui.update("list_row_" .. index .. "_text", { + textAlign = "left" + })) + end + return updates +end + +---@return RuntimeNodeUpdate[] +function ui.button_updates() + return { + runtime_ui.update("button_primary", { + color = state.button_active and theme.primary or "#ff475569", + alpha = state.button_active and 1 or 0.65 + }), + runtime_ui.text_update("button_state_text", state.button_active and "状态:可点击" or "状态:已置灰") + } +end + +---@return RuntimeNodeUpdate[] +function ui.button_image_updates() + return { + runtime_ui.update("image_button_toggle", { + text = state.button_image_enabled and "可切换" or "已禁用", + interactive = state.button_image_enabled + }), + runtime_ui.text_update("image_button_state_text", state.button_image_enabled and "中间按钮:interactive=true" or "中间按钮:interactive=false,显示 disabledAsset") + } +end + +---@return RuntimeNodeUpdate[] +function ui.sprite_updates() + return { + runtime_ui.update("sprite_frame_demo", { + color = state.sprite_variant == "image" and theme.primary or theme.purple, + rotation = state.sprite_variant == "image" and 0 or 0.12 + }), + runtime_ui.text_update("sprite_label_demo", state.sprite_variant == "image" and "当前:image 原图节点" or "当前:sprite 可动画节点") + } +end + +---@param mode string +---@return RuntimeNodeUpdate[] +function ui.layout_updates(mode) + if mode == "column" then + return { + runtime_ui.update("layout_chip_1", { x = 44, y = 46 }), + runtime_ui.update("layout_chip_2", { x = 44, y = 74 }), + runtime_ui.update("layout_chip_3", { x = 44, y = 102 }), + runtime_ui.update("layout_chip_4", { x = 44, y = 130 }), + runtime_ui.text_update("layout_label", "layout.column:纵向排列 + gap") + } + end + if mode == "box" then + return { + runtime_ui.update("layout_chip_1", { x = 44, y = 54 }), + runtime_ui.update("layout_chip_2", { x = 116, y = 54 }), + runtime_ui.update("layout_chip_3", { x = 44, y = 90 }), + runtime_ui.update("layout_chip_4", { x = 116, y = 90 }), + runtime_ui.text_update("layout_label", "layout.box:2 行 × 2 列网格") + } + end + return { + runtime_ui.update("layout_chip_1", { x = 34, y = 70 }), + runtime_ui.update("layout_chip_2", { x = 108, y = 70 }), + runtime_ui.update("layout_chip_3", { x = 182, y = 70 }), + runtime_ui.update("layout_chip_4", { x = 256, y = 70 }), + runtime_ui.text_update("layout_label", "layout.row:横向排列 + gap") + } +end + +---@return RuntimeNodeUpdate[] +function ui.progress_updates() + return { + runtime_ui.update("sample_progress", { value = state.progress }), + runtime_ui.update("widget_progress", { value = state.progress }) + } +end + +---@return RuntimeNodeUpdate[] +function ui.visibility_updates() + return { + runtime_ui.visible_update("sample_rect", state.visible), + runtime_ui.alpha_update("sample_circle", state.visible and 1 or 0.35) + } +end + +---@return RuntimeNode[] +function ui.dialog_nodes() + return widgets.dialog("sample_dialog", "Widget Dialog", "这是 Lua 侧组合组件:overlay + card + text + button。输出仍然只是普通 RuntimeNode。", 150, 190, 420, 230, { + screenWidth = theme.screen_width, + screenHeight = theme.screen_height, + buttons = { + { text = "播放音效", handler = "demo_sound" }, + { text = "关闭", handler = "close_dialog", color = theme.danger } + } + }) +end + +---@return { id: string }[] +function ui.dialog_removes() + return { + { id = "sample_dialog_overlay" }, + { id = "sample_dialog" }, + { id = "sample_dialog_title" }, + { id = "sample_dialog_message" }, + { id = "sample_dialog_button_1" }, + { id = "sample_dialog_button_2" } + } +end + +---@return RuntimeNode[] +function ui.temp_nodes() + local m = metrics() + return { + runtime_ui.rect("temp_node", m.detail_x + 270, m.preview_y + 98, 90, 28, { + color = theme.warning, + radius = 8, + layer = 45 + }), + runtime_ui.text("temp_node_text", "临时节点", m.detail_x + 284, m.preview_y + 103, 72, 18, { + color = "#ff111827", + fontSize = 13, + layer = 46 + }) + } +end + +return ui diff --git a/example/assets/games/template/manifest.json b/example/assets/games/template/manifest.json new file mode 100644 index 0000000..bfbde94 --- /dev/null +++ b/example/assets/games/template/manifest.json @@ -0,0 +1,27 @@ +{ + "gameId": "template", + "name": "Lua Runtime Template", + "version": "0.1.0", + "runtimeApiVersion": 1, + "entry": "scripts/main.lua", + "assetsBase": "assets", + "defaultLocale": "zh-Hans", + "supportedLocales": [ + "zh-Hans", + "en" + ], + "display": { + "designWidth": 720, + "designHeight": 720, + "scaleMode": "fit" + }, + "modules": { + "runtime_ui": "runtime:runtime_ui.lua", + "runtime_commands": "runtime:runtime_commands.lua", + "runtime_widgets": "runtime:runtime_widgets.lua", + "layout": "runtime:layout.lua", + "state": "scripts/state.lua", + "ui": "scripts/ui.lua" + }, + "resources": {} +} diff --git a/example/assets/games/template/scripts/main.lua b/example/assets/games/template/scripts/main.lua new file mode 100644 index 0000000..d94e00a --- /dev/null +++ b/example/assets/games/template/scripts/main.lua @@ -0,0 +1,35 @@ +local state = runtime.import("state") +local ui = runtime.import("ui") +---@type RuntimeCommands +local commands = runtime.import("runtime_commands") + +function smoke_test(ctx) + return ctx ~= nil + and ctx.runtimeApiVersion ~= nil + and ui.create_nodes ~= nil + and commands.toast ~= nil +end + +function init(ctx) + state.status = "Template ready: " .. tostring(ctx.gameId or "template") + return { + render = { creates = ui.create_nodes() }, + ui = {}, + commands = {} + } +end + +function on_event(event) + if event.handler == "template_start" then + state.started = true + state.clicks = state.clicks + 1 + state.status = "收到 tap 事件,Lua 已返回 Diff 和 toast command。" + return { + ui = { updates = ui.state_updates() }, + commands = { + commands.toast("Template event handled in Lua", { duration = 1.2 }) + } + } + end + return {} +end diff --git a/example/assets/games/template/scripts/runtime_defs.lua b/example/assets/games/template/scripts/runtime_defs.lua new file mode 100644 index 0000000..c5038c9 --- /dev/null +++ b/example/assets/games/template/scripts/runtime_defs.lua @@ -0,0 +1,557 @@ +---@meta +--- COMMON RUNTIME TYPES SECTION. +--- Source of truth: tool/lua_runtime_defs_common.lua +--- After editing this common section, run: +--- dart run tool/generate_lua_runtime_defs.dart + + +---@alias RuntimeNodeType +---| 'panel' +---| 'button' +---| 'text' +---| 'circle' +---| 'rect' +---| 'line' +---| 'progress' +---| 'listView' +---| 'sprite' +---| 'image' +---| 'spine' +---| 'particle' + +---@alias RuntimeAnchor +---| 'center' +---| 'topLeft' +---| 'topRight' +---| 'bottomLeft' +---| 'bottomRight' + +---@alias RuntimeTextAlign +---| 'left' +---| 'center' +---| 'right' + +---@alias RuntimeParticlePreset +---| 'burst' +---| 'trail' +---| 'snow' +---| 'confetti' + +---@alias RuntimeCommandType +---| 'move_path' +---| 'move_to' +---| 'fade_to' +---| 'scale_to' +---| 'rotate_to' +---| 'remove_node' +---| 'sequence' +---| 'parallel' +---| 'delay' +---| 'toast' +---| 'play_sound' +---| 'play_bgm' +---| 'pause_bgm' +---| 'resume_bgm' +---| 'stop_bgm' +---| 'preload_resources' +---| 'evict_resources' +---| 'cancel_commands' +---| 'play_spine_animation' +---| 'copy_text' + +---@alias RuntimeEventType +---| 'tap' +---| 'animation_done' +---| 'resize' +---| 'scroll' + +---@alias RuntimeScaleMode +---| 'fit' +---| 'fill' +---| 'stretch' +---| 'none' + +---@alias RuntimeLayoutAlign +---| 'start' +---| 'center' +---| 'end' + +---@alias RuntimeButtonVariant +---| 'primary' +---| 'secondary' +---| 'ghost' + +---@class (exact) RuntimeNode +---@field id string +---@field type RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---@class (exact) RuntimeNodeProps +---@field type? RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---Helper-only fields accepted by runtime_ui/runtime_widgets. They are normalized +---before the node/update crosses the Dart Runtime protocol boundary. +---@class RuntimeNodeInit: RuntimeNodeProps +---@field w? number Alias for width. +---@field h? number Alias for height. +---@field size? number Alias for both width and height. +---@field handler? string Alias for onTap. +---@field onClick? string Alias for onTap. + +---@class (exact) RuntimeNodeUpdate +---@field id string +---@field props RuntimeNodeProps + +---@class (exact) RuntimeNodeRemove +---@field id string + +---@class (exact) RuntimeDiffSection +---@field creates? RuntimeNode[] +---@field updates? RuntimeNodeUpdate[] +---@field removes? (string|RuntimeNodeRemove)[] + +---@class (exact) RuntimeDiff +---@field render? RuntimeDiffSection +---@field ui? RuntimeDiffSection +---@field commands? RuntimeCommand[] + +---@class (exact) RuntimeEvent +---@field type RuntimeEventType|string +---@field target? string +---@field handler? string +---@field x? number +---@field y? number +---@field data? table + +---@class (exact) RuntimeCommand +---@field type RuntimeCommandType +---@field target? string +---@field scope? string +---@field id? string +---@field group? string +---@field commandGroup? string +---@field onComplete? string +---@field duration? number +---@field commands? RuntimeCommand[] +---@field path? RuntimePoint[] +---@field x? number +---@field y? number +---@field alpha? number +---@field scale? number +---@field angle? number +---@field text? string +---@field message? string +---@field asset? string +---@field name? string +---@field volume? number +---@field channel? string +---@field loop? boolean +---@field failOnError? boolean +---@field animation? string +---@field track? integer +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeCommandOpts +---@field id? string +---@field group? string +---@field commandGroup? string +---@field scope? string +---@field onComplete? string +---@field duration? number + +---@class (exact) RuntimeAudioCommandOpts: RuntimeCommandOpts +---@field volume? number +---@field name? string + +---@class (exact) RuntimeBgmCommandOpts: RuntimeAudioCommandOpts +---@field channel? string +---@field loop? boolean + +---@class (exact) RuntimeSpineCommandOpts: RuntimeCommandOpts +---@field track? integer +---@field loop? boolean +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeResourceCommandOpts: RuntimeCommandOpts +---@field failOnError? boolean + +---@class (exact) RuntimePoint +---@field x number +---@field y number + +---@class (exact) RuntimeLocaleContext +---@field requested string +---@field resolved string +---@field default string +---@field supported string[] +---@field languageCode string +---@field scriptCode? string +---@field countryCode? string + +---@class (exact) RuntimeScreenContext +---@field width number +---@field height number + +---@class (exact) RuntimeDesignContext +---@field width number +---@field height number + +---@class (exact) RuntimeViewportContext +---@field x number +---@field y number +---@field width number +---@field height number +---@field scaleX number +---@field scaleY number +---@field scaleMode RuntimeScaleMode|string + +---@class (exact) RuntimeContext +---@field screen RuntimeScreenContext +---@field design RuntimeDesignContext +---@field viewport RuntimeViewportContext +---@field seed integer +---@field runtimeApiVersion integer +---@field gameId string +---@field gameVersion string +---@field locale? RuntimeLocaleContext + +---@class RuntimeUi +---@field style fun(base?: RuntimeNodeProps, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field with_parent fun(parent: string, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field node fun(node_type: RuntimeNodeType, id: string, opts?: RuntimeNodeInit): RuntimeNode +---@field panel fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field rect fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field circle fun(id: string, x: number|RuntimeNodeInit, y?: number, size?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field line fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field particle fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field text fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field button fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field list_view fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field image fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field sprite fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field spine fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, animation?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field update fun(id: string, props: RuntimeNodeInit): RuntimeNodeUpdate +---@field text_update fun(id: string, text: string): RuntimeNodeUpdate +---@field visible_update fun(id: string, visible: boolean): RuntimeNodeUpdate +---@field alpha_update fun(id: string, alpha: number): RuntimeNodeUpdate +---@field scale_update fun(id: string, scale: number): RuntimeNodeUpdate +---@field position_update fun(id: string, x: number, y: number): RuntimeNodeUpdate +---@field size_update fun(id: string, width: number, height: number): RuntimeNodeUpdate +---@field transform_update fun(id: string, x: number, y: number, scale: number, rotation: number): RuntimeNodeUpdate +---@field batch_update fun(ids: string[], props: RuntimeNodeInit): RuntimeNodeUpdate[] +---@field append fun(nodes: RuntimeNode[], node: RuntimeNode): RuntimeNode[] +---@field append_all fun(nodes: RuntimeNode[], extra_nodes: RuntimeNode[]): RuntimeNode[] + +---@class (exact) RuntimeDialogButton +---@field id? string +---@field text string +---@field handler string +---@field color? string + +---@class (exact) RuntimeDialogOpts +---@field screenWidth? number +---@field screenHeight? number +---@field overlay? boolean +---@field overlayColor? string +---@field blockInput? boolean +---@field layer? integer +---@field color? string +---@field radius? number +---@field panelStyle? RuntimeNodeProps +---@field titleColor? string +---@field titleSize? number +---@field titleStyle? RuntimeNodeProps +---@field messageColor? string +---@field messageSize? number +---@field messageStyle? RuntimeNodeProps +---@field buttons? RuntimeDialogButton[] +---@field buttonGap? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeLabeledProgressOpts: RuntimeNodeInit +---@field labelHeight? number +---@field labelStyle? RuntimeNodeProps + +---@class RuntimePillOpts: RuntimeNodeInit +---@field panelStyle? RuntimeNodeProps +---@field textStyle? RuntimeNodeProps + +---@class RuntimeTextButtonOpts: RuntimeNodeInit +---@field variant? RuntimeButtonVariant + +---@class RuntimeListItemOpts: RuntimeTextButtonOpts +---@field selected? boolean +---@field activeColor? string +---@field inactiveColor? string + +---@class RuntimeTabItem +---@field id? string +---@field key? string +---@field text string +---@field handler? string +---@field selected? boolean + +---@class RuntimeTabsOpts: RuntimeNodeInit +---@field tabs? RuntimeTabItem[] +---@field selected? string +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field activeColor? string +---@field inactiveColor? string +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeActionItem +---@field id? string +---@field text string +---@field handler? string +---@field visible? boolean +---@field color? string +---@field style? RuntimeNodeProps + +---@class RuntimeActionRowOpts: RuntimeNodeInit +---@field actions? RuntimeActionItem[] +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimePanelHeaderOpts: RuntimeNodeInit +---@field eyebrow? string +---@field title string +---@field summary? string +---@field gap? number +---@field eyebrowId? string +---@field titleId? string +---@field summaryId? string +---@field eyebrowHeight? number +---@field titleHeight? number +---@field summaryHeight? number +---@field eyebrowStyle? RuntimeNodeProps +---@field titleStyle? RuntimeNodeProps +---@field summaryStyle? RuntimeNodeProps + +---@class RuntimeWidgetTheme +---@field primary? string +---@field secondary? string +---@field success? string +---@field overlay? string +---@field surface? string +---@field surfaceAlt? string +---@field card? string +---@field text? string +---@field muted? string +---@field progress? string +---@field transparent? string + +---@class RuntimeWidgets +---@field configure fun(tokens?: RuntimeWidgetTheme): RuntimeWidgets +---@field label fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field section_title fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field pill fun(id: string, text: string|RuntimePillOpts, x?: number, y?: number, width?: number, height?: number, opts?: RuntimePillOpts): RuntimeNode[] +---@field text_button fun(id: string, text: string|RuntimeTextButtonOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeTextButtonOpts): RuntimeNode +---@field list_item fun(id: string, text: string|RuntimeListItemOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeListItemOpts): RuntimeNode +---@field tabs fun(id: string, tabs: RuntimeTabItem[]|RuntimeTabsOpts, opts?: RuntimeTabsOpts): RuntimeNode[] +---@field action_row fun(id: string, actions: RuntimeActionItem[]|RuntimeActionRowOpts, opts?: RuntimeActionRowOpts): RuntimeNode[] +---@field panel_header fun(id: string, opts: RuntimePanelHeaderOpts): RuntimeNode[] +---@field overlay fun(id: string, width: number|RuntimeNodeInit, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field card fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress_bar fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field labeled_progress fun(id: string, label: string, x: number, y: number, width: number, height: number, value: number, opts?: RuntimeLabeledProgressOpts): RuntimeNode[] +---@field button_row fun(parent: string, id: string, buttons: RuntimeDialogButton[], x: number, y: number, width: number, height: number, gap?: number, opts?: RuntimeNodeProps): RuntimeNode[] +---@field dialog fun(id: string, title: string, message: string, x: number, y: number, width: number, height: number, opts?: RuntimeDialogOpts): RuntimeNode[] + +---@class (exact) RuntimeLayoutItem +---@field node RuntimeNode +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLayoutItemOpts +---@field margin? number +---@field mx? number +---@field my? number +---@field ml? number +---@field mr? number +---@field mt? number +---@field mb? number +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLinearLayoutOpts +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field gap? number +---@field align? RuntimeLayoutAlign +---@field padding? number +---@field paddingX? number +---@field paddingY? number +---@field px? number +---@field py? number +---@field paddingLeft? number +---@field paddingTop? number + +---@class RuntimeBoxLayoutOpts: RuntimeLinearLayoutOpts +---@field rows? integer +---@field columns? integer +---@field cols? integer +---@field cellWidth? number +---@field cellHeight? number +---@field cellW? number +---@field cellH? number +---@field gapX? number +---@field gapY? number +---@field valign? RuntimeLayoutAlign + +---@class RuntimeLayout +---@field item fun(node: RuntimeNode, opts?: RuntimeLayoutItemOpts): RuntimeLayoutItem +---@field local_position fun(origin: RuntimePoint, position: RuntimePoint): RuntimePoint +---@field row fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field column fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field stack fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field box fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeBoxLayoutOpts): RuntimeNode[] + +---@class RuntimeCommands +---@field toast fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field copy_text fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field delay fun(duration: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field sequence fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field parallel fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_path fun(target: string, path: RuntimePoint[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_to fun(target: string, x: number, y: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field fade_to fun(target: string, alpha: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field scale_to fun(target: string, scale: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field rotate_to fun(target: string, angle: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field remove_node fun(target: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field play_spine_animation fun(target: string, animation: string, opts?: RuntimeSpineCommandOpts): RuntimeCommand +---@field play_sound fun(asset: string, opts?: RuntimeAudioCommandOpts): RuntimeCommand +---@field play_bgm fun(asset: string, opts?: RuntimeBgmCommandOpts): RuntimeCommand +---@field pause_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field resume_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field stop_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field preload_group fun(group: string, opts?: RuntimeResourceCommandOpts): RuntimeCommand +---@field evict_group fun(group: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field cancel_id fun(id: string): RuntimeCommand +---@field cancel_group fun(group: string): RuntimeCommand +---@field cancel_scope fun(scope: string): RuntimeCommand + +---@class RuntimeImportApi +---@field import fun(moduleName: string): table + +---@type RuntimeImportApi +runtime = runtime diff --git a/example/assets/games/template/scripts/state.lua b/example/assets/games/template/scripts/state.lua new file mode 100644 index 0000000..e1f8498 --- /dev/null +++ b/example/assets/games/template/scripts/state.lua @@ -0,0 +1,8 @@ +---@class TemplateState +local state = { + started = false, + clicks = 0, + status = "点击 Start 验证 RuntimeEvent -> Lua -> Diff / Command" +} + +return state diff --git a/example/assets/games/template/scripts/ui.lua b/example/assets/games/template/scripts/ui.lua new file mode 100644 index 0000000..ca0751a --- /dev/null +++ b/example/assets/games/template/scripts/ui.lua @@ -0,0 +1,100 @@ +---@type RuntimeUi +local runtime_ui = runtime.import("runtime_ui") +---@type RuntimeWidgets +local widgets = runtime.import("runtime_widgets") +local state = runtime.import("state") + +widgets.configure({ + primary = "#ff2563eb", + secondary = "#ff475569", + success = "#ff22c55e", + overlay = "#99000000", + surface = "#ff1e293b", + surfaceAlt = "#ff334155", + card = "#ee111827", + text = "#ffe2e8f0", + muted = "#ff94a3b8", + progress = "#ff22c55e", + transparent = "#00000000" +}) + +---@class TemplateUi +local ui = {} + +---@return RuntimeNode[] +function ui.create_nodes() + return { + runtime_ui.rect("template_bg", { + x = 0, + y = 0, + w = 720, + h = 720, + color = "#ff0f172a" + }), + runtime_ui.panel("template_card", { + x = 72, + y = 96, + w = 576, + h = 360, + color = "#ee111827", + radius = 18, + layer = 5 + }), + widgets.section_title("template_title", { + text = "Lua Runtime Template", + x = 104, + y = 132, + w = 420, + h = 34, + color = "#ffe2e8f0", + fontSize = 24, + layer = 10 + }), + widgets.label("template_desc", { + text = "最小接入示例:一个 manifest、一个 main.lua、一组 RuntimeNode。", + x = 104, + y = 178, + w = 500, + h = 24, + color = "#ff94a3b8", + fontSize = 14, + layer = 10 + }), + runtime_ui.button("template_start", { + text = state.started and "Started" or "Start", + x = 104, + y = 236, + w = 144, + h = 44, + handler = "template_start", + color = state.started and "#ff16a34a" or "#ff2563eb", + radius = 12, + fontSize = 15, + layer = 10 + }), + runtime_ui.text("template_counter", "Clicks: " .. tostring(state.clicks), 272, 246, 180, 24, { + color = "#ffe2e8f0", + fontSize = 15, + layer = 10 + }), + runtime_ui.text("template_status", state.status, 104, 320, 500, 24, { + color = "#fffacc15", + fontSize = 13, + layer = 10 + }) + } +end + +---@return RuntimeNodeUpdate[] +function ui.state_updates() + return { + runtime_ui.update("template_start", { + text = state.started and "Started" or "Start", + color = state.started and "#ff16a34a" or "#ff2563eb" + }), + runtime_ui.text_update("template_counter", "Clicks: " .. tostring(state.clicks)), + runtime_ui.text_update("template_status", state.status) + } +end + +return ui diff --git a/example/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e241ed9 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..938d353 --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Flame Lua Runtime Showcase + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + flame_lua_runtime_showcase + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..833c481 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,41 @@ +import 'package:flame_spine/flame_spine.dart' as spine; +import 'package:flutter/material.dart'; + +import 'package:flame_lua_runtime/flame_lua_runtime.dart'; + +const _serverUrl = String.fromEnvironment('LUA_GAME_SERVER'); +const _gameId = String.fromEnvironment('LUA_GAME_ID', defaultValue: 'showcase'); +const _locale = String.fromEnvironment('LUA_GAME_LOCALE'); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await spine.initSpineFlutter(); + runApp(const FlameLuaRuntimeShowcaseApp()); +} + +class FlameLuaRuntimeShowcaseApp extends StatelessWidget { + const FlameLuaRuntimeShowcaseApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Flame Lua Runtime Showcase', + theme: ThemeData.dark(useMaterial3: true), + home: Scaffold( + body: SafeArea( + child: LuaGameWidget( + gameId: _gameId, + serverUrl: _serverUrl.isEmpty ? null : Uri.parse(_serverUrl), + runtimeOptions: const RuntimeOptions( + runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua', + ), + localeOverride: _locale.isEmpty + ? null + : RuntimeLocaleResolver.localeFromTag(_locale), + ), + ), + ), + ); + } +} diff --git a/example/linux/.gitignore b/example/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/example/linux/CMakeLists.txt b/example/linux/CMakeLists.txt new file mode 100644 index 0000000..88305da --- /dev/null +++ b/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "flame_lua_runtime_showcase") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "dev.flame_lua.runtime_showcase") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/example/linux/flutter/CMakeLists.txt b/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..1830e5c --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); +} diff --git a/example/linux/flutter/generated_plugin_registrant.h b/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..894343b --- /dev/null +++ b/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni + spine_flutter +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/linux/runner/CMakeLists.txt b/example/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/example/linux/runner/main.cc b/example/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/example/linux/runner/my_application.cc b/example/linux/runner/my_application.cc new file mode 100644 index 0000000..987e345 --- /dev/null +++ b/example/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "flame_lua_runtime_showcase"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "flame_lua_runtime_showcase"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/example/linux/runner/my_application.h b/example/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/example/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/example/macos/.gitignore b/example/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/example/macos/Flutter/Flutter-Debug.xcconfig b/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/Flutter-Release.xcconfig b/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..a9f2f23 --- /dev/null +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import audioplayers_darwin +import path_provider_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) +} diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..eb02791 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* flame_lua_ludo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "flame_lua_ludo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* flame_lua_ludo.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* flame_lua_ludo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flame_lua_ludo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flame_lua_ludo"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flame_lua_ludo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flame_lua_ludo"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/flame_lua_ludo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/flame_lua_ludo"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..b49244f --- /dev/null +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/example/macos/Runner/Base.lproj/MainMenu.xib b/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..77521e7 --- /dev/null +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = flame_lua_ludo + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.flameLuaLudo + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/example/macos/Runner/Configs/Debug.xcconfig b/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Release.xcconfig b/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Warnings.xcconfig b/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/example/macos/Runner/Info.plist b/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/example/macos/Runner/MainFlutterWindow.swift b/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/example/macos/RunnerTests/RunnerTests.swift b/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..1e334af --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,34 @@ +name: flame_lua_runtime_example +description: Showcase app for the Flame Lua Runtime package. +publish_to: "none" +version: 1.0.0+1 + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + flame_spine: ^0.3.0+3 + flame_lua_runtime: + path: .. + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + assets: + - assets/games/ludo/manifest.json + - assets/games/ludo/scripts/ + - assets/games/ludo/assets/ + - assets/games/flight/manifest.json + - assets/games/flight/scripts/ + - assets/games/showcase/manifest.json + - assets/games/showcase/scripts/ + - assets/games/showcase/assets/ + - assets/games/template/manifest.json + - assets/games/template/scripts/ + + uses-material-design: true diff --git a/example/pyrightconfig.json b/example/pyrightconfig.json new file mode 100644 index 0000000..0b6f292 --- /dev/null +++ b/example/pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "reportMissingImports": "none", + "exclude": [ + "**/.dart_tool", + "**/build", + "ios/Flutter/ephemeral", + "macos/Flutter/ephemeral", + "windows/flutter/ephemeral", + "linux/flutter/ephemeral" + ] +} diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..9e9f1fc --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flame_lua_runtime_example/main.dart'; + +void main() { + testWidgets('Flame Lua app builds', (tester) async { + await tester.pumpWidget(const FlameLuaRuntimeShowcaseApp()); + expect(find.byType(FlameLuaRuntimeShowcaseApp), findsOneWidget); + }); +} diff --git a/example/web/favicon.png b/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/example/web/favicon.png differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/example/web/icons/Icon-512.png differ diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ diff --git a/example/web/index.html b/example/web/index.html new file mode 100644 index 0000000..f12e537 --- /dev/null +++ b/example/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + flame_lua_runtime_showcase + + + + + + diff --git a/example/web/manifest.json b/example/web/manifest.json new file mode 100644 index 0000000..addb2fc --- /dev/null +++ b/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "flame_lua_runtime_showcase", + "short_name": "flame_lua_runtime_showcase", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Showcase app for the Flame Lua Runtime package.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/example/windows/.gitignore b/example/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/example/windows/CMakeLists.txt b/example/windows/CMakeLists.txt new file mode 100644 index 0000000..bb86186 --- /dev/null +++ b/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(flame_lua_runtime_showcase LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "flame_lua_runtime_showcase") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/example/windows/flutter/CMakeLists.txt b/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..09e8e2c --- /dev/null +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); +} diff --git a/example/windows/flutter/generated_plugin_registrant.h b/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..1ada336 --- /dev/null +++ b/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni + spine_flutter +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/windows/runner/CMakeLists.txt b/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc new file mode 100644 index 0000000..28c1728 --- /dev/null +++ b/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "flame_lua_runtime_showcase" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "flame_lua_runtime_showcase" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "flame_lua_runtime_showcase.exe" "\0" + VALUE "ProductName", "flame_lua_runtime_showcase" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/example/windows/runner/flutter_window.cpp b/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/example/windows/runner/flutter_window.h b/example/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/example/windows/runner/main.cpp b/example/windows/runner/main.cpp new file mode 100644 index 0000000..2d4ad87 --- /dev/null +++ b/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"flame_lua_runtime_showcase", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/example/windows/runner/resource.h b/example/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/example/windows/runner/resources/app_icon.ico b/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/example/windows/runner/resources/app_icon.ico differ diff --git a/example/windows/runner/runner.exe.manifest b/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/example/windows/runner/utils.cpp b/example/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/example/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/example/windows/runner/utils.h b/example/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/example/windows/runner/win32_window.cpp b/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/example/windows/runner/win32_window.h b/example/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/lib/flame_lua_runtime.dart b/lib/flame_lua_runtime.dart new file mode 100644 index 0000000..65e20df --- /dev/null +++ b/lib/flame_lua_runtime.dart @@ -0,0 +1,14 @@ +library; + +export 'runtime/game/flame_lua_game.dart' show FlameLuaGame; +export 'runtime/game/lua_game_widget.dart' show LuaGameWidget; +export 'runtime/game/runtime_locale.dart' show RuntimeLocaleResolver; +export 'runtime/game/runtime_options.dart' show RuntimeOptions; +export 'runtime/packages/game_package_repository.dart' + show + AssetGamePackageRepository, + GamePackageRepository, + RemoteGamePackageRepository; +export 'runtime/scripting/lua_dardo_script_engine.dart' + show LuaDardoScriptEngine; +export 'runtime/scripting/script_engine.dart' show ScriptEngine; diff --git a/lib/runtime/audio/runtime_audio_cache.dart b/lib/runtime/audio/runtime_audio_cache.dart new file mode 100644 index 0000000..219c737 --- /dev/null +++ b/lib/runtime/audio/runtime_audio_cache.dart @@ -0,0 +1,90 @@ +part of 'runtime_audio_manager.dart'; + +extension _RuntimeAudioManagerCache on RuntimeAudioManager { + void _releaseCachedAudio() { + _loadLimiter.clearPending(); + for (final path in _audios.keys.toList(growable: false)) { + _removeAudioRecord(path); + } + + _channels.clear(); + final players = _players.toList(growable: false); + _players.clear(); + final pooledPlayers = _sfxPool.toList(growable: false); + _sfxPool.clear(); + for (final player in [...players, ...pooledPlayers]) { + player.dispose(); + } + } + + RuntimeAudioPlayer _takeSfxPlayer() { + if (_sfxPool.isNotEmpty) { + return _sfxPool.removeLast(); + } + return _playerFactory(); + } + + Future _releaseSfxPlayer(RuntimeAudioPlayer player) async { + if (_disposed || _sfxPool.length >= _maxSfxPoolSize) { + await player.dispose(); + return; + } + _sfxPool.add(player); + } + + void _touch(_AudioResourceRecord record) { + record.lastUsed = ++_accessCounter; + } + + void _enforceAudioBudget() { + while (_isOverBudget()) { + final victim = _leastRecentlyUsedAudio(); + if (victim == null || !_removeAudioRecord(victim)) { + return; + } + } + } + + bool _isOverBudget() { + final maxBytes = _maxCacheBytes; + final maxEntries = _maxCacheEntries; + return (maxBytes != null && _cacheBytes > maxBytes) || + (maxEntries != null && _readyAudioCount > maxEntries); + } + + int get _readyAudioCount => _audios.values + .where((record) => record.state == GameResourceState.ready) + .length; + + String? _leastRecentlyUsedAudio() { + String? victimPath; + _AudioResourceRecord? victim; + for (final entry in _audios.entries) { + final record = entry.value; + if (record.state != GameResourceState.ready) { + continue; + } + if (victim == null || record.lastUsed < victim.lastUsed) { + victim = record; + victimPath = entry.key; + } + } + return victimPath; + } + + bool _removeAudioRecord(String path) { + final record = _audios.remove(path); + if (record == null) { + return false; + } + _cacheBytes -= record.bytes?.length ?? 0; + if (_cacheBytes < 0) { + _cacheBytes = 0; + } + record + ..state = GameResourceState.disposed + ..bytes = null + ..inflight = null; + return true; + } +} diff --git a/lib/runtime/audio/runtime_audio_debug.dart b/lib/runtime/audio/runtime_audio_debug.dart new file mode 100644 index 0000000..c9c7e80 --- /dev/null +++ b/lib/runtime/audio/runtime_audio_debug.dart @@ -0,0 +1,27 @@ +part of 'runtime_audio_manager.dart'; + +extension _RuntimeAudioManagerDebug on RuntimeAudioManager { + Map _audioRecordDebugJson({ + required String? key, + required String path, + required String? preload, + required bool declared, + }) { + final record = _audios[path]; + return { + if (key != null) 'key': key, + 'path': path, + 'type': GameResourceType.audio, + 'declared': declared, + if (preload != null) 'preload': preload, + if (key != null && _package?.manifest.resources[key]?.group != null) + 'group': _package?.manifest.resources[key]?.group, + 'state': (record?.state ?? GameResourceState.idle).name, + if (record != null) 'generation': record.generation, + 'loading': record?.inflight != null, + 'ready': record?.bytes != null, + if (record?.bytes != null) 'bytes': record!.bytes!.length, + if (record?.lastError != null) 'error': record!.lastError.toString(), + }; + } +} diff --git a/lib/runtime/audio/runtime_audio_loading.dart b/lib/runtime/audio/runtime_audio_loading.dart new file mode 100644 index 0000000..5088535 --- /dev/null +++ b/lib/runtime/audio/runtime_audio_loading.dart @@ -0,0 +1,124 @@ +part of 'runtime_audio_manager.dart'; + +extension _RuntimeAudioManagerLoading on RuntimeAudioManager { + Future _loadAudio( + String? keyOrPath, { + required bool failOnError, + }) { + if (keyOrPath == null || keyOrPath.isEmpty) { + return Future.value(null); + } + + final requestToken = _asyncGate.token; + final requestGeneration = requestToken.generation; + final path = _tryResolve(keyOrPath); + if (path == null) { + return Future.value(null); + } + + final existing = _audios[path]; + if (existing != null) { + final bytes = existing.bytes; + if (existing.generation == requestGeneration && + existing.state == GameResourceState.ready && + bytes != null) { + _touch(existing); + return Future.value(bytes); + } + final inflight = existing.inflight; + if (existing.generation == requestGeneration && inflight != null) { + return failOnError + ? _throwIfNull(inflight, keyOrPath) + : inflight.catchError((_) => null); + } + } + + final record = _AudioResourceRecord(generation: requestGeneration) + ..state = GameResourceState.loading; + _audios[path] = record; + + final future = _readAudio(path, record, requestToken); + record.inflight = future; + return failOnError ? _throwIfNull(future, keyOrPath) : future; + } + + Future _throwIfNull( + Future future, + String keyOrPath, + ) async { + final bytes = await future; + if (bytes == null) { + throw ResourceLoadException('Required audio resource failed: $keyOrPath'); + } + return bytes; + } + + Future _readAudio( + String path, + _AudioResourceRecord record, + RuntimeAsyncToken requestToken, + ) async { + try { + final activePackage = _package; + if (activePackage == null) { + throw StateError('RuntimeAudioManager has no active package'); + } + + final ownedBytes = await _loadLimiter.run(() async { + final data = await activePackage.readBytes(path); + final bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + return Uint8List.fromList(bytes); + }); + record.inflight = null; + + if (_disposed || + !_asyncGate.accepts(requestToken) || + _audios[path] != record) { + record.state = GameResourceState.disposed; + return null; + } + + record + ..bytes = ownedBytes + ..state = GameResourceState.ready + ..lastError = null; + _cacheBytes += ownedBytes.length; + _touch(record); + _enforceAudioBudget(); + return ownedBytes; + } catch (error) { + record.inflight = null; + if (_disposed || + !_asyncGate.accepts(requestToken) || + _audios[path] != record) { + record.state = GameResourceState.disposed; + return null; + } + record + ..state = GameResourceState.failed + ..lastError = error; + _diagnostics?.record( + type: RuntimeDiagnosticType.resourceLoadError, + message: 'Audio resource failed to load', + error: error, + context: {'path': path, 'generation': requestToken.generation}, + ); + return null; + } + } + + String? _tryResolve(String keyOrPath) { + try { + final activePackage = _package; + if (activePackage == null) { + return null; + } + return activePackage.resolveResourcePath(keyOrPath); + } catch (_) { + return null; + } + } +} diff --git a/lib/runtime/audio/runtime_audio_manager.dart b/lib/runtime/audio/runtime_audio_manager.dart new file mode 100644 index 0000000..1a86091 --- /dev/null +++ b/lib/runtime/audio/runtime_audio_manager.dart @@ -0,0 +1,352 @@ +import 'dart:typed_data'; + +import '../diagnostics/runtime_diagnostics.dart'; +import '../lifecycle/runtime_async_gate.dart'; +import '../packages/game_package.dart'; +import '../packages/game_package_manifest.dart'; +import '../resources/game_resource_manager.dart'; +import '../resources/resource_load_limiter.dart'; +import 'runtime_audio_player.dart'; + +// These part files only group RuntimeAudioManager private helpers. The public +// facade stays in RuntimeAudioManager so callers do not depend on extensions. +part 'runtime_audio_loading.dart'; +part 'runtime_audio_debug.dart'; +part 'runtime_audio_cache.dart'; + +class RuntimeAudioManager { + RuntimeAudioManager({ + RuntimeDiagnostics? diagnostics, + RuntimeAudioPlayer Function()? playerFactory, + int maxSfxPoolSize = 8, + int? maxCacheBytes, + int? maxCacheEntries, + int maxConcurrentLoads = 4, + }) : _diagnostics = diagnostics, + _playerFactory = playerFactory ?? AudioplayersRuntimeAudioPlayer.new, + _maxSfxPoolSize = maxSfxPoolSize, + _maxCacheBytes = maxCacheBytes, + _maxCacheEntries = maxCacheEntries, + _loadLimiter = ResourceLoadLimiter(maxConcurrentLoads); + + final RuntimeDiagnostics? _diagnostics; + final RuntimeAudioPlayer Function() _playerFactory; + final int _maxSfxPoolSize; + final int? _maxCacheBytes; + final int? _maxCacheEntries; + final ResourceLoadLimiter _loadLimiter; + final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true); + final Map _audios = {}; + final Set _players = {}; + final Map _channels = {}; + final List _sfxPool = []; + GamePackage? _package; + int _cacheBytes = 0; + int _accessCounter = 0; + bool _disposed = false; + + int get generation => _asyncGate.generation; + + bool get hasPackage => _package != null; + + Future mount(GamePackage package) async { + _releaseCachedAudio(); + _asyncGate.activate(); + _disposed = false; + _package = package; + await preloadDeclaredAudio(package.manifest); + } + + void dispose() { + _disposed = true; + _asyncGate.close(); + _releaseCachedAudio(); + _package = null; + } + + GameResourceState audioState(String keyOrPath) { + final path = _tryResolve(keyOrPath); + if (path == null) { + return GameResourceState.failed; + } + return _audios[path]?.state ?? GameResourceState.idle; + } + + Object? audioError(String keyOrPath) { + final path = _tryResolve(keyOrPath); + if (path == null) { + return StateError('RuntimeAudioManager has no active package'); + } + return _audios[path]?.lastError; + } + + Map audioDebugJson() { + final activePackage = _package; + final declaredPaths = {}; + final resources = >[]; + + if (activePackage != null) { + for (final entry in activePackage.manifest.resources.entries) { + final resource = entry.value; + if (resource.type != GameResourceType.audio) { + continue; + } + final path = activePackage.resolveResourcePath(entry.key); + declaredPaths.add(path); + resources.add( + _audioRecordDebugJson( + key: entry.key, + path: path, + preload: resource.preload, + declared: true, + ), + ); + } + } + + for (final path in _audios.keys) { + if (declaredPaths.contains(path)) { + continue; + } + resources.add( + _audioRecordDebugJson( + key: null, + path: path, + preload: null, + declared: false, + ), + ); + } + + return { + 'generation': generation, + 'hasPackage': activePackage != null, + 'count': resources.length, + 'activeLoads': _loadLimiter.activeCount, + 'pendingLoads': _loadLimiter.pendingCount, + 'activePlayers': _players.length, + 'pooledPlayers': _sfxPool.length, + 'channels': _channels.keys.toList(growable: false)..sort(), + 'resources': resources, + }; + } + + bool evictAudio(String keyOrPath) { + final path = _tryResolve(keyOrPath); + if (path == null) { + return false; + } + return _removeAudioRecord(path); + } + + Future retryAudio(String keyOrPath) async { + evictAudio(keyOrPath); + final bytes = await _loadAudio(keyOrPath, failOnError: false); + return bytes != null; + } + + Future preloadDeclaredAudio(GamePackageManifest manifest) async { + final futures = >[]; + for (final entry in manifest.resources.entries) { + final resource = entry.value; + if (resource.type != GameResourceType.audio || + resource.preload == GameResourcePreload.lazy) { + continue; + } + + final failOnError = resource.preload == GameResourcePreload.required; + futures.add(_loadAudio(entry.key, failOnError: failOnError).then((_) {})); + } + await Future.wait(futures); + } + + Future play( + String? keyOrPath, { + double volume = 1, + }) async { + if (_disposed) { + return null; + } + final bytes = await _loadAudio(keyOrPath, failOnError: false); + if (_disposed || bytes == null) { + return null; + } + + final player = _takeSfxPlayer(); + _players.add(player); + try { + await player.start(bytes, volume: volume); + } catch (error) { + _players.remove(player); + await player.dispose(); + _diagnostics?.record( + type: RuntimeDiagnosticType.resourceLoadError, + message: 'Audio resource failed to play', + error: error, + context: {'resource': keyOrPath, 'generation': generation}, + ); + return null; + } + + final playback = RuntimeAudioPlayback._(player, player.done); + playback.done.whenComplete(() async { + _players.remove(player); + if (playback.isCancelled) { + await player.dispose(); + return; + } + await _releaseSfxPlayer(player); + }); + return playback; + } + + Future playBgm( + String? keyOrPath, { + String channel = RuntimeAudioChannel.defaultBgm, + double volume = 1, + bool loop = true, + }) async { + if (_disposed) { + return null; + } + final bytes = await _loadAudio(keyOrPath, failOnError: false); + if (_disposed || bytes == null) { + return null; + } + + await stopBgm(channel: channel); + if (_disposed) { + return null; + } + + final player = _playerFactory(); + _players.add(player); + try { + await player.start(bytes, volume: volume, loop: loop); + } catch (error) { + _players.remove(player); + await player.dispose(); + _diagnostics?.record( + type: RuntimeDiagnosticType.resourceLoadError, + message: 'BGM resource failed to play', + error: error, + context: { + 'resource': keyOrPath, + 'channel': channel, + 'generation': generation, + }, + ); + return null; + } + + final playback = RuntimeAudioPlayback._(player, player.done); + _channels[channel] = playback; + playback.done.whenComplete(() async { + if (_channels[channel] == playback) { + _channels.remove(channel); + } + _players.remove(player); + await player.dispose(); + }); + return playback; + } + + Future pauseBgm({String channel = RuntimeAudioChannel.defaultBgm}) { + return _channels[channel]?.pause() ?? Future.value(); + } + + Future resumeBgm({String channel = RuntimeAudioChannel.defaultBgm}) { + return _channels[channel]?.resume() ?? Future.value(); + } + + Future stopBgm({ + String channel = RuntimeAudioChannel.defaultBgm, + }) async { + final playback = _channels.remove(channel); + await playback?.stop(); + } + + bool hasBgm({String channel = RuntimeAudioChannel.defaultBgm}) { + return _channels.containsKey(channel); + } + + Future preloadGroup(String group, {bool failOnError = false}) async { + final activePackage = _package; + if (activePackage == null) { + throw StateError('RuntimeAudioManager has no active package'); + } + final futures = >[]; + for (final entry in activePackage.manifest.resources.entries) { + final resource = entry.value; + if (resource.type == GameResourceType.audio && resource.group == group) { + futures.add( + _loadAudio(entry.key, failOnError: failOnError).then((_) {}), + ); + } + } + await Future.wait(futures); + } + + int evictGroup(String group) { + final activePackage = _package; + if (activePackage == null) { + return 0; + } + var count = 0; + for (final entry in activePackage.manifest.resources.entries) { + final resource = entry.value; + if (resource.type != GameResourceType.audio || resource.group != group) { + continue; + } + final path = activePackage.resolveResourcePath(entry.key); + if (_removeAudioRecord(path)) { + count++; + } + } + return count; + } +} + +abstract final class RuntimeAudioChannel { + static const defaultBgm = 'bgm'; +} + +class RuntimeAudioPlayback { + RuntimeAudioPlayback._(this._player, this.done); + + final RuntimeAudioPlayer _player; + bool _cancelled = false; + + final Future done; + + bool get isCancelled => _cancelled; + + Future pause() { + return _player.pause(); + } + + Future resume() { + return _player.resume(); + } + + Future stop() async { + _cancelled = true; + await _player.stop(); + } + + Future cancel() async { + _cancelled = true; + await _player.dispose(); + } +} + +class _AudioResourceRecord { + _AudioResourceRecord({required this.generation}); + + final int generation; + GameResourceState state = GameResourceState.idle; + Future? inflight; + Uint8List? bytes; + Object? lastError; + int lastUsed = 0; +} diff --git a/lib/runtime/audio/runtime_audio_player.dart b/lib/runtime/audio/runtime_audio_player.dart new file mode 100644 index 0000000..eea1201 --- /dev/null +++ b/lib/runtime/audio/runtime_audio_player.dart @@ -0,0 +1,99 @@ +import 'dart:async' as async; +import 'dart:typed_data'; + +import 'package:audioplayers/audioplayers.dart'; + +abstract class RuntimeAudioPlayer { + Future start( + Uint8List bytes, { + required double volume, + bool loop = false, + }); + + Future pause(); + + Future resume(); + + Future stop(); + + Future get done; + + Future dispose(); +} + +class AudioplayersRuntimeAudioPlayer implements RuntimeAudioPlayer { + AudioplayersRuntimeAudioPlayer({AudioPlayer? player}) + : _player = player ?? AudioPlayer(); + + final AudioPlayer _player; + async.Completer _done = async.Completer(); + async.StreamSubscription? _completionSubscription; + bool _disposed = false; + + @override + Future get done => _done.future; + + @override + Future start( + Uint8List bytes, { + required double volume, + bool loop = false, + }) async { + if (_disposed) { + return; + } + await _completionSubscription?.cancel(); + _completionSubscription = null; + if (_done.isCompleted) { + _done = async.Completer(); + } + _completionSubscription = _player.onPlayerComplete.listen((_) { + _completeDone(); + }); + await _player.setReleaseMode(loop ? ReleaseMode.loop : ReleaseMode.release); + await _player.play(BytesSource(bytes), volume: volume); + } + + @override + Future pause() async { + if (_disposed) { + return; + } + await _player.pause(); + } + + @override + Future resume() async { + if (_disposed) { + return; + } + await _player.resume(); + } + + @override + Future stop() async { + if (_disposed) { + return; + } + await _player.stop(); + _completeDone(); + } + + @override + Future dispose() async { + if (_disposed) { + return; + } + _disposed = true; + await _completionSubscription?.cancel(); + _completionSubscription = null; + await _player.dispose(); + _completeDone(); + } + + void _completeDone() { + if (!_done.isCompleted) { + _done.complete(); + } + } +} diff --git a/lib/runtime/commands/command_audio.dart b/lib/runtime/commands/command_audio.dart new file mode 100644 index 0000000..a8200b9 --- /dev/null +++ b/lib/runtime/commands/command_audio.dart @@ -0,0 +1,125 @@ +part of 'command_executor.dart'; + +extension _CommandExecutorAudio on CommandExecutor { + Future<_CommandResult> _playSound( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) async { + final audio = _audio; + if (audio == null) { + _emitCommandCompletion(command, context); + return _CommandResult.completed; + } + + final scope = _scopeFor(command, context, defaultTarget: false); + final scopeEpoch = _scopeEpochFor(scope, context); + if (!_scopeIsAlive(scope)) { + return _CommandResult.cancelled; + } + + final task = _registerTask(scope, handle); + final playback = await audio.play( + _requiredAudioResource(command), + volume: _optionalVolume(command), + ); + if (_disposed || + task.isCancelled || + (handle?.isCancelled ?? false) || + !_scopeIsAlive(scope)) { + await playback?.cancel(); + task.complete(_CommandResult.cancelled); + return task.future; + } + if (playback == null) { + task.complete(_CommandResult.cancelled); + return task.future; + } + + task.addCancelCallback(() { + async.unawaited(playback.cancel()); + }); + await playback.done; + + if (_disposed || + task.isCancelled || + (handle?.isCancelled ?? false) || + !_scopeIsAlive(scope)) { + task.complete(_CommandResult.cancelled); + return task.future; + } + + _emitCommandCompletion( + command, + context.copyWith(scope: scope, scopeEpoch: scopeEpoch), + ); + task.complete(_CommandResult.completed); + return task.future; + } + + Future<_CommandResult> _playBgm( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) async { + final audio = _audio; + if (audio == null) { + _emitCommandCompletion(command, context); + return _CommandResult.completed; + } + + final scope = _scopeFor(command, context, defaultTarget: false); + final scopeEpoch = _scopeEpochFor(scope, context); + if (!_scopeIsAlive(scope)) { + return _CommandResult.cancelled; + } + + final channel = _audioChannel(command); + final playback = await audio.playBgm( + _requiredAudioResource(command), + channel: channel, + volume: _optionalVolume(command), + loop: _optionalBool(command.payload['loop'], 'play_bgm.loop') ?? true, + ); + if (_disposed || (handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) { + await audio.stopBgm(channel: channel); + return _CommandResult.cancelled; + } + if (playback == null) { + return _CommandResult.cancelled; + } + + _registerBgmChannel(channel: channel, scope: scope); + handle?.addCancelCallback(() { + _unregisterBgmChannel(channel); + async.unawaited(audio.stopBgm(channel: channel)); + }); + _emitCommandCompletion( + command, + context.copyWith(scope: scope, scopeEpoch: scopeEpoch), + ); + return _CommandResult.completed; + } + + Future<_CommandResult> _controlBgm( + RuntimeCommand command, + _CommandContext context, + _BgmControl control, + ) async { + final audio = _audio; + final channel = _audioChannel(command); + if (audio != null) { + switch (control) { + case _BgmControl.pause: + await audio.pauseBgm(channel: channel); + case _BgmControl.resume: + await audio.resumeBgm(channel: channel); + case _BgmControl.stop: + await audio.stopBgm(channel: channel); + _unregisterBgmChannel(channel); + } + } + _emitCommandCompletion(command, context); + return _CommandResult.completed; + } +} diff --git a/lib/runtime/commands/command_composite.dart b/lib/runtime/commands/command_composite.dart new file mode 100644 index 0000000..442b2be --- /dev/null +++ b/lib/runtime/commands/command_composite.dart @@ -0,0 +1,52 @@ +part of 'command_executor.dart'; + +extension _CommandExecutorComposite on CommandExecutor { + Future<_CommandResult> _sequence( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) async { + final commands = _commandsFromPayload(command); + final childContext = _childContextFor(command, context); + for (final child in commands) { + if (_disposed || + (handle?.isCancelled ?? false) || + !_scopeIsAlive(childContext.scope)) { + return _CommandResult.cancelled; + } + final result = await _execute(child, childContext); + if (result == _CommandResult.cancelled) { + return _CommandResult.cancelled; + } + } + + if (_disposed || + (handle?.isCancelled ?? false) || + !_scopeIsAlive(childContext.scope)) { + return _CommandResult.cancelled; + } + _emitCommandCompletion(command, childContext); + return _CommandResult.completed; + } + + Future<_CommandResult> _parallel( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) async { + final commands = _commandsFromPayload(command); + final childContext = _childContextFor(command, context); + final results = await Future.wait( + commands.map((child) => _execute(child, childContext)), + ); + if (_disposed || + (handle?.isCancelled ?? false) || + !_scopeIsAlive(childContext.scope) || + results.contains(_CommandResult.cancelled)) { + return _CommandResult.cancelled; + } + + _emitCommandCompletion(command, childContext); + return _CommandResult.completed; + } +} diff --git a/lib/runtime/commands/command_executor.dart b/lib/runtime/commands/command_executor.dart new file mode 100644 index 0000000..bdc4711 --- /dev/null +++ b/lib/runtime/commands/command_executor.dart @@ -0,0 +1,230 @@ +import 'dart:async' as async; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flutter/services.dart'; + +import '../audio/runtime_audio_manager.dart'; +import '../lifecycle/runtime_task_registry.dart'; +import '../models/game_diff.dart'; +import '../models/runtime_command.dart'; +import '../models/runtime_event.dart'; +import '../models/runtime_node.dart'; +import '../protocol/runtime_protocol.dart'; +import '../rendering/render_tree_controller.dart'; +import '../resources/game_resource_manager.dart'; +import '../rendering/runtime_component.dart'; +import 'runtime_command_registry.dart'; + +// These part files keep CommandExecutor as a single private implementation +// unit while grouping command handlers by responsibility. They are not a +// plugin system and should not expose additional public API. +part 'command_target_effects.dart'; +part 'command_composite.dart'; +part 'command_audio.dart'; +part 'command_resources.dart'; +part 'command_lifecycle_context.dart'; +part 'command_toast.dart'; +part 'command_validation.dart'; +part 'command_support.dart'; + +class CommandExecutor { + CommandExecutor({ + required RenderTreeController renderTree, + required void Function(RuntimeEvent event) eventSink, + RuntimeAudioManager? audio, + GameResourceManager? resources, + Vector2? overlaySize, + }) : _renderTree = renderTree, + _eventSink = eventSink, + _audio = audio, + _resources = resources, + _overlaySize = overlaySize ?? Vector2(720, 720); + + final RenderTreeController _renderTree; + final void Function(RuntimeEvent event) _eventSink; + final RuntimeAudioManager? _audio; + final GameResourceManager? _resources; + final Vector2 _overlaySize; + late final RuntimeTaskRegistry<_CommandResult> _tasks = + RuntimeTaskRegistry<_CommandResult>( + cancelledValue: _CommandResult.cancelled, + ); + final RuntimeCommandRegistry _commandRegistry = RuntimeCommandRegistry(); + final Set _ownedBgmChannels = {}; + final Map> _bgmChannelsByScope = {}; + final Map _bgmScopeByChannel = {}; + int _toastSerial = 0; + bool _disposed = false; + + void dispose() { + _disposed = true; + _commandRegistry.dispose(); + _tasks.dispose(); + final channels = _ownedBgmChannels.toList(growable: false); + _ownedBgmChannels.clear(); + _bgmChannelsByScope.clear(); + _bgmScopeByChannel.clear(); + for (final channel in channels) { + async.unawaited(_audio?.stopBgm(channel: channel)); + } + } + + void cancelScope(String scope) { + _commandRegistry.cancelScope(scope); + _tasks.cancelScope(scope); + final channels = _bgmChannelsByScope.remove(scope) ?? const {}; + for (final channel in channels) { + _bgmScopeByChannel.remove(channel); + _ownedBgmChannels.remove(channel); + async.unawaited(_audio?.stopBgm(channel: channel)); + } + } + + void executeAll(List commands) { + for (final command in commands) { + execute(command); + } + } + + void execute(RuntimeCommand command) { + if (_disposed) { + return; + } + _validate(command); + async.unawaited(_execute(command, const _CommandContext())); + } + + Future<_CommandResult> _execute( + RuntimeCommand command, + _CommandContext context, + ) async { + if (_disposed) { + return _CommandResult.cancelled; + } + + final commandContext = _commandContextFor(command, context); + final handle = _createCommandHandle(command, commandContext); + try { + if (handle?.isCancelled ?? false) { + return _CommandResult.cancelled; + } + final result = await _executeCore(command, commandContext, handle); + if (handle?.isCancelled ?? false) { + return _CommandResult.cancelled; + } + return result; + } finally { + handle?.complete(); + } + } + + Future<_CommandResult> _executeCore( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) async { + if (_disposed || (handle?.isCancelled ?? false)) { + return _CommandResult.cancelled; + } + + switch (command.type) { + case RuntimeCommandType.movePath: + return _movePath(command, context, handle); + case RuntimeCommandType.moveTo: + return _targetEffect(command, context, handle, (component, duration) { + return MoveToEffect( + _requiredVector(command), + EffectController(duration: duration), + ); + }); + case RuntimeCommandType.fadeTo: + return _targetEffect(command, context, handle, (component, duration) { + final alpha = _requiredNormalizedDouble( + command.payload['alpha'], + 'fade_to.alpha', + ); + final start = component.renderAlpha; + return FunctionEffect((progress, _) { + final t = _readDouble(progress) ?? 1; + component.setRuntimeAlpha(start + (alpha - start) * t); + }, EffectController(duration: duration)); + }); + case RuntimeCommandType.scaleTo: + return _targetEffect(command, context, handle, (component, duration) { + final scale = _requiredDouble( + command.payload['scale'], + 'scale_to.scale', + ); + return ScaleEffect.to( + Vector2.all(scale), + EffectController(duration: duration), + ); + }); + case RuntimeCommandType.rotateTo: + return _targetEffect(command, context, handle, (component, duration) { + final angle = _requiredDouble( + command.payload['angle'], + 'rotate_to.angle', + ); + return RotateEffect.to(angle, EffectController(duration: duration)); + }); + case RuntimeCommandType.removeNode: + return _removeNode(command, context); + case RuntimeCommandType.sequence: + return _sequence(command, context, handle); + case RuntimeCommandType.parallel: + return _parallel(command, context, handle); + case RuntimeCommandType.delay: + return _delay(command, context, handle); + case RuntimeCommandType.toast: + return _toast(command, context, handle); + case RuntimeCommandType.playSound: + return _playSound(command, context, handle); + case RuntimeCommandType.playBgm: + return _playBgm(command, context, handle); + case RuntimeCommandType.pauseBgm: + return _controlBgm(command, context, _BgmControl.pause); + case RuntimeCommandType.resumeBgm: + return _controlBgm(command, context, _BgmControl.resume); + case RuntimeCommandType.stopBgm: + return _controlBgm(command, context, _BgmControl.stop); + case RuntimeCommandType.preloadResources: + return _preloadResources(command, context, handle); + case RuntimeCommandType.evictResources: + return _evictResources(command, context, handle); + case RuntimeCommandType.cancelCommands: + return _cancelCommands(command, context); + case RuntimeCommandType.playSpineAnimation: + return _playSpineAnimation(command, context); + case RuntimeCommandType.copyText: + await Clipboard.setData( + ClipboardData(text: _requiredText(command, 'copy_text.text')), + ); + _emitCommandCompletion(command, context); + return _CommandResult.completed; + default: + throw UnsupportedError('Unsupported runtime command: ${command.type}'); + } + } +} + +class _CommandContext { + const _CommandContext({this.scope, this.scopeEpoch, this.group}); + + final String? scope; + final int? scopeEpoch; + final String? group; + + _CommandContext copyWith({String? scope, int? scopeEpoch, String? group}) { + return _CommandContext( + scope: scope ?? this.scope, + scopeEpoch: scopeEpoch ?? this.scopeEpoch, + group: group ?? this.group, + ); + } +} + +enum _CommandResult { completed, cancelled } + +enum _BgmControl { pause, resume, stop } diff --git a/lib/runtime/commands/command_lifecycle_context.dart b/lib/runtime/commands/command_lifecycle_context.dart new file mode 100644 index 0000000..002ed21 --- /dev/null +++ b/lib/runtime/commands/command_lifecycle_context.dart @@ -0,0 +1,154 @@ +part of 'command_executor.dart'; + +extension _CommandExecutorLifecycle on CommandExecutor { + Future<_CommandResult> _delay( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) { + final scope = _scopeFor(command, context, defaultTarget: false); + final scopeEpoch = _scopeEpochFor(scope, context); + final task = _registerTask(scope, handle); + _schedule(_duration(command, defaultValue: 0), task, () { + if ((handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) { + task.cancel(); + return; + } + _emitCommandCompletion( + command, + context.copyWith(scope: scope, scopeEpoch: scopeEpoch), + ); + task.complete(_CommandResult.completed); + }); + return task.future; + } + + void _schedule( + double seconds, + RuntimeTask<_CommandResult> task, + void Function() callback, + ) { + void guardedCallback() { + if (_disposed || task.isCancelled) { + return; + } + callback(); + } + + if (seconds <= 0) { + async.scheduleMicrotask(guardedCallback); + return; + } + + late final async.Timer timer; + timer = async.Timer(Duration(milliseconds: (seconds * 1000).round()), () { + task.removeTimer(timer); + guardedCallback(); + }); + task.addTimer(timer); + } + + RuntimeTask<_CommandResult> _registerTask( + String? scope, + RuntimeCommandHandle? handle, + ) { + final task = _tasks.create(scope: scope); + handle?.addCancelCallback(task.cancel); + return task; + } + + _CommandContext _commandContextFor( + RuntimeCommand command, + _CommandContext context, + ) { + return context.copyWith(group: _commandGroupFor(command, context)); + } + + RuntimeCommandHandle? _createCommandHandle( + RuntimeCommand command, + _CommandContext context, + ) { + if (command.type == RuntimeCommandType.cancelCommands) { + return null; + } + final id = _optionalString(command.payload['id'], 'id'); + final group = context.group; + final scope = _completionScopeFor(command, context); + if (id == null && group == null && scope == null) { + return null; + } + return _commandRegistry.create(id: id, group: group, scope: scope); + } + + _CommandContext _childContextFor( + RuntimeCommand command, + _CommandContext context, + ) { + final scope = _scopeFor(command, context, defaultTarget: false); + return context.copyWith( + scope: scope, + scopeEpoch: _scopeEpochFor(scope, context), + group: _commandGroupFor(command, context), + ); + } + + String? _commandGroupFor(RuntimeCommand command, _CommandContext context) { + final commandGroup = _optionalString( + command.payload['commandGroup'], + 'commandGroup', + ); + if (commandGroup != null) { + return commandGroup; + } + if (_usesGroupAsCommandGroup(command.type)) { + final legacyGroup = _optionalString(command.payload['group'], 'group'); + if (legacyGroup != null) { + return legacyGroup; + } + } + return context.group; + } + + bool _usesGroupAsCommandGroup(String commandType) { + return commandType != RuntimeCommandType.preloadResources && + commandType != RuntimeCommandType.evictResources && + commandType != RuntimeCommandType.cancelCommands; + } + + String? _scopeFor( + RuntimeCommand command, + _CommandContext context, { + required bool defaultTarget, + }) { + final explicit = _optionalString(command.payload['scope'], 'scope'); + if (explicit != null) { + return explicit; + } + if (context.scope != null) { + return context.scope; + } + if (defaultTarget) { + return command.target; + } + return null; + } + + String? _completionScopeFor(RuntimeCommand command, _CommandContext context) { + final explicit = _optionalString(command.payload['scope'], 'scope'); + return explicit ?? context.scope; + } + + int? _scopeEpochFor(String? scope, _CommandContext context) { + if (scope == null) { + return null; + } + if (scope == context.scope && context.scopeEpoch != null) { + return context.scopeEpoch; + } + return _renderTree.epochOf(scope); + } + + bool _scopeIsAlive(String? scope) { + return scope == null || _renderTree.contains(scope); + } +} diff --git a/lib/runtime/commands/command_resources.dart b/lib/runtime/commands/command_resources.dart new file mode 100644 index 0000000..045efa1 --- /dev/null +++ b/lib/runtime/commands/command_resources.dart @@ -0,0 +1,83 @@ +part of 'command_executor.dart'; + +extension _CommandExecutorResources on CommandExecutor { + Future<_CommandResult> _preloadResources( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) async { + final group = _requiredResourceGroup(command); + final failOnError = + _optionalBool( + command.payload['failOnError'], + 'preload_resources.failOnError', + ) ?? + false; + final resources = _resources; + final audio = _audio; + if (resources != null && resources.hasPackage) { + await resources.preloadGroup(group, failOnError: failOnError); + } + if (audio != null && audio.hasPackage) { + await audio.preloadGroup(group, failOnError: failOnError); + } + if (handle?.isCancelled ?? false) { + return _CommandResult.cancelled; + } + _emitCommandCompletion(command, context); + return _CommandResult.completed; + } + + Future<_CommandResult> _evictResources( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) async { + final group = _requiredResourceGroup(command); + final resources = _resources; + final audio = _audio; + if (resources != null && resources.hasPackage) { + resources.evictGroup(group); + } + if (audio != null && audio.hasPackage) { + audio.evictGroup(group); + } + if (handle?.isCancelled ?? false) { + return _CommandResult.cancelled; + } + _emitCommandCompletion(command, context); + return _CommandResult.completed; + } + + Future<_CommandResult> _cancelCommands( + RuntimeCommand command, + _CommandContext context, + ) async { + final id = _optionalString(command.payload['id'], 'cancel_commands.id'); + final group = _optionalString( + command.payload['group'], + 'cancel_commands.group', + ); + final scope = _optionalString( + command.payload['scope'], + 'cancel_commands.scope', + ); + if (id == null && group == null && scope == null) { + throw const FormatException( + 'cancel_commands requires id, group or scope', + ); + } + if (id != null) { + _commandRegistry.cancelId(id); + } + if (group != null) { + _commandRegistry.cancelGroup(group); + } + if (scope != null) { + _commandRegistry.cancelScope(scope); + _tasks.cancelScope(scope); + } + _emitCommandCompletion(command, context); + return _CommandResult.completed; + } +} diff --git a/lib/runtime/commands/command_support.dart b/lib/runtime/commands/command_support.dart new file mode 100644 index 0000000..67c9609 --- /dev/null +++ b/lib/runtime/commands/command_support.dart @@ -0,0 +1,248 @@ +part of 'command_executor.dart'; + +extension _CommandExecutorSupport on CommandExecutor { + void _appendCompletionEffect( + List effects, + RuntimeCommand command, + String target, + int targetEpoch, + RuntimeTask<_CommandResult> task, + String? scope, + int? scopeEpoch, + ) { + effects.add( + FunctionEffect((_, __) { + if (!_scopeIsAlive(scope) || + !_renderTree.isNodeEpochAlive(target, targetEpoch)) { + task.cancel(); + return; + } + _emitCompletion(command, target, scope, targetEpoch, scopeEpoch); + task.complete(_CommandResult.completed); + }, EffectController(duration: 0.01)), + ); + } + + void _emitCompletion( + RuntimeCommand command, + String target, + String? scope, [ + int? targetEpoch, + int? scopeEpoch, + ]) { + final onComplete = _optionalString( + command.payload['onComplete'], + 'onComplete', + ); + if (onComplete == null) { + return; + } + _emitEventIfScopeAlive( + RuntimeEvent( + type: RuntimeEventType.animationDone, + target: target, + handler: onComplete, + ), + scope, + targetEpoch: targetEpoch, + scopeEpoch: scopeEpoch, + ); + } + + void _emitCommandCompletion(RuntimeCommand command, _CommandContext context) { + final onComplete = _optionalString( + command.payload['onComplete'], + 'onComplete', + ); + if (onComplete == null) { + return; + } + _emitEventIfScopeAlive( + RuntimeEvent( + type: RuntimeEventType.animationDone, + target: command.target, + handler: onComplete, + ), + _completionScopeFor(command, context), + scopeEpoch: context.scopeEpoch, + ); + } + + void _emitEventIfScopeAlive( + RuntimeEvent event, + String? scope, { + int? targetEpoch, + int? scopeEpoch, + }) { + if (!_scopeIsAlive(scope) || _disposed) { + return; + } + _eventSink( + event.withLifecycle( + scope: scope, + targetEpoch: targetEpoch, + scopeEpoch: scopeEpoch, + ), + ); + } + + Vector2 _requiredVector(RuntimeCommand command) { + final x = _readDouble(command.payload['x']); + final y = _readDouble(command.payload['y']); + if (x == null || y == null) { + throw FormatException('${command.type}.x/y are required numbers'); + } + return Vector2(x, y); + } + + String _requiredTarget(RuntimeCommand command) { + final target = command.target; + if (target == null || target.isEmpty) { + throw FormatException('${command.type}.target is required'); + } + return target; + } + + String _requiredText(RuntimeCommand command, String field) { + return _optionalString(command.payload['text'], field)!; + } + + double _duration(RuntimeCommand command, {required double defaultValue}) { + final duration = _readDouble(command.payload['duration']) ?? defaultValue; + if (duration < 0) { + throw FormatException('${command.type}.duration must be >= 0'); + } + return duration; + } + + double _requiredDouble(Object? value, String field) { + final result = _readDouble(value); + if (result == null) { + throw FormatException('$field must be a number'); + } + return result; + } + + void _registerBgmChannel({required String channel, required String? scope}) { + _unregisterBgmChannel(channel); + _ownedBgmChannels.add(channel); + if (scope == null) { + return; + } + _bgmScopeByChannel[channel] = scope; + _bgmChannelsByScope.putIfAbsent(scope, () => {}).add(channel); + } + + void _unregisterBgmChannel(String channel) { + _ownedBgmChannels.remove(channel); + final oldScope = _bgmScopeByChannel.remove(channel); + if (oldScope == null) { + return; + } + final channels = _bgmChannelsByScope[oldScope]; + channels?.remove(channel); + if (channels != null && channels.isEmpty) { + _bgmChannelsByScope.remove(oldScope); + } + } + + String _audioChannel(RuntimeCommand command) { + return _optionalString(command.payload['channel'], 'channel') ?? + RuntimeAudioChannel.defaultBgm; + } + + String _requiredResourceGroup(RuntimeCommand command) { + return _optionalString(command.payload['group'], '${command.type}.group')!; + } + + void _validateCancelCommands(RuntimeCommand command) { + final id = _optionalString(command.payload['id'], 'cancel_commands.id'); + final group = _optionalString( + command.payload['group'], + 'cancel_commands.group', + ); + final scope = _optionalString( + command.payload['scope'], + 'cancel_commands.scope', + ); + if (id == null && group == null && scope == null) { + throw const FormatException( + 'cancel_commands requires id, group or scope', + ); + } + } + + String _requiredAudioResource(RuntimeCommand command) { + final asset = command.payload['asset'] ?? command.payload['name']; + return _optionalString(asset, 'play_sound.asset/name')!; + } + + String _requiredSpineAnimation(RuntimeCommand command) { + final value = _optionalString( + command.payload['animation'], + 'play_spine_animation.animation', + ); + if (value == null) { + throw const FormatException( + 'play_spine_animation.animation must be a non-empty string', + ); + } + return value; + } + + bool? _optionalBool(Object? value, String field) { + if (value == null) { + return null; + } + if (value is bool) { + return value; + } + throw FormatException('$field must be a boolean'); + } + + double _optionalVolume(RuntimeCommand command) { + final value = command.payload['volume']; + if (value == null) { + return 1; + } + return _requiredNormalizedDouble(value, 'play_sound.volume'); + } + + double _requiredNormalizedDouble(Object? value, String field) { + final result = _requiredDouble(value, field); + if (result < 0 || result > 1) { + throw FormatException('$field must be between 0 and 1'); + } + return result; + } + + String? _optionalString(Object? value, String field) { + if (value == null) { + return null; + } + if (value is String && value.isNotEmpty) { + return value; + } + throw FormatException('$field must be a non-empty string'); + } + + int? _optionalInt(Object? value, String field) { + if (value == null) { + return null; + } + if (value is num) { + return value.toInt(); + } + throw FormatException('$field must be an integer'); + } + + double? _readDouble(Object? value) { + if (value == null) { + return null; + } + if (value is num) { + return value.toDouble(); + } + return null; + } +} diff --git a/lib/runtime/commands/command_target_effects.dart b/lib/runtime/commands/command_target_effects.dart new file mode 100644 index 0000000..e6842d4 --- /dev/null +++ b/lib/runtime/commands/command_target_effects.dart @@ -0,0 +1,143 @@ +part of 'command_executor.dart'; + +extension _CommandExecutorTargetEffects on CommandExecutor { + Future<_CommandResult> _movePath( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) { + final target = _requiredTarget(command); + final component = _renderTree.componentById(target); + if (component == null) { + return Future.value(_CommandResult.cancelled); + } + + final scope = _scopeFor(command, context, defaultTarget: true); + final scopeEpoch = _scopeEpochFor(scope, context); + final targetEpoch = _renderTree.epochOf(target); + final task = _registerTask(scope, handle); + final pathValue = command.payload['path'] as List; + final duration = _duration(command, defaultValue: 0.4); + final perStepDuration = duration / pathValue.length; + final effects = []; + + for (final point in pathValue) { + final map = point as Map; + final x = _readDouble(map['x'])!; + final y = _readDouble(map['y'])!; + effects.add( + MoveToEffect( + Vector2(x, y), + EffectController(duration: perStepDuration), + ), + ); + } + + _appendCompletionEffect( + effects, + command, + target, + targetEpoch, + task, + scope, + scopeEpoch, + ); + final effect = SequenceEffect(effects); + handle?.addCancelCallback(effect.removeFromParent); + component.add(effect); + return task.future; + } + + Future<_CommandResult> _targetEffect( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + Effect Function(RuntimeComponent component, double duration) factory, + ) { + final target = _requiredTarget(command); + final component = _renderTree.componentById(target); + if (component == null) { + return Future.value(_CommandResult.cancelled); + } + + final scope = _scopeFor(command, context, defaultTarget: true); + final scopeEpoch = _scopeEpochFor(scope, context); + final targetEpoch = _renderTree.epochOf(target); + final task = _registerTask(scope, handle); + final effects = [ + factory(component, _duration(command, defaultValue: 0.2)), + ]; + _appendCompletionEffect( + effects, + command, + target, + targetEpoch, + task, + scope, + scopeEpoch, + ); + final effect = SequenceEffect(effects); + handle?.addCancelCallback(effect.removeFromParent); + component.add(effect); + return task.future; + } + + Future<_CommandResult> _playSpineAnimation( + RuntimeCommand command, + _CommandContext context, + ) { + final target = _requiredTarget(command); + final component = _renderTree.componentById(target); + if (component == null) { + return Future.value(_CommandResult.cancelled); + } + + final animation = _requiredSpineAnimation(command); + final track = + _optionalInt(command.payload['track'], 'play_spine_animation.track') ?? + 0; + final loop = + _optionalBool(command.payload['loop'], 'play_spine_animation.loop') ?? + true; + final queue = + _optionalBool(command.payload['queue'], 'play_spine_animation.queue') ?? + false; + final delay = _readDouble(command.payload['delay']) ?? 0; + if (track < 0) { + throw const FormatException('play_spine_animation.track must be >= 0'); + } + if (delay < 0) { + throw const FormatException('play_spine_animation.delay must be >= 0'); + } + + final played = component.playSpineAnimation( + animation, + track: track, + loop: loop, + queue: queue, + delay: delay, + ); + if (!played) { + return Future.value(_CommandResult.cancelled); + } + final scope = _completionScopeFor(command, context); + _emitCompletion( + command, + target, + scope, + _renderTree.epochOf(target), + context.scopeEpoch, + ); + return Future.value(_CommandResult.completed); + } + + Future<_CommandResult> _removeNode( + RuntimeCommand command, + _CommandContext context, + ) { + final target = _requiredTarget(command); + _renderTree.removeById(target); + _emitCompletion(command, target, _completionScopeFor(command, context)); + return Future.value(_CommandResult.completed); + } +} diff --git a/lib/runtime/commands/command_toast.dart b/lib/runtime/commands/command_toast.dart new file mode 100644 index 0000000..5388608 --- /dev/null +++ b/lib/runtime/commands/command_toast.dart @@ -0,0 +1,96 @@ +part of 'command_executor.dart'; + +extension _CommandExecutorToast on CommandExecutor { + Future<_CommandResult> _toast( + RuntimeCommand command, + _CommandContext context, + RuntimeCommandHandle? handle, + ) { + final text = _toastText(command); + final duration = _duration(command, defaultValue: 1.8); + final scope = _scopeFor(command, context, defaultTarget: false); + final scopeEpoch = _scopeEpochFor(scope, context); + final task = _registerTask(scope, handle); + final toastId = 'runtime_toast_${++_toastSerial}'; + final toastTextId = '${toastId}_text'; + + _renderTree.apply( + NodeDiff( + creates: _toastNodes(id: toastId, textId: toastTextId, text: text), + ), + ); + task.addCancelCallback(() => _renderTree.removeById(toastId)); + + _schedule(duration, task, () { + if ((handle?.isCancelled ?? false) || !_scopeIsAlive(scope)) { + _renderTree.removeById(toastId); + task.cancel(); + return; + } + _renderTree.removeById(toastId); + _emitCommandCompletion( + command, + context.copyWith(scope: scope, scopeEpoch: scopeEpoch), + ); + task.complete(_CommandResult.completed); + }); + + return task.future; + } + + List _toastNodes({ + required String id, + required String textId, + required String text, + }) { + const width = 360.0; + const minHeight = 38.0; + final lineCount = text.split('\n').length; + final height = (minHeight + (lineCount - 1) * 16).clamp(38.0, 92.0); + final x = ((_overlaySize.x - width) / 2).clamp(12.0, _overlaySize.x); + final y = (_overlaySize.y - height - 58).clamp(12.0, _overlaySize.y); + + return [ + RuntimeNode( + id: id, + type: RuntimeNodeType.panel, + x: x, + y: y, + width: width, + height: height, + color: const Color(0xee020617), + radius: 12, + layer: 10000, + ), + RuntimeNode( + id: textId, + type: RuntimeNodeType.text, + parent: id, + text: text, + x: 14, + y: 0, + width: width - 28, + height: height, + color: const Color(0xfff8fafc), + fontSize: 13, + textAlign: RuntimeTextAlignValue.center, + layer: 10001, + ), + ]; + } + + String _toastText(RuntimeCommand command) { + final text = _optionalString(command.payload['text'], 'toast.text'); + final message = _optionalString( + command.payload['message'], + 'toast.message', + ); + if (text != null) { + return text; + } + if (message != null) { + return message; + } + throw const FormatException('toast.text or toast.message is required'); + } +} diff --git a/lib/runtime/commands/command_validation.dart b/lib/runtime/commands/command_validation.dart new file mode 100644 index 0000000..773e580 --- /dev/null +++ b/lib/runtime/commands/command_validation.dart @@ -0,0 +1,154 @@ +part of 'command_executor.dart'; + +extension _CommandExecutorValidation on CommandExecutor { + void _validate(RuntimeCommand command) { + if (!RuntimeCommandType.isSupported(command.type)) { + throw UnsupportedError('Unsupported runtime command: ${command.type}'); + } + RuntimeProtocolSchema.ensureKnownKeys( + command.payload, + allowed: RuntimeProtocolSchema.allowedCommandPayloadFields(command.type), + context: 'RuntimeCommand.${command.type}.payload', + ); + _optionalString(command.payload['id'], 'id'); + _optionalString(command.payload['group'], 'group'); + _optionalString(command.payload['commandGroup'], 'commandGroup'); + _optionalString(command.payload['scope'], 'scope'); + _estimatedDuration(command); + } + + double _estimatedDuration(RuntimeCommand command) { + _optionalString(command.payload['onComplete'], 'onComplete'); + + switch (command.type) { + case RuntimeCommandType.movePath: + _requiredTarget(command); + _validatePath(command.payload['path']); + return _duration(command, defaultValue: 0.4); + case RuntimeCommandType.moveTo: + _requiredTarget(command); + _requiredVector(command); + return _duration(command, defaultValue: 0.2); + case RuntimeCommandType.fadeTo: + _requiredTarget(command); + _requiredNormalizedDouble(command.payload['alpha'], 'fade_to.alpha'); + return _duration(command, defaultValue: 0.2); + case RuntimeCommandType.scaleTo: + _requiredTarget(command); + _requiredDouble(command.payload['scale'], 'scale_to.scale'); + return _duration(command, defaultValue: 0.2); + case RuntimeCommandType.rotateTo: + _requiredTarget(command); + _requiredDouble(command.payload['angle'], 'rotate_to.angle'); + return _duration(command, defaultValue: 0.2); + case RuntimeCommandType.removeNode: + _requiredTarget(command); + return 0; + case RuntimeCommandType.delay: + return _duration(command, defaultValue: 0); + case RuntimeCommandType.sequence: + return _commandsFromPayload( + command, + ).fold(0, (sum, child) => sum + _estimatedDuration(child)); + case RuntimeCommandType.parallel: + var maxDuration = 0.0; + for (final child in _commandsFromPayload(command)) { + final duration = _estimatedDuration(child); + if (duration > maxDuration) { + maxDuration = duration; + } + } + return maxDuration; + case RuntimeCommandType.toast: + _toastText(command); + return _duration(command, defaultValue: 1.8); + case RuntimeCommandType.playSound: + _requiredAudioResource(command); + _optionalVolume(command); + return 0; + case RuntimeCommandType.playBgm: + _requiredAudioResource(command); + _optionalVolume(command); + _audioChannel(command); + _optionalBool(command.payload['loop'], 'play_bgm.loop'); + return 0; + case RuntimeCommandType.pauseBgm: + case RuntimeCommandType.resumeBgm: + case RuntimeCommandType.stopBgm: + _audioChannel(command); + return 0; + case RuntimeCommandType.preloadResources: + _requiredResourceGroup(command); + _optionalBool( + command.payload['failOnError'], + 'preload_resources.failOnError', + ); + return 0; + case RuntimeCommandType.evictResources: + _requiredResourceGroup(command); + return 0; + case RuntimeCommandType.cancelCommands: + _validateCancelCommands(command); + return 0; + case RuntimeCommandType.playSpineAnimation: + _requiredTarget(command); + _requiredSpineAnimation(command); + final track = _optionalInt( + command.payload['track'], + 'play_spine_animation.track', + ); + if (track != null && track < 0) { + throw const FormatException( + 'play_spine_animation.track must be >= 0', + ); + } + _optionalBool(command.payload['loop'], 'play_spine_animation.loop'); + _optionalBool(command.payload['queue'], 'play_spine_animation.queue'); + final delay = _readDouble(command.payload['delay']); + if (delay != null && delay < 0) { + throw const FormatException( + 'play_spine_animation.delay must be >= 0', + ); + } + return 0; + case RuntimeCommandType.copyText: + _requiredText(command, 'copy_text.text'); + return 0; + default: + throw UnsupportedError('Unsupported runtime command: ${command.type}'); + } + } + + void _validatePath(Object? pathValue) { + if (pathValue is! List || pathValue.isEmpty) { + throw const FormatException('move_path.path must be a non-empty list'); + } + for (final point in pathValue) { + if (point is! Map) { + throw const FormatException('move_path.path item must be a map'); + } + final x = _readDouble(point['x']); + final y = _readDouble(point['y']); + if (x == null || y == null) { + throw const FormatException('move_path point requires x/y'); + } + } + } + + List _commandsFromPayload(RuntimeCommand command) { + final value = command.payload['commands']; + if (value is! List) { + throw FormatException('${command.type}.commands must be a list'); + } + return value + .map((item) { + if (item is! Map) { + throw FormatException( + '${command.type}.commands item must be a map', + ); + } + return RuntimeCommand.fromMap(Map.from(item)); + }) + .toList(growable: false); + } +} diff --git a/lib/runtime/commands/runtime_command_registry.dart b/lib/runtime/commands/runtime_command_registry.dart new file mode 100644 index 0000000..4e1626e --- /dev/null +++ b/lib/runtime/commands/runtime_command_registry.dart @@ -0,0 +1,146 @@ +class RuntimeCommandRegistry { + final Set _handles = {}; + final Map> _handlesById = {}; + final Map> _handlesByGroup = {}; + final Map> _handlesByScope = {}; + bool _disposed = false; + + int get activeHandleCount => _handles.length; + + RuntimeCommandHandle create({String? id, String? group, String? scope}) { + if (_disposed) { + throw StateError('RuntimeCommandRegistry has been disposed'); + } + + late final RuntimeCommandHandle handle; + handle = RuntimeCommandHandle._( + id: id, + group: group, + scope: scope, + onComplete: _unregister, + ); + _handles.add(handle); + _index(_handlesById, id, handle); + _index(_handlesByGroup, group, handle); + _index(_handlesByScope, scope, handle); + return handle; + } + + void cancelId(String id) { + _cancelAll(_handlesById[id]); + } + + void cancelGroup(String group) { + _cancelAll(_handlesByGroup[group]); + } + + void cancelScope(String scope) { + _cancelAll(_handlesByScope[scope]); + } + + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _cancelAll(_handles); + _handles.clear(); + _handlesById.clear(); + _handlesByGroup.clear(); + _handlesByScope.clear(); + } + + void _index( + Map> index, + String? key, + RuntimeCommandHandle handle, + ) { + if (key == null) { + return; + } + index.putIfAbsent(key, () => {}).add(handle); + } + + void _cancelAll(Set? handles) { + final snapshot = handles?.toList(growable: false) ?? const []; + for (final handle in snapshot) { + handle.cancel(); + } + } + + void _unregister(RuntimeCommandHandle handle) { + _handles.remove(handle); + _unindex(_handlesById, handle.id, handle); + _unindex(_handlesByGroup, handle.group, handle); + _unindex(_handlesByScope, handle.scope, handle); + } + + void _unindex( + Map> index, + String? key, + RuntimeCommandHandle handle, + ) { + if (key == null) { + return; + } + final handles = index[key]; + handles?.remove(handle); + if (handles != null && handles.isEmpty) { + index.remove(key); + } + } +} + +class RuntimeCommandHandle { + RuntimeCommandHandle._({ + required this.id, + required this.group, + required this.scope, + required void Function(RuntimeCommandHandle handle) onComplete, + }) : _onComplete = onComplete; + + final String? id; + final String? group; + final String? scope; + final void Function(RuntimeCommandHandle handle) _onComplete; + final List _cancelCallbacks = []; + bool _cancelled = false; + bool _completed = false; + + bool get isCancelled => _cancelled; + + bool get isCompleted => _completed; + + void addCancelCallback(void Function() callback) { + if (_cancelled) { + callback(); + return; + } + if (_completed) { + return; + } + _cancelCallbacks.add(callback); + } + + void complete() { + if (_completed) { + return; + } + _completed = true; + _cancelCallbacks.clear(); + _onComplete(this); + } + + void cancel() { + if (_cancelled || _completed) { + return; + } + _cancelled = true; + final callbacks = _cancelCallbacks.toList(growable: false); + _cancelCallbacks.clear(); + for (final callback in callbacks) { + callback(); + } + complete(); + } +} diff --git a/lib/runtime/diagnostics/runtime_diagnostics.dart b/lib/runtime/diagnostics/runtime_diagnostics.dart new file mode 100644 index 0000000..29d2f58 --- /dev/null +++ b/lib/runtime/diagnostics/runtime_diagnostics.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +class RuntimeDiagnostics { + RuntimeDiagnostics({this.maxEntries = 100}); + + final int maxEntries; + final List _entries = []; + + List get entries => List.unmodifiable(_entries); + + Map toDebugJson() { + return { + 'maxEntries': maxEntries, + 'count': _entries.length, + 'entries': _entries.map((entry) => entry.toDebugJson()).toList(), + }; + } + + String dumpText() { + if (_entries.isEmpty) { + return 'RuntimeDiagnostics: no entries'; + } + + final buffer = StringBuffer( + 'RuntimeDiagnostics (${_entries.length}/$maxEntries)', + ); + for (final entry in _entries) { + buffer + ..writeln() + ..write(entry.dumpText()); + } + return buffer.toString(); + } + + void record({ + required RuntimeDiagnosticType type, + required String message, + Object? error, + Map context = const {}, + }) { + if (_entries.length >= maxEntries) { + _entries.removeAt(0); + } + _entries.add( + RuntimeDiagnosticEntry( + type: type, + message: message, + error: error, + context: context, + timestamp: DateTime.now(), + ), + ); + } + + void clear() { + _entries.clear(); + } +} + +class RuntimeDiagnosticEntry { + const RuntimeDiagnosticEntry({ + required this.type, + required this.message, + required this.timestamp, + this.error, + this.context = const {}, + }); + + final RuntimeDiagnosticType type; + final String message; + final DateTime timestamp; + final Object? error; + final Map context; + + Map toDebugJson() { + return { + 'timestamp': timestamp.toIso8601String(), + 'type': type.name, + 'message': message, + if (error != null) 'error': error.toString(), + if (context.isNotEmpty) 'context': _toDebugValue(context), + }; + } + + String dumpText() { + final buffer = StringBuffer( + '[${timestamp.toIso8601String()}] ${type.name}: $message', + ); + if (error != null) { + buffer + ..writeln() + ..write(' error: $error'); + } + if (context.isNotEmpty) { + buffer + ..writeln() + ..write(' context: ${_formatDebugValue(context)}'); + } + return buffer.toString(); + } +} + +Object? _toDebugValue(Object? value) { + if (value == null || value is String || value is num || value is bool) { + return value; + } + if (value is DateTime) { + return value.toIso8601String(); + } + if (value is Map) { + final entries = value.entries.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return { + for (final entry in entries) + entry.key.toString(): _toDebugValue(entry.value), + }; + } + if (value is Iterable) { + return value.map(_toDebugValue).toList(); + } + return value.toString(); +} + +String _formatDebugValue(Object? value) { + try { + return jsonEncode(_toDebugValue(value)); + } catch (_) { + return value.toString(); + } +} + +enum RuntimeDiagnosticType { + luaEventError, + diffApplyError, + packageActivationError, + resourceLoadError, + commandError, +} diff --git a/lib/runtime/display/runtime_viewport.dart b/lib/runtime/display/runtime_viewport.dart new file mode 100644 index 0000000..afbcf26 --- /dev/null +++ b/lib/runtime/display/runtime_viewport.dart @@ -0,0 +1,160 @@ +import 'package:flame/components.dart'; + +class RuntimeScaleMode { + const RuntimeScaleMode._(); + + static const fit = 'fit'; + static const fill = 'fill'; + static const stretch = 'stretch'; + static const none = 'none'; + + static const all = {fit, fill, stretch, none}; + + static bool isSupported(String value) => all.contains(value); +} + +class RuntimeViewportConfig { + const RuntimeViewportConfig({ + required this.designWidth, + required this.designHeight, + this.scaleMode = RuntimeScaleMode.fit, + }); + + final double designWidth; + final double designHeight; + final String scaleMode; + + Vector2 get designSize => Vector2(designWidth, designHeight); +} + +class RuntimeViewportTransform { + const RuntimeViewportTransform({ + required this.x, + required this.y, + required this.width, + required this.height, + required this.scaleX, + required this.scaleY, + required this.scaleMode, + }); + + final double x; + final double y; + final double width; + final double height; + final double scaleX; + final double scaleY; + final String scaleMode; + + Map toMap() { + return { + 'x': x, + 'y': y, + 'width': width, + 'height': height, + 'scaleX': scaleX, + 'scaleY': scaleY, + 'scaleMode': scaleMode, + }; + } +} + +class RuntimeViewport { + const RuntimeViewport._(); + + static RuntimeViewportTransform compute({ + required Vector2 screenSize, + required RuntimeViewportConfig config, + }) { + final designWidth = config.designWidth; + final designHeight = config.designHeight; + final screenWidth = screenSize.x; + final screenHeight = screenSize.y; + + if (designWidth <= 0 || designHeight <= 0) { + throw const FormatException('Runtime viewport design size must be > 0'); + } + if (!RuntimeScaleMode.isSupported(config.scaleMode)) { + throw FormatException( + 'Runtime viewport scaleMode is unsupported: ${config.scaleMode}', + ); + } + + final safeScreenWidth = screenWidth <= 0 ? designWidth : screenWidth; + final safeScreenHeight = screenHeight <= 0 ? designHeight : screenHeight; + + final scaleX = safeScreenWidth / designWidth; + final scaleY = safeScreenHeight / designHeight; + + return switch (config.scaleMode) { + RuntimeScaleMode.fit => _uniform( + designWidth: designWidth, + designHeight: designHeight, + screenWidth: safeScreenWidth, + screenHeight: safeScreenHeight, + scale: scaleX < scaleY ? scaleX : scaleY, + scaleMode: config.scaleMode, + ), + RuntimeScaleMode.fill => _uniform( + designWidth: designWidth, + designHeight: designHeight, + screenWidth: safeScreenWidth, + screenHeight: safeScreenHeight, + scale: scaleX > scaleY ? scaleX : scaleY, + scaleMode: config.scaleMode, + ), + RuntimeScaleMode.stretch => RuntimeViewportTransform( + x: 0, + y: 0, + width: safeScreenWidth, + height: safeScreenHeight, + scaleX: scaleX, + scaleY: scaleY, + scaleMode: config.scaleMode, + ), + RuntimeScaleMode.none => RuntimeViewportTransform( + x: (safeScreenWidth - designWidth) / 2, + y: (safeScreenHeight - designHeight) / 2, + width: designWidth, + height: designHeight, + scaleX: 1, + scaleY: 1, + scaleMode: config.scaleMode, + ), + _ => throw FormatException( + 'Runtime viewport scaleMode is unsupported: ${config.scaleMode}', + ), + }; + } + + static void apply( + PositionComponent root, + RuntimeViewportTransform transform, + ) { + root + ..position = Vector2(transform.x, transform.y) + ..scale = Vector2(transform.scaleX, transform.scaleY) + ..size = Vector2(transform.width, transform.height); + } + + static RuntimeViewportTransform _uniform({ + required double designWidth, + required double designHeight, + required double screenWidth, + required double screenHeight, + required double scale, + required String scaleMode, + }) { + final width = designWidth * scale; + final height = designHeight * scale; + return RuntimeViewportTransform( + x: (screenWidth - width) / 2, + y: (screenHeight - height) / 2, + width: width, + height: height, + scaleX: scale, + scaleY: scale, + scaleMode: scaleMode, + ); + } +} diff --git a/lib/runtime/events/runtime_event_dispatcher.dart b/lib/runtime/events/runtime_event_dispatcher.dart new file mode 100644 index 0000000..2d2491d --- /dev/null +++ b/lib/runtime/events/runtime_event_dispatcher.dart @@ -0,0 +1,80 @@ +import '../diagnostics/runtime_diagnostics.dart'; +import '../lifecycle/runtime_serial_queue.dart'; +import '../lifecycle/runtime_session.dart'; +import '../models/game_diff.dart'; +import '../models/runtime_event.dart'; +import '../scripting/script_engine.dart'; +import 'runtime_event_gate.dart'; + +class RuntimeEventDispatcher { + RuntimeEventDispatcher({ + required RuntimeSession session, + required ScriptEngine scriptEngine, + required bool Function(String id) isScopeAlive, + bool Function(String id, int epoch)? isNodeEpochAlive, + required void Function(GameDiff diff) applyDiff, + RuntimeDiagnostics? diagnostics, + void Function(Object error)? onError, + }) : _scriptEngine = scriptEngine, + _applyDiff = applyDiff, + _diagnostics = diagnostics, + _onError = onError, + _gate = RuntimeEventGate( + session: session, + isScopeAlive: isScopeAlive, + isNodeEpochAlive: isNodeEpochAlive, + ) { + _queue = RuntimeSerialQueue( + shouldContinue: () => !_disposed && session.isActive, + onItem: _dispatch, + ); + } + + final ScriptEngine _scriptEngine; + final void Function(GameDiff diff) _applyDiff; + final RuntimeDiagnostics? _diagnostics; + final void Function(Object error)? _onError; + final RuntimeEventGate _gate; + late final RuntimeSerialQueue _queue; + bool _disposed = false; + + int get pendingEventCount => _queue.pendingCount; + + void enqueue(RuntimeEvent event) { + if (_disposed || !_gate.session.isActive) { + return; + } + _queue.enqueue(_gate.attachSession(event)); + } + + void dispose() { + _disposed = true; + _queue.dispose(); + } + + void _dispatch(RuntimeEvent event) { + if (!_gate.accepts(event)) { + return; + } + + try { + final diff = _scriptEngine.dispatchEvent(event); + if (_disposed || !_gate.session.isActive || !_gate.accepts(event)) { + return; + } + _applyDiff(diff); + } catch (error) { + _diagnostics?.record( + type: RuntimeDiagnosticType.luaEventError, + message: 'Lua event dispatch failed', + error: error, + context: { + 'eventType': event.type, + if (event.target != null) 'target': event.target, + if (event.handler != null) 'handler': event.handler, + }, + ); + _onError?.call(error); + } + } +} diff --git a/lib/runtime/events/runtime_event_gate.dart b/lib/runtime/events/runtime_event_gate.dart new file mode 100644 index 0000000..b74af07 --- /dev/null +++ b/lib/runtime/events/runtime_event_gate.dart @@ -0,0 +1,47 @@ +import '../lifecycle/runtime_session.dart'; +import '../models/runtime_event.dart'; + +class RuntimeEventGate { + const RuntimeEventGate({ + required this.session, + required bool Function(String id) isScopeAlive, + bool Function(String id, int epoch)? isNodeEpochAlive, + }) : _isScopeAlive = isScopeAlive, + _isNodeEpochAlive = isNodeEpochAlive; + + final RuntimeSession session; + final bool Function(String id) _isScopeAlive; + final bool Function(String id, int epoch)? _isNodeEpochAlive; + + RuntimeEvent attachSession(RuntimeEvent event) { + return event.withLifecycle(sessionId: event.sessionId ?? session.id); + } + + bool accepts(RuntimeEvent event) { + final eventSessionId = event.sessionId; + if (eventSessionId != null && !session.accepts(eventSessionId)) { + return false; + } + + final target = event.target; + final targetEpoch = event.targetEpoch; + final epochChecker = _isNodeEpochAlive; + if (target != null && targetEpoch != null && epochChecker != null) { + if (!epochChecker(target, targetEpoch)) { + return false; + } + } + + final scope = event.scope; + if (scope != null && !_isScopeAlive(scope)) { + return false; + } + final scopeEpoch = event.scopeEpoch; + if (scope != null && scopeEpoch != null && epochChecker != null) { + if (!epochChecker(scope, scopeEpoch)) { + return false; + } + } + return true; + } +} diff --git a/lib/runtime/game/flame_lua_game.dart b/lib/runtime/game/flame_lua_game.dart new file mode 100644 index 0000000..13f227d --- /dev/null +++ b/lib/runtime/game/flame_lua_game.dart @@ -0,0 +1,377 @@ +import 'dart:ui' show PlatformDispatcher; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; + +import '../audio/runtime_audio_manager.dart'; +import '../commands/command_executor.dart'; +import '../diagnostics/runtime_diagnostics.dart'; +import '../events/runtime_event_dispatcher.dart'; +import '../lifecycle/runtime_session.dart'; +import '../models/game_diff.dart'; +import '../models/runtime_event.dart'; +import '../packages/game_package.dart'; +import '../packages/game_package_activation_controller.dart'; +import '../packages/game_package_repository.dart'; +import '../packages/stable_package_store.dart'; +import '../protocol/runtime_protocol.dart'; +import '../rendering/render_tree_controller.dart'; +import '../display/runtime_viewport.dart'; +import '../resources/game_resource_manager.dart'; +import '../scripting/script_engine.dart'; +import 'runtime_locale.dart'; +import 'runtime_options.dart'; + +class FlameLuaGame extends FlameGame with PanDetector, ScrollDetector { + FlameLuaGame({ + required ScriptEngine scriptEngine, + ScriptEngine Function()? scriptEngineFactory, + required GamePackageRepository packageRepository, + required this.gameId, + RuntimeDiagnostics? diagnostics, + this.imageCacheMaxBytes, + this.imageCacheMaxEntries, + this.imageMaxConcurrentLoads = 4, + this.audioCacheMaxBytes, + this.audioCacheMaxEntries, + this.audioMaxConcurrentLoads = 4, + this.audioSfxPoolSize = 8, + this.runtimeOptions = const RuntimeOptions(), + Locale? localeOverride, + }) : _bootstrapScriptEngine = scriptEngine, + _localeOverride = localeOverride, + _scriptEngineFactory = scriptEngineFactory, + _packageRepository = packageRepository, + diagnostics = diagnostics ?? RuntimeDiagnostics(); + + final ScriptEngine _bootstrapScriptEngine; + final ScriptEngine Function()? _scriptEngineFactory; + late ScriptEngine _scriptEngine; + final GamePackageRepository _packageRepository; + final String gameId; + final RuntimeDiagnostics diagnostics; + final int? imageCacheMaxBytes; + final int? imageCacheMaxEntries; + final int imageMaxConcurrentLoads; + final int? audioCacheMaxBytes; + final int? audioCacheMaxEntries; + final int audioMaxConcurrentLoads; + final int audioSfxPoolSize; + final RuntimeOptions runtimeOptions; + final Locale? _localeOverride; + + late final GameResourceManager _resources; + late final RuntimeAudioManager _audio; + late final RenderTreeController _renderTree; + late final PositionComponent _viewportRoot; + RuntimeViewportConfig? _viewportConfig; + late final CommandExecutor _commands; + RuntimeSession? _session; + RuntimeEventDispatcher? _events; + String? _draggingListViewId; + bool _runtimeInitialized = false; + String? loadError; + + List get diagnosticEntries => diagnostics.entries; + + Map diagnosticsDebugJson() => diagnostics.toDebugJson(); + + String diagnosticsDumpText() => diagnostics.dumpText(); + + Map resourcesDebugJson() { + if (!_runtimeInitialized) { + return {'initialized': false}; + } + return { + 'initialized': true, + 'images': _resources.imagesDebugJson(), + 'audio': _audio.audioDebugJson(), + }; + } + + @override + Color backgroundColor() => const Color(0xff0f172a); + + @override + Future onLoad() async { + await super.onLoad(); + + final session = RuntimeSession(gameId: gameId)..beginLoading(); + _session = session; + + try { + final activation = + await PackageActivationController( + repository: _packageRepository, + resources: _createResourceManager(), + scriptEngine: _bootstrapScriptEngine, + audio: _createAudioManager(), + resourceManagerFactory: _createResourceManager, + audioManagerFactory: _createAudioManager, + scriptEngineFactory: _scriptEngineFactory, + store: StablePackageStore(runtimeOptions: runtimeOptions), + assetFallback: AssetGamePackageRepository( + runtimeOptions: runtimeOptions, + ), + ).activate( + gameId: gameId, + contextBuilder: _buildContext, + shouldContinue: () => session.acceptsWork, + ); + if (!session.acceptsWork) { + activation.resources.dispose(); + activation.audio?.dispose(); + return; + } + session.activate(); + + _resources = activation.resources; + _audio = activation.audio ?? _createAudioManager(); + _scriptEngine = activation.scriptEngine; + _viewportConfig = activation.package.manifest.display.toViewportConfig(); + _viewportRoot = PositionComponent(); + add(_viewportRoot); + _applyViewportTransform(); + _renderTree = RenderTreeController( + root: _viewportRoot, + resources: _resources, + eventSink: _emitEvent, + ); + _commands = CommandExecutor( + renderTree: _renderTree, + eventSink: _emitEvent, + audio: _audio, + resources: _resources, + overlaySize: _viewportConfig?.designSize, + ); + _renderTree.onScopeRemoved = _commands.cancelScope; + _events = RuntimeEventDispatcher( + session: session, + scriptEngine: _scriptEngine, + isScopeAlive: _renderTree.contains, + isNodeEpochAlive: _renderTree.isNodeEpochAlive, + applyDiff: _applyDiff, + diagnostics: diagnostics, + onError: (error) => debugPrint('Lua event failed: $error'), + ); + _runtimeInitialized = true; + _applyDiff(activation.initialDiff); + } catch (error) { + session.dispose(); + loadError = error.toString(); + diagnostics.record( + type: RuntimeDiagnosticType.packageActivationError, + message: 'Lua game package activation failed', + error: error, + context: {'gameId': gameId}, + ); + debugPrint('Lua game load failed: $error'); + } + } + + GameResourceManager _createResourceManager() { + return GameResourceManager( + diagnostics: diagnostics, + maxCacheBytes: imageCacheMaxBytes, + maxCacheEntries: imageCacheMaxEntries, + maxConcurrentLoads: imageMaxConcurrentLoads, + ); + } + + RuntimeAudioManager _createAudioManager() { + return RuntimeAudioManager( + diagnostics: diagnostics, + maxSfxPoolSize: audioSfxPoolSize, + maxCacheBytes: audioCacheMaxBytes, + maxCacheEntries: audioCacheMaxEntries, + maxConcurrentLoads: audioMaxConcurrentLoads, + ); + } + + Map _buildContext(GamePackage package) { + final display = package.manifest.display; + final viewport = RuntimeViewport.compute( + screenSize: size, + config: display.toViewportConfig(), + ); + final locale = RuntimeLocaleResolver.resolve( + requested: _localeOverride ?? PlatformDispatcher.instance.locale, + defaultLocale: package.manifest.defaultLocale, + supportedLocales: package.manifest.supportedLocales, + ); + + return { + 'screen': {'width': size.x, 'height': size.y}, + 'design': {'width': display.designWidth, 'height': display.designHeight}, + 'viewport': viewport.toMap(), + 'seed': DateTime.now().millisecondsSinceEpoch, + 'runtimeApiVersion': 1, + 'gameId': package.manifest.gameId, + 'gameVersion': package.manifest.version, + 'locale': locale.toMap(), + }; + } + + void _emitEvent(RuntimeEvent event) { + final session = _session; + if (session == null || !session.isActive) { + return; + } + _events?.enqueue(event.withLifecycle(sessionId: session.id)); + } + + @override + void onScroll(PointerScrollInfo info) { + if (!_runtimeInitialized) { + return; + } + _renderTree.scrollListViewAt( + info.eventPosition.widget, + deltaX: info.scrollDelta.global.x, + deltaY: info.scrollDelta.global.y, + source: 'wheel', + ); + } + + @override + void onPanStart(DragStartInfo info) { + if (!_runtimeInitialized) { + _draggingListViewId = null; + return; + } + _draggingListViewId = _renderTree.listViewAt(info.eventPosition.widget); + final id = _draggingListViewId; + if (id != null) { + _renderTree.stopListViewVelocity(id); + info.handled = true; + } + } + + @override + void onPanUpdate(DragUpdateInfo info) { + final id = _draggingListViewId; + if (!_runtimeInitialized || id == null) { + return; + } + final consumed = _renderTree.scrollListView( + id, + deltaX: -info.delta.global.x, + deltaY: -info.delta.global.y, + source: 'drag', + ); + if (consumed) { + info.handled = true; + } + } + + @override + void onPanEnd(DragEndInfo info) { + final id = _draggingListViewId; + if (id != null) { + _renderTree.setListViewVelocity( + id, + Vector2(-info.velocity.x, -info.velocity.y), + ); + info.handled = true; + } + _draggingListViewId = null; + } + + @override + void onPanCancel() { + _draggingListViewId = null; + } + + @override + void update(double dt) { + super.update(dt); + if (_runtimeInitialized) { + _renderTree.updateListViewInertia(dt); + } + } + + @override + void onGameResize(Vector2 size) { + super.onGameResize(size); + if (_runtimeInitialized) { + _applyViewportTransform(); + _emitResizeEvent(); + } + } + + void _emitResizeEvent() { + final config = _viewportConfig; + if (config == null) { + return; + } + final viewport = RuntimeViewport.compute(screenSize: size, config: config); + _emitEvent( + RuntimeEvent( + type: RuntimeEventType.resize, + data: { + 'screen': {'width': size.x, 'height': size.y}, + 'viewport': viewport.toMap(), + }, + ), + ); + } + + void _applyViewportTransform() { + final config = _viewportConfig; + if (config == null) { + return; + } + RuntimeViewport.apply( + _viewportRoot, + RuntimeViewport.compute(screenSize: size, config: config), + ); + } + + @override + void onRemove() { + _draggingListViewId = null; + _session?.beginDisposing(); + _events?.dispose(); + if (_runtimeInitialized) { + _commands.dispose(); + _renderTree.clear(); + _audio.dispose(); + _resources.dispose(); + } + _session?.dispose(); + super.onRemove(); + } + + void _applyDiff(GameDiff diff) { + final session = _session; + if (session == null || !session.isActive) { + return; + } + try { + _renderTree + ..apply(diff.render) + ..apply(diff.ui); + } catch (error) { + diagnostics.record( + type: RuntimeDiagnosticType.diffApplyError, + message: 'Runtime diff apply failed', + error: error, + ); + debugPrint('Runtime diff apply failed: $error'); + return; + } + + try { + _commands.executeAll(diff.commands); + } catch (error) { + diagnostics.record( + type: RuntimeDiagnosticType.commandError, + message: 'Runtime command execution failed', + error: error, + ); + debugPrint('Runtime command execution failed: $error'); + } + } +} diff --git a/lib/runtime/game/lua_game_widget.dart b/lib/runtime/game/lua_game_widget.dart new file mode 100644 index 0000000..5bff845 --- /dev/null +++ b/lib/runtime/game/lua_game_widget.dart @@ -0,0 +1,45 @@ +import 'package:flame/game.dart'; +import 'package:flutter/widgets.dart'; + +import '../packages/game_package_repository.dart'; +import '../scripting/lua_dardo_script_engine.dart'; +import 'flame_lua_game.dart'; +import 'runtime_options.dart'; + +class LuaGameWidget extends StatelessWidget { + const LuaGameWidget({ + required this.gameId, + this.packageRepository, + this.serverUrl, + this.localeOverride, + this.runtimeOptions = const RuntimeOptions(), + super.key, + }); + + final String gameId; + final GamePackageRepository? packageRepository; + final Uri? serverUrl; + final Locale? localeOverride; + final RuntimeOptions runtimeOptions; + + @override + Widget build(BuildContext context) { + return GameWidget( + game: FlameLuaGame( + scriptEngine: LuaDardoScriptEngine(), + scriptEngineFactory: LuaDardoScriptEngine.new, + packageRepository: + packageRepository ?? + (serverUrl == null + ? AssetGamePackageRepository(runtimeOptions: runtimeOptions) + : RemoteGamePackageRepository( + baseUri: serverUrl!, + runtimeOptions: runtimeOptions, + )), + gameId: gameId, + runtimeOptions: runtimeOptions, + localeOverride: localeOverride, + ), + ); + } +} diff --git a/lib/runtime/game/runtime_locale.dart b/lib/runtime/game/runtime_locale.dart new file mode 100644 index 0000000..501fb9c --- /dev/null +++ b/lib/runtime/game/runtime_locale.dart @@ -0,0 +1,162 @@ +import 'dart:ui' show Locale; + +class RuntimeLocaleInfo { + const RuntimeLocaleInfo({ + required this.requested, + required this.resolved, + required this.defaultLocale, + required this.supportedLocales, + required this.languageCode, + this.scriptCode, + this.countryCode, + }); + + final String requested; + final String resolved; + final String defaultLocale; + final List supportedLocales; + final String languageCode; + final String? scriptCode; + final String? countryCode; + + Map toMap() { + return { + 'requested': requested, + 'resolved': resolved, + 'default': defaultLocale, + 'supported': supportedLocales, + 'languageCode': languageCode, + if (scriptCode != null) 'scriptCode': scriptCode, + if (countryCode != null) 'countryCode': countryCode, + }; + } +} + +class RuntimeLocaleResolver { + const RuntimeLocaleResolver._(); + + static RuntimeLocaleInfo resolve({ + required Locale requested, + required String defaultLocale, + required List supportedLocales, + }) { + final requestedTag = normalizeTag(tagOf(requested)); + final fallback = normalizeTag(defaultLocale); + final supported = supportedLocales.isEmpty + ? [fallback] + : supportedLocales.map(normalizeTag).toList(growable: false); + final resolved = _resolveTag( + requestedTag: requestedTag, + fallback: fallback, + supported: supported, + ); + + return RuntimeLocaleInfo( + requested: requestedTag, + resolved: resolved, + defaultLocale: fallback, + supportedLocales: supported, + languageCode: requested.languageCode, + scriptCode: requested.scriptCode, + countryCode: requested.countryCode, + ); + } + + static Locale localeFromTag(String tag) { + final parts = normalizeTag(tag).split('-'); + if (parts.isEmpty || parts.first.isEmpty) { + throw const FormatException('Locale tag must not be empty'); + } + + String? scriptCode; + String? countryCode; + for (final part in parts.skip(1)) { + if (part.length == 4 && scriptCode == null) { + scriptCode = part; + } else { + countryCode ??= part; + } + } + + return Locale.fromSubtags( + languageCode: parts.first, + scriptCode: scriptCode, + countryCode: countryCode, + ); + } + + static String tagOf(Locale locale) { + final parts = [locale.languageCode]; + final scriptCode = locale.scriptCode; + final countryCode = locale.countryCode; + if (scriptCode != null && scriptCode.isNotEmpty) { + parts.add(scriptCode); + } + if (countryCode != null && countryCode.isNotEmpty) { + parts.add(countryCode); + } + return normalizeTag(parts.join('-')); + } + + static String normalizeTag(String tag) { + final normalized = tag.trim().replaceAll('_', '-'); + if (normalized.isEmpty) { + throw const FormatException('Locale tag must not be empty'); + } + + final parts = normalized + .split('-') + .where((part) => part.isNotEmpty) + .toList(growable: false); + if (parts.isEmpty) { + throw const FormatException('Locale tag must not be empty'); + } + if (!_isLocalePart(parts.first)) { + throw FormatException('Locale language code is invalid: ${parts.first}'); + } + + final result = [parts.first.toLowerCase()]; + for (final part in parts.skip(1)) { + if (!_isLocalePart(part)) { + throw FormatException('Locale tag part is invalid: $part'); + } + if (part.length == 4) { + result.add( + '${part[0].toUpperCase()}${part.substring(1).toLowerCase()}', + ); + } else if (part.length == 2 || part.length == 3) { + result.add(part.toUpperCase()); + } else { + result.add(part.toLowerCase()); + } + } + return result.join('-'); + } + + static String _resolveTag({ + required String requestedTag, + required String fallback, + required List supported, + }) { + final supportedSet = supported.toSet(); + if (supportedSet.contains(requestedTag)) { + return requestedTag; + } + + final requestedLanguage = requestedTag.split('-').first; + for (final candidate in supported) { + if (candidate.split('-').first == requestedLanguage) { + return candidate; + } + } + + if (supportedSet.contains(fallback)) { + return fallback; + } + return supported.first; + } + + static bool _isLocalePart(String value) { + return RegExp(r'^[A-Za-z0-9]{2,8}$').hasMatch(value); + } +} diff --git a/lib/runtime/game/runtime_options.dart b/lib/runtime/game/runtime_options.dart new file mode 100644 index 0000000..746e8e9 --- /dev/null +++ b/lib/runtime/game/runtime_options.dart @@ -0,0 +1,7 @@ +class RuntimeOptions { + const RuntimeOptions({this.runtimeLuaRoot = defaultRuntimeLuaRoot}); + + static const defaultRuntimeLuaRoot = 'assets/runtime/lua'; + + final String runtimeLuaRoot; +} diff --git a/lib/runtime/lifecycle/runtime_async_gate.dart b/lib/runtime/lifecycle/runtime_async_gate.dart new file mode 100644 index 0000000..138a60c --- /dev/null +++ b/lib/runtime/lifecycle/runtime_async_gate.dart @@ -0,0 +1,50 @@ +class RuntimeAsyncGate { + RuntimeAsyncGate({bool initiallyClosed = false}) : _closed = initiallyClosed; + + int _generation = 0; + bool _closed; + + int get generation => _generation; + + bool get isOpen => !_closed; + + bool get isClosed => _closed; + + RuntimeAsyncToken get token => RuntimeAsyncToken._(this, _generation); + + RuntimeAsyncToken activate() { + _closed = false; + _generation++; + return token; + } + + RuntimeAsyncToken advance() { + _generation++; + return token; + } + + RuntimeAsyncToken close() { + _closed = true; + _generation++; + return token; + } + + bool accepts(RuntimeAsyncToken token) { + return !_closed && + identical(token._gate, this) && + token.generation == _generation; + } + + bool acceptsGeneration(int generation) { + return !_closed && generation == _generation; + } +} + +class RuntimeAsyncToken { + const RuntimeAsyncToken._(this._gate, this.generation); + + final RuntimeAsyncGate _gate; + final int generation; + + bool get isAccepted => _gate.accepts(this); +} diff --git a/lib/runtime/lifecycle/runtime_serial_queue.dart b/lib/runtime/lifecycle/runtime_serial_queue.dart new file mode 100644 index 0000000..ff440e5 --- /dev/null +++ b/lib/runtime/lifecycle/runtime_serial_queue.dart @@ -0,0 +1,77 @@ +import 'dart:async' as async; + +class RuntimeSerialQueue { + RuntimeSerialQueue({required this.onItem, bool Function()? shouldContinue}) + : _shouldContinue = shouldContinue; + + final void Function(T item) onItem; + final bool Function()? _shouldContinue; + final List _queue = []; + int _head = 0; + bool _disposed = false; + bool _draining = false; + + int get pendingCount => _queue.length - _head; + + bool get isDraining => _draining; + + bool get isDisposed => _disposed; + + void enqueue(T item) { + if (_disposed || !_canContinue()) { + return; + } + _queue.add(item); + _scheduleDrain(); + } + + void clear() { + _queue.clear(); + _head = 0; + } + + void dispose() { + _disposed = true; + clear(); + } + + void _scheduleDrain() { + if (_draining) { + return; + } + _draining = true; + async.scheduleMicrotask(_drain); + } + + void _drain() { + try { + while (pendingCount > 0 && !_disposed && _canContinue()) { + onItem(_queue[_head++]); + _compactIfNeeded(); + } + } finally { + _draining = false; + } + + if (pendingCount > 0 && !_disposed && _canContinue()) { + _scheduleDrain(); + } + } + + void _compactIfNeeded() { + if (_head == 0) { + return; + } + if (_head == _queue.length) { + clear(); + return; + } + if (_head < 32 || _head * 2 < _queue.length) { + return; + } + _queue.removeRange(0, _head); + _head = 0; + } + + bool _canContinue() => _shouldContinue?.call() ?? true; +} diff --git a/lib/runtime/lifecycle/runtime_session.dart b/lib/runtime/lifecycle/runtime_session.dart new file mode 100644 index 0000000..19f1a3f --- /dev/null +++ b/lib/runtime/lifecycle/runtime_session.dart @@ -0,0 +1,71 @@ +enum RuntimeSessionState { created, loading, active, disposing, disposed } + +class RuntimeSession { + RuntimeSession({required this.gameId}) : id = _nextId++; + + static int _nextId = 1; + + final int id; + final String gameId; + RuntimeSessionState _state = RuntimeSessionState.created; + + RuntimeSessionState get state => _state; + + bool get isLoading => _state == RuntimeSessionState.loading; + + bool get isActive => _state == RuntimeSessionState.active; + + bool get isDisposing => _state == RuntimeSessionState.disposing; + + bool get isDisposed => _state == RuntimeSessionState.disposed; + + bool get acceptsWork => + _state != RuntimeSessionState.disposing && + _state != RuntimeSessionState.disposed; + + void beginLoading() { + _transition( + RuntimeSessionState.loading, + allowedFrom: const {RuntimeSessionState.created}, + ); + } + + void activate() { + _transition( + RuntimeSessionState.active, + allowedFrom: const { + RuntimeSessionState.created, + RuntimeSessionState.loading, + }, + ); + } + + void beginDisposing() { + if (_state == RuntimeSessionState.disposed || + _state == RuntimeSessionState.disposing) { + return; + } + _state = RuntimeSessionState.disposing; + } + + void dispose() { + _state = RuntimeSessionState.disposed; + } + + bool accepts(int sessionId) => isActive && id == sessionId; + + bool acceptsWorkFor(int sessionId) => acceptsWork && id == sessionId; + + void _transition( + RuntimeSessionState next, { + required Set allowedFrom, + }) { + if (_state == next) { + return; + } + if (!allowedFrom.contains(_state)) { + throw StateError('Invalid runtime session transition: $_state -> $next'); + } + _state = next; + } +} diff --git a/lib/runtime/lifecycle/runtime_task_registry.dart b/lib/runtime/lifecycle/runtime_task_registry.dart new file mode 100644 index 0000000..2b9d4a6 --- /dev/null +++ b/lib/runtime/lifecycle/runtime_task_registry.dart @@ -0,0 +1,129 @@ +import 'dart:async' as async; + +class RuntimeTaskRegistry { + RuntimeTaskRegistry({required this.cancelledValue}); + + final T cancelledValue; + final Set> _tasks = {}; + final Map>> _tasksByScope = {}; + bool _disposed = false; + + int get activeTaskCount => _tasks.length; + + int scopedTaskCount(String scope) => _tasksByScope[scope]?.length ?? 0; + + RuntimeTask create({String? scope}) { + if (_disposed) { + throw StateError('RuntimeTaskRegistry has been disposed'); + } + + late final RuntimeTask task; + task = RuntimeTask._( + scope: scope, + cancelledValue: cancelledValue, + onComplete: _unregister, + ); + _tasks.add(task); + if (scope != null) { + _tasksByScope.putIfAbsent(scope, () => {}).add(task); + } + return task; + } + + void cancelScope(String scope) { + final tasks = _tasksByScope[scope]?.toList(growable: false) ?? const []; + for (final task in tasks) { + task.cancel(); + } + } + + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + final tasks = _tasks.toList(growable: false); + for (final task in tasks) { + task.cancel(); + } + _tasks.clear(); + _tasksByScope.clear(); + } + + void _unregister(RuntimeTask task) { + _tasks.remove(task); + final scope = task.scope; + if (scope == null) { + return; + } + final scopedTasks = _tasksByScope[scope]; + scopedTasks?.remove(task); + if (scopedTasks != null && scopedTasks.isEmpty) { + _tasksByScope.remove(scope); + } + } +} + +class RuntimeTask { + RuntimeTask._({ + required this.scope, + required this.cancelledValue, + required void Function(RuntimeTask task) onComplete, + }) : _onComplete = onComplete; + + final String? scope; + final T cancelledValue; + final void Function(RuntimeTask task) _onComplete; + final async.Completer _completer = async.Completer(); + final Set _timers = {}; + final List _cancelCallbacks = []; + bool _cancelled = false; + + Future get future => _completer.future; + + bool get isCancelled => _cancelled; + + void addTimer(async.Timer timer) { + if (_cancelled) { + timer.cancel(); + return; + } + _timers.add(timer); + } + + void removeTimer(async.Timer timer) { + _timers.remove(timer); + } + + void addCancelCallback(void Function() callback) { + if (_cancelled) { + callback(); + return; + } + _cancelCallbacks.add(callback); + } + + void complete(T result) { + if (_completer.isCompleted) { + return; + } + _completer.complete(result); + _onComplete(this); + } + + void cancel() { + if (_cancelled) { + return; + } + _cancelled = true; + for (final timer in _timers) { + timer.cancel(); + } + _timers.clear(); + for (final callback in _cancelCallbacks) { + callback(); + } + _cancelCallbacks.clear(); + complete(cancelledValue); + } +} diff --git a/lib/runtime/models/game_diff.dart b/lib/runtime/models/game_diff.dart new file mode 100644 index 0000000..5e3fdbe --- /dev/null +++ b/lib/runtime/models/game_diff.dart @@ -0,0 +1,173 @@ +import 'runtime_command.dart'; +import 'runtime_node.dart'; + +import '../protocol/runtime_protocol.dart'; + +class NodeUpdate { + const NodeUpdate({required this.id, required this.props}); + + final String id; + final Map props; + + static NodeUpdate fromMap(Map map) { + RuntimeProtocolSchema.ensureKnownKeys( + map, + allowed: RuntimeProtocolSchema.nodeUpdateFields, + context: 'NodeUpdate', + ); + final id = map[RuntimeProtocolField.id]; + if (id is! String || id.isEmpty) { + throw const FormatException('NodeUpdate.id must be a string'); + } + + final props = map[RuntimeProtocolField.props]; + if (props is! Map) { + throw const FormatException('NodeUpdate.props must be a map'); + } + + final typedProps = Map.from(props); + RuntimeProtocolSchema.ensureKnownKeys( + typedProps, + allowed: RuntimeProtocolSchema.nodePropsFields, + context: 'RuntimeNode.props', + ); + + return NodeUpdate(id: id, props: typedProps); + } +} + +class NodeRemove { + const NodeRemove({required this.id}); + + final String id; + + static NodeRemove fromValue(Object? value) { + if (value is String && value.isNotEmpty) { + return NodeRemove(id: value); + } + if (value is Map) { + RuntimeProtocolSchema.ensureKnownKeys( + value, + allowed: RuntimeProtocolSchema.nodeRemoveFields, + context: 'NodeRemove', + ); + final id = value[RuntimeProtocolField.id]; + if (id is String && id.isNotEmpty) { + return NodeRemove(id: id); + } + } + throw const FormatException('NodeRemove must be an id string or {id}'); + } +} + +class NodeDiff { + const NodeDiff({ + this.creates = const [], + this.updates = const [], + this.removes = const [], + }); + + final List creates; + final List updates; + final List removes; + + static NodeDiff empty = const NodeDiff(); + + static NodeDiff fromMap(Object? value) { + if (value == null) { + return NodeDiff.empty; + } + if (value is! Map) { + throw const FormatException('NodeDiff must be a map'); + } + RuntimeProtocolSchema.ensureKnownKeys( + value, + allowed: RuntimeProtocolSchema.nodeDiffFields, + context: 'NodeDiff', + ); + + return NodeDiff( + creates: _readList( + value[RuntimeProtocolField.creates], + (item) => RuntimeNode.fromMap(Map.from(item as Map)), + ), + updates: _readList( + value[RuntimeProtocolField.updates], + (item) => NodeUpdate.fromMap(Map.from(item as Map)), + ), + removes: _readList( + value[RuntimeProtocolField.removes], + NodeRemove.fromValue, + ), + ); + } + + static List _readList(Object? value, T Function(Object? value) mapper) { + if (value == null) { + return const []; + } + if (value is List) { + return value.map(mapper).toList(growable: false); + } + if (value is Map && value.isEmpty) { + return const []; + } + if (value is Map && value.keys.every(_isPositiveIntegerKey)) { + final entries = value.entries.toList() + ..sort( + (a, b) => int.parse( + a.key.toString(), + ).compareTo(int.parse(b.key.toString())), + ); + return entries + .map((entry) => mapper(entry.value)) + .toList(growable: false); + } + throw const FormatException('Diff field must be a list'); + } + + static bool _isPositiveIntegerKey(Object? key) { + final value = int.tryParse(key.toString()); + return value != null && value > 0; + } +} + +class GameDiff { + const GameDiff({ + required this.render, + required this.ui, + required this.commands, + }); + + final NodeDiff render; + final NodeDiff ui; + final List commands; + + static const empty = GameDiff( + render: NodeDiff(), + ui: NodeDiff(), + commands: [], + ); + + static GameDiff fromMap(Map map) { + RuntimeProtocolSchema.ensureKnownKeys( + map, + allowed: RuntimeProtocolSchema.gameDiffFields, + context: 'GameDiff', + ); + final commandsValue = map[RuntimeProtocolField.commands]; + final commands = commandsValue == null + ? const [] + : NodeDiff._readList( + commandsValue, + (item) => + RuntimeCommand.fromMap(Map.from(item as Map)), + ); + + return GameDiff( + render: NodeDiff.fromMap(map[RuntimeProtocolField.render]), + ui: NodeDiff.fromMap(map[RuntimeProtocolField.ui]), + commands: commands, + ); + } +} diff --git a/lib/runtime/models/runtime_command.dart b/lib/runtime/models/runtime_command.dart new file mode 100644 index 0000000..78f30f2 --- /dev/null +++ b/lib/runtime/models/runtime_command.dart @@ -0,0 +1,43 @@ +import '../protocol/runtime_protocol.dart'; + +class RuntimeCommand { + const RuntimeCommand({ + required this.type, + this.target, + this.payload = const {}, + }); + + final String type; + final String? target; + final Map payload; + + static RuntimeCommand fromMap(Map map) { + final type = map[RuntimeProtocolField.type]; + if (type is! String || type.isEmpty) { + throw const FormatException('RuntimeCommand.type must be a string'); + } + + if (!RuntimeCommandType.isSupported(type)) { + throw FormatException('RuntimeCommand.type is unsupported: $type'); + } + RuntimeProtocolSchema.ensureKnownKeys( + map, + allowed: RuntimeProtocolSchema.allowedCommandFields(type), + context: 'RuntimeCommand.$type', + ); + + final targetValue = map[RuntimeProtocolField.target]; + if (targetValue != null && targetValue is! String) { + throw const FormatException('RuntimeCommand.target must be a string'); + } + + final payload = Map.from(map) + ..remove(RuntimeProtocolField.type) + ..remove(RuntimeProtocolField.target); + return RuntimeCommand( + type: type, + target: targetValue as String?, + payload: payload, + ); + } +} diff --git a/lib/runtime/models/runtime_event.dart b/lib/runtime/models/runtime_event.dart new file mode 100644 index 0000000..c591ef3 --- /dev/null +++ b/lib/runtime/models/runtime_event.dart @@ -0,0 +1,64 @@ +class RuntimeEvent { + const RuntimeEvent({ + required this.type, + this.target, + this.handler, + this.x, + this.y, + this.data = const {}, + this.sessionId, + this.scope, + this.targetEpoch, + this.scopeEpoch, + }); + + final String type; + final String? target; + final String? handler; + final double? x; + final double? y; + final Map data; + + /// Runtime-internal lifecycle session. Not exposed to Lua. + final int? sessionId; + + /// Runtime-internal lifecycle scope. Not exposed to Lua. + final String? scope; + + /// Runtime-internal target node epoch. Not exposed to Lua. + final int? targetEpoch; + + /// Runtime-internal scope node epoch. Not exposed to Lua. + final int? scopeEpoch; + + RuntimeEvent withLifecycle({ + int? sessionId, + String? scope, + int? targetEpoch, + int? scopeEpoch, + }) { + return RuntimeEvent( + type: type, + target: target, + handler: handler, + x: x, + y: y, + data: data, + sessionId: sessionId ?? this.sessionId, + scope: scope ?? this.scope, + targetEpoch: targetEpoch ?? this.targetEpoch, + scopeEpoch: scopeEpoch ?? this.scopeEpoch, + ); + } + + Map toMap() { + return { + 'type': type, + if (target != null) 'target': target, + if (handler != null) 'handler': handler, + if (x != null) 'x': x, + if (y != null) 'y': y, + if (data.isNotEmpty) 'data': data, + }; + } +} diff --git a/lib/runtime/models/runtime_node.dart b/lib/runtime/models/runtime_node.dart new file mode 100644 index 0000000..e7c82b4 --- /dev/null +++ b/lib/runtime/models/runtime_node.dart @@ -0,0 +1,572 @@ +import 'package:flutter/material.dart'; + +import '../protocol/runtime_protocol.dart'; + +class RuntimeNode { + const RuntimeNode({ + required this.id, + required this.type, + this.parent, + this.asset, + this.pressedAsset, + this.disabledAsset, + this.animation, + this.skin, + this.loop = true, + this.text, + this.x = 0, + this.y = 0, + this.width, + this.height, + this.paddingLeft = 0, + this.paddingTop = 0, + this.paddingRight = 0, + this.paddingBottom = 0, + this.anchor = RuntimeAnchorValue.topLeft, + this.layer = 0, + this.visible = true, + this.alpha = 1, + this.scale = 1, + this.rotation = 0, + this.color, + this.fontSize, + this.textAlign = RuntimeTextAlignValue.center, + this.radius, + this.strokeWidth, + this.value, + this.scrollX = 0, + this.scrollY = 0, + this.contentWidth, + this.contentHeight, + this.virtualized = false, + this.cacheExtent = 0, + this.inertia = true, + this.scrollbarThumbColor, + this.scrollbarTrackColor, + this.scrollbarThickness, + this.scrollbarVisible = true, + this.interactive = false, + this.onTap, + this.onScroll, + this.preset, + this.count, + this.duration, + this.speedMin, + this.speedMax, + this.gravityX, + this.gravityY, + this.spread, + this.colorTo, + this.radiusTo, + this.autoRemove = true, + this.fadeOut = true, + }); + + final String id; + final String type; + final String? parent; + final String? asset; + final String? pressedAsset; + final String? disabledAsset; + final String? animation; + final String? skin; + final bool loop; + final String? text; + final double x; + final double y; + final double? width; + final double? height; + final double paddingLeft; + final double paddingTop; + final double paddingRight; + final double paddingBottom; + final String anchor; + final int layer; + final bool visible; + final double alpha; + final double scale; + final double rotation; + final Color? color; + final double? fontSize; + final String textAlign; + final double? radius; + final double? strokeWidth; + final double? value; + final double scrollX; + final double scrollY; + final double? contentWidth; + final double? contentHeight; + final bool virtualized; + final double cacheExtent; + final bool inertia; + final Color? scrollbarThumbColor; + final Color? scrollbarTrackColor; + final double? scrollbarThickness; + final bool scrollbarVisible; + final bool interactive; + final String? onTap; + final String? onScroll; + final String? preset; + final int? count; + final double? duration; + final double? speedMin; + final double? speedMax; + final double? gravityX; + final double? gravityY; + final double? spread; + final Color? colorTo; + final double? radiusTo; + final bool autoRemove; + final bool fadeOut; + + RuntimeNode copyWithProps(Map props) { + RuntimeProtocolSchema.ensureKnownKeys( + props, + allowed: RuntimeProtocolSchema.nodePropsFields, + context: 'RuntimeNode.props', + ); + final nextType = _stringProp(props, RuntimeProtocolField.type) ?? type; + if (!RuntimeNodeType.isSupported(nextType)) { + throw FormatException('RuntimeNode.type is unsupported: $nextType'); + } + final nextAnchor = + _stringProp(props, RuntimeProtocolField.anchor) ?? anchor; + if (!RuntimeAnchorValue.isSupported(nextAnchor)) { + throw FormatException('RuntimeNode.anchor is unsupported: $nextAnchor'); + } + final nextTextAlign = + _stringProp(props, RuntimeProtocolField.textAlign) ?? textAlign; + if (!RuntimeTextAlignValue.isSupported(nextTextAlign)) { + throw FormatException( + 'RuntimeNode.textAlign is unsupported: $nextTextAlign', + ); + } + + final nextPreset = + _stringProp(props, RuntimeProtocolField.preset) ?? preset; + _validateParticlePreset(nextPreset); + + final nextWidth = _doubleProp(props, RuntimeProtocolField.width) ?? width; + final nextHeight = + _doubleProp(props, RuntimeProtocolField.height) ?? height; + final nextContentWidth = + _doubleProp(props, RuntimeProtocolField.contentWidth) ?? contentWidth; + final nextContentHeight = + _doubleProp(props, RuntimeProtocolField.contentHeight) ?? contentHeight; + final nextPaddingLeft = + _nonNegativeDoubleProp(props, RuntimeProtocolField.paddingLeft) ?? + paddingLeft; + final nextPaddingTop = + _nonNegativeDoubleProp(props, RuntimeProtocolField.paddingTop) ?? + paddingTop; + final nextPaddingRight = + _nonNegativeDoubleProp(props, RuntimeProtocolField.paddingRight) ?? + paddingRight; + final nextPaddingBottom = + _nonNegativeDoubleProp(props, RuntimeProtocolField.paddingBottom) ?? + paddingBottom; + final nextViewportWidth = nextWidth == null + ? null + : (nextWidth - nextPaddingLeft - nextPaddingRight) + .clamp(0.0, nextWidth) + .toDouble(); + final nextViewportHeight = nextHeight == null + ? null + : (nextHeight - nextPaddingTop - nextPaddingBottom) + .clamp(0.0, nextHeight) + .toDouble(); + final nextScrollX = props.containsKey(RuntimeProtocolField.scrollX) + ? _scrollProp( + props, + RuntimeProtocolField.scrollX, + contentExtent: nextContentWidth, + viewportExtent: nextViewportWidth, + )! + : _clampScroll( + scrollX, + contentExtent: nextContentWidth, + viewportExtent: nextViewportWidth, + ); + final nextScrollY = props.containsKey(RuntimeProtocolField.scrollY) + ? _scrollProp( + props, + RuntimeProtocolField.scrollY, + contentExtent: nextContentHeight, + viewportExtent: nextViewportHeight, + )! + : _clampScroll( + scrollY, + contentExtent: nextContentHeight, + viewportExtent: nextViewportHeight, + ); + + return RuntimeNode( + id: id, + type: nextType, + parent: _parentProp(props, currentParent: parent, nodeId: id), + asset: _stringProp(props, RuntimeProtocolField.asset) ?? asset, + pressedAsset: + _stringProp(props, RuntimeProtocolField.pressedAsset) ?? pressedAsset, + disabledAsset: + _stringProp(props, RuntimeProtocolField.disabledAsset) ?? + disabledAsset, + animation: + _stringProp(props, RuntimeProtocolField.animation) ?? animation, + skin: _stringProp(props, RuntimeProtocolField.skin) ?? skin, + loop: _boolProp(props, RuntimeProtocolField.loop) ?? loop, + text: _stringProp(props, RuntimeProtocolField.text) ?? text, + x: _doubleProp(props, RuntimeProtocolField.x) ?? x, + y: _doubleProp(props, RuntimeProtocolField.y) ?? y, + width: nextWidth, + height: nextHeight, + paddingLeft: nextPaddingLeft, + paddingTop: nextPaddingTop, + paddingRight: nextPaddingRight, + paddingBottom: nextPaddingBottom, + anchor: nextAnchor, + layer: _intProp(props, RuntimeProtocolField.layer) ?? layer, + visible: _boolProp(props, RuntimeProtocolField.visible) ?? visible, + alpha: _doubleProp(props, RuntimeProtocolField.alpha) ?? alpha, + scale: _doubleProp(props, RuntimeProtocolField.scale) ?? scale, + rotation: _doubleProp(props, RuntimeProtocolField.rotation) ?? rotation, + color: _colorProp(props, RuntimeProtocolField.color) ?? color, + fontSize: _doubleProp(props, RuntimeProtocolField.fontSize) ?? fontSize, + textAlign: nextTextAlign, + radius: _doubleProp(props, RuntimeProtocolField.radius) ?? radius, + strokeWidth: + _doubleProp(props, RuntimeProtocolField.strokeWidth) ?? strokeWidth, + value: _normalizedValueProp(props, RuntimeProtocolField.value) ?? value, + scrollX: nextScrollX, + scrollY: nextScrollY, + contentWidth: nextContentWidth, + contentHeight: nextContentHeight, + virtualized: + _boolProp(props, RuntimeProtocolField.virtualized) ?? virtualized, + cacheExtent: + _nonNegativeDoubleProp(props, RuntimeProtocolField.cacheExtent) ?? + cacheExtent, + inertia: _boolProp(props, RuntimeProtocolField.inertia) ?? inertia, + scrollbarThumbColor: + _colorProp(props, RuntimeProtocolField.scrollbarThumbColor) ?? + scrollbarThumbColor, + scrollbarTrackColor: + _colorProp(props, RuntimeProtocolField.scrollbarTrackColor) ?? + scrollbarTrackColor, + scrollbarThickness: + _nonNegativeDoubleProp( + props, + RuntimeProtocolField.scrollbarThickness, + ) ?? + scrollbarThickness, + scrollbarVisible: + _boolProp(props, RuntimeProtocolField.scrollbarVisible) ?? + scrollbarVisible, + interactive: + _boolProp(props, RuntimeProtocolField.interactive) ?? interactive, + onTap: _stringProp(props, RuntimeProtocolField.onTap) ?? onTap, + onScroll: _stringProp(props, RuntimeProtocolField.onScroll) ?? onScroll, + preset: nextPreset, + count: _positiveIntProp(props, RuntimeProtocolField.count) ?? count, + duration: + _nonNegativeDoubleProp(props, RuntimeProtocolField.duration) ?? + duration, + speedMin: + _nonNegativeDoubleProp(props, RuntimeProtocolField.speedMin) ?? + speedMin, + speedMax: + _nonNegativeDoubleProp(props, RuntimeProtocolField.speedMax) ?? + speedMax, + gravityX: _doubleProp(props, RuntimeProtocolField.gravityX) ?? gravityX, + gravityY: _doubleProp(props, RuntimeProtocolField.gravityY) ?? gravityY, + spread: + _nonNegativeDoubleProp(props, RuntimeProtocolField.spread) ?? spread, + colorTo: _colorProp(props, RuntimeProtocolField.colorTo) ?? colorTo, + radiusTo: + _nonNegativeDoubleProp(props, RuntimeProtocolField.radiusTo) ?? + radiusTo, + autoRemove: + _boolProp(props, RuntimeProtocolField.autoRemove) ?? autoRemove, + fadeOut: _boolProp(props, RuntimeProtocolField.fadeOut) ?? fadeOut, + ); + } + + static RuntimeNode fromMap(Map map) { + RuntimeProtocolSchema.ensureKnownKeys( + map, + allowed: RuntimeProtocolSchema.nodeFields, + context: 'RuntimeNode', + ); + final type = _requiredString(map, RuntimeProtocolField.type); + if (!RuntimeNodeType.isSupported(type)) { + throw FormatException('RuntimeNode.type is unsupported: $type'); + } + final anchor = + _stringProp(map, RuntimeProtocolField.anchor) ?? + RuntimeAnchorValue.topLeft; + if (!RuntimeAnchorValue.isSupported(anchor)) { + throw FormatException('RuntimeNode.anchor is unsupported: $anchor'); + } + final textAlign = + _stringProp(map, RuntimeProtocolField.textAlign) ?? + RuntimeTextAlignValue.center; + if (!RuntimeTextAlignValue.isSupported(textAlign)) { + throw FormatException('RuntimeNode.textAlign is unsupported: $textAlign'); + } + + final preset = _stringProp(map, RuntimeProtocolField.preset); + _validateParticlePreset(preset); + + return RuntimeNode( + id: _requiredString(map, RuntimeProtocolField.id), + type: type, + parent: _parentProp( + map, + currentParent: null, + nodeId: _requiredString(map, RuntimeProtocolField.id), + ), + asset: _stringProp(map, RuntimeProtocolField.asset), + pressedAsset: _stringProp(map, RuntimeProtocolField.pressedAsset), + disabledAsset: _stringProp(map, RuntimeProtocolField.disabledAsset), + animation: _stringProp(map, RuntimeProtocolField.animation), + skin: _stringProp(map, RuntimeProtocolField.skin), + loop: _boolProp(map, RuntimeProtocolField.loop) ?? true, + text: _stringProp(map, RuntimeProtocolField.text), + x: _doubleProp(map, RuntimeProtocolField.x) ?? 0, + y: _doubleProp(map, RuntimeProtocolField.y) ?? 0, + width: _doubleProp(map, RuntimeProtocolField.width), + height: _doubleProp(map, RuntimeProtocolField.height), + paddingLeft: + _nonNegativeDoubleProp(map, RuntimeProtocolField.paddingLeft) ?? 0, + paddingTop: + _nonNegativeDoubleProp(map, RuntimeProtocolField.paddingTop) ?? 0, + paddingRight: + _nonNegativeDoubleProp(map, RuntimeProtocolField.paddingRight) ?? 0, + paddingBottom: + _nonNegativeDoubleProp(map, RuntimeProtocolField.paddingBottom) ?? 0, + anchor: anchor, + layer: _intProp(map, RuntimeProtocolField.layer) ?? 0, + visible: _boolProp(map, RuntimeProtocolField.visible) ?? true, + alpha: _doubleProp(map, RuntimeProtocolField.alpha) ?? 1, + scale: _doubleProp(map, RuntimeProtocolField.scale) ?? 1, + rotation: _doubleProp(map, RuntimeProtocolField.rotation) ?? 0, + color: _colorProp(map, RuntimeProtocolField.color), + fontSize: _doubleProp(map, RuntimeProtocolField.fontSize), + textAlign: textAlign, + radius: _doubleProp(map, RuntimeProtocolField.radius), + strokeWidth: _doubleProp(map, RuntimeProtocolField.strokeWidth), + value: _normalizedValueProp(map, RuntimeProtocolField.value), + scrollX: + _scrollProp( + map, + RuntimeProtocolField.scrollX, + contentExtent: _doubleProp(map, RuntimeProtocolField.contentWidth), + viewportExtent: _doubleProp(map, RuntimeProtocolField.width), + ) ?? + 0, + scrollY: + _scrollProp( + map, + RuntimeProtocolField.scrollY, + contentExtent: _doubleProp(map, RuntimeProtocolField.contentHeight), + viewportExtent: _doubleProp(map, RuntimeProtocolField.height), + ) ?? + 0, + contentWidth: _doubleProp(map, RuntimeProtocolField.contentWidth), + contentHeight: _doubleProp(map, RuntimeProtocolField.contentHeight), + virtualized: _boolProp(map, RuntimeProtocolField.virtualized) ?? false, + cacheExtent: + _nonNegativeDoubleProp(map, RuntimeProtocolField.cacheExtent) ?? 0, + inertia: _boolProp(map, RuntimeProtocolField.inertia) ?? true, + scrollbarThumbColor: _colorProp( + map, + RuntimeProtocolField.scrollbarThumbColor, + ), + scrollbarTrackColor: _colorProp( + map, + RuntimeProtocolField.scrollbarTrackColor, + ), + scrollbarThickness: _nonNegativeDoubleProp( + map, + RuntimeProtocolField.scrollbarThickness, + ), + scrollbarVisible: + _boolProp(map, RuntimeProtocolField.scrollbarVisible) ?? true, + interactive: _boolProp(map, RuntimeProtocolField.interactive) ?? false, + onTap: _stringProp(map, RuntimeProtocolField.onTap), + onScroll: _stringProp(map, RuntimeProtocolField.onScroll), + preset: preset, + count: _positiveIntProp(map, RuntimeProtocolField.count), + duration: _nonNegativeDoubleProp(map, RuntimeProtocolField.duration), + speedMin: _nonNegativeDoubleProp(map, RuntimeProtocolField.speedMin), + speedMax: _nonNegativeDoubleProp(map, RuntimeProtocolField.speedMax), + gravityX: _doubleProp(map, RuntimeProtocolField.gravityX), + gravityY: _doubleProp(map, RuntimeProtocolField.gravityY), + spread: _nonNegativeDoubleProp(map, RuntimeProtocolField.spread), + colorTo: _colorProp(map, RuntimeProtocolField.colorTo), + radiusTo: _nonNegativeDoubleProp(map, RuntimeProtocolField.radiusTo), + autoRemove: _boolProp(map, RuntimeProtocolField.autoRemove) ?? true, + fadeOut: _boolProp(map, RuntimeProtocolField.fadeOut) ?? true, + ); + } + + static String _requiredString(Map map, String key) { + final value = map[key]; + if (value is String && value.isNotEmpty) { + return value; + } + throw FormatException('RuntimeNode.$key must be a non-empty string'); + } + + static void _validateParticlePreset(String? preset) { + if (preset != null && !RuntimeParticlePresetValue.isSupported(preset)) { + throw FormatException('RuntimeNode.preset is unsupported: $preset'); + } + } + + static String? _stringProp(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + if (value is String) { + return value; + } + throw FormatException('RuntimeNode.$key must be a string'); + } + + static String? _parentProp( + Map map, { + required String? currentParent, + required String nodeId, + }) { + if (!map.containsKey(RuntimeProtocolField.parent)) { + return currentParent; + } + + final value = _stringProp(map, RuntimeProtocolField.parent); + if (value == null || value.isEmpty) { + return null; + } + if (value == nodeId) { + throw const FormatException('RuntimeNode.parent cannot reference itself'); + } + return value; + } + + static bool? _boolProp(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + if (value is bool) { + return value; + } + throw FormatException('RuntimeNode.$key must be a boolean'); + } + + static double? _doubleProp(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + if (value is num) { + return value.toDouble(); + } + throw FormatException('RuntimeNode.$key must be a number'); + } + + static double? _normalizedValueProp(Map map, String key) { + final value = _doubleProp(map, key); + if (value == null) { + return null; + } + if (value < 0 || value > 1) { + throw FormatException('RuntimeNode.$key must be between 0 and 1'); + } + return value; + } + + static double? _nonNegativeDoubleProp(Map map, String key) { + final value = _doubleProp(map, key); + if (value == null) { + return null; + } + if (value < 0) { + throw FormatException('RuntimeNode.$key must be >= 0'); + } + return value; + } + + static double? _scrollProp( + Map map, + String key, { + required double? contentExtent, + required double? viewportExtent, + }) { + final value = _doubleProp(map, key); + if (value == null) { + return null; + } + if (value < 0) { + throw FormatException('RuntimeNode.$key must be >= 0'); + } + return _clampScroll( + value, + contentExtent: contentExtent, + viewportExtent: viewportExtent, + ); + } + + static double _clampScroll( + double value, { + required double? contentExtent, + required double? viewportExtent, + }) { + final maxScroll = (contentExtent ?? 0) - (viewportExtent ?? 0); + if (maxScroll <= 0) { + return 0; + } + return value.clamp(0, maxScroll).toDouble(); + } + + static int? _positiveIntProp(Map map, String key) { + final value = _intProp(map, key); + if (value == null) { + return null; + } + if (value <= 0) { + throw FormatException('RuntimeNode.$key must be > 0'); + } + return value; + } + + static int? _intProp(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + if (value is num) { + return value.toInt(); + } + throw FormatException('RuntimeNode.$key must be an integer'); + } + + static Color? _colorProp(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + if (value is! String || !value.startsWith('#')) { + throw FormatException('RuntimeNode.$key must be a hex color'); + } + + final hex = value.substring(1); + if (hex.length == 6) { + return Color(int.parse('ff$hex', radix: 16)); + } + if (hex.length == 8) { + return Color(int.parse(hex, radix: 16)); + } + throw FormatException('RuntimeNode.$key must be #RRGGBB or #AARRGGBB'); + } +} diff --git a/lib/runtime/packages/game_package.dart b/lib/runtime/packages/game_package.dart new file mode 100644 index 0000000..2ebe129 --- /dev/null +++ b/lib/runtime/packages/game_package.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import '../game/runtime_options.dart'; +import 'game_package_manifest.dart'; + +class GamePackage { + const GamePackage.asset({ + required this.rootPath, + required this.manifest, + this.runtimeLuaRoot = RuntimeOptions.defaultRuntimeLuaRoot, + }) : source = GamePackageSource.asset; + + static const runtimeLuaPrefix = 'runtime:'; + + const GamePackage.file({ + required this.rootPath, + required this.manifest, + this.runtimeLuaRoot = RuntimeOptions.defaultRuntimeLuaRoot, + }) : source = GamePackageSource.file; + + final String rootPath; + final GamePackageSource source; + final GamePackageManifest manifest; + final String runtimeLuaRoot; + + String get entryPath => _join(rootPath, manifest.entry); + + bool get isAsset => source == GamePackageSource.asset; + + Future readText(String relativeOrAbsolutePath) async { + final runtimePath = _resolveRuntimeLuaPath(relativeOrAbsolutePath); + if (runtimePath != null) { + return rootBundle.loadString(runtimePath); + } + + final path = _resolvePackagePath(relativeOrAbsolutePath); + if (isAsset) { + return rootBundle.loadString(path); + } + return File(path).readAsString(); + } + + Future readBytes(String relativeOrAbsolutePath) async { + final path = _resolvePackagePath(relativeOrAbsolutePath); + if (isAsset) { + return rootBundle.load(path); + } + final bytes = await File(path).readAsBytes(); + return ByteData.sublistView(bytes); + } + + String resolveResourcePath(String keyOrPath) { + final resource = manifest.resources[keyOrPath]; + if (resource != null) { + return _join(rootPath, resource.path); + } + + if (keyOrPath.startsWith(rootPath)) { + return keyOrPath; + } + if (keyOrPath.contains('/')) { + return _join(rootPath, keyOrPath); + } + return _join(_join(rootPath, manifest.assetsBase), keyOrPath); + } + + String _resolvePackagePath(String relativeOrAbsolutePath) { + if (relativeOrAbsolutePath.startsWith(rootPath)) { + return relativeOrAbsolutePath; + } + return _join(rootPath, relativeOrAbsolutePath); + } + + String? _resolveRuntimeLuaPath(String path) { + if (!path.startsWith(runtimeLuaPrefix)) { + return null; + } + final name = path.substring(runtimeLuaPrefix.length); + if (name.isEmpty || name.contains('/') || name.contains('..')) { + throw FormatException('Invalid runtime Lua module path: $path'); + } + return _join(runtimeLuaRoot, name); + } + + String _join(String left, String right) { + final normalizedLeft = left.endsWith('/') + ? left.substring(0, left.length - 1) + : left; + final normalizedRight = right.startsWith('/') ? right.substring(1) : right; + return '$normalizedLeft/$normalizedRight'; + } +} + +enum GamePackageSource { asset, file } diff --git a/lib/runtime/packages/game_package_activation_controller.dart b/lib/runtime/packages/game_package_activation_controller.dart new file mode 100644 index 0000000..2f6e32e --- /dev/null +++ b/lib/runtime/packages/game_package_activation_controller.dart @@ -0,0 +1,231 @@ +import '../audio/runtime_audio_manager.dart'; +import '../models/game_diff.dart'; +import '../resources/game_resource_manager.dart'; +import '../scripting/script_engine.dart'; +import 'game_package.dart'; +import 'game_package_repository.dart'; +import 'package_verifier.dart'; +import 'stable_package_store.dart'; + +class PackageActivationController { + const PackageActivationController({ + required this.repository, + required this.resources, + required this.scriptEngine, + this.audio, + this.runtimeApiVersion = 1, + this.store = const StablePackageStore(), + this.assetFallback = const AssetGamePackageRepository(), + this.resourceManagerFactory, + this.audioManagerFactory, + this.scriptEngineFactory, + }); + + final GamePackageRepository repository; + final GameResourceManager resources; + final ScriptEngine scriptEngine; + final RuntimeAudioManager? audio; + final int runtimeApiVersion; + final StablePackageStore store; + final GamePackageRepository assetFallback; + final GameResourceManager Function()? resourceManagerFactory; + final RuntimeAudioManager Function()? audioManagerFactory; + final ScriptEngine Function()? scriptEngineFactory; + + Future activate({ + required String gameId, + required Map Function(GamePackage package) contextBuilder, + bool Function()? shouldContinue, + }) async { + final plan = await prepare( + gameId: gameId, + contextBuilder: contextBuilder, + shouldContinue: shouldContinue, + ); + await commit(plan, shouldContinue: shouldContinue); + return PackageActivationResult.fromPlan(plan); + } + + Future prepare({ + required String gameId, + required Map Function(GamePackage package) contextBuilder, + bool Function()? shouldContinue, + }) async { + final verifier = PackageVerifier(runtimeApiVersion: runtimeApiVersion); + final candidates = await _candidatePackages(gameId, shouldContinue); + + Object? lastError; + for (final candidate in candidates) { + try { + _ensureContinue(shouldContinue); + final plan = await _prepareCandidate( + candidate: candidate, + verifier: verifier, + contextBuilder: contextBuilder, + shouldContinue: shouldContinue, + ); + return plan; + } catch (error) { + if (shouldContinue != null && !shouldContinue()) { + rethrow; + } + lastError = error; + } + } + + throw StateError( + 'No activatable package for $gameId. Last error: $lastError', + ); + } + + Future commit( + PackageActivationPlan plan, { + bool Function()? shouldContinue, + }) async { + _ensureContinue(shouldContinue); + await store.markStable(plan.package); + _ensureContinue(shouldContinue); + } + + Future> _candidatePackages( + String gameId, + bool Function()? shouldContinue, + ) async { + final candidates = []; + + try { + final package = await repository.load(gameId); + _ensureContinue(shouldContinue); + candidates.add(package); + } catch (_) { + // Continue with stable/fallback candidates. + } + _ensureContinue(shouldContinue); + + final stable = await store.stablePackage(gameId); + if (stable != null && !_containsPackage(candidates, stable)) { + candidates.add(stable); + } + + _ensureContinue(shouldContinue); + + final previous = await store.previousStablePackage(gameId); + if (previous != null && !_containsPackage(candidates, previous)) { + candidates.add(previous); + } + + _ensureContinue(shouldContinue); + + final fallback = await assetFallback.load(gameId); + if (!_containsPackage(candidates, fallback)) { + candidates.add(fallback); + } + _ensureContinue(shouldContinue); + + return candidates; + } + + Future _prepareCandidate({ + required GamePackage candidate, + required PackageVerifier verifier, + required Map Function(GamePackage package) contextBuilder, + required bool Function()? shouldContinue, + }) async { + final preparedResources = resourceManagerFactory?.call() ?? resources; + final preparedAudio = audioManagerFactory?.call() ?? audio; + final preparedScriptEngine = scriptEngineFactory?.call() ?? scriptEngine; + final ownsPreparedResources = preparedResources != resources; + final ownsPreparedAudio = preparedAudio != null && preparedAudio != audio; + + try { + await verifier.verify(candidate); + _ensureContinue(shouldContinue); + await preparedResources.mount(candidate); + _ensureContinue(shouldContinue); + await preparedAudio?.mount(candidate); + _ensureContinue(shouldContinue); + await preparedScriptEngine.loadPackage(candidate); + _ensureContinue(shouldContinue); + + final context = contextBuilder(candidate); + _ensureContinue(shouldContinue); + if (!preparedScriptEngine.smokeTest(context)) { + throw StateError('Lua package smoke_test returned false'); + } + + _ensureContinue(shouldContinue); + final diff = preparedScriptEngine.init(context); + _ensureContinue(shouldContinue); + return PackageActivationPlan( + package: candidate, + initialDiff: diff, + resources: preparedResources, + scriptEngine: preparedScriptEngine, + audio: preparedAudio, + ); + } catch (_) { + if (ownsPreparedResources) { + preparedResources.dispose(); + } + if (ownsPreparedAudio) { + preparedAudio.dispose(); + } + rethrow; + } + } + + void _ensureContinue(bool Function()? shouldContinue) { + if (shouldContinue != null && !shouldContinue()) { + throw StateError('Package activation cancelled'); + } + } + + bool _containsPackage(List packages, GamePackage package) { + return packages.any( + (item) => + item.source == package.source && item.rootPath == package.rootPath, + ); + } +} + +class PackageActivationPlan { + const PackageActivationPlan({ + required this.package, + required this.initialDiff, + required this.resources, + required this.scriptEngine, + this.audio, + }); + + final GamePackage package; + final GameDiff initialDiff; + final GameResourceManager resources; + final ScriptEngine scriptEngine; + final RuntimeAudioManager? audio; +} + +class PackageActivationResult { + const PackageActivationResult({ + required this.package, + required this.initialDiff, + required this.resources, + required this.scriptEngine, + this.audio, + }); + + factory PackageActivationResult.fromPlan(PackageActivationPlan plan) { + return PackageActivationResult( + package: plan.package, + initialDiff: plan.initialDiff, + resources: plan.resources, + scriptEngine: plan.scriptEngine, + audio: plan.audio, + ); + } + + final GamePackage package; + final GameDiff initialDiff; + final GameResourceManager resources; + final ScriptEngine scriptEngine; + final RuntimeAudioManager? audio; +} diff --git a/lib/runtime/packages/game_package_manifest.dart b/lib/runtime/packages/game_package_manifest.dart new file mode 100644 index 0000000..e3ad011 --- /dev/null +++ b/lib/runtime/packages/game_package_manifest.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; + +import '../display/runtime_viewport.dart'; + +class GamePackageManifest { + const GamePackageManifest({ + required this.gameId, + required this.name, + required this.version, + required this.runtimeApiVersion, + required this.entry, + required this.assetsBase, + this.defaultLocale = 'en', + this.supportedLocales = const ['en'], + this.display = const GameDisplayConfig(), + this.resources = const {}, + this.modules = const {}, + }); + + final String gameId; + final String name; + final String version; + final int runtimeApiVersion; + final String entry; + final String assetsBase; + final String defaultLocale; + final List supportedLocales; + final GameDisplayConfig display; + final Map resources; + final Map modules; + + static GamePackageManifest fromJsonString(String source) { + return fromMap(jsonDecode(source) as Map); + } + + static GamePackageManifest fromMap(Map map) { + final resourcesValue = map['resources']; + final resources = {}; + if (resourcesValue is Map) { + for (final entry in resourcesValue.entries) { + if (entry.key is! String || entry.value is! Map) { + throw const FormatException('manifest.resources must be a map'); + } + resources[entry.key as String] = GameResource.fromMap( + Map.from(entry.value as Map), + ); + } + } + + final modulesValue = map['modules']; + final modules = {}; + if (modulesValue is Map) { + for (final entry in modulesValue.entries) { + if (entry.key is! String || entry.value is! String) { + throw const FormatException('manifest.modules must be a string map'); + } + modules[entry.key as String] = entry.value as String; + } + } + + final defaultLocale = (map['defaultLocale'] as String?) ?? 'en'; + final supportedLocales = _stringList( + map, + 'supportedLocales', + fallback: [defaultLocale], + ); + if (!supportedLocales.contains(defaultLocale)) { + throw const FormatException( + 'manifest.supportedLocales must include defaultLocale', + ); + } + + final displayValue = map['display']; + final display = displayValue == null + ? const GameDisplayConfig() + : GameDisplayConfig.fromMap( + Map.from(displayValue as Map), + ); + + return GamePackageManifest( + gameId: _string(map, 'gameId'), + name: _string(map, 'name'), + version: _string(map, 'version'), + runtimeApiVersion: _int(map, 'runtimeApiVersion'), + entry: _string(map, 'entry'), + assetsBase: (map['assetsBase'] as String?) ?? 'assets', + defaultLocale: defaultLocale, + supportedLocales: supportedLocales, + display: display, + resources: resources, + modules: modules, + ); + } + + static String _string(Map map, String key) { + final value = map[key]; + if (value is String && value.isNotEmpty) { + return value; + } + throw FormatException('manifest.$key must be a non-empty string'); + } + + static List _stringList( + Map map, + String key, { + required List fallback, + }) { + final value = map[key]; + if (value == null) { + return fallback; + } + if (value is! List || value.isEmpty) { + throw FormatException('manifest.$key must be a non-empty string list'); + } + final result = []; + for (final item in value) { + if (item is! String || item.isEmpty) { + throw FormatException('manifest.$key must be a non-empty string list'); + } + result.add(item); + } + return result; + } + + static int _int(Map map, String key) { + final value = map[key]; + if (value is num) { + return value.toInt(); + } + throw FormatException('manifest.$key must be an integer'); + } +} + +class GameDisplayConfig { + const GameDisplayConfig({ + this.designWidth = 720, + this.designHeight = 720, + this.scaleMode = RuntimeScaleMode.fit, + }); + + final double designWidth; + final double designHeight; + final String scaleMode; + + RuntimeViewportConfig toViewportConfig() { + return RuntimeViewportConfig( + designWidth: designWidth, + designHeight: designHeight, + scaleMode: scaleMode, + ); + } + + static GameDisplayConfig fromMap(Map map) { + final designWidth = _number(map, 'designWidth', fallback: 720); + final designHeight = _number(map, 'designHeight', fallback: 720); + final scaleMode = (map['scaleMode'] as String?) ?? RuntimeScaleMode.fit; + if (designWidth <= 0 || designHeight <= 0) { + throw const FormatException('manifest.display design size must be > 0'); + } + if (!RuntimeScaleMode.isSupported(scaleMode)) { + throw const FormatException('manifest.display.scaleMode is unsupported'); + } + return GameDisplayConfig( + designWidth: designWidth, + designHeight: designHeight, + scaleMode: scaleMode, + ); + } + + static double _number( + Map map, + String key, { + required double fallback, + }) { + final value = map[key]; + if (value == null) { + return fallback; + } + if (value is num) { + return value.toDouble(); + } + throw FormatException('manifest.display.$key must be a number'); + } +} + +class GameResource { + const GameResource({ + required this.type, + required this.path, + this.preload = GameResourcePreload.required, + this.group, + this.atlas, + this.skeleton, + }); + + final String type; + final String path; + final String preload; + final String? group; + final String? atlas; + final String? skeleton; + + static GameResource fromMap(Map map) { + final type = map['type']; + final path = map['path']; + final atlas = map['atlas']; + final skeleton = map['skeleton']; + if (type is! String || type.isEmpty) { + throw const FormatException('resource.type must be a non-empty string'); + } + if (!GameResourceType.isSupported(type)) { + throw const FormatException('resource.type is unsupported'); + } + if (type == GameResourceType.spine) { + if (atlas is! String || atlas.isEmpty) { + throw const FormatException( + 'spine resource.atlas must be a non-empty string', + ); + } + if (skeleton is! String || skeleton.isEmpty) { + throw const FormatException( + 'spine resource.skeleton must be a non-empty string', + ); + } + } else if (path is! String || path.isEmpty) { + throw const FormatException('resource.path must be a non-empty string'); + } + final preload = map['preload'] as String? ?? GameResourcePreload.required; + if (!GameResourcePreload.isSupported(preload)) { + throw const FormatException('resource.preload is unsupported'); + } + final group = map['group']; + if (group != null && (group is! String || group.isEmpty)) { + throw const FormatException('resource.group must be a non-empty string'); + } + return GameResource( + type: type, + path: path as String? ?? '', + preload: preload, + group: group as String?, + atlas: atlas as String?, + skeleton: skeleton as String?, + ); + } +} + +abstract final class GameResourceType { + static const image = 'image'; + static const audio = 'audio'; + static const spine = 'spine'; + + static bool isSupported(String value) { + return value == image || value == audio || value == spine; + } +} + +abstract final class GameResourcePreload { + static const required = 'required'; + static const lazy = 'lazy'; + static const optional = 'optional'; + + static bool isSupported(String value) { + return value == required || value == lazy || value == optional; + } +} diff --git a/lib/runtime/packages/game_package_repository.dart b/lib/runtime/packages/game_package_repository.dart new file mode 100644 index 0000000..cc6b5c6 --- /dev/null +++ b/lib/runtime/packages/game_package_repository.dart @@ -0,0 +1,226 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; + +import '../game/runtime_options.dart'; +import 'game_package.dart'; +import 'game_package_manifest.dart'; +import 'package_verifier.dart'; +import 'stable_package_store.dart'; + +abstract interface class GamePackageRepository { + Future load(String gameId); +} + +class AssetGamePackageRepository implements GamePackageRepository { + const AssetGamePackageRepository({ + this.basePath = 'assets/games', + this.runtimeOptions = const RuntimeOptions(), + }); + + final String basePath; + final RuntimeOptions runtimeOptions; + + @override + Future load(String gameId) async { + final root = '$basePath/$gameId'; + final source = await rootBundle.loadString('$root/manifest.json'); + return GamePackage.asset( + rootPath: root, + manifest: GamePackageManifest.fromJsonString(source), + runtimeLuaRoot: runtimeOptions.runtimeLuaRoot, + ); + } +} + +class RemoteGamePackageRepository implements GamePackageRepository { + RemoteGamePackageRepository({ + required this.baseUri, + this.runtimeApiVersion = 1, + this.runtimeOptions = const RuntimeOptions(), + GamePackageRepository? fallback, + StablePackageStore? store, + http.Client? client, + }) : fallback = + fallback ?? + AssetGamePackageRepository(runtimeOptions: runtimeOptions), + store = store ?? StablePackageStore(runtimeOptions: runtimeOptions), + _client = client; + + final Uri baseUri; + final int runtimeApiVersion; + final RuntimeOptions runtimeOptions; + final GamePackageRepository fallback; + final StablePackageStore store; + final http.Client? _client; + + @override + Future load(String gameId) async { + final verifier = PackageVerifier(runtimeApiVersion: runtimeApiVersion); + final client = _client ?? http.Client(); + final shouldCloseClient = _client == null; + + try { + final package = await _loadRemoteCandidate(client, gameId); + await verifier.verify(package); + return package; + } catch (_) { + final stable = await store.stablePackage(gameId); + if (stable != null) { + try { + await verifier.verify(stable); + return stable; + } catch (_) { + final previous = await store.previousStablePackage(gameId); + if (previous != null) { + try { + await verifier.verify(previous); + return previous; + } catch (_) { + // Fall through to bundled fallback. + } + } + } + } + return fallback.load(gameId); + } finally { + if (shouldCloseClient) { + client.close(); + } + } + } + + Future _loadRemoteCandidate( + http.Client client, + String gameId, + ) async { + final remoteManifest = await _fetchRemoteManifest(client, gameId); + if (remoteManifest.gameId != gameId) { + throw const FormatException('Remote manifest gameId mismatch'); + } + + final packageRoot = await _downloadAndExtract( + client, + gameId, + remoteManifest, + ); + final manifestFile = File(p.join(packageRoot.path, 'manifest.json')); + final packageManifest = GamePackageManifest.fromJsonString( + await manifestFile.readAsString(), + ); + if (packageManifest.gameId != gameId) { + throw const FormatException('Package manifest gameId mismatch'); + } + if (packageManifest.version != remoteManifest.version) { + throw const FormatException('Package manifest version mismatch'); + } + return GamePackage.file( + rootPath: packageRoot.path, + manifest: packageManifest, + runtimeLuaRoot: runtimeOptions.runtimeLuaRoot, + ); + } + + Future _fetchRemoteManifest( + http.Client client, + String gameId, + ) async { + final uri = baseUri.resolve('$gameId/remote_manifest.json'); + final response = await client.get(uri); + if (response.statusCode != 200) { + throw HttpException( + 'Remote manifest failed: ${response.statusCode}', + uri: uri, + ); + } + return RemotePackageManifest.fromMap( + jsonDecode(response.body) as Map, + ); + } + + Future _downloadAndExtract( + http.Client client, + String gameId, + RemotePackageManifest manifest, + ) async { + final packageBytes = await _downloadPackage(client, manifest.packageUrl); + _verifySha256(packageBytes, manifest.sha256); + + final packageRoot = await store.versionDirectory(gameId, manifest.version); + if (packageRoot.existsSync()) { + packageRoot.deleteSync(recursive: true); + } + packageRoot.createSync(recursive: true); + + final archive = ZipDecoder().decodeBytes(packageBytes); + for (final file in archive.files) { + final targetPath = p.normalize(p.join(packageRoot.path, file.name)); + if (!p.isWithin(packageRoot.path, targetPath) && + targetPath != packageRoot.path) { + throw const FormatException('Unsafe zip entry path'); + } + if (file.isFile) { + File(targetPath) + ..createSync(recursive: true) + ..writeAsBytesSync(file.content as List); + } else { + Directory(targetPath).createSync(recursive: true); + } + } + return packageRoot; + } + + Future> _downloadPackage(http.Client client, Uri uri) async { + final response = await client.get(uri); + if (response.statusCode != 200) { + throw HttpException( + 'Package download failed: ${response.statusCode}', + uri: uri, + ); + } + return response.bodyBytes; + } + + void _verifySha256(List bytes, String expected) { + final actual = sha256.convert(bytes).toString(); + if (actual != expected) { + throw const FormatException('Package sha256 mismatch'); + } + } +} + +class RemotePackageManifest { + const RemotePackageManifest({ + required this.gameId, + required this.version, + required this.packageUrl, + required this.sha256, + }); + + final String gameId; + final String version; + final Uri packageUrl; + final String sha256; + + static RemotePackageManifest fromMap(Map map) { + return RemotePackageManifest( + gameId: _string(map, 'gameId'), + version: _string(map, 'version'), + packageUrl: Uri.parse(_string(map, 'packageUrl')), + sha256: _string(map, 'sha256'), + ); + } + + static String _string(Map map, String key) { + final value = map[key]; + if (value is String && value.isNotEmpty) { + return value; + } + throw FormatException('remote_manifest.$key must be a non-empty string'); + } +} diff --git a/lib/runtime/packages/package_verifier.dart b/lib/runtime/packages/package_verifier.dart new file mode 100644 index 0000000..51b8848 --- /dev/null +++ b/lib/runtime/packages/package_verifier.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'game_package.dart'; +import 'game_package_manifest.dart'; + +class PackageVerifier { + const PackageVerifier({required this.runtimeApiVersion}); + + final int runtimeApiVersion; + + Future verify(GamePackage package) async { + _verifyManifest(package); + await _verifyEntry(package); + await _verifyDeclaredModules(package); + await _verifyDeclaredResources(package); + } + + void _verifyManifest(GamePackage package) { + final manifest = package.manifest; + if (manifest.runtimeApiVersion > runtimeApiVersion) { + throw FormatException( + 'Package runtimeApiVersion ${manifest.runtimeApiVersion} is newer than runtime $runtimeApiVersion', + ); + } + if (manifest.gameId.isEmpty || + manifest.version.isEmpty || + manifest.entry.isEmpty) { + throw const FormatException('Package manifest is incomplete'); + } + } + + Future _verifyEntry(GamePackage package) async { + final script = await package.readText(package.manifest.entry); + if (!script.contains('function init')) { + throw const FormatException('Lua package must define function init(ctx)'); + } + if (!script.contains('function on_event')) { + throw const FormatException( + 'Lua package must define function on_event(event)', + ); + } + if (!script.contains('function smoke_test')) { + throw const FormatException( + 'Lua package must define function smoke_test(ctx)', + ); + } + } + + Future _verifyDeclaredModules(GamePackage package) async { + for (final entry in package.manifest.modules.entries) { + final name = entry.key; + final path = entry.value; + if (!_isSafeModuleName(name)) { + throw FormatException('Unsafe Lua module name: $name'); + } + if (!_isSafeModulePath(path)) { + throw FormatException( + 'Lua module path must be scripts/*.lua or runtime:*.lua: $path', + ); + } + await package.readText(path); + } + } + + bool _isSafeModuleName(String value) { + return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) && + !value.contains('..') && + !value.startsWith('.') && + !value.endsWith('.'); + } + + bool _isSafeModulePath(String path) { + if (path.startsWith(GamePackage.runtimeLuaPrefix)) { + final name = path.substring(GamePackage.runtimeLuaPrefix.length); + return name.isNotEmpty && + name.endsWith('.lua') && + !name.contains('/') && + !name.contains('..'); + } + return path.startsWith('scripts/') && + path.endsWith('.lua') && + !path.contains('..'); + } + + Future _verifyDeclaredResources(GamePackage package) async { + for (final resource in package.manifest.resources.values) { + final paths = _resourcePaths(resource); + for (final path in paths) { + if (path.contains('..')) { + throw const FormatException('Resource path must not contain ..'); + } + if (package.isAsset) { + await package.readBytes(path); + continue; + } + + final root = p.normalize(package.rootPath); + final target = p.normalize(p.join(root, path)); + if (!p.isWithin(root, target) && target != root) { + throw const FormatException('Resource path escapes package root'); + } + if (!File(target).existsSync()) { + throw FormatException('Missing declared resource: $path'); + } + } + } + } + + Iterable _resourcePaths(GameResource resource) { + if (resource.type == GameResourceType.spine) { + return [resource.atlas!, resource.skeleton!]; + } + return [resource.path]; + } +} diff --git a/lib/runtime/packages/stable_package_store.dart b/lib/runtime/packages/stable_package_store.dart new file mode 100644 index 0000000..cd578ea --- /dev/null +++ b/lib/runtime/packages/stable_package_store.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../game/runtime_options.dart'; +import 'game_package.dart'; +import 'game_package_manifest.dart'; + +class StablePackageStore { + const StablePackageStore({ + RuntimeOptions runtimeOptions = const RuntimeOptions(), + }) : _runtimeOptions = runtimeOptions; + + final RuntimeOptions _runtimeOptions; + + Future cacheRoot() async { + final support = await getApplicationSupportDirectory(); + final root = Directory(p.join(support.path, 'flame_lua_packages')); + root.createSync(recursive: true); + return root; + } + + Future versionDirectory(String gameId, String version) async { + final root = await cacheRoot(); + return Directory(p.join(root.path, gameId, version)); + } + + Future markStable(GamePackage package) async { + if (package.source != GamePackageSource.file) { + return; + } + final marker = await _markerFile(package.manifest.gameId); + marker.createSync(recursive: true); + final previous = await stablePackage(package.manifest.gameId); + final data = { + 'current': package.rootPath, + if (previous != null && previous.rootPath != package.rootPath) + 'previous': previous.rootPath, + }; + marker.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data)); + } + + Future stablePackage(String gameId) async { + final marker = await _markerFile(gameId); + if (!marker.existsSync()) { + return null; + } + final data = + jsonDecode(await marker.readAsString()) as Map; + return _packageFromPath(data['current']); + } + + Future previousStablePackage(String gameId) async { + final marker = await _markerFile(gameId); + if (!marker.existsSync()) { + return null; + } + final data = + jsonDecode(await marker.readAsString()) as Map; + return _packageFromPath(data['previous']); + } + + Future _markerFile(String gameId) async { + final root = await cacheRoot(); + return File(p.join(root.path, gameId, 'stable.json')); + } + + GamePackage? _packageFromPath(Object? pathValue) { + if (pathValue is! String || pathValue.isEmpty) { + return null; + } + final manifestFile = File(p.join(pathValue, 'manifest.json')); + if (!manifestFile.existsSync()) { + return null; + } + return GamePackage.file( + rootPath: pathValue, + manifest: GamePackageManifest.fromJsonString( + manifestFile.readAsStringSync(), + ), + runtimeLuaRoot: _runtimeOptions.runtimeLuaRoot, + ); + } +} diff --git a/lib/runtime/protocol/runtime_protocol.dart b/lib/runtime/protocol/runtime_protocol.dart new file mode 100644 index 0000000..fba464f --- /dev/null +++ b/lib/runtime/protocol/runtime_protocol.dart @@ -0,0 +1,490 @@ +class RuntimeNodeType { + const RuntimeNodeType._(); + + static const panel = 'panel'; + static const button = 'button'; + static const text = 'text'; + static const circle = 'circle'; + static const rect = 'rect'; + static const line = 'line'; + static const progress = 'progress'; + static const listView = 'listView'; + static const sprite = 'sprite'; + static const image = 'image'; + static const spine = 'spine'; + static const particle = 'particle'; + + static const all = { + panel, + button, + text, + circle, + rect, + line, + progress, + listView, + sprite, + image, + spine, + particle, + }; + + static bool isSupported(String value) => all.contains(value); +} + +class RuntimeAnchorValue { + const RuntimeAnchorValue._(); + + static const center = 'center'; + static const topLeft = 'topLeft'; + static const topRight = 'topRight'; + static const bottomLeft = 'bottomLeft'; + static const bottomRight = 'bottomRight'; + + static const all = {center, topLeft, topRight, bottomLeft, bottomRight}; + + static bool isSupported(String value) => all.contains(value); +} + +class RuntimeTextAlignValue { + const RuntimeTextAlignValue._(); + + static const left = 'left'; + static const center = 'center'; + static const right = 'right'; + + static const all = {left, center, right}; + + static bool isSupported(String value) => all.contains(value); +} + +class RuntimeParticlePresetValue { + const RuntimeParticlePresetValue._(); + + static const burst = 'burst'; + static const trail = 'trail'; + static const snow = 'snow'; + static const confetti = 'confetti'; + + static const all = {burst, trail, snow, confetti}; + + static bool isSupported(String value) => all.contains(value); +} + +class RuntimeEventType { + const RuntimeEventType._(); + + static const tap = 'tap'; + static const animationDone = 'animation_done'; + static const resize = 'resize'; + static const scroll = 'scroll'; +} + +class RuntimeCommandType { + const RuntimeCommandType._(); + + static const movePath = 'move_path'; + static const moveTo = 'move_to'; + static const fadeTo = 'fade_to'; + static const scaleTo = 'scale_to'; + static const rotateTo = 'rotate_to'; + static const removeNode = 'remove_node'; + static const sequence = 'sequence'; + static const parallel = 'parallel'; + static const delay = 'delay'; + static const toast = 'toast'; + static const playSound = 'play_sound'; + static const playBgm = 'play_bgm'; + static const pauseBgm = 'pause_bgm'; + static const resumeBgm = 'resume_bgm'; + static const stopBgm = 'stop_bgm'; + static const preloadResources = 'preload_resources'; + static const evictResources = 'evict_resources'; + static const cancelCommands = 'cancel_commands'; + static const playSpineAnimation = 'play_spine_animation'; + static const copyText = 'copy_text'; + + static const all = { + movePath, + moveTo, + fadeTo, + scaleTo, + rotateTo, + removeNode, + sequence, + parallel, + delay, + toast, + playSound, + playBgm, + pauseBgm, + resumeBgm, + stopBgm, + preloadResources, + evictResources, + cancelCommands, + playSpineAnimation, + copyText, + }; + + static bool isSupported(String value) => all.contains(value); +} + +class RuntimeProtocolField { + const RuntimeProtocolField._(); + + static const id = 'id'; + static const type = 'type'; + static const target = 'target'; + static const parent = 'parent'; + static const asset = 'asset'; + static const pressedAsset = 'pressedAsset'; + static const disabledAsset = 'disabledAsset'; + static const animation = 'animation'; + static const skin = 'skin'; + static const loop = 'loop'; + static const text = 'text'; + static const x = 'x'; + static const y = 'y'; + static const width = 'width'; + static const height = 'height'; + static const paddingLeft = 'paddingLeft'; + static const paddingTop = 'paddingTop'; + static const paddingRight = 'paddingRight'; + static const paddingBottom = 'paddingBottom'; + static const anchor = 'anchor'; + static const layer = 'layer'; + static const visible = 'visible'; + static const alpha = 'alpha'; + static const scale = 'scale'; + static const rotation = 'rotation'; + static const color = 'color'; + static const fontSize = 'fontSize'; + static const textAlign = 'textAlign'; + static const radius = 'radius'; + static const strokeWidth = 'strokeWidth'; + static const value = 'value'; + static const scrollX = 'scrollX'; + static const scrollY = 'scrollY'; + static const contentWidth = 'contentWidth'; + static const contentHeight = 'contentHeight'; + static const virtualized = 'virtualized'; + static const cacheExtent = 'cacheExtent'; + static const inertia = 'inertia'; + static const scrollbarThumbColor = 'scrollbarThumbColor'; + static const scrollbarTrackColor = 'scrollbarTrackColor'; + static const scrollbarThickness = 'scrollbarThickness'; + static const scrollbarVisible = 'scrollbarVisible'; + static const interactive = 'interactive'; + static const onTap = 'onTap'; + static const onScroll = 'onScroll'; + static const props = 'props'; + static const creates = 'creates'; + static const updates = 'updates'; + static const removes = 'removes'; + static const render = 'render'; + static const ui = 'ui'; + static const commands = 'commands'; + static const path = 'path'; + static const duration = 'duration'; + static const angle = 'angle'; + static const message = 'message'; + static const name = 'name'; + static const volume = 'volume'; + static const channel = 'channel'; + static const group = 'group'; + static const commandGroup = 'commandGroup'; + static const scope = 'scope'; + static const onComplete = 'onComplete'; + static const failOnError = 'failOnError'; + static const track = 'track'; + static const queue = 'queue'; + static const delay = 'delay'; + static const preset = 'preset'; + static const count = 'count'; + static const speedMin = 'speedMin'; + static const speedMax = 'speedMax'; + static const gravityX = 'gravityX'; + static const gravityY = 'gravityY'; + static const spread = 'spread'; + static const colorTo = 'colorTo'; + static const radiusTo = 'radiusTo'; + static const autoRemove = 'autoRemove'; + static const fadeOut = 'fadeOut'; +} + +class RuntimeProtocolSchema { + const RuntimeProtocolSchema._(); + + static const nodeFields = { + RuntimeProtocolField.id, + RuntimeProtocolField.type, + RuntimeProtocolField.parent, + RuntimeProtocolField.asset, + RuntimeProtocolField.pressedAsset, + RuntimeProtocolField.disabledAsset, + RuntimeProtocolField.animation, + RuntimeProtocolField.skin, + RuntimeProtocolField.loop, + RuntimeProtocolField.text, + RuntimeProtocolField.x, + RuntimeProtocolField.y, + RuntimeProtocolField.width, + RuntimeProtocolField.height, + RuntimeProtocolField.paddingLeft, + RuntimeProtocolField.paddingTop, + RuntimeProtocolField.paddingRight, + RuntimeProtocolField.paddingBottom, + RuntimeProtocolField.anchor, + RuntimeProtocolField.layer, + RuntimeProtocolField.visible, + RuntimeProtocolField.alpha, + RuntimeProtocolField.scale, + RuntimeProtocolField.rotation, + RuntimeProtocolField.color, + RuntimeProtocolField.fontSize, + RuntimeProtocolField.textAlign, + RuntimeProtocolField.radius, + RuntimeProtocolField.strokeWidth, + RuntimeProtocolField.value, + RuntimeProtocolField.scrollX, + RuntimeProtocolField.scrollY, + RuntimeProtocolField.contentWidth, + RuntimeProtocolField.contentHeight, + RuntimeProtocolField.virtualized, + RuntimeProtocolField.cacheExtent, + RuntimeProtocolField.inertia, + RuntimeProtocolField.scrollbarThumbColor, + RuntimeProtocolField.scrollbarTrackColor, + RuntimeProtocolField.scrollbarThickness, + RuntimeProtocolField.scrollbarVisible, + RuntimeProtocolField.interactive, + RuntimeProtocolField.onTap, + RuntimeProtocolField.onScroll, + RuntimeProtocolField.preset, + RuntimeProtocolField.count, + RuntimeProtocolField.duration, + RuntimeProtocolField.speedMin, + RuntimeProtocolField.speedMax, + RuntimeProtocolField.gravityX, + RuntimeProtocolField.gravityY, + RuntimeProtocolField.spread, + RuntimeProtocolField.colorTo, + RuntimeProtocolField.radiusTo, + RuntimeProtocolField.autoRemove, + RuntimeProtocolField.fadeOut, + }; + + static const nodeUpdateFields = { + RuntimeProtocolField.id, + RuntimeProtocolField.props, + }; + + static const nodeRemoveFields = {RuntimeProtocolField.id}; + + static const nodePropsFields = { + RuntimeProtocolField.type, + RuntimeProtocolField.parent, + RuntimeProtocolField.asset, + RuntimeProtocolField.pressedAsset, + RuntimeProtocolField.disabledAsset, + RuntimeProtocolField.animation, + RuntimeProtocolField.skin, + RuntimeProtocolField.loop, + RuntimeProtocolField.text, + RuntimeProtocolField.x, + RuntimeProtocolField.y, + RuntimeProtocolField.width, + RuntimeProtocolField.height, + RuntimeProtocolField.paddingLeft, + RuntimeProtocolField.paddingTop, + RuntimeProtocolField.paddingRight, + RuntimeProtocolField.paddingBottom, + RuntimeProtocolField.anchor, + RuntimeProtocolField.layer, + RuntimeProtocolField.visible, + RuntimeProtocolField.alpha, + RuntimeProtocolField.scale, + RuntimeProtocolField.rotation, + RuntimeProtocolField.color, + RuntimeProtocolField.fontSize, + RuntimeProtocolField.textAlign, + RuntimeProtocolField.radius, + RuntimeProtocolField.strokeWidth, + RuntimeProtocolField.value, + RuntimeProtocolField.scrollX, + RuntimeProtocolField.scrollY, + RuntimeProtocolField.contentWidth, + RuntimeProtocolField.contentHeight, + RuntimeProtocolField.virtualized, + RuntimeProtocolField.cacheExtent, + RuntimeProtocolField.inertia, + RuntimeProtocolField.scrollbarThumbColor, + RuntimeProtocolField.scrollbarTrackColor, + RuntimeProtocolField.scrollbarThickness, + RuntimeProtocolField.scrollbarVisible, + RuntimeProtocolField.interactive, + RuntimeProtocolField.onTap, + RuntimeProtocolField.onScroll, + RuntimeProtocolField.preset, + RuntimeProtocolField.count, + RuntimeProtocolField.duration, + RuntimeProtocolField.speedMin, + RuntimeProtocolField.speedMax, + RuntimeProtocolField.gravityX, + RuntimeProtocolField.gravityY, + RuntimeProtocolField.spread, + RuntimeProtocolField.colorTo, + RuntimeProtocolField.radiusTo, + RuntimeProtocolField.autoRemove, + RuntimeProtocolField.fadeOut, + }; + + static const nodeDiffFields = { + RuntimeProtocolField.creates, + RuntimeProtocolField.updates, + RuntimeProtocolField.removes, + }; + + static const gameDiffFields = { + RuntimeProtocolField.render, + RuntimeProtocolField.ui, + RuntimeProtocolField.commands, + }; + + static const commandEnvelopeFields = { + RuntimeProtocolField.type, + RuntimeProtocolField.target, + }; + + static const commandCommonPayloadFields = { + RuntimeProtocolField.id, + RuntimeProtocolField.group, + RuntimeProtocolField.commandGroup, + RuntimeProtocolField.scope, + RuntimeProtocolField.onComplete, + }; + + static const commandPayloadFieldsByType = { + RuntimeCommandType.movePath: { + ...commandCommonPayloadFields, + RuntimeProtocolField.path, + RuntimeProtocolField.duration, + }, + RuntimeCommandType.moveTo: { + ...commandCommonPayloadFields, + RuntimeProtocolField.x, + RuntimeProtocolField.y, + RuntimeProtocolField.duration, + }, + RuntimeCommandType.fadeTo: { + ...commandCommonPayloadFields, + RuntimeProtocolField.alpha, + RuntimeProtocolField.duration, + }, + RuntimeCommandType.scaleTo: { + ...commandCommonPayloadFields, + RuntimeProtocolField.scale, + RuntimeProtocolField.duration, + }, + RuntimeCommandType.rotateTo: { + ...commandCommonPayloadFields, + RuntimeProtocolField.angle, + RuntimeProtocolField.duration, + }, + RuntimeCommandType.removeNode: commandCommonPayloadFields, + RuntimeCommandType.sequence: { + ...commandCommonPayloadFields, + RuntimeProtocolField.commands, + }, + RuntimeCommandType.parallel: { + ...commandCommonPayloadFields, + RuntimeProtocolField.commands, + }, + RuntimeCommandType.delay: { + ...commandCommonPayloadFields, + RuntimeProtocolField.duration, + }, + RuntimeCommandType.toast: { + ...commandCommonPayloadFields, + RuntimeProtocolField.text, + RuntimeProtocolField.message, + RuntimeProtocolField.duration, + }, + RuntimeCommandType.playSound: { + ...commandCommonPayloadFields, + RuntimeProtocolField.asset, + RuntimeProtocolField.name, + RuntimeProtocolField.volume, + }, + RuntimeCommandType.playBgm: { + ...commandCommonPayloadFields, + RuntimeProtocolField.asset, + RuntimeProtocolField.name, + RuntimeProtocolField.volume, + RuntimeProtocolField.channel, + RuntimeProtocolField.loop, + }, + RuntimeCommandType.pauseBgm: { + ...commandCommonPayloadFields, + RuntimeProtocolField.channel, + }, + RuntimeCommandType.resumeBgm: { + ...commandCommonPayloadFields, + RuntimeProtocolField.channel, + }, + RuntimeCommandType.stopBgm: { + ...commandCommonPayloadFields, + RuntimeProtocolField.channel, + }, + RuntimeCommandType.preloadResources: { + ...commandCommonPayloadFields, + RuntimeProtocolField.failOnError, + }, + RuntimeCommandType.evictResources: commandCommonPayloadFields, + RuntimeCommandType.cancelCommands: commandCommonPayloadFields, + RuntimeCommandType.playSpineAnimation: { + ...commandCommonPayloadFields, + RuntimeProtocolField.animation, + RuntimeProtocolField.track, + RuntimeProtocolField.loop, + RuntimeProtocolField.queue, + RuntimeProtocolField.delay, + }, + RuntimeCommandType.copyText: { + ...commandCommonPayloadFields, + RuntimeProtocolField.text, + }, + }; + + static void ensureKnownKeys( + Map map, { + required Set allowed, + required String context, + }) { + for (final key in map.keys) { + if (key is! String) { + throw FormatException('$context field key must be a string: $key'); + } + if (!allowed.contains(key)) { + throw FormatException('$context has unsupported field: $key'); + } + } + } + + static Set allowedCommandFields(String commandType) { + final payloadFields = commandPayloadFieldsByType[commandType]; + if (payloadFields == null) { + throw UnsupportedError('Unsupported runtime command: $commandType'); + } + return {...commandEnvelopeFields, ...payloadFields}; + } + + static Set allowedCommandPayloadFields(String commandType) { + final payloadFields = commandPayloadFieldsByType[commandType]; + if (payloadFields == null) { + throw UnsupportedError('Unsupported runtime command: $commandType'); + } + return payloadFields; + } +} diff --git a/lib/runtime/rendering/render_tree_controller.dart b/lib/runtime/rendering/render_tree_controller.dart new file mode 100644 index 0000000..31f3cb8 --- /dev/null +++ b/lib/runtime/rendering/render_tree_controller.dart @@ -0,0 +1,489 @@ +import 'package:flame/components.dart'; + +import '../models/game_diff.dart'; +import '../models/runtime_event.dart'; +import '../models/runtime_node.dart'; +import '../protocol/runtime_protocol.dart'; +import '../resources/game_resource_manager.dart'; +import 'runtime_component.dart'; + +class RenderTreeController { + RenderTreeController({ + required Component root, + required GameResourceManager resources, + required void Function(RuntimeEvent event) eventSink, + this.onScopeRemoved, + }) : _root = root, + _resources = resources, + _eventSink = eventSink; + + final Component _root; + final GameResourceManager _resources; + final void Function(RuntimeEvent event) _eventSink; + final Map _components = {}; + final Map _epochs = {}; + final Map _scrollVelocities = {}; + void Function(String id)? onScopeRemoved; + + RuntimeComponent? componentById(String id) => _components[id]; + + bool contains(String id) => _components.containsKey(id); + + String? listViewAt(Vector2 canvasPosition) { + final hits = + _components.values + .where( + (component) => + component.node.type == RuntimeNodeType.listView && + component.containsVisualPoint(canvasPosition), + ) + .toList(growable: false) + ..sort((a, b) => b.priority.compareTo(a.priority)); + return hits.isEmpty ? null : hits.first.node.id; + } + + bool scrollListViewAt( + Vector2 canvasPosition, { + double deltaX = 0, + double deltaY = 0, + String source = 'wheel', + }) { + final id = listViewAt(canvasPosition); + if (id == null) { + return false; + } + return scrollListView(id, deltaX: deltaX, deltaY: deltaY, source: source); + } + + bool scrollListView( + String id, { + double deltaX = 0, + double deltaY = 0, + String source = 'program', + }) { + final component = _components[id]; + if (component == null || component.node.type != RuntimeNodeType.listView) { + return false; + } + final node = component.node; + final viewport = _listViewContentViewport(node); + final nextScrollX = _clampScroll( + node.scrollX + deltaX, + viewportExtent: viewport.x, + contentExtent: node.contentWidth, + ); + final nextScrollY = _clampScroll( + node.scrollY + deltaY, + viewportExtent: viewport.y, + contentExtent: node.contentHeight, + ); + if (nextScrollX == node.scrollX && nextScrollY == node.scrollY) { + return false; + } + component.updateNode( + node.copyWithProps({'scrollX': nextScrollX, 'scrollY': nextScrollY}), + ); + _reattachChildrenOf(id); + _emitScrollEvent(component, source: source); + return true; + } + + void setListViewVelocity(String id, Vector2 velocity) { + final component = _components[id]; + if (component == null || component.node.type != RuntimeNodeType.listView) { + return; + } + if (!component.node.inertia) { + _scrollVelocities.remove(id); + return; + } + if (velocity.length2 < 1) { + _scrollVelocities.remove(id); + return; + } + _scrollVelocities[id] = velocity; + } + + void stopListViewVelocity(String id) { + _scrollVelocities.remove(id); + } + + void updateListViewInertia(double dt) { + if (_scrollVelocities.isEmpty || dt <= 0) { + return; + } + for (final entry in _scrollVelocities.entries.toList(growable: false)) { + final id = entry.key; + final velocity = entry.value; + final consumed = scrollListView( + id, + deltaX: velocity.x * dt, + deltaY: velocity.y * dt, + source: 'inertia', + ); + final next = velocity * 0.88; + if (!consumed || next.length2 < 25) { + _scrollVelocities.remove(id); + } else { + _scrollVelocities[id] = next; + } + } + } + + int epochOf(String id) => _epochs[id] ?? 0; + + bool isNodeEpochAlive(String id, int epoch) { + return _components.containsKey(id) && epochOf(id) == epoch; + } + + void clear() { + final ids = _components.keys.toList(growable: false); + for (final component in _components.values) { + component.removeFromParent(); + } + _components.clear(); + for (final id in ids) { + _bumpEpoch(id); + onScopeRemoved?.call(id); + } + } + + void removeById(String id) { + final removedIds = []; + for (final childId in _descendantIdsOf(id)) { + final child = _components.remove(childId); + if (child != null) { + removedIds.add(childId); + _bumpEpoch(childId); + child.removeFromParent(); + } + } + + final component = _components.remove(id); + if (component != null) { + removedIds.add(id); + _bumpEpoch(id); + component.removeFromParent(); + } + + for (final removedId in removedIds) { + onScopeRemoved?.call(removedId); + } + } + + void apply(NodeDiff diff) { + _validateDiff(diff); + + for (final remove in diff.removes) { + removeById(remove.id); + } + + for (final create in diff.creates) { + _createOrReplace(create); + } + + for (final update in diff.updates) { + final component = _components[update.id]; + if (component == null) { + continue; + } + final nextNode = component.node.copyWithProps(update.props); + component.updateNode(nextNode); + _attachToParent(component); + _reattachChildrenOf(component.node.id); + } + } + + void _validateDiff(NodeDiff diff) { + final nodes = { + for (final entry in _components.entries) entry.key: entry.value.node, + }; + + for (final remove in diff.removes) { + _removeNodeSnapshot(nodes, remove.id); + } + + for (final create in diff.creates) { + nodes[create.id] = create; + } + + for (final update in diff.updates) { + final current = nodes[update.id]; + if (current == null) { + continue; + } + nodes[update.id] = current.copyWithProps(update.props); + } + + _validateParentGraph(nodes); + } + + void _removeNodeSnapshot(Map nodes, String id) { + final descendants = _descendantIdsOfSnapshot(nodes, id); + for (final childId in descendants) { + nodes.remove(childId); + } + nodes.remove(id); + } + + List _descendantIdsOfSnapshot( + Map nodes, + String parentId, + ) { + final descendants = []; + for (final node in nodes.values) { + if (node.parent == parentId) { + descendants.add(node.id); + descendants.addAll(_descendantIdsOfSnapshot(nodes, node.id)); + } + } + return descendants; + } + + void _validateParentGraph(Map nodes) { + for (final node in nodes.values) { + final parentId = node.parent; + if (parentId == null) { + continue; + } + if (parentId == node.id) { + throw const FormatException( + 'RuntimeNode.parent cannot reference itself', + ); + } + + final seen = {node.id}; + var currentId = parentId; + while (true) { + if (!seen.add(currentId)) { + throw FormatException( + 'RuntimeNode.parent would create a cycle: ${node.id} -> $parentId', + ); + } + final parent = nodes[currentId]; + final nextId = parent?.parent; + if (nextId == null) { + break; + } + currentId = nextId; + } + } + } + + void _createOrReplace(RuntimeNode node) { + _validateParent(node); + + final existing = _components.remove(node.id); + existing?.removeFromParent(); + final epoch = _bumpEpoch(node.id); + + late final RuntimeComponent component; + component = RuntimeComponent( + node: node, + resources: _resources, + onNodeTap: (tappedNode, localPosition) { + if (_components[tappedNode.id] != component) { + return; + } + _eventSink( + RuntimeEvent( + type: RuntimeEventType.tap, + target: tappedNode.id, + handler: tappedNode.onTap, + x: localPosition.x, + y: localPosition.y, + scope: tappedNode.id, + targetEpoch: epoch, + scopeEpoch: epoch, + ), + ); + }, + ); + _components[node.id] = component; + _attachToParent(component); + _reattachChildrenOf(node.id); + } + + int _bumpEpoch(String id) { + final next = (_epochs[id] ?? 0) + 1; + _epochs[id] = next; + return next; + } + + void _attachToParent(RuntimeComponent component) { + final parentId = component.node.parent; + final parent = parentId == null ? null : _components[parentId]; + final target = parent ?? _root; + final parentIsListView = parent?.node.type == RuntimeNodeType.listView; + final parentScrollX = parentIsListView ? parent!.node.scrollX : 0.0; + final parentScrollY = parentIsListView ? parent!.node.scrollY : 0.0; + final parentContentOffset = parentIsListView + ? parent!.listViewContentOffset() + : Vector2.zero(); + component.setParentScroll( + x: parentScrollX, + y: parentScrollY, + contentOffsetX: parentContentOffset.x, + contentOffsetY: parentContentOffset.y, + ); + component.setViewportCulled(_isCulledByParentListView(component, parent)); + if (component.parent == target) { + return; + } + + component.removeFromParent(); + target.add(component); + } + + void _validateParent(RuntimeNode node) { + final parentId = node.parent; + if (parentId != null && _wouldCreateCycle(node.id, parentId)) { + throw FormatException( + 'RuntimeNode.parent would create a cycle: ${node.id} -> $parentId', + ); + } + } + + void _reattachChildrenOf(String parentId) { + for (final component in _components.values.toList(growable: false)) { + if (component.node.parent == parentId) { + _attachToParent(component); + } + } + } + + List _descendantIdsOf(String parentId) { + final descendants = []; + for (final component in _components.values) { + if (component.node.parent == parentId) { + descendants.add(component.node.id); + descendants.addAll(_descendantIdsOf(component.node.id)); + } + } + return descendants; + } + + bool _isCulledByParentListView( + RuntimeComponent component, + RuntimeComponent? parent, + ) { + final parentNode = parent?.node; + if (parentNode == null || + parentNode.type != RuntimeNodeType.listView || + !parentNode.virtualized) { + return false; + } + final cache = parentNode.cacheExtent; + final viewportLeft = parentNode.scrollX - cache; + final viewportTop = parentNode.scrollY - cache; + final viewport = parent?.listViewContentViewport() ?? Vector2.zero(); + final viewportRight = parentNode.scrollX + viewport.x + cache; + final viewportBottom = parentNode.scrollY + viewport.y + cache; + final node = component.node; + final childLeft = node.x; + final childTop = node.y; + final childRight = node.x + (node.width ?? 0); + final childBottom = node.y + (node.height ?? 0); + return childRight < viewportLeft || + childLeft > viewportRight || + childBottom < viewportTop || + childTop > viewportBottom; + } + + Vector2 _listViewContentViewport(RuntimeNode node) { + final width = node.width ?? 0; + final height = node.height ?? 0; + final left = node.paddingLeft.clamp(0.0, width).toDouble(); + final top = node.paddingTop.clamp(0.0, height).toDouble(); + if (!node.scrollbarVisible) { + return Vector2( + (width - left - node.paddingRight).clamp(0.0, width).toDouble(), + (height - top - node.paddingBottom).clamp(0.0, height).toDouble(), + ); + } + final thickness = (node.scrollbarThickness ?? 5).clamp(1.0, 16.0); + final gutter = thickness + 8; + var vertical = (node.contentHeight ?? 0) > height && height > 0; + var horizontal = (node.contentWidth ?? 0) > width && width > 0; + for (var i = 0; i < 2; i += 1) { + final viewportWidth = + width - left - node.paddingRight - (vertical ? gutter : 0); + final viewportHeight = + height - top - node.paddingBottom - (horizontal ? gutter : 0); + vertical = + (node.contentHeight ?? 0) > viewportHeight && viewportHeight > 0; + horizontal = + (node.contentWidth ?? 0) > viewportWidth && viewportWidth > 0; + } + return Vector2( + (width - left - node.paddingRight - (vertical ? gutter : 0)) + .clamp(0.0, width) + .toDouble(), + (height - top - node.paddingBottom - (horizontal ? gutter : 0)) + .clamp(0.0, height) + .toDouble(), + ); + } + + double _clampScroll( + double value, { + required double? viewportExtent, + required double? contentExtent, + }) { + final maxScroll = (contentExtent ?? 0) - (viewportExtent ?? 0); + if (maxScroll <= 0) { + return 0; + } + return value.clamp(0, maxScroll).toDouble(); + } + + void _emitScrollEvent(RuntimeComponent component, {required String source}) { + final node = component.node; + final handler = node.onScroll; + if (handler == null || handler.isEmpty) { + return; + } + final id = node.id; + _eventSink( + RuntimeEvent( + type: RuntimeEventType.scroll, + target: id, + handler: handler, + scope: id, + targetEpoch: epochOf(id), + scopeEpoch: epochOf(id), + data: { + 'scrollX': node.scrollX, + 'scrollY': node.scrollY, + 'maxScrollX': _clampScroll( + double.infinity, + viewportExtent: node.width, + contentExtent: node.contentWidth, + ), + 'maxScrollY': _clampScroll( + double.infinity, + viewportExtent: node.height, + contentExtent: node.contentHeight, + ), + 'source': source, + }, + ), + ); + } + + bool _wouldCreateCycle(String nodeId, String parentId) { + var currentId = parentId; + while (true) { + if (currentId == nodeId) { + return true; + } + + final parent = _components[currentId]; + final nextId = parent?.node.parent; + if (nextId == null) { + return false; + } + currentId = nextId; + } + } +} diff --git a/lib/runtime/rendering/runtime_component.dart b/lib/runtime/rendering/runtime_component.dart new file mode 100644 index 0000000..6404075 --- /dev/null +++ b/lib/runtime/rendering/runtime_component.dart @@ -0,0 +1,1075 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/particles.dart'; +import 'package:flame_spine/flame_spine.dart' as spine; +import 'package:flutter/material.dart'; + +import '../models/runtime_node.dart'; +import '../protocol/runtime_protocol.dart'; +import '../resources/game_resource_manager.dart'; + +class RuntimeComponent extends PositionComponent + with HasVisibility, TapCallbacks { + RuntimeComponent({ + required RuntimeNode node, + required GameResourceManager resources, + required this.onNodeTap, + }) : _node = node, + _resources = resources { + priority = node.layer; + _applyBase(node); + _syncImage(node); + _syncSpine(node); + _syncParticle(node); + } + + RuntimeNode get node => _node; + + final GameResourceManager _resources; + final void Function(RuntimeNode node, Vector2 localPosition) onNodeTap; + RuntimeNode _node; + TextComponent? _textComponent; + ui.Image? _image; + String? _loadedAsset; + int? _loadedGeneration; + int _imageLoadToken = 0; + spine.SpineComponent? _spineComponent; + ParticleSystemComponent? _particleComponent; + String? _particleSignature; + String? _loadedSpineAsset; + int? _loadedSpineGeneration; + int _spineLoadToken = 0; + String? _appliedSpineAnimation; + bool? _appliedSpineLoop; + String? _pendingSpineAnimation; + int _pendingSpineTrack = 0; + bool _pendingSpineLoop = true; + bool _pendingSpineQueue = false; + double _pendingSpineDelay = 0; + String? _appliedSpineSkin; + double? _runtimeAlpha; + double _parentScrollX = 0; + double _parentScrollY = 0; + double _parentContentOffsetX = 0; + double _parentContentOffsetY = 0; + bool _viewportCulled = false; + bool _pressed = false; + + double get renderAlpha => _runtimeAlpha ?? _node.alpha; + + void setRuntimeAlpha(double value) { + _runtimeAlpha = value.clamp(0, 1).toDouble(); + } + + void setParentScroll({ + double x = 0, + double y = 0, + double contentOffsetX = 0, + double contentOffsetY = 0, + }) { + _parentScrollX = x < 0 ? 0 : x; + _parentScrollY = y < 0 ? 0 : y; + _parentContentOffsetX = contentOffsetX < 0 ? 0 : contentOffsetX; + _parentContentOffsetY = contentOffsetY < 0 ? 0 : contentOffsetY; + _syncPosition(); + } + + void setViewportCulled(bool value) { + _viewportCulled = value; + _syncVisibility(); + } + + void updateNode(RuntimeNode node) { + _node = node; + priority = node.layer; + _applyBase(node); + _syncImage(node); + _syncSpine(node); + _syncParticle(node); + } + + bool containsVisualPoint(Vector2 point) { + if (!_node.visible) { + return false; + } + final local = absoluteToLocal(point); + return local.x >= 0 && + local.y >= 0 && + local.x <= size.x && + local.y <= size.y; + } + + @override + bool containsLocalPoint(Vector2 point) { + if (!_node.visible || !_node.interactive) { + return false; + } + return point.x >= 0 && + point.y >= 0 && + point.x <= size.x && + point.y <= size.y; + } + + @override + void onTapDown(TapDownEvent event) { + if (!_node.visible || !_node.interactive) { + return; + } + if (_node.type == RuntimeNodeType.button) { + _pressed = true; + _syncImage(_node); + } + onNodeTap(_node, event.localPosition); + } + + @override + void onTapUp(TapUpEvent event) { + _releasePressedState(); + } + + @override + void onTapCancel(TapCancelEvent event) { + _releasePressedState(); + } + + void _releasePressedState() { + if (!_pressed) { + return; + } + _pressed = false; + _syncImage(_node); + } + + @override + void renderTree(Canvas canvas) { + if (_node.type != RuntimeNodeType.listView) { + super.renderTree(canvas); + return; + } + + if (!isVisible) { + return; + } + + final paint = Paint() + ..color = (_node.color ?? _defaultColor()).withValues(alpha: renderAlpha); + + canvas.save(); + canvas.transform(Float64List.fromList(transform.transformMatrix.storage)); + _renderBoxOrImage(canvas, paint); + canvas.save(); + canvas.clipRRect(_listViewClipRRect()); + for (final child in children) { + child.renderTree(canvas); + } + canvas.restore(); + _renderListViewScrollbar(canvas); + if (debugMode) { + renderDebugMode(canvas); + } + canvas.restore(); + } + + @override + void render(Canvas canvas) { + final paint = Paint() + ..color = (_node.color ?? _defaultColor()).withValues(alpha: renderAlpha); + + switch (_node.type) { + case RuntimeNodeType.circle: + canvas.drawCircle(Offset(size.x / 2, size.y / 2), size.x / 2, paint); + break; + case RuntimeNodeType.line: + _renderLine(canvas, paint); + break; + case RuntimeNodeType.progress: + _renderProgress(canvas, paint); + break; + case RuntimeNodeType.listView: + _renderListView(canvas, paint); + break; + case RuntimeNodeType.panel: + case RuntimeNodeType.button: + case RuntimeNodeType.rect: + case RuntimeNodeType.sprite: + case RuntimeNodeType.image: + _renderBoxOrImage(canvas, paint); + break; + case RuntimeNodeType.spine: + _renderSpinePlaceholder(canvas, paint); + break; + case RuntimeNodeType.particle: + break; + default: + break; + } + } + + void _renderLine(Canvas canvas, Paint paint) { + final stroke = _node.strokeWidth ?? 2; + canvas.drawLine( + Offset.zero, + Offset(size.x, size.y), + paint + ..style = PaintingStyle.stroke + ..strokeWidth = stroke, + ); + } + + void _renderProgress(Canvas canvas, Paint paint) { + final rect = Rect.fromLTWH(0, 0, size.x, size.y); + final radius = Radius.circular(_node.radius ?? 4); + final backgroundPaint = Paint() + ..color = const Color(0x33475569).withValues(alpha: renderAlpha); + canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), backgroundPaint); + + final value = _node.value ?? 0; + if (value <= 0) { + return; + } + final fillRect = Rect.fromLTWH(0, 0, size.x * value, size.y); + canvas.drawRRect(RRect.fromRectAndRadius(fillRect, radius), paint); + } + + void _renderSpinePlaceholder(Canvas canvas, Paint paint) { + if (_spineComponent != null) { + return; + } + final rect = Rect.fromLTWH(0, 0, size.x, size.y); + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(4)), + paint, + ); + } + + void _renderListView(Canvas canvas, Paint paint) { + _renderBoxOrImage(canvas, paint); + } + + double get _scrollbarThickness => + (_node.scrollbarThickness ?? 5).clamp(1.0, 16.0).toDouble(); + + double get _scrollbarGutter => _scrollbarThickness + 8; + + ({bool horizontal, bool vertical}) _listViewScrollbarVisibility() { + if (!_node.scrollbarVisible) { + return (horizontal: false, vertical: false); + } + final left = _node.paddingLeft.clamp(0.0, size.x).toDouble(); + final top = _node.paddingTop.clamp(0.0, size.y).toDouble(); + final right = _node.paddingRight.clamp(0.0, size.x).toDouble(); + final bottom = _node.paddingBottom.clamp(0.0, size.y).toDouble(); + var viewportWidth = size.x - left - right; + var viewportHeight = size.y - top - bottom; + var vertical = + (_node.contentHeight ?? 0) > viewportHeight && viewportHeight > 0; + var horizontal = + (_node.contentWidth ?? 0) > viewportWidth && viewportWidth > 0; + for (var i = 0; i < 2; i += 1) { + viewportWidth = size.x - left - right - (vertical ? _scrollbarGutter : 0); + viewportHeight = + size.y - top - bottom - (horizontal ? _scrollbarGutter : 0); + vertical = + (_node.contentHeight ?? 0) > viewportHeight && viewportHeight > 0; + horizontal = + (_node.contentWidth ?? 0) > viewportWidth && viewportWidth > 0; + } + return (horizontal: horizontal, vertical: vertical); + } + + Rect _listViewContentRect() { + final scrollbars = _listViewScrollbarVisibility(); + final left = _node.paddingLeft.clamp(0.0, size.x).toDouble(); + final top = _node.paddingTop.clamp(0.0, size.y).toDouble(); + final rightInset = + (_node.paddingRight + (scrollbars.vertical ? _scrollbarGutter : 0)) + .clamp(0.0, size.x) + .toDouble(); + final bottomInset = + (_node.paddingBottom + (scrollbars.horizontal ? _scrollbarGutter : 0)) + .clamp(0.0, size.y) + .toDouble(); + final width = (size.x - left - rightInset).clamp(0.0, size.x).toDouble(); + final height = (size.y - top - bottomInset).clamp(0.0, size.y).toDouble(); + return Rect.fromLTWH(left, top, width, height); + } + + RRect _listViewClipRRect() { + final radius = Radius.circular((_node.radius ?? 0).clamp(0.0, 10000.0)); + return RRect.fromRectAndRadius(_listViewContentRect(), radius); + } + + Vector2 listViewContentOffset() { + if (_node.type != RuntimeNodeType.listView) { + return Vector2.zero(); + } + final rect = _listViewContentRect(); + return Vector2(rect.left, rect.top); + } + + Vector2 listViewContentViewport() { + if (_node.type != RuntimeNodeType.listView) { + return Vector2.zero(); + } + final rect = _listViewContentRect(); + return Vector2(rect.width, rect.height); + } + + void _renderListViewScrollbar(Canvas canvas) { + if (!_node.scrollbarVisible) { + return; + } + final thickness = _scrollbarThickness; + final contentRect = _listViewContentRect(); + final scrollbars = _listViewScrollbarVisibility(); + final trackPaint = Paint() + ..color = (_node.scrollbarTrackColor ?? const Color(0x33475569)) + .withValues(alpha: renderAlpha); + final thumbPaint = Paint() + ..color = (_node.scrollbarThumbColor ?? const Color(0xaa94a3b8)) + .withValues(alpha: renderAlpha); + + final contentHeight = _node.contentHeight; + if (scrollbars.vertical && + contentHeight != null && + contentRect.height > 0) { + final trackHeight = (contentRect.height - 12).clamp( + 4.0, + contentRect.height, + ); + final maxScroll = contentHeight - contentRect.height; + final thumbHeight = + (contentRect.height * contentRect.height / contentHeight).clamp( + 18.0, + contentRect.height, + ); + final thumbY = maxScroll <= 0 + ? 0.0 + : (_node.scrollY / maxScroll) * (contentRect.height - thumbHeight); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(size.x - thickness - 2, 6, thickness, trackHeight), + Radius.circular(thickness / 2), + ), + trackPaint, + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + size.x - thickness - 2, + thumbY + 6, + thickness, + (thumbHeight - 12).clamp(4.0, contentRect.height), + ), + Radius.circular(thickness / 2), + ), + thumbPaint, + ); + } + + final contentWidth = _node.contentWidth; + if (scrollbars.horizontal && + contentWidth != null && + contentRect.width > 0) { + final trackWidth = (contentRect.width - 12).clamp(4.0, contentRect.width); + final maxScroll = contentWidth - contentRect.width; + final thumbWidth = (contentRect.width * contentRect.width / contentWidth) + .clamp(18.0, contentRect.width); + final thumbX = maxScroll <= 0 + ? 0.0 + : (_node.scrollX / maxScroll) * (contentRect.width - thumbWidth); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(6, size.y - thickness - 2, trackWidth, thickness), + Radius.circular(thickness / 2), + ), + trackPaint, + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + thumbX + 6, + size.y - thickness - 2, + (thumbWidth - 12).clamp(4.0, contentRect.width), + thickness, + ), + Radius.circular(thickness / 2), + ), + thumbPaint, + ); + } + } + + void _renderBoxOrImage(Canvas canvas, Paint paint) { + final image = _image; + final rect = Rect.fromLTWH(0, 0, size.x, size.y); + if (image != null && + (_node.type == RuntimeNodeType.sprite || + _node.type == RuntimeNodeType.image || + _node.type == RuntimeNodeType.button)) { + canvas.drawImageRect( + image, + Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), + rect, + Paint()..color = Colors.white.withValues(alpha: renderAlpha), + ); + return; + } + + final radius = + _node.radius ?? (_node.type == RuntimeNodeType.button ? 12.0 : 4.0); + canvas.drawRRect( + RRect.fromRectAndRadius(rect, Radius.circular(radius)), + paint, + ); + } + + void _applyBase(RuntimeNode node) { + _syncVisibility(); + size = Vector2(node.width ?? 40, node.height ?? 40); + _syncPosition(); + scale = Vector2.all(node.scale); + angle = node.rotation; + anchor = _anchorFromString(node.anchor); + _syncText(node); + _syncSpineLayout(); + } + + void _syncVisibility() { + isVisible = _node.visible && !_viewportCulled; + } + + void _syncPosition() { + position = Vector2( + _parentContentOffsetX + _node.x - _parentScrollX, + _parentContentOffsetY + _node.y - _parentScrollY, + ); + } + + void _syncImage(RuntimeNode node) { + if (node.type != RuntimeNodeType.sprite && + node.type != RuntimeNodeType.image && + node.type != RuntimeNodeType.button) { + _imageLoadToken++; + _releaseLoadedImage(); + return; + } + final asset = _currentImageAsset(node); + if (asset == null || asset.isEmpty) { + _imageLoadToken++; + _releaseLoadedImage(); + return; + } + + final requestedGeneration = _resources.generation; + if (asset == _loadedAsset && requestedGeneration == _loadedGeneration) { + return; + } + + final requestToken = ++_imageLoadToken; + _releaseLoadedImage(); + _loadedAsset = asset; + _loadedGeneration = requestedGeneration; + _resources.loadImage(asset, retain: true).then((image) { + if (_imageLoadToken != requestToken) { + _releaseRetainedImage(asset, requestedGeneration, image); + return; + } + if (_loadedAsset != asset || _loadedGeneration != requestedGeneration) { + _releaseRetainedImage(asset, requestedGeneration, image); + return; + } + if (_resources.generation != requestedGeneration) { + _releaseRetainedImage(asset, requestedGeneration, image); + return; + } + _image = image; + }); + } + + String? _currentImageAsset(RuntimeNode node) { + if (node.type != RuntimeNodeType.button) { + return node.asset; + } + if (!node.interactive && node.disabledAsset != null) { + return node.disabledAsset; + } + if (_pressed && node.pressedAsset != null) { + return node.pressedAsset; + } + return node.asset; + } + + void _releaseRetainedImage(String asset, int generation, ui.Image? image) { + if (image == null) { + return; + } + _resources.releaseImage(asset, generation: generation); + } + + void _syncParticle(RuntimeNode node) { + if (node.type != RuntimeNodeType.particle) { + _releaseParticle(); + return; + } + + final signature = _particleNodeSignature(node); + if (signature == _particleSignature && _particleComponent != null) { + _syncParticleLayout(); + return; + } + + _releaseParticle(); + _particleSignature = signature; + final component = ParticleSystemComponent( + particle: _buildParticle(node), + position: Vector2.zero(), + size: size, + anchor: Anchor.topLeft, + priority: priority + 1, + ); + _particleComponent = component; + add(component); + } + + String _particleNodeSignature(RuntimeNode node) { + return [ + node.preset, + node.count, + node.duration, + node.color, + node.colorTo, + node.radius, + node.radiusTo, + node.speedMin, + node.speedMax, + node.gravityX, + node.gravityY, + node.spread, + node.autoRemove, + node.fadeOut, + node.width, + node.height, + ].join('|'); + } + + Particle _buildParticle(RuntimeNode node) { + final preset = node.preset ?? RuntimeParticlePresetValue.burst; + final count = (node.count ?? _defaultParticleCount(preset)).clamp(1, 300); + final duration = node.duration ?? _defaultParticleDuration(preset); + return Particle.generate( + count: count, + lifespan: node.autoRemove ? duration : 86400, + generator: (index) => ComputedParticle( + lifespan: node.autoRemove ? duration : 86400, + renderer: (canvas, particle) => _renderComputedParticle( + canvas, + particle, + node, + preset, + index, + count, + duration, + ), + ), + ); + } + + int _defaultParticleCount(String preset) { + return switch (preset) { + RuntimeParticlePresetValue.snow => 80, + RuntimeParticlePresetValue.confetti => 72, + RuntimeParticlePresetValue.trail => 16, + _ => 36, + }; + } + + double _defaultParticleDuration(String preset) { + return switch (preset) { + RuntimeParticlePresetValue.snow => 8, + RuntimeParticlePresetValue.confetti => 1.2, + RuntimeParticlePresetValue.trail => 0.35, + _ => 0.55, + }; + } + + void _renderComputedParticle( + Canvas canvas, + Particle particle, + RuntimeNode node, + String preset, + int index, + int count, + double duration, + ) { + final config = _particleConfig(node, preset, index, count); + final progress = node.autoRemove + ? particle.progress + : ((particle.progress * 86400 / duration) % 1.0); + final x = + config.start.x + + config.velocity.x * progress * duration + + 0.5 * config.gravity.x * progress * progress * duration * duration; + final y = + config.start.y + + config.velocity.y * progress * duration + + 0.5 * config.gravity.y * progress * progress * duration * duration; + final radius = config.radius + (config.radiusTo - config.radius) * progress; + if (radius <= 0) { + return; + } + final color = + Color.lerp(config.color, config.colorTo, progress) ?? config.color; + final alpha = node.fadeOut ? (1 - progress).clamp(0.0, 1.0) : 1.0; + final paint = Paint() + ..color = color.withValues(alpha: color.a * renderAlpha * alpha); + canvas.drawCircle(Offset(x, y), radius, paint); + } + + _ParticleConfig _particleConfig( + RuntimeNode node, + String preset, + int index, + int count, + ) { + final width = node.width ?? size.x; + final height = node.height ?? size.y; + final center = Vector2(width / 2, height / 2); + final baseColor = node.color ?? _defaultParticleColor(preset); + final colorTo = node.colorTo ?? baseColor.withValues(alpha: 0); + final radius = node.radius ?? _defaultParticleRadius(preset); + final radiusTo = node.radiusTo ?? 0; + final speedMin = node.speedMin ?? _defaultParticleSpeedMin(preset); + final speedMax = node.speedMax ?? _defaultParticleSpeedMax(preset); + final gravity = Vector2( + node.gravityX ?? 0, + node.gravityY ?? _defaultParticleGravityY(preset), + ); + + if (preset == RuntimeParticlePresetValue.snow) { + final x = width * _stableUnit(index, 3); + final y = height * _stableUnit(index, 7); + final drift = -12 + 24 * _stableUnit(index, 11); + final fall = speedMin + (speedMax - speedMin) * _stableUnit(index, 13); + return _ParticleConfig( + start: Vector2(x, y), + velocity: Vector2(drift, fall), + gravity: gravity, + color: baseColor, + colorTo: colorTo, + radius: radius * (0.6 + _stableUnit(index, 17)), + radiusTo: radiusTo, + ); + } + + if (preset == RuntimeParticlePresetValue.trail) { + final angle = -math.pi / 2 + (_stableUnit(index, 23) - 0.5) * math.pi; + final speed = speedMin + _stableUnit(index, 29) * (speedMax - speedMin); + return _ParticleConfig( + start: center, + velocity: Vector2(math.cos(angle), math.sin(angle)) * speed, + gravity: gravity, + color: baseColor, + colorTo: colorTo, + radius: radius, + radiusTo: radiusTo, + ); + } + + final spread = (node.spread ?? 360) * math.pi / 180; + final baseAngle = preset == RuntimeParticlePresetValue.confetti + ? -math.pi / 2 + : 0.0; + final angle = + baseAngle - + spread / 2 + + spread * (count <= 1 ? 0 : index / (count - 1)); + final speed = speedMin + _stableUnit(index, 31) * (speedMax - speedMin); + return _ParticleConfig( + start: center, + velocity: Vector2(math.cos(angle), math.sin(angle)) * speed, + gravity: gravity, + color: baseColor, + colorTo: node.colorTo ?? _defaultParticleColorTo(preset, baseColor), + radius: radius, + radiusTo: radiusTo, + ); + } + + double _stableUnit(int index, int salt) { + final value = math.sin((index + 1) * (salt + 17) * 12.9898) * 43758.5453; + return value - value.floorToDouble(); + } + + Color _defaultParticleColor(String preset) { + return switch (preset) { + RuntimeParticlePresetValue.snow => const Color(0xccffffff), + RuntimeParticlePresetValue.confetti => const Color(0xffff4d6d), + RuntimeParticlePresetValue.trail => const Color(0xff38bdf8), + _ => const Color(0xffffcc33), + }; + } + + Color _defaultParticleColorTo(String preset, Color base) { + return switch (preset) { + RuntimeParticlePresetValue.confetti => const Color(0xfffacc15), + _ => base.withValues(alpha: 0), + }; + } + + double _defaultParticleRadius(String preset) { + return switch (preset) { + RuntimeParticlePresetValue.snow => 1.6, + RuntimeParticlePresetValue.confetti => 2.6, + RuntimeParticlePresetValue.trail => 2.0, + _ => 2.4, + }; + } + + double _defaultParticleSpeedMin(String preset) { + return switch (preset) { + RuntimeParticlePresetValue.snow => 12, + RuntimeParticlePresetValue.confetti => 120, + RuntimeParticlePresetValue.trail => 20, + _ => 80, + }; + } + + double _defaultParticleSpeedMax(String preset) { + return switch (preset) { + RuntimeParticlePresetValue.snow => 28, + RuntimeParticlePresetValue.confetti => 260, + RuntimeParticlePresetValue.trail => 70, + _ => 220, + }; + } + + double _defaultParticleGravityY(String preset) { + return switch (preset) { + RuntimeParticlePresetValue.snow => 4, + RuntimeParticlePresetValue.confetti => 240, + RuntimeParticlePresetValue.trail => 0, + _ => 80, + }; + } + + void _syncParticleLayout() { + final particle = _particleComponent; + if (particle == null) { + return; + } + particle + ..position = Vector2.zero() + ..size = size + ..anchor = Anchor.topLeft + ..priority = priority + 1; + } + + void _releaseParticle() { + _particleComponent?.removeFromParent(); + _particleComponent = null; + _particleSignature = null; + } + + void _syncSpine(RuntimeNode node) { + if (node.type != RuntimeNodeType.spine) { + _spineLoadToken++; + _releaseLoadedSpine(); + return; + } + final asset = node.asset; + if (asset == null || asset.isEmpty) { + _spineLoadToken++; + _releaseLoadedSpine(); + return; + } + + final requestedGeneration = _resources.generation; + if (asset == _loadedSpineAsset && + requestedGeneration == _loadedSpineGeneration) { + _syncSpineLayout(); + _syncSpineSkin(node); + _syncSpineAnimation(node); + return; + } + + final requestToken = ++_spineLoadToken; + _releaseLoadedSpine(); + _loadedSpineAsset = asset; + _loadedSpineGeneration = requestedGeneration; + _resources.createSpineComponent(asset).then((spine) { + if (_spineLoadToken != requestToken) { + spine?.dispose(); + return; + } + if (_loadedSpineAsset != asset || + _loadedSpineGeneration != requestedGeneration) { + spine?.dispose(); + return; + } + if (_resources.generation != requestedGeneration) { + spine?.dispose(); + return; + } + if (spine == null) { + return; + } + _spineComponent = spine; + add(spine); + _syncSpineLayout(); + _syncSpineSkin(_node); + _syncSpineAnimation(_node); + }); + } + + bool playSpineAnimation( + String animation, { + int track = 0, + bool loop = true, + bool queue = false, + double delay = 0, + }) { + final spine = _spineComponent; + if (spine == null) { + if (_node.type != RuntimeNodeType.spine) { + return false; + } + _pendingSpineAnimation = animation; + _pendingSpineTrack = track; + _pendingSpineLoop = loop; + _pendingSpineQueue = queue; + _pendingSpineDelay = delay; + return true; + } + if (queue) { + spine.animationState.addAnimation(track, animation, loop, delay); + } else { + spine.animationState.setAnimation(track, animation, loop); + } + _appliedSpineAnimation = animation; + _appliedSpineLoop = loop; + _pendingSpineAnimation = null; + return true; + } + + void _syncSpineLayout() { + final spine = _spineComponent; + if (spine == null) { + return; + } + spine + ..position = Vector2.zero() + ..anchor = Anchor.topLeft + ..priority = priority; + final width = _node.width; + final height = _node.height; + if (width != null && + height != null && + spine.size.x > 0 && + spine.size.y > 0) { + spine.scale = Vector2(width / spine.size.x, height / spine.size.y); + } + } + + void _syncSpineSkin(RuntimeNode node) { + final skin = node.skin; + if (skin == null || skin == _appliedSpineSkin) { + return; + } + _spineComponent?.skeleton.setSkin(skin); + _appliedSpineSkin = skin; + } + + void _syncSpineAnimation(RuntimeNode node) { + final pendingAnimation = _pendingSpineAnimation; + if (pendingAnimation != null) { + playSpineAnimation( + pendingAnimation, + track: _pendingSpineTrack, + loop: _pendingSpineLoop, + queue: _pendingSpineQueue, + delay: _pendingSpineDelay, + ); + return; + } + + final animation = node.animation; + if (animation == null) { + return; + } + if (animation == _appliedSpineAnimation && node.loop == _appliedSpineLoop) { + return; + } + playSpineAnimation(animation, loop: node.loop); + } + + void _releaseLoadedSpine() { + final spine = _spineComponent; + if (spine != null) { + spine.removeFromParent(); + spine.dispose(); + } + _spineComponent = null; + _loadedSpineAsset = null; + _loadedSpineGeneration = null; + _appliedSpineAnimation = null; + _appliedSpineLoop = null; + _pendingSpineAnimation = null; + _pendingSpineTrack = 0; + _pendingSpineLoop = true; + _pendingSpineQueue = false; + _pendingSpineDelay = 0; + _appliedSpineSkin = null; + } + + void _releaseLoadedImage() { + final asset = _loadedAsset; + final generation = _loadedGeneration; + if (_image != null && asset != null && generation != null) { + _resources.releaseImage(asset, generation: generation); + } + _image = null; + _loadedAsset = null; + _loadedGeneration = null; + } + + void _syncText(RuntimeNode node) { + final label = node.text; + if (label == null && node.type != RuntimeNodeType.text) { + _textComponent?.removeFromParent(); + _textComponent = null; + return; + } + + final text = label ?? ''; + final color = _textColor(node); + final style = TextStyle( + color: color.withValues(alpha: renderAlpha), + fontSize: node.fontSize ?? 18, + fontWeight: node.type == RuntimeNodeType.button + ? FontWeight.w600 + : FontWeight.normal, + ); + + final component = _textComponent; + if (component == null) { + _textComponent = TextComponent( + text: text, + textRenderer: TextPaint(style: style), + anchor: _textAnchor(node), + position: _textPosition(node), + priority: priority + 1, + ); + add(_textComponent!); + return; + } + + component + ..text = text + ..textRenderer = TextPaint(style: style) + ..anchor = _textAnchor(node) + ..position = _textPosition(node) + ..priority = priority + 1; + } + + @override + void onRemove() { + _imageLoadToken++; + _spineLoadToken++; + _releaseLoadedImage(); + _releaseLoadedSpine(); + _releaseParticle(); + _runtimeAlpha = null; + _pressed = false; + _textComponent = null; + super.onRemove(); + } + + Anchor _textAnchor(RuntimeNode node) { + final topAligned = _usesTopAlignedText(node); + return switch (node.textAlign) { + RuntimeTextAlignValue.left => + topAligned ? Anchor.topLeft : Anchor.centerLeft, + RuntimeTextAlignValue.right => + topAligned ? Anchor.topRight : Anchor.centerRight, + _ => topAligned ? Anchor.topCenter : Anchor.center, + }; + } + + Vector2 _textPosition(RuntimeNode node) { + final topAligned = _usesTopAlignedText(node); + return switch (node.textAlign) { + RuntimeTextAlignValue.left => + topAligned ? Vector2.zero() : Vector2(0, size.y / 2), + RuntimeTextAlignValue.right => + topAligned ? Vector2(size.x, 0) : Vector2(size.x, size.y / 2), + _ => topAligned ? Vector2(size.x / 2, 0) : size / 2, + }; + } + + bool _usesTopAlignedText(RuntimeNode node) { + if (node.type == RuntimeNodeType.button) { + return false; + } + return (node.text ?? '').contains('\n'); + } + + Color _textColor(RuntimeNode node) { + if (node.type == RuntimeNodeType.button) { + return Colors.white; + } + return node.color ?? Colors.white; + } + + Color _defaultColor() { + return switch (_node.type) { + RuntimeNodeType.button => const Color(0xff3662d8), + RuntimeNodeType.panel => const Color(0xaa111827), + RuntimeNodeType.text => Colors.transparent, + RuntimeNodeType.circle => const Color(0xffef4444), + RuntimeNodeType.rect => const Color(0xff334155), + RuntimeNodeType.listView => const Color(0xff111827), + RuntimeNodeType.line => const Color(0xffffffff), + RuntimeNodeType.progress => const Color(0xff22c55e), + RuntimeNodeType.sprite || + RuntimeNodeType.image || + RuntimeNodeType.spine => const Color(0xff64748b), + _ => const Color(0xff94a3b8), + }; + } + + Anchor _anchorFromString(String value) { + return switch (value) { + RuntimeAnchorValue.center => Anchor.center, + RuntimeAnchorValue.topLeft => Anchor.topLeft, + RuntimeAnchorValue.topRight => Anchor.topRight, + RuntimeAnchorValue.bottomLeft => Anchor.bottomLeft, + RuntimeAnchorValue.bottomRight => Anchor.bottomRight, + _ => Anchor.topLeft, + }; + } +} + +class _ParticleConfig { + const _ParticleConfig({ + required this.start, + required this.velocity, + required this.gravity, + required this.color, + required this.colorTo, + required this.radius, + required this.radiusTo, + }); + + final Vector2 start; + final Vector2 velocity; + final Vector2 gravity; + final Color color; + final Color colorTo; + final double radius; + final double radiusTo; +} diff --git a/lib/runtime/resources/game_resource_cache.dart b/lib/runtime/resources/game_resource_cache.dart new file mode 100644 index 0000000..31db9e0 --- /dev/null +++ b/lib/runtime/resources/game_resource_cache.dart @@ -0,0 +1,66 @@ +part of 'game_resource_manager.dart'; + +extension _GameResourceManagerCache on GameResourceManager { + void _touch(_ImageResourceRecord record) { + record.lastUsed = ++_accessCounter; + } + + void _enforceImageBudget() { + while (_isOverBudget()) { + final victim = _leastRecentlyUsedEvictableImage(); + if (victim == null || !_removeImageRecord(victim)) { + return; + } + } + } + + bool _isOverBudget() { + final maxBytes = _maxCacheBytes; + final maxEntries = _maxCacheEntries; + return (maxBytes != null && _cacheBytes > maxBytes) || + (maxEntries != null && _readyImageCount > maxEntries); + } + + int get _readyImageCount => _images.values + .where((record) => record.state == GameResourceState.ready) + .length; + + String? _leastRecentlyUsedEvictableImage() { + String? victimPath; + _ImageResourceRecord? victim; + for (final entry in _images.entries) { + final record = entry.value; + if (record.state != GameResourceState.ready || record.refCount > 0) { + continue; + } + if (victim == null || record.lastUsed < victim.lastUsed) { + victim = record; + victimPath = entry.key; + } + } + return victimPath; + } + + void _releaseCachedImages() { + _loadLimiter.clearPending(); + for (final path in _images.keys.toList(growable: false)) { + _removeImageRecord(path); + } + } + + bool _removeImageRecord(String path) { + final record = _images.remove(path); + if (record == null) { + return false; + } + record.state = GameResourceState.disposed; + _cacheBytes -= record.estimatedBytes; + if (_cacheBytes < 0) { + _cacheBytes = 0; + } + record.image?.dispose(); + record.image = null; + record.inflight = null; + return true; + } +} diff --git a/lib/runtime/resources/game_resource_debug.dart b/lib/runtime/resources/game_resource_debug.dart new file mode 100644 index 0000000..fbf16c9 --- /dev/null +++ b/lib/runtime/resources/game_resource_debug.dart @@ -0,0 +1,28 @@ +part of 'game_resource_manager.dart'; + +extension _GameResourceManagerDebug on GameResourceManager { + Map _imageRecordDebugJson({ + required String? key, + required String path, + required String? preload, + required bool declared, + }) { + final record = _images[path]; + return { + if (key != null) 'key': key, + 'path': path, + 'type': GameResourceType.image, + 'declared': declared, + if (preload != null) 'preload': preload, + if (key != null && _package?.manifest.resources[key]?.group != null) + 'group': _package?.manifest.resources[key]?.group, + 'state': (record?.state ?? GameResourceState.idle).name, + if (record != null) 'generation': record.generation, + 'loading': record?.inflight != null, + 'ready': record?.image != null, + if (record != null) 'refCount': record.refCount, + if (record != null) 'bytes': record.estimatedBytes, + if (record?.lastError != null) 'error': record!.lastError.toString(), + }; + } +} diff --git a/lib/runtime/resources/game_resource_loading.dart b/lib/runtime/resources/game_resource_loading.dart new file mode 100644 index 0000000..9a616c9 --- /dev/null +++ b/lib/runtime/resources/game_resource_loading.dart @@ -0,0 +1,186 @@ +part of 'game_resource_manager.dart'; + +extension _GameResourceManagerLoading on GameResourceManager { + Future _loadImage( + String? keyOrPath, { + required bool failOnError, + bool retain = false, + }) { + if (keyOrPath == null || keyOrPath.isEmpty) { + return Future.value(null); + } + + final requestToken = _asyncGate.token; + final requestGeneration = requestToken.generation; + final path = _tryResolve(keyOrPath); + if (path == null) { + return Future.value(null); + } + + final existing = _images[path]; + if (existing != null) { + final image = existing.image; + if (existing.generation == requestGeneration && + existing.state == GameResourceState.ready && + image != null) { + if (retain) { + existing.refCount++; + } + _touch(existing); + return Future.value(image); + } + final inflight = existing.inflight; + if (existing.generation == requestGeneration && inflight != null) { + return failOnError + ? _throwIfNull(inflight, keyOrPath) + : inflight.catchError((_) => null); + } + } + + final record = _ImageResourceRecord(generation: requestGeneration) + ..state = GameResourceState.loading; + _images[path] = record; + + final future = _decodeImage(path, record, requestToken, retain: retain); + record.inflight = future; + return failOnError ? _throwIfNull(future, keyOrPath) : future; + } + + Future _throwIfNull( + Future future, + String keyOrPath, + ) async { + final image = await future; + if (image == null) { + throw ResourceLoadException('Required image resource failed: $keyOrPath'); + } + return image; + } + + Future _decodeImage( + String path, + _ImageResourceRecord record, + RuntimeAsyncToken requestToken, { + required bool retain, + }) async { + try { + final activePackage = _package; + if (activePackage == null) { + throw StateError('GameResourceManager has no active package'); + } + + final frame = await _loadLimiter.run(() async { + final bytes = await activePackage.readBytes(path); + final codec = await ui.instantiateImageCodec( + bytes.buffer.asUint8List(), + ); + return codec.getNextFrame(); + }); + record.inflight = null; + + if (!_asyncGate.accepts(requestToken) || _images[path] != record) { + frame.image.dispose(); + record.state = GameResourceState.disposed; + return null; + } + + record + ..image = frame.image + ..estimatedBytes = frame.image.width * frame.image.height * 4 + ..state = GameResourceState.ready + ..lastError = null; + if (retain) { + record.refCount++; + } + _cacheBytes += record.estimatedBytes; + _touch(record); + _enforceImageBudget(); + return frame.image; + } catch (error) { + record.inflight = null; + if (!_asyncGate.accepts(requestToken) || _images[path] != record) { + record.state = GameResourceState.disposed; + return null; + } + record + ..state = GameResourceState.failed + ..lastError = error; + _diagnostics?.record( + type: RuntimeDiagnosticType.resourceLoadError, + message: 'Image resource failed to load', + error: error, + context: {'path': path, 'generation': requestToken.generation}, + ); + return null; + } + } + + Future _preloadSpine( + String keyOrPath, { + required bool failOnError, + }) async { + final spine = await _createSpineComponent(keyOrPath); + spine?.dispose(); + if (failOnError && spine == null) { + throw ResourceLoadException('Required spine resource failed: $keyOrPath'); + } + } + + Future _createSpineComponent(String? keyOrPath) async { + if (keyOrPath == null || keyOrPath.isEmpty) { + return null; + } + final requestToken = _asyncGate.token; + final activePackage = _package; + if (activePackage == null) { + return null; + } + final resource = activePackage.manifest.resources[keyOrPath]; + if (resource == null || resource.type != GameResourceType.spine) { + return null; + } + + try { + await initSpineFlutter(); + final atlasPath = activePackage.resolveResourcePath(resource.atlas!); + final skeletonPath = activePackage.resolveResourcePath( + resource.skeleton!, + ); + final drawable = await _loadLimiter.run(() { + return SkeletonDrawableFlutter.fromMemory(atlasPath, skeletonPath, ( + name, + ) async { + final bytes = await activePackage.readBytes(name); + return bytes.buffer.asUint8List( + bytes.offsetInBytes, + bytes.lengthInBytes, + ); + }); + }); + if (!_asyncGate.accepts(requestToken)) { + drawable.dispose(); + return null; + } + return SpineComponent(drawable); + } catch (error) { + if (!_asyncGate.accepts(requestToken)) { + return null; + } + _diagnostics?.record( + type: RuntimeDiagnosticType.resourceLoadError, + message: 'Spine resource failed to load', + error: error, + context: {'key': keyOrPath, 'generation': requestToken.generation}, + ); + return null; + } + } + + String? _tryResolve(String keyOrPath) { + try { + return resolve(keyOrPath); + } catch (_) { + return null; + } + } +} diff --git a/lib/runtime/resources/game_resource_manager.dart b/lib/runtime/resources/game_resource_manager.dart new file mode 100644 index 0000000..2f6ce1b --- /dev/null +++ b/lib/runtime/resources/game_resource_manager.dart @@ -0,0 +1,279 @@ +import 'dart:ui' as ui; + +import 'package:flame_spine/flame_spine.dart'; + +import '../diagnostics/runtime_diagnostics.dart'; +import '../lifecycle/runtime_async_gate.dart'; +import '../packages/game_package.dart'; +import '../packages/game_package_manifest.dart'; +import 'resource_load_limiter.dart'; + +// These part files only group GameResourceManager private helpers. The public +// facade stays in GameResourceManager so callers do not depend on extensions. +part 'game_resource_loading.dart'; +part 'game_resource_debug.dart'; +part 'game_resource_cache.dart'; + +class GameResourceManager { + GameResourceManager({ + RuntimeDiagnostics? diagnostics, + int? maxCacheBytes, + int? maxCacheEntries, + int maxConcurrentLoads = 4, + }) : _diagnostics = diagnostics, + _maxCacheBytes = maxCacheBytes, + _maxCacheEntries = maxCacheEntries, + _loadLimiter = ResourceLoadLimiter(maxConcurrentLoads); + + final RuntimeDiagnostics? _diagnostics; + final int? _maxCacheBytes; + final int? _maxCacheEntries; + final ResourceLoadLimiter _loadLimiter; + final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true); + GamePackage? _package; + final Map _images = {}; + int _cacheBytes = 0; + int _accessCounter = 0; + + int get generation => _asyncGate.generation; + + bool get hasPackage => _package != null; + + GamePackage get package { + final value = _package; + if (value == null) { + throw StateError('GameResourceManager has no active package'); + } + return value; + } + + Future mount(GamePackage package) async { + _releaseCachedImages(); + _asyncGate.activate(); + _package = package; + await preloadDeclaredImages(package.manifest); + await preloadDeclaredSpines(package.manifest); + } + + void dispose() { + _asyncGate.close(); + _releaseCachedImages(); + _package = null; + } + + String resolve(String keyOrPath) { + return package.resolveResourcePath(keyOrPath); + } + + GameResourceState imageState(String keyOrPath) { + final path = _tryResolve(keyOrPath); + if (path == null) { + return GameResourceState.failed; + } + return _images[path]?.state ?? GameResourceState.idle; + } + + Object? imageError(String keyOrPath) { + final path = _tryResolve(keyOrPath); + if (path == null) { + return StateError('GameResourceManager has no active package'); + } + return _images[path]?.lastError; + } + + Map imagesDebugJson() { + final activePackage = _package; + final declaredPaths = {}; + final resources = >[]; + + if (activePackage != null) { + for (final entry in activePackage.manifest.resources.entries) { + final resource = entry.value; + if (resource.type != GameResourceType.image) { + continue; + } + final path = activePackage.resolveResourcePath(entry.key); + declaredPaths.add(path); + resources.add( + _imageRecordDebugJson( + key: entry.key, + path: path, + preload: resource.preload, + declared: true, + ), + ); + } + } + + for (final path in _images.keys) { + if (declaredPaths.contains(path)) { + continue; + } + resources.add( + _imageRecordDebugJson( + key: null, + path: path, + preload: null, + declared: false, + ), + ); + } + + return { + 'generation': generation, + 'hasPackage': activePackage != null, + 'count': resources.length, + 'activeLoads': _loadLimiter.activeCount, + 'pendingLoads': _loadLimiter.pendingCount, + 'resources': resources, + }; + } + + bool evictImage(String keyOrPath) { + final path = _tryResolve(keyOrPath); + if (path == null) { + return false; + } + return _removeImageRecord(path); + } + + Future retryImage(String keyOrPath) { + evictImage(keyOrPath); + return loadImage(keyOrPath); + } + + Future loadImage(String? keyOrPath, {bool retain = false}) { + return _loadImage(keyOrPath, failOnError: false, retain: retain); + } + + Future createSpineComponent(String? keyOrPath) { + return _createSpineComponent(keyOrPath); + } + + bool retainImage(String keyOrPath, {int? generation}) { + final path = _tryResolve(keyOrPath); + if (path == null) { + return false; + } + final record = _images[path]; + if (record == null || + record.state != GameResourceState.ready || + (generation != null && record.generation != generation)) { + return false; + } + record.refCount++; + _touch(record); + return true; + } + + bool releaseImage(String keyOrPath, {int? generation}) { + final path = _tryResolve(keyOrPath); + if (path == null) { + return false; + } + final record = _images[path]; + if (record == null || + (generation != null && record.generation != generation)) { + return false; + } + if (record.refCount > 0) { + record.refCount--; + } + _touch(record); + _enforceImageBudget(); + return true; + } + + Future preloadGroup(String group, {bool failOnError = false}) async { + final activePackage = _package; + if (activePackage == null) { + throw StateError('GameResourceManager has no active package'); + } + final futures = >[]; + for (final entry in activePackage.manifest.resources.entries) { + final resource = entry.value; + if (resource.type == GameResourceType.image && resource.group == group) { + futures.add( + _loadImage(entry.key, failOnError: failOnError).then((_) {}), + ); + } + if (resource.type == GameResourceType.spine && resource.group == group) { + futures.add(_preloadSpine(entry.key, failOnError: failOnError)); + } + } + await Future.wait(futures); + } + + int evictGroup(String group) { + final activePackage = _package; + if (activePackage == null) { + return 0; + } + var count = 0; + for (final entry in activePackage.manifest.resources.entries) { + final resource = entry.value; + if (resource.type != GameResourceType.image || resource.group != group) { + continue; + } + final path = activePackage.resolveResourcePath(entry.key); + if (_removeImageRecord(path)) { + count++; + } + } + return count; + } + + Future preloadDeclaredImages(GamePackageManifest manifest) async { + final futures = >[]; + for (final entry in manifest.resources.entries) { + final resource = entry.value; + if (resource.type != GameResourceType.image || + resource.preload == GameResourcePreload.lazy) { + continue; + } + + final failOnError = resource.preload == GameResourcePreload.required; + futures.add(_loadImage(entry.key, failOnError: failOnError).then((_) {})); + } + await Future.wait(futures); + } + + Future preloadDeclaredSpines(GamePackageManifest manifest) async { + final futures = >[]; + for (final entry in manifest.resources.entries) { + final resource = entry.value; + if (resource.type != GameResourceType.spine || + resource.preload == GameResourcePreload.lazy) { + continue; + } + + final failOnError = resource.preload == GameResourcePreload.required; + futures.add(_preloadSpine(entry.key, failOnError: failOnError)); + } + await Future.wait(futures); + } +} + +enum GameResourceState { idle, loading, ready, failed, disposed } + +class ResourceLoadException implements Exception { + const ResourceLoadException(this.message); + + final String message; + + @override + String toString() => 'ResourceLoadException: $message'; +} + +class _ImageResourceRecord { + _ImageResourceRecord({required this.generation}); + + final int generation; + GameResourceState state = GameResourceState.idle; + Future? inflight; + ui.Image? image; + Object? lastError; + int estimatedBytes = 0; + int refCount = 0; + int lastUsed = 0; +} diff --git a/lib/runtime/resources/resource_load_limiter.dart b/lib/runtime/resources/resource_load_limiter.dart new file mode 100644 index 0000000..0e3a572 --- /dev/null +++ b/lib/runtime/resources/resource_load_limiter.dart @@ -0,0 +1,91 @@ +import 'dart:async' as async; +import 'dart:collection'; + +class ResourceLoadLimiter { + ResourceLoadLimiter(int maxConcurrent) + : maxConcurrent = _validateMaxConcurrent(maxConcurrent); + + final int maxConcurrent; + final Queue<_QueuedLoadBase> _queue = Queue<_QueuedLoadBase>(); + var _active = 0; + + int get activeCount => _active; + + int get pendingCount => _queue.length; + + Future run(Future Function() task) { + final queued = _QueuedLoad(task); + _queue.add(queued); + _pump(); + return queued.completer.future; + } + + void clearPending() { + while (_queue.isNotEmpty) { + _queue.removeFirst().cancel(); + } + } + + void _pump() { + while (_active < maxConcurrent && _queue.isNotEmpty) { + final queued = _queue.removeFirst(); + _active++; + async.unawaited(_runQueued(queued)); + } + } + + Future _runQueued(_QueuedLoadBase queued) async { + try { + await queued.run(); + } finally { + _active--; + _pump(); + } + } + + static int _validateMaxConcurrent(int value) { + if (value < 1) { + throw ArgumentError.value(value, 'maxConcurrent', 'must be >= 1'); + } + return value; + } +} + +abstract class _QueuedLoadBase { + Future run(); + + void cancel(); +} + +class _QueuedLoad implements _QueuedLoadBase { + _QueuedLoad(this._task); + + final Future Function() _task; + final completer = async.Completer(); + + @override + Future run() async { + if (completer.isCompleted) { + return; + } + try { + completer.complete(await _task()); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + } + + @override + void cancel() { + if (!completer.isCompleted) { + completer.completeError(const ResourceLoadCancelledException()); + } + } +} + +class ResourceLoadCancelledException implements Exception { + const ResourceLoadCancelledException(); + + @override + String toString() => 'ResourceLoadCancelledException'; +} diff --git a/lib/runtime/scripting/lua_dardo_script_engine.dart b/lib/runtime/scripting/lua_dardo_script_engine.dart new file mode 100644 index 0000000..a00ff64 --- /dev/null +++ b/lib/runtime/scripting/lua_dardo_script_engine.dart @@ -0,0 +1,296 @@ +import 'package:lua_dardo_plus/lua.dart'; + +import '../models/game_diff.dart'; +import '../models/runtime_event.dart'; +import '../packages/game_package.dart'; +import 'script_engine.dart'; + +class LuaDardoScriptEngine implements ScriptEngine { + late final LuaState _lua; + late final Map _moduleScripts; + final Set _loadingModules = {}; + + @override + Future loadPackage(GamePackage package) async { + final script = await package.readText(package.manifest.entry); + _moduleScripts = {}; + for (final entry in package.manifest.modules.entries) { + _moduleScripts[entry.key] = await package.readText(entry.value); + } + _loadingModules.clear(); + + _lua = LuaState.newState(); + _lua.openLibs(); + _disableUnsafeGlobals(); + _installRuntimeApi(); + + final ok = _lua.doString(script); + if (!ok) { + final error = _lua.toStr(-1) ?? 'unknown Lua load error'; + _lua.pop(1); + throw StateError(error); + } + } + + @override + bool smokeTest(Map context) { + _lua.getGlobal('smoke_test'); + if (!_lua.isFunction(-1)) { + _lua.pop(1); + return true; + } + + _pushValue(context); + final status = _lua.pCall(1, 1, 0); + if (status != ThreadStatus.luaOk) { + final error = _lua.toStr(-1) ?? 'unknown Lua smoke_test error'; + _lua.pop(1); + throw StateError(error); + } + final result = _lua.toBoolean(-1); + _lua.pop(1); + return result; + } + + @override + GameDiff init(Map context) { + return _callDiffFunction('init', context); + } + + @override + GameDiff dispatchEvent(RuntimeEvent event) { + return _callDiffFunction('on_event', event.toMap()); + } + + GameDiff _callDiffFunction(String name, Map argument) { + _lua.getGlobal(name); + if (!_lua.isFunction(-1)) { + _lua.pop(1); + return GameDiff.empty; + } + + _pushValue(argument); + final status = _lua.pCall(1, 1, 0); + if (status != ThreadStatus.luaOk) { + final error = _lua.toStr(-1) ?? 'unknown Lua runtime error'; + _lua.pop(1); + throw StateError(error); + } + + if (_lua.isNil(-1)) { + _lua.pop(1); + return GameDiff.empty; + } + if (!_lua.isTable(-1)) { + _lua.pop(1); + throw StateError('Lua function $name must return a table or nil'); + } + + final value = _readValue(-1); + _lua.pop(1); + + if (value is! Map) { + throw StateError('Lua function $name returned a non-map value'); + } + return GameDiff.fromMap(Map.from(value)); + } + + void _installRuntimeApi() { + _lua.newTable(); + + _lua.newTable(); + _lua.setField(-2, '__modules'); + + _lua.pushDartFunction(_importModule); + _lua.setField(-2, 'import'); + + _lua.setGlobal('runtime'); + } + + int _importModule(LuaState lua) { + final moduleName = lua.toStr(1); + if (moduleName == null || moduleName.isEmpty) { + throw const FormatException( + 'runtime.import(moduleName) requires a module name', + ); + } + if (!_isSafeModuleName(moduleName)) { + throw FormatException('Unsafe Lua module name: $moduleName'); + } + + final source = _moduleScripts[moduleName]; + if (source == null) { + throw FormatException( + 'Lua module is not declared in manifest.modules: $moduleName', + ); + } + + lua.getGlobal('runtime'); + lua.getField(-1, '__modules'); + final modulesIndex = lua.absIndex(-1); + + lua.getField(modulesIndex, moduleName); + if (!lua.isNil(-1)) { + lua.remove(-2); + lua.remove(-2); + return 1; + } + lua.pop(1); + + if (_loadingModules.contains(moduleName)) { + lua.pop(2); + throw FormatException('Circular Lua module import: $moduleName'); + } + + _loadingModules.add(moduleName); + try { + final loadStatus = lua.loadString(source); + if (loadStatus != ThreadStatus.luaOk) { + final error = lua.toStr(-1) ?? 'unknown Lua module load error'; + lua.pop(3); + throw StateError('Failed to load Lua module $moduleName: $error'); + } + + final callStatus = lua.pCall(0, 1, 0); + if (callStatus != ThreadStatus.luaOk) { + final error = lua.toStr(-1) ?? 'unknown Lua module runtime error'; + lua.pop(3); + throw StateError('Failed to run Lua module $moduleName: $error'); + } + + if (lua.isNil(-1)) { + lua.pop(1); + lua.pushBoolean(true); + } + if (!lua.isTable(-1) && !lua.isBoolean(-1)) { + lua.pop(3); + throw StateError( + 'Lua module $moduleName must return a table, true, or nil', + ); + } + + lua.pushValue(-1); + lua.setField(modulesIndex, moduleName); + lua.remove(-2); + lua.remove(-2); + return 1; + } finally { + _loadingModules.remove(moduleName); + } + } + + bool _isSafeModuleName(String value) { + return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) && + !value.contains('..') && + !value.startsWith('.') && + !value.endsWith('.'); + } + + void _disableUnsafeGlobals() { + for (final name in const [ + 'os', + 'package', + 'dofile', + 'loadfile', + 'require', + ]) { + _lua.pushNil(); + _lua.setGlobal(name); + } + } + + void _pushValue(Object? value) { + switch (value) { + case null: + _lua.pushNil(); + case bool v: + _lua.pushBoolean(v); + case int v: + _lua.pushInteger(v); + case double v: + _lua.pushNumber(v); + case String v: + _lua.pushString(v); + case List v: + _pushList(v); + case Map v: + _pushMap(v); + default: + throw UnsupportedError('Unsupported value for Lua: $value'); + } + } + + void _pushList(List values) { + _lua.newTable(); + for (var i = 0; i < values.length; i++) { + _pushValue(values[i]); + _lua.setI(-2, i + 1); + } + } + + void _pushMap(Map values) { + _lua.newTable(); + for (final entry in values.entries) { + _pushValue(entry.value); + _lua.setField(-2, entry.key); + } + } + + Object? _readValue(int index) { + if (_lua.isNil(index) || _lua.isNone(index)) { + return null; + } + if (_lua.isBoolean(index)) { + return _lua.toBoolean(index); + } + if (_lua.isInteger(index)) { + return _lua.toInteger(index); + } + if (_lua.isNumber(index)) { + return _lua.toNumber(index); + } + if (_lua.isString(index)) { + return _lua.toStr(index); + } + if (_lua.isTable(index)) { + return _readTable(index); + } + throw UnsupportedError('Unsupported Lua type: ${_lua.typeName2(index)}'); + } + + Object _readTable(int index) { + final tableIndex = _lua.absIndex(index); + final length = _lua.rawLen(tableIndex); + final list = []; + var hasOnlyArrayKeys = length > 0; + final map = {}; + + _lua.pushNil(); + while (_lua.next(tableIndex)) { + final value = _readValue(-1); + final key = _readValue(-2); + _lua.pop(1); + + if (key is int && key >= 1 && key <= length) { + while (list.length < key) { + list.add(null); + } + list[key - 1] = value; + } else { + hasOnlyArrayKeys = false; + map[key.toString()] = value; + } + } + + if (hasOnlyArrayKeys && map.isEmpty) { + return list; + } + + for (var i = 0; i < list.length; i++) { + if (list[i] != null) { + map[(i + 1).toString()] = list[i]; + } + } + return map; + } +} diff --git a/lib/runtime/scripting/script_engine.dart b/lib/runtime/scripting/script_engine.dart new file mode 100644 index 0000000..2fba010 --- /dev/null +++ b/lib/runtime/scripting/script_engine.dart @@ -0,0 +1,13 @@ +import '../models/game_diff.dart'; +import '../models/runtime_event.dart'; +import '../packages/game_package.dart'; + +abstract interface class ScriptEngine { + Future loadPackage(GamePackage package); + + bool smokeTest(Map context); + + GameDiff init(Map context); + + GameDiff dispatchEvent(RuntimeEvent event); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..eb470c8 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,29 @@ +name: flame_lua_runtime +description: Flutter + Flame + Lua runtime kit for manifest-driven 2D game packages. +version: 0.1.0 +publish_to: "none" + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + flame: ^1.35.1 + flame_spine: ^0.3.0+3 + lua_dardo_plus: ^0.3.0 + archive: ^4.0.9 + crypto: ^3.0.7 + http: ^1.6.0 + path_provider: ^2.1.5 + path: ^1.9.1 + audioplayers: ^6.7.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + assets: + - assets/runtime/lua/ diff --git a/test/public_api_test.dart b/test/public_api_test.dart new file mode 100644 index 0000000..7d94e54 --- /dev/null +++ b/test/public_api_test.dart @@ -0,0 +1,20 @@ +import 'package:flame_lua_runtime/flame_lua_runtime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('public runtime API exposes minimal integration surface', () { + const repository = AssetGamePackageRepository(); + const options = RuntimeOptions(runtimeLuaRoot: 'custom/runtime/lua'); + const widget = LuaGameWidget( + gameId: 'template', + packageRepository: repository, + runtimeOptions: options, + ); + + expect(widget.gameId, 'template'); + expect(widget.packageRepository, same(repository)); + expect(widget.runtimeOptions.runtimeLuaRoot, 'custom/runtime/lua'); + expect(LuaDardoScriptEngine.new, isA()); + expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans'); + }); +} diff --git a/test/runtime/audio/runtime_audio_manager_test.dart b/test/runtime/audio/runtime_audio_manager_test.dart new file mode 100644 index 0000000..29e2ff0 --- /dev/null +++ b/test/runtime/audio/runtime_audio_manager_test.dart @@ -0,0 +1,484 @@ +import 'dart:async' as async; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart'; +import 'package:flame_lua_runtime/runtime/audio/runtime_audio_player.dart'; +import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeAudioManager', () { + test('preloads required audio and exposes ready state', () async { + final package = await _createPackage( + 'required_audio', + preload: GameResourcePreload.required, + ); + final audio = RuntimeAudioManager(); + + await audio.mount(package); + + expect(audio.audioState('dice'), GameResourceState.ready); + audio.dispose(); + }); + + test('lazy audio plays from package bytes', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final package = await _createPackage('lazy_audio'); + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(package); + + final playback = await audio.play('dice', volume: 0.5); + + expect(playback, isNotNull); + expect(audio.audioState('dice'), GameResourceState.ready); + expect(players.single.startedBytes, _audioBytes); + expect(players.single.volume, 0.5); + + players.single.complete(); + await playback!.done; + audio.dispose(); + }); + + test('lazy audio failure records state, error and diagnostics', () async { + final diagnostics = RuntimeDiagnostics(); + final package = await _createPackage('missing_audio', writeAudio: false); + final audio = RuntimeAudioManager(diagnostics: diagnostics); + + await audio.mount(package); + final playback = await audio.play('dice'); + + expect(playback, isNull); + expect(audio.audioState('dice'), GameResourceState.failed); + expect(audio.audioError('dice'), isNotNull); + expect( + diagnostics.entries.single.type, + RuntimeDiagnosticType.resourceLoadError, + ); + audio.dispose(); + }); + + test( + 'exports audio debug json, evicts and retries failed records', + () async { + final root = await Directory.systemTemp.createTemp('audio_retry_'); + Directory('${root.path}/assets').createSync(recursive: true); + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + final package = _createPackageAt( + root, + preload: GameResourcePreload.lazy, + ); + final audio = RuntimeAudioManager(); + + await audio.mount(package); + + expect(audio.audioDebugJson(), { + 'generation': 1, + 'hasPackage': true, + 'count': 1, + 'activeLoads': 0, + 'pendingLoads': 0, + 'activePlayers': 0, + 'pooledPlayers': 0, + 'channels': [], + 'resources': [ + { + 'key': 'dice', + 'path': endsWith('/assets/dice.wav'), + 'type': GameResourceType.audio, + 'declared': true, + 'preload': GameResourcePreload.lazy, + 'state': 'idle', + 'loading': false, + 'ready': false, + }, + ], + }); + + expect(await audio.retryAudio('dice'), isFalse); + expect(audio.audioState('dice'), GameResourceState.failed); + expect(audio.audioDebugJson().toString(), contains('error')); + + File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); + expect(await audio.retryAudio('dice'), isTrue); + expect(audio.audioState('dice'), GameResourceState.ready); + + expect(audio.evictAudio('dice'), isTrue); + expect(audio.audioState('dice'), GameResourceState.idle); + expect(audio.evictAudio('dice'), isFalse); + audio.dispose(); + }, + ); + + test('preloads and evicts audio resource groups', () async { + final package = await _createMultiAudioPackage('audio_group'); + final audio = RuntimeAudioManager(); + + await audio.mount(package); + await audio.preloadGroup('scene'); + + expect(audio.audioState('dice'), GameResourceState.ready); + expect(audio.audioState('click'), GameResourceState.ready); + expect(audio.audioState('bgm'), GameResourceState.idle); + expect(audio.evictGroup('scene'), 2); + expect(audio.audioState('dice'), GameResourceState.idle); + expect(audio.audioState('click'), GameResourceState.idle); + audio.dispose(); + }); + + test('limits concurrent audio reads during group preload', () async { + final package = _CountingAudioPackage( + await _createMultiAudioPackage('audio_concurrency'), + ); + final audio = RuntimeAudioManager(maxConcurrentLoads: 1); + + await audio.mount(package); + await audio.preloadGroup('scene'); + + expect(package.maxActiveReads, 1); + expect(audio.audioState('dice'), GameResourceState.ready); + expect(audio.audioState('click'), GameResourceState.ready); + audio.dispose(); + }); + + test( + 'drops stale audio load result after dispose without diagnostics', + () async { + final diagnostics = RuntimeDiagnostics(); + final package = _BlockingAudioPackage( + await _createPackage('stale_audio'), + ); + final audio = RuntimeAudioManager(diagnostics: diagnostics); + + await audio.mount(package); + final playback = audio.play('dice'); + + audio.dispose(); + package.releaseReads(); + + expect(await playback, isNull); + expect(diagnostics.entries, isEmpty); + }, + ); + + test('audio LRU evicts least recently used bytes', () async { + final package = await _createMultiAudioPackage('audio_lru'); + final audio = RuntimeAudioManager(maxCacheEntries: 1); + + await audio.mount(package); + expect(await audio.retryAudio('dice'), isTrue); + expect(audio.audioState('dice'), GameResourceState.ready); + + expect(await audio.retryAudio('bgm'), isTrue); + + expect(audio.audioState('dice'), GameResourceState.idle); + expect(audio.audioState('bgm'), GameResourceState.ready); + audio.dispose(); + }); + + test('reuses pooled one-shot audio players', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final package = await _createPackage('sfx_pool'); + final audio = RuntimeAudioManager( + maxSfxPoolSize: 1, + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(package); + + final first = await audio.play('dice'); + players.single.complete(); + await first!.done; + expect(audio.audioDebugJson()['pooledPlayers'], 1); + + final second = await audio.play('dice'); + players.single.complete(); + await second!.done; + + expect(players, hasLength(1)); + expect(players.single.startCount, 2); + audio.dispose(); + }); + + test('plays, pauses, resumes and stops bgm channel', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final package = await _createPackage('bgm_audio'); + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(package); + + final playback = await audio.playBgm( + 'dice', + channel: 'music', + volume: 0.3, + loop: true, + ); + + expect(playback, isNotNull); + expect(audio.hasBgm(channel: 'music'), isTrue); + expect(players.single.startedBytes, _audioBytes); + expect(players.single.volume, 0.3); + expect(players.single.loop, isTrue); + + await audio.pauseBgm(channel: 'music'); + await audio.resumeBgm(channel: 'music'); + await audio.stopBgm(channel: 'music'); + + expect(players.single.paused, isTrue); + expect(players.single.resumed, isTrue); + expect(players.single.stopped, isTrue); + expect(audio.hasBgm(channel: 'music'), isFalse); + audio.dispose(); + }); + + test('starting same bgm channel replaces previous playback', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final package = await _createPackage('replace_bgm_audio'); + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(package); + + await audio.playBgm('dice', channel: 'music'); + await audio.playBgm('dice', channel: 'music'); + + expect(players, hasLength(2)); + expect(players.first.stopped, isTrue); + expect(audio.hasBgm(channel: 'music'), isTrue); + audio.dispose(); + }); + + test('required audio preload failure fails mount', () async { + final package = await _createPackage( + 'required_missing_audio', + preload: GameResourcePreload.required, + writeAudio: false, + ); + final audio = RuntimeAudioManager(); + + await expectLater( + audio.mount(package), + throwsA(isA()), + ); + expect(audio.audioState('dice'), GameResourceState.failed); + audio.dispose(); + }); + }); +} + +const _audioBytes = [1, 2, 3, 4, 5]; + +Future _createPackage( + String name, { + String preload = GameResourcePreload.lazy, + bool writeAudio = true, +}) async { + final root = await Directory.systemTemp.createTemp('audio_${name}_'); + Directory('${root.path}/assets').createSync(recursive: true); + if (writeAudio) { + File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); + } + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return _createPackageAt(root, preload: preload); +} + +Future _createMultiAudioPackage(String name) async { + final root = await Directory.systemTemp.createTemp('audio_${name}_'); + Directory('${root.path}/assets').createSync(recursive: true); + File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); + File('${root.path}/assets/click.wav').writeAsBytesSync([6, 7, 8]); + File('${root.path}/assets/bgm.wav').writeAsBytesSync([9, 10, 11, 12]); + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return GamePackage.file( + rootPath: root.path, + manifest: GamePackageManifest( + gameId: 'test', + name: 'Test', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: const { + 'dice': GameResource( + type: GameResourceType.audio, + path: 'assets/dice.wav', + preload: GameResourcePreload.lazy, + group: 'scene', + ), + 'click': GameResource( + type: GameResourceType.audio, + path: 'assets/click.wav', + preload: GameResourcePreload.lazy, + group: 'scene', + ), + 'bgm': GameResource( + type: GameResourceType.audio, + path: 'assets/bgm.wav', + preload: GameResourcePreload.lazy, + group: 'music', + ), + }, + ), + ); +} + +GamePackage _createPackageAt( + Directory root, { + String preload = GameResourcePreload.lazy, +}) { + return GamePackage.file( + rootPath: root.path, + manifest: GamePackageManifest( + gameId: 'test', + name: 'Test', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: { + 'dice': GameResource( + type: GameResourceType.audio, + path: 'assets/dice.wav', + preload: preload, + ), + }, + ), + ); +} + +class _CountingAudioPackage extends GamePackage { + _CountingAudioPackage(GamePackage package) + : super.file(rootPath: package.rootPath, manifest: package.manifest); + + var activeReads = 0; + var maxActiveReads = 0; + + @override + Future readBytes(String relativeOrAbsolutePath) async { + activeReads++; + if (activeReads > maxActiveReads) { + maxActiveReads = activeReads; + } + await Future.delayed(const Duration(milliseconds: 5)); + try { + return await super.readBytes(relativeOrAbsolutePath); + } finally { + activeReads--; + } + } +} + +class _BlockingAudioPackage extends GamePackage { + _BlockingAudioPackage(GamePackage package) + : _releaseReads = async.Completer(), + super.file(rootPath: package.rootPath, manifest: package.manifest); + + final async.Completer _releaseReads; + + void releaseReads() { + if (!_releaseReads.isCompleted) { + _releaseReads.complete(); + } + } + + @override + Future readBytes(String relativeOrAbsolutePath) async { + await _releaseReads.future; + return super.readBytes(relativeOrAbsolutePath); + } +} + +class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer { + async.Completer _done = async.Completer(); + List? startedBytes; + double? volume; + var loop = false; + var paused = false; + var resumed = false; + var stopped = false; + var disposed = false; + var startCount = 0; + + @override + Future get done => _done.future; + + @override + Future start( + Uint8List bytes, { + required double volume, + bool loop = false, + }) async { + if (_done.isCompleted) { + _done = async.Completer(); + } + startCount++; + startedBytes = bytes.toList(growable: false); + this.volume = volume; + this.loop = loop; + } + + void complete() { + if (!_done.isCompleted) { + _done.complete(); + } + } + + @override + Future pause() async { + paused = true; + } + + @override + Future resume() async { + resumed = true; + } + + @override + Future stop() async { + stopped = true; + complete(); + } + + @override + Future dispose() async { + disposed = true; + complete(); + } +} diff --git a/test/runtime/commands/command_executor_test.dart b/test/runtime/commands/command_executor_test.dart new file mode 100644 index 0000000..2b17029 --- /dev/null +++ b/test/runtime/commands/command_executor_test.dart @@ -0,0 +1,940 @@ +import 'dart:async' as async; +import 'dart:io'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart'; +import 'package:flame_lua_runtime/runtime/audio/runtime_audio_player.dart'; +import 'package:flame_lua_runtime/runtime/commands/command_executor.dart'; +import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_command.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_node.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; +import 'package:flame_lua_runtime/runtime/rendering/render_tree_controller.dart'; +import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('CommandExecutor', () { + test('adds move_path sequence effect to target component', () { + final harness = _CommandHarness(); + harness.createNode('piece'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.movePath, + target: 'piece', + payload: { + 'path': [ + {'x': 10, 'y': 20}, + {'x': 30, 'y': 40}, + ], + 'duration': 0.6, + 'onComplete': 'done', + }, + ), + ); + + final component = harness.controller.componentById('piece')!; + expect(component.children.whereType(), isNotEmpty); + }); + + test('adds generic transform effects to target component', () { + final harness = _CommandHarness(); + harness.createNode('node'); + + for (final command in const [ + RuntimeCommand( + type: RuntimeCommandType.moveTo, + target: 'node', + payload: {'x': 10, 'y': 20}, + ), + RuntimeCommand( + type: RuntimeCommandType.fadeTo, + target: 'node', + payload: {'alpha': 0.5}, + ), + RuntimeCommand( + type: RuntimeCommandType.scaleTo, + target: 'node', + payload: {'scale': 1.5}, + ), + RuntimeCommand( + type: RuntimeCommandType.rotateTo, + target: 'node', + payload: {'angle': 1.2}, + ), + ]) { + harness.executor.execute(command); + } + + final component = harness.controller.componentById('node')!; + expect(component.children.whereType(), hasLength(4)); + }); + + test( + 'remove_node removes target and emits completion event immediately', + () { + final harness = _CommandHarness(); + harness.createNode('node'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.removeNode, + target: 'node', + payload: {'onComplete': 'removed'}, + ), + ); + + expect(harness.controller.componentById('node'), isNull); + expect(harness.events.map((event) => event.toMap()), [ + { + 'type': RuntimeEventType.animationDone, + 'target': 'node', + 'handler': 'removed', + }, + ]); + }, + ); + + test('ignores transform commands for missing targets', () { + final harness = _CommandHarness(); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.moveTo, + target: 'missing', + payload: {'x': 1, 'y': 2}, + ), + ); + + expect(harness.events, isEmpty); + }); + + test('copy_text writes text to the platform clipboard', () async { + final harness = _CommandHarness(); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + calls.add(call); + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.copyText, + payload: {'text': 'copy me'}, + ), + ); + await Future.delayed(Duration.zero); + + expect(calls.single.method, 'Clipboard.setData'); + expect(calls.single.arguments, {'text': 'copy me'}); + }); + + test('toast creates temporary overlay and emits completion', () async { + final harness = _CommandHarness(); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.toast, + payload: { + 'text': 'Hello toast', + 'duration': 0.01, + 'onComplete': 'toast_done', + }, + ), + ); + + expect(harness.controller.componentById('runtime_toast_1'), isNotNull); + final text = harness.controller.componentById('runtime_toast_1_text')!; + expect(text.node.text, 'Hello toast'); + expect(text.node.parent, 'runtime_toast_1'); + + await Future.delayed(const Duration(milliseconds: 30)); + + expect(harness.controller.componentById('runtime_toast_1'), isNull); + expect(harness.controller.componentById('runtime_toast_1_text'), isNull); + expect(harness.events.map((event) => event.toMap()), [ + {'type': RuntimeEventType.animationDone, 'handler': 'toast_done'}, + ]); + }); + + test('cancel_commands removes active toast overlay', () async { + final harness = _CommandHarness(); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.toast, + payload: {'text': 'Cancel me', 'duration': 10, 'id': 'toast_a'}, + ), + ); + expect(harness.controller.componentById('runtime_toast_1'), isNotNull); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.cancelCommands, + payload: {'id': 'toast_a'}, + ), + ); + await Future.delayed(Duration.zero); + + expect(harness.controller.componentById('runtime_toast_1'), isNull); + }); + + test('runs sequence commands in order', () async { + final harness = _CommandHarness(); + harness + ..createNode('first') + ..createNode('second'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.sequence, + payload: { + 'commands': [ + {'type': RuntimeCommandType.removeNode, 'target': 'first'}, + {'type': RuntimeCommandType.removeNode, 'target': 'second'}, + ], + 'onComplete': 'sequence_done', + }, + ), + ); + + expect(harness.controller.componentById('first'), isNull); + expect(harness.controller.componentById('second'), isNotNull); + + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(harness.controller.componentById('second'), isNull); + expect(harness.events.map((event) => event.toMap()), [ + {'type': RuntimeEventType.animationDone, 'handler': 'sequence_done'}, + ]); + }); + + test('runs parallel commands together', () async { + final harness = _CommandHarness(); + harness + ..createNode('first') + ..createNode('second'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.parallel, + payload: { + 'commands': [ + {'type': RuntimeCommandType.removeNode, 'target': 'first'}, + {'type': RuntimeCommandType.removeNode, 'target': 'second'}, + ], + 'onComplete': 'parallel_done', + }, + ), + ); + + expect(harness.controller.componentById('first'), isNull); + expect(harness.controller.componentById('second'), isNull); + + await Future.delayed(Duration.zero); + + expect(harness.events.map((event) => event.toMap()), [ + {'type': RuntimeEventType.animationDone, 'handler': 'parallel_done'}, + ]); + }); + + test('plays sound and emits completion after playback ends', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(await _createAudioPackage('play_sound')); + final harness = _CommandHarness(audio: audio); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.playSound, + payload: {'asset': 'dice', 'volume': 0.4, 'onComplete': 'sound_done'}, + ), + ); + + await _waitFor(() => players.isNotEmpty); + expect(players.single.startedBytes, _audioBytes); + expect(players.single.volume, 0.4); + expect(harness.events, isEmpty); + + players.single.complete(); + await Future.delayed(Duration.zero); + + expect(harness.events.map((event) => event.toMap()), [ + {'type': RuntimeEventType.animationDone, 'handler': 'sound_done'}, + ]); + audio.dispose(); + }); + + test('cancels scoped sound when scope is removed', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(await _createAudioPackage('scoped_sound')); + final harness = _CommandHarness(audio: audio); + harness.createNode('panel'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.playSound, + payload: { + 'asset': 'dice', + 'scope': 'panel', + 'onComplete': 'sound_done', + }, + ), + ); + await _waitFor(() => players.isNotEmpty); + + harness.controller.removeById('panel'); + await Future.delayed(Duration.zero); + + expect(players.single.disposed, isTrue); + expect(harness.events, isEmpty); + audio.dispose(); + }); + + test('starts bgm command and controls channel', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(await _createAudioPackage('bgm_command')); + final harness = _CommandHarness(audio: audio); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.playBgm, + payload: { + 'asset': 'dice', + 'channel': 'music', + 'volume': 0.2, + 'loop': true, + 'onComplete': 'bgm_started', + }, + ), + ); + + await _waitFor(() => players.isNotEmpty); + await Future.delayed(Duration.zero); + + expect(players.single.startedBytes, _audioBytes); + expect(players.single.volume, 0.2); + expect(players.single.loop, isTrue); + expect(audio.hasBgm(channel: 'music'), isTrue); + expect(harness.events.map((event) => event.toMap()), [ + {'type': RuntimeEventType.animationDone, 'handler': 'bgm_started'}, + ]); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.pauseBgm, + payload: {'channel': 'music'}, + ), + ); + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.resumeBgm, + payload: {'channel': 'music'}, + ), + ); + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.stopBgm, + payload: {'channel': 'music'}, + ), + ); + await Future.delayed(Duration.zero); + + expect(players.single.paused, isTrue); + expect(players.single.resumed, isTrue); + expect(players.single.stopped, isTrue); + expect(audio.hasBgm(channel: 'music'), isFalse); + audio.dispose(); + }); + + test('stops scoped bgm when scope is removed after start', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(await _createAudioPackage('scoped_bgm_command')); + final harness = _CommandHarness(audio: audio); + harness.createNode('panel'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.playBgm, + payload: {'asset': 'dice', 'channel': 'music', 'scope': 'panel'}, + ), + ); + await _waitFor(() => players.isNotEmpty); + + harness.controller.removeById('panel'); + await Future.delayed(Duration.zero); + + expect(players.single.stopped, isTrue); + expect(audio.hasBgm(channel: 'music'), isFalse); + audio.dispose(); + }); + + test('dispose stops owned bgm channels', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(await _createAudioPackage('dispose_bgm_command')); + final harness = _CommandHarness(audio: audio); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.playBgm, + payload: {'asset': 'dice', 'channel': 'music'}, + ), + ); + await _waitFor(() => players.isNotEmpty); + + harness.executor.dispose(); + await Future.delayed(Duration.zero); + + expect(players.single.stopped, isTrue); + expect(audio.hasBgm(channel: 'music'), isFalse); + audio.dispose(); + }); + + test( + 'preloads and evicts resource groups with completion events', + () async { + final audio = RuntimeAudioManager(); + await audio.mount( + await _createGroupedAudioPackage('resource_commands'), + ); + final harness = _CommandHarness(audio: audio); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.preloadResources, + payload: {'group': 'scene', 'onComplete': 'resources_ready'}, + ), + ); + await _waitFor(() => harness.events.isNotEmpty); + + expect(audio.audioState('dice'), GameResourceState.ready); + expect(harness.events.single.handler, 'resources_ready'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.evictResources, + payload: {'group': 'scene', 'onComplete': 'resources_evicted'}, + ), + ); + await _waitFor(() => harness.events.length == 2); + + expect(audio.audioState('dice'), GameResourceState.idle); + expect(harness.events.last.handler, 'resources_evicted'); + audio.dispose(); + }, + ); + + test('runs delay completion asynchronously', () async { + final harness = _CommandHarness(); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.delay, + payload: {'duration': 0, 'onComplete': 'delay_done'}, + ), + ); + + expect(harness.events, isEmpty); + await Future.delayed(Duration.zero); + + expect(harness.events.map((event) => event.toMap()), [ + {'type': RuntimeEventType.animationDone, 'handler': 'delay_done'}, + ]); + }); + + test('drops delayed completion when scope node was removed', () async { + final harness = _CommandHarness(); + harness.createNode('panel'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.delay, + payload: {'duration': 0, 'onComplete': 'late_done', 'scope': 'panel'}, + ), + ); + harness.controller.removeById('panel'); + + await Future.delayed(Duration.zero); + + expect(harness.events, isEmpty); + }); + + test('scope removal cancels inherited pending sequence commands', () async { + final harness = _CommandHarness(); + harness + ..createNode('panel') + ..createNode('second'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.sequence, + payload: { + 'scope': 'panel', + 'commands': [ + {'type': RuntimeCommandType.delay, 'duration': 0.02}, + {'type': RuntimeCommandType.removeNode, 'target': 'second'}, + ], + 'onComplete': 'sequence_done', + }, + ), + ); + harness.controller.removeById('panel'); + + await Future.delayed(const Duration(milliseconds: 40)); + + expect(harness.controller.componentById('second'), isNotNull); + expect(harness.events, isEmpty); + }); + + test('dispose cancels pending delayed completion', () async { + final harness = _CommandHarness(); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.delay, + payload: {'duration': 0, 'onComplete': 'late_done'}, + ), + ); + harness.executor.dispose(); + + await Future.delayed(Duration.zero); + + expect(harness.events, isEmpty); + }); + + test('cancel_commands cancels pending command by id', () async { + final harness = _CommandHarness(); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.delay, + payload: { + 'id': 'intro_delay', + 'duration': 0.02, + 'onComplete': 'late_done', + }, + ), + ); + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.cancelCommands, + payload: {'id': 'intro_delay'}, + ), + ); + + await Future.delayed(const Duration(milliseconds: 40)); + + expect(harness.events, isEmpty); + }); + + test('cancel_commands cancels inherited command group', () async { + final harness = _CommandHarness(); + harness.createNode('node'); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.sequence, + payload: { + 'group': 'intro', + 'commands': [ + {'type': RuntimeCommandType.delay, 'duration': 0.02}, + {'type': RuntimeCommandType.removeNode, 'target': 'node'}, + ], + 'onComplete': 'sequence_done', + }, + ), + ); + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.cancelCommands, + payload: {'group': 'intro'}, + ), + ); + + await Future.delayed(const Duration(milliseconds: 40)); + + expect(harness.controller.componentById('node'), isNotNull); + expect(harness.events, isEmpty); + }); + + test('cancel_commands cancels scoped sound by command group', () async { + final players = <_FakeRuntimeAudioPlayer>[]; + final audio = RuntimeAudioManager( + playerFactory: () { + final player = _FakeRuntimeAudioPlayer(); + players.add(player); + return player; + }, + ); + await audio.mount(await _createAudioPackage('cancel_sound_group')); + final harness = _CommandHarness(audio: audio); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.playSound, + payload: { + 'asset': 'dice', + 'group': 'sfx_intro', + 'onComplete': 'sound_done', + }, + ), + ); + await _waitFor(() => players.isNotEmpty); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.cancelCommands, + payload: {'group': 'sfx_intro'}, + ), + ); + await Future.delayed(Duration.zero); + + expect(players.single.disposed, isTrue); + expect(harness.events, isEmpty); + audio.dispose(); + }); + + test( + 'resource commands use commandGroup without confusing resource group', + () async { + final audio = RuntimeAudioManager(); + await audio.mount( + await _createGroupedAudioPackage('resource_group_safe'), + ); + final harness = _CommandHarness(audio: audio); + + harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.preloadResources, + payload: { + 'group': 'scene', + 'commandGroup': 'loading', + 'onComplete': 'resources_ready', + }, + ), + ); + await _waitFor(() => harness.events.isNotEmpty); + + expect(audio.audioState('dice'), GameResourceState.ready); + expect(harness.events.single.handler, 'resources_ready'); + audio.dispose(); + }, + ); + + test('validates composite commands before executing children', () { + final harness = _CommandHarness(); + harness + ..createNode('first') + ..createNode('second'); + + expect( + () => harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.sequence, + payload: { + 'commands': [ + {'type': RuntimeCommandType.removeNode, 'target': 'first'}, + { + 'type': RuntimeCommandType.fadeTo, + 'target': 'second', + 'alpha': 2, + }, + ], + }, + ), + ), + throwsFormatException, + ); + + expect(harness.controller.componentById('first'), isNotNull); + expect(harness.controller.componentById('second'), isNotNull); + }); + + test('validates required command payloads', () { + final harness = _CommandHarness(); + harness.createNode('node'); + + expect( + () => harness.executor.execute( + const RuntimeCommand(type: RuntimeCommandType.moveTo, target: 'node'), + ), + throwsFormatException, + ); + expect( + () => harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.fadeTo, + target: 'node', + payload: {'alpha': 2}, + ), + ), + throwsFormatException, + ); + expect( + () => harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.scaleTo, + target: 'node', + payload: {'scale': 'big'}, + ), + ), + throwsFormatException, + ); + expect( + () => harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.rotateTo, + target: 'node', + payload: {'angle': 0, 'duration': -1}, + ), + ), + throwsFormatException, + ); + expect( + () => harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.playSpineAnimation, + target: 'node', + ), + ), + throwsFormatException, + ); + expect( + () => harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.playSpineAnimation, + target: 'node', + payload: {'animation': 'walk', 'track': -1}, + ), + ), + throwsFormatException, + ); + expect( + () => harness.executor.execute( + const RuntimeCommand( + type: RuntimeCommandType.moveTo, + target: 'node', + payload: {'x': 1, 'y': 2, 'duraton': 0.2}, + ), + ), + throwsFormatException, + ); + }); + }); +} + +Future _waitFor(bool Function() predicate) async { + for (var i = 0; i < 20; i++) { + if (predicate()) { + return; + } + await Future.delayed(Duration.zero); + } + throw StateError('Timed out waiting for test condition'); +} + +class _CommandHarness { + _CommandHarness({ + RuntimeAudioManager? audio, + GameResourceManager? resources, + }) { + final activeResources = resources ?? GameResourceManager(); + controller = RenderTreeController( + root: Component(), + resources: activeResources, + eventSink: events.add, + ); + executor = CommandExecutor( + renderTree: controller, + eventSink: events.add, + audio: audio, + resources: activeResources, + ); + controller.onScopeRemoved = executor.cancelScope; + } + + final events = []; + late final RenderTreeController controller; + late final CommandExecutor executor; + + void createNode(String id) { + controller.apply( + NodeDiff( + creates: [ + RuntimeNode( + id: id, + type: RuntimeNodeType.rect, + width: 100, + height: 100, + ), + ], + ), + ); + } +} + +const _audioBytes = [9, 8, 7, 6]; + +Future _createAudioPackage(String name) async { + final root = await Directory.systemTemp.createTemp('command_audio_${name}_'); + Directory('${root.path}/assets').createSync(recursive: true); + File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return GamePackage.file( + rootPath: root.path, + manifest: const GamePackageManifest( + gameId: 'test', + name: 'Test', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: { + 'dice': GameResource( + type: GameResourceType.audio, + path: 'assets/dice.wav', + preload: GameResourcePreload.lazy, + ), + }, + ), + ); +} + +Future _createGroupedAudioPackage(String name) async { + final root = await Directory.systemTemp.createTemp('command_audio_${name}_'); + Directory('${root.path}/assets').createSync(recursive: true); + File('${root.path}/assets/dice.wav').writeAsBytesSync(_audioBytes); + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return GamePackage.file( + rootPath: root.path, + manifest: const GamePackageManifest( + gameId: 'test', + name: 'Test', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: { + 'dice': GameResource( + type: GameResourceType.audio, + path: 'assets/dice.wav', + preload: GameResourcePreload.lazy, + group: 'scene', + ), + }, + ), + ); +} + +class _FakeRuntimeAudioPlayer implements RuntimeAudioPlayer { + final _done = async.Completer(); + List? startedBytes; + double? volume; + var loop = false; + var paused = false; + var resumed = false; + var stopped = false; + var disposed = false; + + @override + Future get done => _done.future; + + @override + Future start( + Uint8List bytes, { + required double volume, + bool loop = false, + }) async { + startedBytes = bytes.toList(growable: false); + this.volume = volume; + this.loop = loop; + } + + void complete() { + if (!_done.isCompleted) { + _done.complete(); + } + } + + @override + Future pause() async { + paused = true; + } + + @override + Future resume() async { + resumed = true; + } + + @override + Future stop() async { + stopped = true; + complete(); + } + + @override + Future dispose() async { + disposed = true; + complete(); + } +} diff --git a/test/runtime/commands/runtime_command_registry_test.dart b/test/runtime/commands/runtime_command_registry_test.dart new file mode 100644 index 0000000..50761be --- /dev/null +++ b/test/runtime/commands/runtime_command_registry_test.dart @@ -0,0 +1,65 @@ +import 'package:flame_lua_runtime/runtime/commands/runtime_command_registry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeCommandRegistry', () { + test('cancels handles by id, group and scope', () { + final registry = RuntimeCommandRegistry(); + var idCancelled = false; + var groupCancelled = false; + var scopeCancelled = false; + + registry.create(id: 'intro').addCancelCallback(() { + idCancelled = true; + }); + registry.create(group: 'scene').addCancelCallback(() { + groupCancelled = true; + }); + registry.create(scope: 'panel').addCancelCallback(() { + scopeCancelled = true; + }); + + registry + ..cancelId('intro') + ..cancelGroup('scene') + ..cancelScope('panel'); + + expect(idCancelled, isTrue); + expect(groupCancelled, isTrue); + expect(scopeCancelled, isTrue); + expect(registry.activeHandleCount, 0); + }); + + test('completed handles ignore later cancellation', () { + final registry = RuntimeCommandRegistry(); + var cancelled = false; + final handle = registry.create(group: 'scene') + ..addCancelCallback(() { + cancelled = true; + }); + + handle.complete(); + registry.cancelGroup('scene'); + + expect(cancelled, isFalse); + expect(registry.activeHandleCount, 0); + }); + + test('dispose cancels all handles', () { + final registry = RuntimeCommandRegistry(); + var cancelCount = 0; + + registry.create(id: 'a').addCancelCallback(() { + cancelCount++; + }); + registry.create(group: 'b').addCancelCallback(() { + cancelCount++; + }); + + registry.dispose(); + + expect(cancelCount, 2); + expect(registry.activeHandleCount, 0); + }); + }); +} diff --git a/test/runtime/diagnostics/runtime_diagnostics_test.dart b/test/runtime/diagnostics/runtime_diagnostics_test.dart new file mode 100644 index 0000000..1baa620 --- /dev/null +++ b/test/runtime/diagnostics/runtime_diagnostics_test.dart @@ -0,0 +1,120 @@ +import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeDiagnostics', () { + test('keeps entries in insertion order', () { + final diagnostics = RuntimeDiagnostics(); + + diagnostics + ..record( + type: RuntimeDiagnosticType.packageActivationError, + message: 'activation failed', + ) + ..record( + type: RuntimeDiagnosticType.luaEventError, + message: 'event failed', + error: StateError('boom'), + context: {'eventType': 'tap'}, + ); + + expect(diagnostics.entries, hasLength(2)); + expect( + diagnostics.entries.first.type, + RuntimeDiagnosticType.packageActivationError, + ); + expect(diagnostics.entries.last.error, isA()); + expect(diagnostics.entries.last.context, {'eventType': 'tap'}); + }); + + test('exports debug json with sanitized context and errors', () { + final diagnostics = RuntimeDiagnostics(maxEntries: 3) + ..record( + type: RuntimeDiagnosticType.resourceLoadError, + message: 'audio failed', + error: StateError('missing'), + context: { + 'asset': 'bgm', + 'attempt': 2, + 'nested': {'z': 1, 'a': DateTime.utc(2026)}, + 'values': [StateError('bad'), true], + }, + ); + + final json = diagnostics.toDebugJson(); + + expect(json['maxEntries'], 3); + expect(json['count'], 1); + final entries = json['entries'] as List; + final entry = entries.single as Map; + expect(entry['type'], 'resourceLoadError'); + expect(entry['message'], 'audio failed'); + expect(entry['error'], contains('missing')); + expect(entry['timestamp'], isA()); + expect(entry['context'], { + 'asset': 'bgm', + 'attempt': 2, + 'nested': {'a': '2026-01-01T00:00:00.000Z', 'z': 1}, + 'values': ['Bad state: bad', true], + }); + }); + + test('dumps readable text for empty and populated diagnostics', () { + final diagnostics = RuntimeDiagnostics(maxEntries: 2); + + expect(diagnostics.dumpText(), 'RuntimeDiagnostics: no entries'); + + diagnostics.record( + type: RuntimeDiagnosticType.commandError, + message: 'command failed', + error: ArgumentError('bad command'), + context: {'command': 'play_bgm'}, + ); + + final dump = diagnostics.dumpText(); + + expect(dump, contains('RuntimeDiagnostics (1/2)')); + expect(dump, contains('commandError: command failed')); + expect(dump, contains('error: Invalid argument(s): bad command')); + expect(dump, contains('context: {"command":"play_bgm"}')); + }); + + test('entry debug helpers support direct use', () { + final entry = RuntimeDiagnosticEntry( + type: RuntimeDiagnosticType.luaEventError, + message: 'lua failed', + timestamp: DateTime.utc(2026, 6, 4, 12), + error: 'boom', + context: {'event': 'tap'}, + ); + + expect(entry.toDebugJson(), { + 'timestamp': '2026-06-04T12:00:00.000Z', + 'type': 'luaEventError', + 'message': 'lua failed', + 'error': 'boom', + 'context': {'event': 'tap'}, + }); + expect( + entry.dumpText(), + '[2026-06-04T12:00:00.000Z] luaEventError: lua failed\n' + ' error: boom\n' + ' context: {"event":"tap"}', + ); + }); + + test('evicts oldest entries when max size is reached', () { + final diagnostics = RuntimeDiagnostics(maxEntries: 2); + + diagnostics + ..record(type: RuntimeDiagnosticType.commandError, message: 'one') + ..record(type: RuntimeDiagnosticType.commandError, message: 'two') + ..record(type: RuntimeDiagnosticType.commandError, message: 'three'); + + expect(diagnostics.entries.map((entry) => entry.message), [ + 'two', + 'three', + ]); + }); + }); +} diff --git a/test/runtime/events/runtime_event_dispatcher_test.dart b/test/runtime/events/runtime_event_dispatcher_test.dart new file mode 100644 index 0000000..b523e02 --- /dev/null +++ b/test/runtime/events/runtime_event_dispatcher_test.dart @@ -0,0 +1,170 @@ +import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; +import 'package:flame_lua_runtime/runtime/events/runtime_event_dispatcher.dart'; +import 'package:flame_lua_runtime/runtime/lifecycle/runtime_session.dart'; +import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeEventDispatcher', () { + test('dispatches queued events serially', () async { + final session = _activeSession(); + final script = _FakeScriptEngine(); + final applied = []; + final dispatcher = RuntimeEventDispatcher( + session: session, + scriptEngine: script, + isScopeAlive: (_) => true, + applyDiff: applied.add, + ); + + dispatcher + ..enqueue(const RuntimeEvent(type: 'tap', target: 'a')) + ..enqueue(const RuntimeEvent(type: 'tap', target: 'b')); + + await Future.delayed(Duration.zero); + + expect(script.events.map((event) => event.target), ['a', 'b']); + expect(applied, hasLength(2)); + }); + + test('drops events for removed scope', () async { + final session = _activeSession(); + final script = _FakeScriptEngine(); + var alive = true; + final dispatcher = RuntimeEventDispatcher( + session: session, + scriptEngine: script, + isScopeAlive: (_) => alive, + applyDiff: (_) {}, + ); + + dispatcher.enqueue(const RuntimeEvent(type: 'tap', scope: 'dialog')); + alive = false; + + await Future.delayed(Duration.zero); + + expect(script.events, isEmpty); + }); + + test('drops events with stale target epoch', () async { + final session = _activeSession(); + final script = _FakeScriptEngine(); + var currentEpoch = 2; + final dispatcher = RuntimeEventDispatcher( + session: session, + scriptEngine: script, + isScopeAlive: (_) => true, + isNodeEpochAlive: (_, epoch) => epoch == currentEpoch, + applyDiff: (_) {}, + ); + + dispatcher.enqueue( + const RuntimeEvent(type: 'tap', target: 'button', targetEpoch: 1), + ); + currentEpoch = 3; + + await Future.delayed(Duration.zero); + + expect(script.events, isEmpty); + }); + + test('drops queued events after dispose', () async { + final session = _activeSession(); + final script = _FakeScriptEngine(); + final dispatcher = RuntimeEventDispatcher( + session: session, + scriptEngine: script, + isScopeAlive: (_) => true, + applyDiff: (_) {}, + ); + + dispatcher.enqueue(const RuntimeEvent(type: 'tap')); + dispatcher.dispose(); + + await Future.delayed(Duration.zero); + + expect(script.events, isEmpty); + }); + + test('drops queued events after session dispose', () async { + final session = _activeSession(); + final script = _FakeScriptEngine(); + final dispatcher = RuntimeEventDispatcher( + session: session, + scriptEngine: script, + isScopeAlive: (_) => true, + applyDiff: (_) {}, + ); + + dispatcher.enqueue(const RuntimeEvent(type: 'tap')); + session.dispose(); + + await Future.delayed(Duration.zero); + + expect(script.events, isEmpty); + }); + + test('continues draining after a script error', () async { + final session = _activeSession(); + final script = _FakeScriptEngine()..failNext = true; + final errors = []; + final diagnostics = RuntimeDiagnostics(); + final dispatcher = RuntimeEventDispatcher( + session: session, + scriptEngine: script, + isScopeAlive: (_) => true, + applyDiff: (_) {}, + diagnostics: diagnostics, + onError: errors.add, + ); + + dispatcher + ..enqueue(const RuntimeEvent(type: 'tap', target: 'bad')) + ..enqueue(const RuntimeEvent(type: 'tap', target: 'good')); + + await Future.delayed(Duration.zero); + + expect(errors, hasLength(1)); + expect(diagnostics.entries, hasLength(1)); + expect( + diagnostics.entries.single.type, + RuntimeDiagnosticType.luaEventError, + ); + expect(diagnostics.entries.single.context['target'], 'bad'); + expect(script.events.map((event) => event.target), ['bad', 'good']); + }); + }); +} + +RuntimeSession _activeSession() { + final session = RuntimeSession(gameId: 'test')..beginLoading(); + session.activate(); + return session; +} + +class _FakeScriptEngine implements ScriptEngine { + final events = []; + bool failNext = false; + + @override + Future loadPackage(GamePackage package) async {} + + @override + bool smokeTest(Map context) => true; + + @override + GameDiff init(Map context) => GameDiff.empty; + + @override + GameDiff dispatchEvent(RuntimeEvent event) { + events.add(event); + if (failNext) { + failNext = false; + throw StateError('boom'); + } + return GameDiff.empty; + } +} diff --git a/test/runtime/events/runtime_event_gate_test.dart b/test/runtime/events/runtime_event_gate_test.dart new file mode 100644 index 0000000..7f2f7f5 --- /dev/null +++ b/test/runtime/events/runtime_event_gate_test.dart @@ -0,0 +1,84 @@ +import 'package:flame_lua_runtime/runtime/events/runtime_event_gate.dart'; +import 'package:flame_lua_runtime/runtime/lifecycle/runtime_session.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeEventGate', () { + test('attaches session id without exposing lifecycle fields to Lua', () { + final session = RuntimeSession(gameId: 'game')..activate(); + final gate = RuntimeEventGate( + session: session, + isScopeAlive: (_) => true, + ); + + final event = gate.attachSession(const RuntimeEvent(type: 'tap')); + + expect(event.sessionId, session.id); + expect(event.toMap(), {'type': 'tap'}); + }); + + test('accepts only active matching sessions', () { + final session = RuntimeSession(gameId: 'game')..activate(); + final gate = RuntimeEventGate( + session: session, + isScopeAlive: (_) => true, + ); + + expect( + gate.accepts(RuntimeEvent(type: 'tap', sessionId: session.id)), + isTrue, + ); + expect( + gate.accepts(RuntimeEvent(type: 'tap', sessionId: session.id + 1)), + isFalse, + ); + + session.beginDisposing(); + expect( + gate.accepts(RuntimeEvent(type: 'tap', sessionId: session.id)), + isFalse, + ); + }); + + test('drops dead scopes and stale epochs', () { + final session = RuntimeSession(gameId: 'game')..activate(); + final gate = RuntimeEventGate( + session: session, + isScopeAlive: (scope) => scope == 'alive', + isNodeEpochAlive: (id, epoch) => epoch == 2, + ); + + expect( + gate.accepts( + RuntimeEvent( + type: 'tap', + sessionId: session.id, + scope: 'alive', + scopeEpoch: 2, + target: 'button', + targetEpoch: 2, + ), + ), + isTrue, + ); + expect( + gate.accepts( + RuntimeEvent(type: 'tap', sessionId: session.id, scope: 'dead'), + ), + isFalse, + ); + expect( + gate.accepts( + RuntimeEvent( + type: 'tap', + sessionId: session.id, + target: 'button', + targetEpoch: 1, + ), + ), + isFalse, + ); + }); + }); +} diff --git a/test/runtime/game/flame_lua_game_test.dart b/test/runtime/game/flame_lua_game_test.dart new file mode 100644 index 0000000..3370eee --- /dev/null +++ b/test/runtime/game/flame_lua_game_test.dart @@ -0,0 +1,72 @@ +import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; +import 'package:flame_lua_runtime/runtime/game/flame_lua_game.dart'; +import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart'; +import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('FlameLuaGame diagnostics debug access', () { + test('exposes diagnostics entries, dump text and debug json', () { + final diagnostics = RuntimeDiagnostics() + ..record( + type: RuntimeDiagnosticType.commandError, + message: 'command failed', + context: {'command': 'play_bgm'}, + ); + final game = FlameLuaGame( + scriptEngine: _FakeScriptEngine(), + packageRepository: _FakePackageRepository(), + gameId: 'ludo', + diagnostics: diagnostics, + ); + + expect(game.diagnosticEntries, hasLength(1)); + expect(game.diagnosticsDumpText(), contains('command failed')); + expect(game.diagnosticsDebugJson()['count'], 1); + expect(game.resourcesDebugJson(), {'initialized': false}); + }); + }); +} + +class _FakeScriptEngine implements ScriptEngine { + @override + Future loadPackage(GamePackage package) { + throw UnimplementedError(); + } + + @override + GameDiff dispatchEvent(RuntimeEvent event) { + throw UnimplementedError(); + } + + @override + GameDiff init(Map context) { + throw UnimplementedError(); + } + + @override + bool smokeTest(Map context) { + throw UnimplementedError(); + } +} + +class _FakePackageRepository implements GamePackageRepository { + @override + Future load(String gameId) async { + return GamePackage.asset( + rootPath: 'example/assets/games/$gameId', + manifest: GamePackageManifest( + gameId: gameId, + name: gameId, + version: 'test', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + ), + ); + } +} diff --git a/test/runtime/game/runtime_locale_test.dart b/test/runtime/game/runtime_locale_test.dart new file mode 100644 index 0000000..cecad16 --- /dev/null +++ b/test/runtime/game/runtime_locale_test.dart @@ -0,0 +1,63 @@ +import 'dart:ui' show Locale; + +import 'package:flame_lua_runtime/runtime/game/runtime_locale.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeLocaleResolver', () { + test('normalizes locale tags', () { + expect(RuntimeLocaleResolver.normalizeTag('zh_hans_cn'), 'zh-Hans-CN'); + expect(RuntimeLocaleResolver.normalizeTag('EN-us'), 'en-US'); + expect(RuntimeLocaleResolver.tagOf(const Locale('zh', 'CN')), 'zh-CN'); + }); + + test('resolves exact, language and fallback locales', () { + expect( + RuntimeLocaleResolver.resolve( + requested: const Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hans', + ), + defaultLocale: 'en', + supportedLocales: const ['zh-Hans', 'en'], + ).resolved, + 'zh-Hans', + ); + + expect( + RuntimeLocaleResolver.resolve( + requested: const Locale('en', 'US'), + defaultLocale: 'zh-Hans', + supportedLocales: const ['zh-Hans', 'en'], + ).resolved, + 'en', + ); + + expect( + RuntimeLocaleResolver.resolve( + requested: const Locale('fr', 'FR'), + defaultLocale: 'zh-Hans', + supportedLocales: const ['zh-Hans', 'en'], + ).resolved, + 'zh-Hans', + ); + }); + + test('exports locale context for Lua', () { + final info = RuntimeLocaleResolver.resolve( + requested: const Locale('en', 'US'), + defaultLocale: 'zh-Hans', + supportedLocales: const ['zh-Hans', 'en'], + ); + + expect(info.toMap(), { + 'requested': 'en-US', + 'resolved': 'en', + 'default': 'zh-Hans', + 'supported': ['zh-Hans', 'en'], + 'languageCode': 'en', + 'countryCode': 'US', + }); + }); + }); +} diff --git a/test/runtime/lifecycle/runtime_async_gate_test.dart b/test/runtime/lifecycle/runtime_async_gate_test.dart new file mode 100644 index 0000000..4911b48 --- /dev/null +++ b/test/runtime/lifecycle/runtime_async_gate_test.dart @@ -0,0 +1,36 @@ +import 'package:flame_lua_runtime/runtime/lifecycle/runtime_async_gate.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeAsyncGate', () { + test('accepts only current open generation tokens', () { + final gate = RuntimeAsyncGate(); + final first = gate.activate(); + + expect(gate.accepts(first), isTrue); + expect(first.isAccepted, isTrue); + + final second = gate.advance(); + + expect(gate.accepts(first), isFalse); + expect(gate.accepts(second), isTrue); + }); + + test('close rejects existing and future checks until activated again', () { + final gate = RuntimeAsyncGate(); + final first = gate.activate(); + + gate.close(); + + expect(gate.isClosed, isTrue); + expect(gate.accepts(first), isFalse); + expect(gate.acceptsGeneration(gate.generation), isFalse); + + final second = gate.activate(); + + expect(gate.isOpen, isTrue); + expect(gate.accepts(second), isTrue); + expect(gate.accepts(first), isFalse); + }); + }); +} diff --git a/test/runtime/lifecycle/runtime_serial_queue_test.dart b/test/runtime/lifecycle/runtime_serial_queue_test.dart new file mode 100644 index 0000000..b3a6a6f --- /dev/null +++ b/test/runtime/lifecycle/runtime_serial_queue_test.dart @@ -0,0 +1,59 @@ +import 'package:flame_lua_runtime/runtime/lifecycle/runtime_serial_queue.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeSerialQueue', () { + test('drains queued items in order on a microtask', () async { + final handled = []; + final queue = RuntimeSerialQueue(onItem: handled.add); + + queue + ..enqueue(1) + ..enqueue(2) + ..enqueue(3); + + expect(handled, isEmpty); + expect(queue.pendingCount, 3); + + await Future.delayed(Duration.zero); + + expect(handled, [1, 2, 3]); + expect(queue.pendingCount, 0); + }); + + test('stops draining when shouldContinue turns false', () async { + final handled = []; + var active = true; + late final RuntimeSerialQueue queue; + queue = RuntimeSerialQueue( + shouldContinue: () => active, + onItem: (item) { + handled.add(item); + active = false; + }, + ); + + queue + ..enqueue(1) + ..enqueue(2); + + await Future.delayed(Duration.zero); + + expect(handled, [1]); + expect(queue.pendingCount, 1); + }); + + test('dispose drops pending items', () async { + final handled = []; + final queue = RuntimeSerialQueue(onItem: handled.add); + + queue.enqueue(1); + queue.dispose(); + + await Future.delayed(Duration.zero); + + expect(handled, isEmpty); + expect(queue.pendingCount, 0); + }); + }); +} diff --git a/test/runtime/lifecycle/runtime_session_test.dart b/test/runtime/lifecycle/runtime_session_test.dart new file mode 100644 index 0000000..0db5a27 --- /dev/null +++ b/test/runtime/lifecycle/runtime_session_test.dart @@ -0,0 +1,38 @@ +import 'package:flame_lua_runtime/runtime/lifecycle/runtime_session.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeSession', () { + test('moves through loading, active, disposing and disposed states', () { + final session = RuntimeSession(gameId: 'game'); + + expect(session.state, RuntimeSessionState.created); + expect(session.acceptsWork, isTrue); + expect(session.isActive, isFalse); + + session.beginLoading(); + expect(session.state, RuntimeSessionState.loading); + expect(session.acceptsWorkFor(session.id), isTrue); + expect(session.accepts(session.id), isFalse); + + session.activate(); + expect(session.state, RuntimeSessionState.active); + expect(session.accepts(session.id), isTrue); + + session.beginDisposing(); + expect(session.state, RuntimeSessionState.disposing); + expect(session.acceptsWork, isFalse); + expect(session.accepts(session.id), isFalse); + + session.dispose(); + expect(session.state, RuntimeSessionState.disposed); + expect(session.acceptsWork, isFalse); + }); + + test('rejects invalid transitions', () { + final session = RuntimeSession(gameId: 'game')..activate(); + + expect(session.beginLoading, throwsA(isA())); + }); + }); +} diff --git a/test/runtime/lifecycle/runtime_task_registry_test.dart b/test/runtime/lifecycle/runtime_task_registry_test.dart new file mode 100644 index 0000000..bb0cdb3 --- /dev/null +++ b/test/runtime/lifecycle/runtime_task_registry_test.dart @@ -0,0 +1,61 @@ +import 'dart:async' as async; + +import 'package:flame_lua_runtime/runtime/lifecycle/runtime_task_registry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeTaskRegistry', () { + test('cancels tasks by scope', () async { + final registry = RuntimeTaskRegistry(cancelledValue: 'cancelled'); + final scoped = registry.create(scope: 'panel'); + final other = registry.create(scope: 'other'); + + registry.cancelScope('panel'); + + expect(await scoped.future, 'cancelled'); + expect(scoped.isCancelled, isTrue); + expect(other.isCancelled, isFalse); + expect(registry.scopedTaskCount('panel'), 0); + expect(registry.scopedTaskCount('other'), 1); + + other.complete('done'); + expect(await other.future, 'done'); + expect(registry.activeTaskCount, 0); + }); + + test('cancel runs callbacks and cancels timers', () async { + final registry = RuntimeTaskRegistry(cancelledValue: 'cancelled'); + final task = registry.create(scope: 'panel'); + var callbackCalled = false; + var timerFired = false; + final timer = async.Timer(const Duration(milliseconds: 30), () { + timerFired = true; + }); + + task + ..addTimer(timer) + ..addCancelCallback(() { + callbackCalled = true; + }) + ..cancel(); + + await Future.delayed(const Duration(milliseconds: 40)); + + expect(await task.future, 'cancelled'); + expect(callbackCalled, isTrue); + expect(timerFired, isFalse); + }); + + test('dispose cancels all active tasks', () async { + final registry = RuntimeTaskRegistry(cancelledValue: 'cancelled'); + final first = registry.create(); + final second = registry.create(scope: 'panel'); + + registry.dispose(); + + expect(await first.future, 'cancelled'); + expect(await second.future, 'cancelled'); + expect(registry.activeTaskCount, 0); + }); + }); +} diff --git a/test/runtime/models/game_diff_test.dart b/test/runtime/models/game_diff_test.dart new file mode 100644 index 0000000..9571a58 --- /dev/null +++ b/test/runtime/models/game_diff_test.dart @@ -0,0 +1,121 @@ +import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GameDiff', () { + test('parses render, ui and commands', () { + final diff = GameDiff.fromMap({ + 'render': { + 'creates': [ + {'id': 'board', 'type': 'image', 'asset': 'board'}, + ], + 'updates': [ + { + 'id': 'piece_red_1', + 'props': {'x': 100, 'y': 120}, + }, + ], + 'removes': ['old_piece'], + }, + 'ui': { + 'creates': [ + {'id': 'dice_button', 'type': 'button', 'text': 'Roll'}, + ], + }, + 'commands': [ + {'type': 'move_path', 'target': 'piece_red_1', 'duration': 0.5}, + ], + }); + + expect(diff.render.creates.single.id, 'board'); + expect(diff.render.updates.single.id, 'piece_red_1'); + expect(diff.render.updates.single.props['x'], 100); + expect(diff.render.removes.single.id, 'old_piece'); + expect(diff.ui.creates.single.id, 'dice_button'); + expect(diff.commands.single.type, 'move_path'); + expect(diff.commands.single.target, 'piece_red_1'); + expect(diff.commands.single.payload['duration'], 0.5); + }); + + test('treats missing sections as empty diffs', () { + final diff = GameDiff.fromMap({}); + + expect(diff.render.creates, isEmpty); + expect(diff.render.updates, isEmpty); + expect(diff.render.removes, isEmpty); + expect(diff.ui.creates, isEmpty); + expect(diff.commands, isEmpty); + }); + + test('accepts Lua numeric-key tables as lists', () { + final diff = GameDiff.fromMap({ + 'render': { + 'creates': { + 2: {'id': 'b', 'type': 'text'}, + 1: {'id': 'a', 'type': 'text'}, + }, + 'updates': {}, + 'removes': { + 1: {'id': 'old_a'}, + 2: 'old_b', + }, + }, + 'commands': { + 1: {'type': 'toast', 'message': 'hi'}, + }, + }); + + expect(diff.render.creates.map((node) => node.id), ['a', 'b']); + expect(diff.render.removes.map((node) => node.id), ['old_a', 'old_b']); + expect(diff.commands.single.type, 'toast'); + expect(diff.commands.single.payload['message'], 'hi'); + }); + + test('rejects malformed diff fields', () { + expect( + () => GameDiff.fromMap({ + 'render': {'creates': 'bad'}, + }), + throwsFormatException, + ); + expect( + () => GameDiff.fromMap({ + 'render': { + 'updates': [ + {'id': 'node', 'props': 'bad'}, + ], + }, + }), + throwsFormatException, + ); + expect( + () => GameDiff.fromMap({ + 'commands': [ + {'type': ''}, + ], + }), + throwsFormatException, + ); + expect(() => GameDiff.fromMap({'unknown': {}}), throwsFormatException); + expect( + () => GameDiff.fromMap({ + 'render': {'createz': []}, + }), + throwsFormatException, + ); + expect( + () => GameDiff.fromMap({ + 'render': { + 'updates': [ + { + 'id': 'node', + 'props': {'interative': true}, + }, + ], + }, + }), + throwsFormatException, + ); + }); + }); +} diff --git a/test/runtime/models/runtime_event_command_test.dart b/test/runtime/models/runtime_event_command_test.dart new file mode 100644 index 0000000..98392a4 --- /dev/null +++ b/test/runtime/models/runtime_event_command_test.dart @@ -0,0 +1,77 @@ +import 'package:flame_lua_runtime/runtime/models/runtime_command.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeEvent', () { + test('serializes only present fields', () { + final event = RuntimeEvent( + type: 'tap', + target: 'dice_button', + handler: 'roll_dice', + x: 10, + y: 20, + data: {'pointer': 1}, + ); + + expect(event.toMap(), { + 'type': 'tap', + 'target': 'dice_button', + 'handler': 'roll_dice', + 'x': 10, + 'y': 20, + 'data': {'pointer': 1}, + }); + }); + + test('omits null and empty optional fields', () { + expect(const RuntimeEvent(type: 'animation_done').toMap(), { + 'type': 'animation_done', + }); + }); + }); + + group('RuntimeCommand', () { + test('parses command payload without type and target', () { + final command = RuntimeCommand.fromMap({ + 'type': 'move_path', + 'target': 'piece_red_1', + 'duration': 0.5, + 'onComplete': 'done', + }); + + expect(command.type, 'move_path'); + expect(command.target, 'piece_red_1'); + expect(command.payload, {'duration': 0.5, 'onComplete': 'done'}); + }); + + test('rejects invalid command shape', () { + expect(() => RuntimeCommand.fromMap({'type': ''}), throwsFormatException); + expect( + () => RuntimeCommand.fromMap({'type': 'toast', 'target': 1}), + throwsFormatException, + ); + expect( + () => RuntimeCommand.fromMap({'type': 'unknown'}), + throwsFormatException, + ); + expect( + () => RuntimeCommand.fromMap({ + 'type': 'move_to', + 'target': 'piece', + 'x': 1, + 'y': 2, + 'duraton': 0.5, + }), + throwsFormatException, + ); + expect( + () => RuntimeCommand.fromMap({ + 'type': 'preload_resources', + 'groups': 'pieces', + }), + throwsFormatException, + ); + }); + }); +} diff --git a/test/runtime/models/runtime_node_test.dart b/test/runtime/models/runtime_node_test.dart new file mode 100644 index 0000000..ee1492f --- /dev/null +++ b/test/runtime/models/runtime_node_test.dart @@ -0,0 +1,324 @@ +import 'package:flame_lua_runtime/runtime/models/runtime_node.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeNode', () { + test('parses required and optional fields', () { + final node = RuntimeNode.fromMap({ + 'id': 'dice_button', + 'type': 'button', + 'parent': 'top_bar', + 'asset': 'dice_normal', + 'pressedAsset': 'dice_pressed', + 'disabledAsset': 'dice_disabled', + 'animation': 'idle', + 'skin': 'red', + 'loop': false, + 'text': 'Roll', + 'x': 10, + 'y': 20.5, + 'width': 120, + 'height': 48, + 'paddingLeft': 4, + 'paddingTop': 5, + 'paddingRight': 6, + 'paddingBottom': 7, + 'anchor': 'center', + 'layer': 3, + 'visible': false, + 'alpha': 0.7, + 'scale': 1.2, + 'rotation': 0.4, + 'color': '#112233', + 'fontSize': 18, + 'textAlign': 'left', + 'radius': 10, + 'strokeWidth': 3, + 'value': 0.6, + 'scrollX': 15, + 'scrollY': 20, + 'contentWidth': 220, + 'contentHeight': 180, + 'virtualized': true, + 'cacheExtent': 12, + 'inertia': false, + 'scrollbarThumbColor': '#abcdef', + 'scrollbarTrackColor': '#123456', + 'scrollbarThickness': 6, + 'scrollbarVisible': false, + 'interactive': true, + 'onTap': 'roll_dice', + 'onScroll': 'list_scrolled', + 'preset': 'burst', + 'count': 32, + 'duration': 0.6, + 'speedMin': 60, + 'speedMax': 180, + 'gravityX': 0, + 'gravityY': 120, + 'spread': 360, + 'colorTo': '#00ffcc33', + 'radiusTo': 0, + 'autoRemove': false, + 'fadeOut': false, + }); + + expect(node.id, 'dice_button'); + expect(node.type, 'button'); + expect(node.parent, 'top_bar'); + expect(node.asset, 'dice_normal'); + expect(node.pressedAsset, 'dice_pressed'); + expect(node.disabledAsset, 'dice_disabled'); + expect(node.animation, 'idle'); + expect(node.skin, 'red'); + expect(node.loop, isFalse); + expect(node.text, 'Roll'); + expect(node.x, 10); + expect(node.y, 20.5); + expect(node.width, 120); + expect(node.height, 48); + expect(node.paddingLeft, 4); + expect(node.paddingTop, 5); + expect(node.paddingRight, 6); + expect(node.paddingBottom, 7); + expect(node.anchor, 'center'); + expect(node.layer, 3); + expect(node.visible, isFalse); + expect(node.alpha, 0.7); + expect(node.scale, 1.2); + expect(node.rotation, 0.4); + expect(node.color, const Color(0xff112233)); + expect(node.fontSize, 18); + expect(node.textAlign, 'left'); + expect(node.radius, 10); + expect(node.strokeWidth, 3); + expect(node.value, 0.6); + expect(node.scrollX, 15); + expect(node.scrollY, 20); + expect(node.contentWidth, 220); + expect(node.contentHeight, 180); + expect(node.virtualized, isTrue); + expect(node.cacheExtent, 12); + expect(node.inertia, isFalse); + expect(node.scrollbarThumbColor, const Color(0xffabcdef)); + expect(node.scrollbarTrackColor, const Color(0xff123456)); + expect(node.scrollbarThickness, 6); + expect(node.scrollbarVisible, isFalse); + expect(node.interactive, isTrue); + expect(node.onTap, 'roll_dice'); + expect(node.onScroll, 'list_scrolled'); + expect(node.preset, 'burst'); + expect(node.count, 32); + expect(node.duration, 0.6); + expect(node.speedMin, 60); + expect(node.speedMax, 180); + expect(node.gravityX, 0); + expect(node.gravityY, 120); + expect(node.spread, 360); + expect(node.colorTo, const Color(0x00ffcc33)); + expect(node.radiusTo, 0); + expect(node.autoRemove, isFalse); + expect(node.fadeOut, isFalse); + }); + + test('applies default values', () { + final node = RuntimeNode.fromMap({'id': 'label', 'type': 'text'}); + + expect(node.x, 0); + expect(node.y, 0); + expect(node.anchor, 'topLeft'); + expect(node.layer, 0); + expect(node.visible, isTrue); + expect(node.alpha, 1); + expect(node.scale, 1); + expect(node.rotation, 0); + expect(node.loop, isTrue); + expect(node.textAlign, 'center'); + expect(node.scrollbarVisible, isTrue); + expect(node.paddingLeft, 0); + expect(node.paddingTop, 0); + expect(node.paddingRight, 0); + expect(node.paddingBottom, 0); + expect(node.autoRemove, isTrue); + expect(node.fadeOut, isTrue); + expect(node.interactive, isFalse); + }); + + test('copies only provided props', () { + final node = RuntimeNode.fromMap({ + 'id': 'piece', + 'type': 'circle', + 'x': 1, + 'y': 2, + 'color': '#ff0000', + }); + + final updated = node.copyWithProps({ + 'x': 20, + 'parent': 'board', + 'visible': false, + 'color': '#8000ff00', + 'radius': 8, + 'strokeWidth': 2, + 'value': 0.75, + 'width': 70, + 'height': 60, + 'paddingLeft': 8, + 'paddingTop': 9, + 'paddingRight': 10, + 'paddingBottom': 11, + 'contentWidth': 120, + 'contentHeight': 100, + 'pressedAsset': 'button_pressed', + 'disabledAsset': 'button_disabled', + 'scrollX': 90, + 'scrollY': 80, + 'textAlign': 'right', + 'preset': 'trail', + 'count': 12, + }); + + expect(updated.id, 'piece'); + expect(updated.type, 'circle'); + expect(updated.parent, 'board'); + expect(updated.x, 20); + expect(updated.y, 2); + expect(updated.visible, isFalse); + expect(updated.color, const Color(0x8000ff00)); + expect(updated.radius, 8); + expect(updated.strokeWidth, 2); + expect(updated.value, 0.75); + expect(updated.width, 70); + expect(updated.height, 60); + expect(updated.paddingLeft, 8); + expect(updated.paddingTop, 9); + expect(updated.paddingRight, 10); + expect(updated.paddingBottom, 11); + expect(updated.contentWidth, 120); + expect(updated.contentHeight, 100); + expect(updated.pressedAsset, 'button_pressed'); + expect(updated.disabledAsset, 'button_disabled'); + expect(updated.scrollX, 68); + expect(updated.scrollY, 60); + expect(updated.textAlign, 'right'); + expect(updated.preset, 'trail'); + expect(updated.count, 12); + }); + + test('rejects invalid values', () { + expect( + () => RuntimeNode.fromMap({'id': '', 'type': 'text'}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({'id': 'a', 'type': 'text', 'x': 'bad'}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({'id': 'a', 'type': 'text', 'color': 'red'}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({'id': 'a', 'type': 'unknown'}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'text', + 'anchor': 'middle', + }), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({'id': 'a', 'type': 'progress', 'value': 2}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'text', + 'textAlign': 'justify', + }), + throwsFormatException, + ); + expect( + () => + RuntimeNode.fromMap({'id': 'a', 'type': 'listView', 'scrollY': -1}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'particle', + 'preset': 'unknown', + }), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({'id': 'a', 'type': 'particle', 'count': 0}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'listView', + 'paddingTop': -1, + }), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'listView', + 'cacheExtent': -1, + }), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'rect', + 'interactive': 'yes', + }), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'button', + 'pressedAsset': 1, + }), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({'id': 'a', 'type': 'rect', 'parent': 1}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({'id': 'a', 'type': 'spine', 'loop': 'yes'}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({'id': 'a', 'type': 'rect', 'parent': 'a'}), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'rect', + 'interative': true, + }), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'rect', + }).copyWithProps({'screenWitdh': 720}), + throwsFormatException, + ); + }); + }); +} diff --git a/test/runtime/packages/game_package_activation_controller_test.dart b/test/runtime/packages/game_package_activation_controller_test.dart new file mode 100644 index 0000000..d9524a7 --- /dev/null +++ b/test/runtime/packages/game_package_activation_controller_test.dart @@ -0,0 +1,395 @@ +import 'dart:io'; + +import 'package:flame_lua_runtime/runtime/audio/runtime_audio_manager.dart'; +import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_activation_controller.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_repository.dart'; +import 'package:flame_lua_runtime/runtime/packages/stable_package_store.dart'; +import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; +import 'package:flame_lua_runtime/runtime/scripting/script_engine.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PackageActivationController', () { + test( + 'activates repository candidate and marks stable after init', + () async { + final candidate = await _createPackage('candidate'); + final store = _FakeStablePackageStore(); + final scriptEngine = _FakeScriptEngine(); + + final result = await PackageActivationController( + repository: _FakeRepository(candidate), + resources: GameResourceManager(), + scriptEngine: scriptEngine, + store: store, + assetFallback: _FakeRepository(await _createPackage('asset')), + ).activate(gameId: 'ludo', contextBuilder: _context); + + expect(result.package.rootPath, candidate.rootPath); + expect(scriptEngine.loadedPackages, [candidate.rootPath]); + expect(scriptEngine.initPackages, [candidate.rootPath]); + expect(store.markedPackages, [candidate.rootPath]); + }, + ); + + test('falls back to stable when repository candidate is invalid', () async { + final invalidCandidate = await _createPackage('invalid', script: 'bad'); + final stable = await _createPackage('stable'); + final store = _FakeStablePackageStore(stable: stable); + final scriptEngine = _FakeScriptEngine(); + + final result = await PackageActivationController( + repository: _FakeRepository(invalidCandidate), + resources: GameResourceManager(), + scriptEngine: scriptEngine, + store: store, + assetFallback: _FakeRepository(await _createPackage('asset')), + ).activate(gameId: 'ludo', contextBuilder: _context); + + expect(result.package.rootPath, stable.rootPath); + expect(scriptEngine.loadedPackages, [stable.rootPath]); + expect(store.markedPackages, [stable.rootPath]); + }); + + test( + 'falls back to previous stable when current stable is invalid', + () async { + final invalidCandidate = await _createPackage( + 'invalid_candidate', + script: 'bad', + ); + final invalidStable = await _createPackage( + 'invalid_stable', + script: 'bad', + ); + final previous = await _createPackage('previous'); + final store = _FakeStablePackageStore( + stable: invalidStable, + previous: previous, + ); + final scriptEngine = _FakeScriptEngine(); + + final result = await PackageActivationController( + repository: _FakeRepository(invalidCandidate), + resources: GameResourceManager(), + scriptEngine: scriptEngine, + store: store, + assetFallback: _FakeRepository(await _createPackage('asset')), + ).activate(gameId: 'ludo', contextBuilder: _context); + + expect(result.package.rootPath, previous.rootPath); + expect(scriptEngine.loadedPackages, [previous.rootPath]); + expect(store.markedPackages, [previous.rootPath]); + }, + ); + + test('falls back to asset when repository load fails', () async { + final failedCandidate = await _createPackage('failed_candidate'); + final fallback = await _createPackage('asset'); + final store = _FakeStablePackageStore(); + final scriptEngine = _FakeScriptEngine(); + + final result = await PackageActivationController( + repository: _FakeRepository( + failedCandidate, + error: StateError('network failed'), + ), + resources: GameResourceManager(), + scriptEngine: scriptEngine, + store: store, + assetFallback: _FakeRepository(fallback), + ).activate(gameId: 'ludo', contextBuilder: _context); + + expect(result.package.rootPath, fallback.rootPath); + expect(scriptEngine.loadedPackages, [fallback.rootPath]); + expect(store.markedPackages, [fallback.rootPath]); + }); + + test('stops activation when cancellation guard turns false', () async { + final candidate = await _createPackage('candidate'); + final fallback = await _createPackage('asset'); + final store = _FakeStablePackageStore(); + final scriptEngine = _FakeScriptEngine(); + var active = false; + + await expectLater( + PackageActivationController( + repository: _FakeRepository(candidate), + resources: GameResourceManager(), + scriptEngine: scriptEngine, + store: store, + assetFallback: _FakeRepository(fallback), + ).activate( + gameId: 'ludo', + contextBuilder: _context, + shouldContinue: () => active, + ), + throwsStateError, + ); + + expect(scriptEngine.loadedPackages, isEmpty); + expect(store.markedPackages, isEmpty); + }); + + test('uses staging resources and script engine before commit', () async { + final candidate = await _createPackage('candidate'); + final activeResources = _RecordingResourceManager(); + final activeScriptEngine = _FakeScriptEngine(); + final stagingResources = <_RecordingResourceManager>[]; + final stagingEngines = <_FakeScriptEngine>[]; + + final result = await PackageActivationController( + repository: _FakeRepository(candidate), + resources: activeResources, + scriptEngine: activeScriptEngine, + store: _FakeStablePackageStore(), + assetFallback: _FakeRepository(await _createPackage('asset')), + resourceManagerFactory: () { + final resources = _RecordingResourceManager(); + stagingResources.add(resources); + return resources; + }, + scriptEngineFactory: () { + final engine = _FakeScriptEngine(); + stagingEngines.add(engine); + return engine; + }, + ).activate(gameId: 'ludo', contextBuilder: _context); + + expect(activeResources.mountedPackages, isEmpty); + expect(activeScriptEngine.loadedPackages, isEmpty); + expect(result.resources, same(stagingResources.single)); + expect(result.scriptEngine, same(stagingEngines.single)); + expect(stagingResources.single.mountedPackages, [candidate.rootPath]); + expect(stagingEngines.single.loadedPackages, [candidate.rootPath]); + }); + + test('falls back when required audio preload fails', () async { + final candidate = await _createAudioPackage( + 'candidate_bad_audio', + writeAudio: false, + ); + final fallback = await _createAudioPackage('asset_audio'); + final store = _FakeStablePackageStore(); + final scriptEngine = _FakeScriptEngine(); + + final result = await PackageActivationController( + repository: _FakeRepository(candidate), + resources: GameResourceManager(), + scriptEngine: scriptEngine, + audio: RuntimeAudioManager(), + store: store, + assetFallback: _FakeRepository(fallback), + audioManagerFactory: RuntimeAudioManager.new, + ).activate(gameId: 'ludo', contextBuilder: _context); + + expect(result.package.rootPath, fallback.rootPath); + expect(result.audio, isNotNull); + expect(store.markedPackages, [fallback.rootPath]); + }); + + test('does not mark a package when smoke_test fails', () async { + final candidate = await _createPackage('candidate'); + final fallback = await _createPackage('asset'); + final store = _FakeStablePackageStore(); + final scriptEngine = _FakeScriptEngine( + smokeFailures: {candidate.rootPath}, + ); + + final result = await PackageActivationController( + repository: _FakeRepository(candidate), + resources: GameResourceManager(), + scriptEngine: scriptEngine, + store: store, + assetFallback: _FakeRepository(fallback), + ).activate(gameId: 'ludo', contextBuilder: _context); + + expect(result.package.rootPath, fallback.rootPath); + expect(scriptEngine.loadedPackages, [ + candidate.rootPath, + fallback.rootPath, + ]); + expect(store.markedPackages, [fallback.rootPath]); + }); + }); +} + +Map _context(GamePackage package) { + return { + 'runtimeApiVersion': 1, + 'gameId': package.manifest.gameId, + 'gameVersion': package.manifest.version, + }; +} + +Future _createPackage( + String name, { + String script = _validScript, +}) async { + final root = await Directory.systemTemp.createTemp('activation_${name}_'); + Directory('${root.path}/scripts').createSync(recursive: true); + File('${root.path}/scripts/main.lua').writeAsStringSync(script); + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return GamePackage.file( + rootPath: root.path, + manifest: const GamePackageManifest( + gameId: 'ludo', + name: 'Ludo', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + ), + ); +} + +Future _createAudioPackage( + String name, { + bool writeAudio = true, +}) async { + final root = await Directory.systemTemp.createTemp('activation_${name}_'); + Directory('${root.path}/scripts').createSync(recursive: true); + Directory('${root.path}/assets').createSync(recursive: true); + File('${root.path}/scripts/main.lua').writeAsStringSync(_validScript); + if (writeAudio) { + File('${root.path}/assets/dice.wav').writeAsBytesSync(const [1, 2, 3]); + } + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return GamePackage.file( + rootPath: root.path, + manifest: const GamePackageManifest( + gameId: 'ludo', + name: 'Ludo', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: { + 'dice': GameResource( + type: GameResourceType.audio, + path: 'assets/dice.wav', + preload: GameResourcePreload.required, + ), + }, + ), + ); +} + +class _FakeRepository implements GamePackageRepository { + const _FakeRepository(this.package, {this.error}); + + final GamePackage package; + final Object? error; + + @override + Future load(String gameId) async { + final value = error; + if (value != null) { + throw value; + } + return package; + } +} + +class _FakeStablePackageStore implements StablePackageStore { + _FakeStablePackageStore({this.stable, this.previous}); + + final GamePackage? stable; + final GamePackage? previous; + final markedPackages = []; + + @override + Future cacheRoot() => throw UnimplementedError(); + + @override + Future markStable(GamePackage package) async { + markedPackages.add(package.rootPath); + } + + @override + Future previousStablePackage(String gameId) async => previous; + + @override + Future stablePackage(String gameId) async => stable; + + @override + Future versionDirectory(String gameId, String version) => + throw UnimplementedError(); +} + +class _RecordingResourceManager extends GameResourceManager { + final mountedPackages = []; + var disposed = false; + + @override + Future mount(GamePackage package) async { + mountedPackages.add(package.rootPath); + await super.mount(package); + } + + @override + void dispose() { + disposed = true; + super.dispose(); + } +} + +class _FakeScriptEngine implements ScriptEngine { + _FakeScriptEngine({this.smokeFailures = const {}}); + + final Set smokeFailures; + final loadedPackages = []; + final initPackages = []; + GamePackage? _package; + + @override + Future loadPackage(GamePackage package) async { + _package = package; + loadedPackages.add(package.rootPath); + } + + @override + bool smokeTest(Map context) { + return !smokeFailures.contains(_package?.rootPath); + } + + @override + GameDiff init(Map context) { + final package = _package; + if (package != null) { + initPackages.add(package.rootPath); + } + return GameDiff.empty; + } + + @override + GameDiff dispatchEvent(RuntimeEvent event) => GameDiff.empty; +} + +const _validScript = ''' +function smoke_test(ctx) + return true +end + +function init(ctx) + return {} +end + +function on_event(event) + return {} +end +'''; diff --git a/test/runtime/packages/game_package_manifest_test.dart b/test/runtime/packages/game_package_manifest_test.dart new file mode 100644 index 0000000..d9bfbac --- /dev/null +++ b/test/runtime/packages/game_package_manifest_test.dart @@ -0,0 +1,236 @@ +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GamePackageManifest', () { + test('parses manifest with resources', () { + final manifest = GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'assetsBase': 'assets', + 'defaultLocale': 'zh-Hans', + 'supportedLocales': ['zh-Hans', 'en'], + 'display': { + 'designWidth': 720, + 'designHeight': 1280, + 'scaleMode': 'fit', + }, + 'modules': { + 'runtime_ui': 'runtime:runtime_ui.lua', + 'theme': 'scripts/theme.lua', + }, + 'resources': { + 'board': { + 'type': 'image', + 'path': 'assets/board.png', + 'preload': 'lazy', + 'group': 'board', + }, + 'roll': {'type': 'audio', 'path': 'assets/roll.mp3'}, + 'hero': { + 'type': 'spine', + 'atlas': 'assets/hero.atlas', + 'skeleton': 'assets/hero.skel', + 'preload': 'lazy', + 'group': 'actors', + }, + }, + }); + + expect(manifest.gameId, 'ludo'); + expect(manifest.name, 'Ludo'); + expect(manifest.version, '0.1.0'); + expect(manifest.runtimeApiVersion, 1); + expect(manifest.entry, 'scripts/main.lua'); + expect(manifest.assetsBase, 'assets'); + expect(manifest.defaultLocale, 'zh-Hans'); + expect(manifest.supportedLocales, ['zh-Hans', 'en']); + expect(manifest.display.designWidth, 720); + expect(manifest.display.designHeight, 1280); + expect(manifest.display.scaleMode, 'fit'); + expect(manifest.resources['board']?.type, 'image'); + expect(manifest.resources['board']?.path, 'assets/board.png'); + expect(manifest.resources['board']?.preload, GameResourcePreload.lazy); + expect(manifest.resources['board']?.group, 'board'); + expect(manifest.resources['roll']?.type, GameResourceType.audio); + expect(manifest.resources['roll']?.preload, GameResourcePreload.required); + expect(manifest.resources['hero']?.type, GameResourceType.spine); + expect(manifest.resources['hero']?.atlas, 'assets/hero.atlas'); + expect(manifest.resources['hero']?.skeleton, 'assets/hero.skel'); + expect(manifest.resources['hero']?.path, isEmpty); + expect(manifest.modules, { + 'runtime_ui': 'runtime:runtime_ui.lua', + 'theme': 'scripts/theme.lua', + }); + }); + + test('defaults assetsBase to assets', () { + final manifest = GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + }); + + expect(manifest.assetsBase, 'assets'); + expect(manifest.defaultLocale, 'en'); + expect(manifest.supportedLocales, ['en']); + expect(manifest.display.designWidth, 720); + expect(manifest.display.designHeight, 720); + expect(manifest.display.scaleMode, 'fit'); + expect(manifest.resources, isEmpty); + expect(manifest.modules, isEmpty); + }); + + test('rejects invalid required fields and resources', () { + expect( + () => GamePackageManifest.fromMap({ + 'gameId': '', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': '1', + 'entry': 'scripts/main.lua', + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'resources': { + 'board': {'type': '', 'path': 'assets/board.png'}, + }, + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'resources': { + 'roll': {'type': 'sound', 'path': 'assets/roll.mp3'}, + }, + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'modules': {'theme': 1}, + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'resources': { + 'board': { + 'type': 'image', + 'path': 'assets/board.png', + 'preload': 'eager', + }, + }, + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'resources': { + 'board': {'type': 'image', 'path': 'assets/board.png', 'group': ''}, + }, + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'resources': { + 'hero': {'type': 'spine', 'atlas': 'assets/hero.atlas'}, + }, + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'defaultLocale': 'zh-Hans', + 'supportedLocales': ['en'], + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'display': {'designWidth': 0}, + }), + throwsFormatException, + ); + + expect( + () => GamePackageManifest.fromMap({ + 'gameId': 'ludo', + 'name': 'Ludo', + 'version': '0.1.0', + 'runtimeApiVersion': 1, + 'entry': 'scripts/main.lua', + 'display': {'scaleMode': 'zoom'}, + }), + throwsFormatException, + ); + }); + }); +} diff --git a/test/runtime/packages/game_package_test.dart b/test/runtime/packages/game_package_test.dart new file mode 100644 index 0000000..6cb8aa0 --- /dev/null +++ b/test/runtime/packages/game_package_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GamePackage', () { + test('resolves manifest resource keys', () { + final package = _package(); + + expect( + package.resolveResourcePath('board'), + 'example/assets/games/ludo/assets/board.png', + ); + }); + + test('resolves package-relative paths and assetsBase fallback', () { + final package = _package(); + + expect( + package.resolveResourcePath('scripts/main.lua'), + 'example/assets/games/ludo/scripts/main.lua', + ); + expect( + package.resolveResourcePath('unknown.png'), + 'example/assets/games/ludo/assets/unknown.png', + ); + expect( + package.resolveResourcePath( + 'example/assets/games/ludo/assets/board.png', + ), + 'example/assets/games/ludo/assets/board.png', + ); + }); + + test('keeps runtime Lua root configurable per package', () { + final package = GamePackage.asset( + rootPath: 'example/assets/games/ludo', + manifest: _manifest(), + runtimeLuaRoot: 'packages/flame_lua_runtime/assets/runtime/lua', + ); + + expect( + package.runtimeLuaRoot, + 'packages/flame_lua_runtime/assets/runtime/lua', + ); + }); + + test('reads file package text and bytes', () async { + final root = await Directory.systemTemp.createTemp('game_package_test_'); + addTearDown(() => root.deleteSync(recursive: true)); + Directory('${root.path}/scripts').createSync(recursive: true); + File('${root.path}/scripts/main.lua').writeAsStringSync('return true'); + + final package = GamePackage.file( + rootPath: root.path, + manifest: _manifest(), + ); + + expect(await package.readText('scripts/main.lua'), 'return true'); + expect((await package.readBytes('scripts/main.lua')).lengthInBytes, 11); + }); + }); +} + +GamePackage _package() { + return GamePackage.asset( + rootPath: 'example/assets/games/ludo', + manifest: _manifest(), + ); +} + +GamePackageManifest _manifest() { + return const GamePackageManifest( + gameId: 'ludo', + name: 'Ludo', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: {'board': GameResource(type: 'image', path: 'assets/board.png')}, + ); +} diff --git a/test/runtime/packages/package_verifier_test.dart b/test/runtime/packages/package_verifier_test.dart new file mode 100644 index 0000000..7cfa444 --- /dev/null +++ b/test/runtime/packages/package_verifier_test.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flame_lua_runtime/runtime/packages/package_verifier.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('PackageVerifier', () { + test('accepts a valid file package', () async { + final package = await _createPackage(); + + await expectLater( + const PackageVerifier(runtimeApiVersion: 1).verify(package), + completes, + ); + }); + + test('accepts runtime framework module paths', () async { + final package = await _createPackage( + modules: {'runtime_ui': 'runtime:runtime_ui.lua'}, + ); + + await expectLater( + const PackageVerifier(runtimeApiVersion: 1).verify(package), + completes, + ); + }); + + test('rejects unsupported runtimeApiVersion', () async { + final package = await _createPackage(runtimeApiVersion: 2); + + await expectLater( + const PackageVerifier(runtimeApiVersion: 1).verify(package), + throwsFormatException, + ); + }); + + test('rejects missing Lua entry functions', () async { + final package = await _createPackage(script: 'function init(ctx) end'); + + await expectLater( + const PackageVerifier(runtimeApiVersion: 1).verify(package), + throwsFormatException, + ); + }); + + test('rejects unsafe declared modules', () async { + final package = await _createPackage( + modules: {'../theme': 'scripts/theme.lua'}, + ); + + await expectLater( + const PackageVerifier(runtimeApiVersion: 1).verify(package), + throwsFormatException, + ); + }); + + test('rejects module paths outside scripts directory', () async { + final package = await _createPackage( + modules: {'theme': 'assets/theme.lua'}, + ); + + await expectLater( + const PackageVerifier(runtimeApiVersion: 1).verify(package), + throwsFormatException, + ); + }); + + test('rejects missing declared resources', () async { + final package = await _createPackage(writeResource: false); + + await expectLater( + const PackageVerifier(runtimeApiVersion: 1).verify(package), + throwsFormatException, + ); + }); + + test('rejects resource paths that escape package root', () async { + final package = await _createPackage(resourcePath: '../outside.png'); + + await expectLater( + const PackageVerifier(runtimeApiVersion: 1).verify(package), + throwsFormatException, + ); + }); + }); +} + +Future _createPackage({ + int runtimeApiVersion = 1, + String script = _validScript, + String resourcePath = 'assets/board.png', + bool writeResource = true, + Map modules = const {'theme': 'scripts/theme.lua'}, +}) async { + final root = await Directory.systemTemp.createTemp('package_verifier_test_'); + Directory('${root.path}/scripts').createSync(recursive: true); + Directory('${root.path}/assets').createSync(recursive: true); + File('${root.path}/scripts/main.lua').writeAsStringSync(script); + File('${root.path}/scripts/theme.lua').writeAsStringSync('return {}'); + if (writeResource && !resourcePath.contains('..')) { + File('${root.path}/$resourcePath') + ..createSync(recursive: true) + ..writeAsBytesSync([1, 2, 3]); + } + + final package = GamePackage.file( + rootPath: root.path, + manifest: GamePackageManifest( + gameId: 'ludo', + name: 'Ludo', + version: '0.1.0', + runtimeApiVersion: runtimeApiVersion, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: {'board': GameResource(type: 'image', path: resourcePath)}, + modules: modules, + ), + ); + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + return package; +} + +const _validScript = ''' +function smoke_test(ctx) + return true +end + +function init(ctx) + return {} +end + +function on_event(event) + return {} +end +'''; diff --git a/test/runtime/public_api_test.dart b/test/runtime/public_api_test.dart new file mode 100644 index 0000000..7d94e54 --- /dev/null +++ b/test/runtime/public_api_test.dart @@ -0,0 +1,20 @@ +import 'package:flame_lua_runtime/flame_lua_runtime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('public runtime API exposes minimal integration surface', () { + const repository = AssetGamePackageRepository(); + const options = RuntimeOptions(runtimeLuaRoot: 'custom/runtime/lua'); + const widget = LuaGameWidget( + gameId: 'template', + packageRepository: repository, + runtimeOptions: options, + ); + + expect(widget.gameId, 'template'); + expect(widget.packageRepository, same(repository)); + expect(widget.runtimeOptions.runtimeLuaRoot, 'custom/runtime/lua'); + expect(LuaDardoScriptEngine.new, isA()); + expect(RuntimeLocaleResolver.localeFromTag('zh-Hans').scriptCode, 'Hans'); + }); +} diff --git a/test/runtime/rendering/render_tree_controller_test.dart b/test/runtime/rendering/render_tree_controller_test.dart new file mode 100644 index 0000000..b77d491 --- /dev/null +++ b/test/runtime/rendering/render_tree_controller_test.dart @@ -0,0 +1,593 @@ +import 'package:flame/components.dart'; +import 'package:flame_lua_runtime/runtime/models/game_diff.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_node.dart'; +import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; +import 'package:flame_lua_runtime/runtime/rendering/render_tree_controller.dart'; +import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RenderTreeController', () { + test('creates, updates and removes components by id', () { + final events = []; + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: events.add, + ); + + controller.apply( + NodeDiff( + creates: [ + RuntimeNode( + id: 'panel', + type: RuntimeNodeType.rect, + x: 10, + y: 20, + width: 100, + height: 80, + layer: 4, + ), + ], + ), + ); + + final created = controller.componentById('panel'); + expect(created, isNotNull); + expect(created!.node.x, 10); + expect(created.node.y, 20); + expect(created.node.width, 100); + expect(created.node.height, 80); + expect(created.priority, 4); + + controller.apply( + NodeDiff( + updates: [ + NodeUpdate( + id: 'panel', + props: {'x': 30, 'visible': false, 'layer': 8}, + ), + ], + ), + ); + + final updated = controller.componentById('panel'); + expect(updated, same(created)); + expect(updated!.node.x, 30); + expect(updated.node.y, 20); + expect(updated.node.visible, isFalse); + expect(updated.priority, 8); + + controller.apply(const NodeDiff(removes: [NodeRemove(id: 'panel')])); + + expect(controller.componentById('panel'), isNull); + }); + + test('replaces an existing component when create uses same id', () { + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)], + ), + ); + final first = controller.componentById('node'); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode(id: 'node', type: RuntimeNodeType.circle, layer: 2), + ], + ), + ); + final second = controller.componentById('node'); + + expect(first, isNotNull); + expect(second, isNotNull); + expect(second, isNot(same(first))); + expect(second!.node.type, RuntimeNodeType.circle); + expect(second.priority, 2); + }); + + test('mounts nodes under declared parent and supports reparenting', () { + final root = Component(); + final controller = RenderTreeController( + root: root, + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode(id: 'panel', type: RuntimeNodeType.panel), + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + parent: 'panel', + ), + ], + ), + ); + + final panel = controller.componentById('panel')!; + final button = controller.componentById('button')!; + expect(panel.parent, root); + expect(button.parent, panel); + + controller.apply( + NodeDiff( + updates: [ + NodeUpdate(id: 'button', props: {'parent': ''}), + ], + ), + ); + + expect(button.parent, root); + expect(button.node.parent, isNull); + }); + + test('reattaches child when parent is created later', () { + final root = Component(); + final controller = RenderTreeController( + root: root, + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + parent: 'panel', + ), + ], + ), + ); + + final button = controller.componentById('button')!; + expect(button.parent, root); + + controller.apply( + const NodeDiff( + creates: [RuntimeNode(id: 'panel', type: RuntimeNodeType.panel)], + ), + ); + + expect(button.parent, controller.componentById('panel')); + }); + + test('scrolls listView by id or point and offsets direct children', () { + final root = PositionComponent(); + final events = []; + final controller = RenderTreeController( + root: root, + resources: GameResourceManager(), + eventSink: events.add, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode( + id: 'list', + type: RuntimeNodeType.listView, + width: 160, + height: 60, + contentWidth: 220, + contentHeight: 140, + scrollX: 10, + scrollY: 20, + onScroll: 'list_scrolled', + ), + RuntimeNode( + id: 'row', + type: RuntimeNodeType.button, + parent: 'list', + x: 40, + y: 50, + width: 140, + height: 24, + ), + ], + ), + ); + + final list = controller.componentById('list')!; + final row = controller.componentById('row')!; + expect(row.parent, list); + expect(row.position, Vector2(30, 30)); + expect(controller.listViewAt(Vector2(10, 10)), 'list'); + + expect(controller.scrollListView('list', deltaX: 30, deltaY: 50), isTrue); + expect(controller.componentById('list')!.node.scrollX, 40); + expect(controller.componentById('list')!.node.scrollY, 70); + expect(row.position, Vector2(0, -20)); + expect(events.last.type, RuntimeEventType.scroll); + expect(events.last.handler, 'list_scrolled'); + expect(events.last.data['scrollX'], 40); + expect(events.last.data['scrollY'], 70); + + expect( + controller.scrollListViewAt(Vector2(10, 10), deltaY: 1000), + isTrue, + ); + expect(controller.componentById('list')!.node.scrollY, 80); + expect(row.position, Vector2(0, -30)); + expect( + controller.scrollListViewAt(Vector2(500, 500), deltaY: 20), + isFalse, + ); + }); + + test('listView padding offsets children and reduces scroll viewport', () { + final root = PositionComponent(); + final controller = RenderTreeController( + root: root, + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode( + id: 'list', + type: RuntimeNodeType.listView, + width: 120, + height: 80, + contentWidth: 200, + contentHeight: 168, + paddingLeft: 10, + paddingTop: 12, + paddingRight: 8, + paddingBottom: 6, + scrollbarVisible: false, + ), + RuntimeNode( + id: 'row', + type: RuntimeNodeType.button, + parent: 'list', + x: 4, + y: 5, + width: 40, + height: 20, + ), + ], + ), + ); + + final row = controller.componentById('row')!; + expect(row.position, Vector2(14, 17)); + expect( + controller.scrollListView('list', deltaX: 200, deltaY: 200), + isTrue, + ); + expect(controller.componentById('list')!.node.scrollX, 98); + expect(controller.componentById('list')!.node.scrollY, 106); + expect(row.position, Vector2(-84, -89)); + }); + + test('virtualized listView culls direct children outside cache window', () { + final root = PositionComponent(); + final controller = RenderTreeController( + root: root, + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode( + id: 'list', + type: RuntimeNodeType.listView, + width: 120, + height: 60, + contentHeight: 400, + virtualized: true, + cacheExtent: 0, + ), + RuntimeNode( + id: 'visible_row', + type: RuntimeNodeType.button, + parent: 'list', + y: 20, + width: 100, + height: 20, + ), + RuntimeNode( + id: 'culled_row', + type: RuntimeNodeType.button, + parent: 'list', + y: 180, + width: 100, + height: 20, + ), + ], + ), + ); + + expect(controller.componentById('visible_row')!.isVisible, isTrue); + expect(controller.componentById('culled_row')!.isVisible, isFalse); + + controller.scrollListView('list', deltaY: 150); + + expect(controller.componentById('visible_row')!.isVisible, isFalse); + expect(controller.componentById('culled_row')!.isVisible, isTrue); + }); + + test('removes descendants when removing parent', () { + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode(id: 'panel', type: RuntimeNodeType.panel), + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + parent: 'panel', + ), + RuntimeNode( + id: 'label', + type: RuntimeNodeType.text, + parent: 'button', + ), + ], + ), + ); + + controller.apply(const NodeDiff(removes: [NodeRemove(id: 'panel')])); + + expect(controller.componentById('panel'), isNull); + expect(controller.componentById('button'), isNull); + expect(controller.componentById('label'), isNull); + }); + + test('rejects parent cycles', () { + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode(id: 'panel', type: RuntimeNodeType.panel), + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + parent: 'panel', + ), + ], + ), + ); + + expect( + () => controller.apply( + NodeDiff( + updates: [ + NodeUpdate(id: 'panel', props: {'parent': 'button'}), + ], + ), + ), + throwsFormatException, + ); + expect(controller.componentById('panel')!.node.parent, isNull); + }); + + test('rejects invalid diff before applying any partial mutation', () { + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode(id: 'toast', type: RuntimeNodeType.panel), + RuntimeNode(id: 'panel', type: RuntimeNodeType.panel), + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + parent: 'panel', + ), + ], + ), + ); + + expect( + () => controller.apply( + NodeDiff( + removes: const [NodeRemove(id: 'toast')], + updates: [ + NodeUpdate(id: 'panel', props: {'parent': 'button'}), + ], + ), + ), + throwsFormatException, + ); + + expect(controller.componentById('toast'), isNotNull); + expect(controller.componentById('panel')!.node.parent, isNull); + expect(controller.componentById('button')!.node.parent, 'panel'); + }); + + test('clear removes all tracked components', () { + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode(id: 'panel', type: RuntimeNodeType.panel), + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + parent: 'panel', + ), + ], + ), + ); + + controller.clear(); + + expect(controller.componentById('panel'), isNull); + expect(controller.componentById('button'), isNull); + }); + + test('ignores tap callback from stale replaced component', () { + final events = []; + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: events.add, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + interactive: true, + onTap: 'old_tap', + ), + ], + ), + ); + final stale = controller.componentById('button')!; + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + interactive: true, + onTap: 'new_tap', + ), + ], + ), + ); + + stale.onNodeTap(stale.node, Vector2(1, 2)); + controller + .componentById('button')! + .onNodeTap(controller.componentById('button')!.node, Vector2(3, 4)); + + expect(events.map((event) => event.toMap()), [ + { + 'type': RuntimeEventType.tap, + 'target': 'button', + 'handler': 'new_tap', + 'x': 3.0, + 'y': 4.0, + }, + ]); + }); + + test('emits tap event from interactive node callback', () { + final events = []; + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: events.add, + ); + + controller.apply( + const NodeDiff( + creates: [ + RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + interactive: true, + onTap: 'roll_dice', + ), + ], + ), + ); + + final component = controller.componentById('button')!; + component.onNodeTap(component.node, Vector2(3, 4)); + + expect(events, hasLength(1)); + expect(events.single.toMap(), { + 'type': RuntimeEventType.tap, + 'target': 'button', + 'handler': 'roll_dice', + 'x': 3.0, + 'y': 4.0, + }); + expect(events.single.targetEpoch, controller.epochOf('button')); + expect(events.single.scopeEpoch, controller.epochOf('button')); + }); + + test('increments epoch when node is recreated', () { + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + const NodeDiff( + creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)], + ), + ); + final firstEpoch = controller.epochOf('node'); + + controller.apply(const NodeDiff(removes: [NodeRemove(id: 'node')])); + final removedEpoch = controller.epochOf('node'); + + controller.apply( + const NodeDiff( + creates: [RuntimeNode(id: 'node', type: RuntimeNodeType.rect)], + ), + ); + final recreatedEpoch = controller.epochOf('node'); + + expect(firstEpoch, 1); + expect(removedEpoch, greaterThan(firstEpoch)); + expect(recreatedEpoch, greaterThan(removedEpoch)); + expect(controller.isNodeEpochAlive('node', firstEpoch), isFalse); + expect(controller.isNodeEpochAlive('node', recreatedEpoch), isTrue); + }); + + test('ignores updates and removes for unknown ids', () { + final controller = RenderTreeController( + root: Component(), + resources: GameResourceManager(), + eventSink: (_) {}, + ); + + controller.apply( + NodeDiff( + updates: [ + NodeUpdate(id: 'missing', props: {'x': 1}), + ], + removes: const [NodeRemove(id: 'missing')], + ), + ); + + expect(controller.componentById('missing'), isNull); + }); + }); +} diff --git a/test/runtime/rendering/runtime_component_test.dart b/test/runtime/rendering/runtime_component_test.dart new file mode 100644 index 0000000..6fea432 --- /dev/null +++ b/test/runtime/rendering/runtime_component_test.dart @@ -0,0 +1,165 @@ +import 'package:flame/components.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_node.dart'; +import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; +import 'package:flame_lua_runtime/runtime/rendering/runtime_component.dart'; +import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeComponent', () { + test('applies base transform and priority from node', () { + final component = RuntimeComponent( + node: const RuntimeNode( + id: 'rect', + type: RuntimeNodeType.rect, + x: 10, + y: 20, + width: 120, + height: 48, + scale: 1.5, + rotation: 0.25, + anchor: RuntimeAnchorValue.center, + layer: 7, + ), + resources: GameResourceManager(), + onNodeTap: (_, __) {}, + ); + + expect(component.position, Vector2(10, 20)); + expect(component.size, Vector2(120, 48)); + expect(component.scale, Vector2.all(1.5)); + expect(component.angle, 0.25); + expect(component.anchor, Anchor.center); + expect(component.priority, 7); + expect(component.isVisible, isTrue); + }); + + test('updates node and transform', () { + final component = RuntimeComponent( + node: const RuntimeNode(id: 'node', type: RuntimeNodeType.rect), + resources: GameResourceManager(), + onNodeTap: (_, __) {}, + ); + + component.updateNode( + const RuntimeNode( + id: 'node', + type: RuntimeNodeType.progress, + x: 30, + y: 40, + width: 200, + height: 16, + value: 0.5, + layer: 3, + ), + ); + + expect(component.node.type, RuntimeNodeType.progress); + expect(component.node.value, 0.5); + expect(component.position, Vector2(30, 40)); + expect(component.size, Vector2(200, 16)); + expect(component.priority, 3); + expect(component.isVisible, isTrue); + }); + + test('visibility hides component subtree and disables hit testing', () { + final component = RuntimeComponent( + node: const RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + text: 'Hidden', + width: 100, + height: 40, + visible: false, + interactive: true, + ), + resources: GameResourceManager(), + onNodeTap: (_, __) {}, + ); + + expect(component.isVisible, isFalse); + expect(component.containsLocalPoint(Vector2(10, 10)), isFalse); + + component.updateNode( + const RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + text: 'Shown', + width: 100, + height: 40, + visible: true, + interactive: true, + ), + ); + + expect(component.isVisible, isTrue); + expect(component.containsLocalPoint(Vector2(10, 10)), isTrue); + }); + + test('supports runtime alpha override for fade commands', () { + final component = RuntimeComponent( + node: const RuntimeNode( + id: 'panel', + type: RuntimeNodeType.rect, + alpha: 0.8, + ), + resources: GameResourceManager(), + onNodeTap: (_, __) {}, + ); + + expect(component.renderAlpha, 0.8); + component.setRuntimeAlpha(0.25); + expect(component.renderAlpha, 0.25); + component.setRuntimeAlpha(2); + expect(component.renderAlpha, 1); + }); + + test('multi-line non-button text is top aligned', () { + final component = RuntimeComponent( + node: const RuntimeNode( + id: 'text', + type: RuntimeNodeType.text, + text: 'line1\nline2', + width: 120, + height: 80, + textAlign: RuntimeTextAlignValue.left, + ), + resources: GameResourceManager(), + onNodeTap: (_, __) {}, + ); + + final text = component.children.whereType().single; + expect(text.anchor, Anchor.topLeft); + expect(text.position, Vector2.zero()); + }); + + test('only interactive nodes contain local points', () { + final passive = RuntimeComponent( + node: const RuntimeNode( + id: 'passive', + type: RuntimeNodeType.rect, + width: 100, + height: 40, + ), + resources: GameResourceManager(), + onNodeTap: (_, __) {}, + ); + final interactive = RuntimeComponent( + node: const RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + width: 100, + height: 40, + interactive: true, + ), + resources: GameResourceManager(), + onNodeTap: (_, __) {}, + ); + + expect(passive.containsLocalPoint(Vector2(10, 10)), isFalse); + expect(interactive.containsLocalPoint(Vector2(10, 10)), isTrue); + expect(interactive.containsLocalPoint(Vector2(101, 10)), isFalse); + expect(interactive.containsLocalPoint(Vector2(10, 41)), isFalse); + }); + }); +} diff --git a/test/runtime/rendering/runtime_viewport_test.dart b/test/runtime/rendering/runtime_viewport_test.dart new file mode 100644 index 0000000..3537663 --- /dev/null +++ b/test/runtime/rendering/runtime_viewport_test.dart @@ -0,0 +1,98 @@ +import 'package:flame/components.dart'; +import 'package:flame_lua_runtime/runtime/display/runtime_viewport.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RuntimeViewport', () { + test('fits design size inside screen with letterboxing', () { + final transform = RuntimeViewport.compute( + screenSize: Vector2(1280, 720), + config: const RuntimeViewportConfig( + designWidth: 720, + designHeight: 720, + ), + ); + + expect(transform.x, 280); + expect(transform.y, 0); + expect(transform.width, 720); + expect(transform.height, 720); + expect(transform.scaleX, 1); + expect(transform.scaleY, 1); + }); + + test('fills screen by preserving aspect ratio and cropping', () { + final transform = RuntimeViewport.compute( + screenSize: Vector2(1280, 720), + config: const RuntimeViewportConfig( + designWidth: 720, + designHeight: 720, + scaleMode: RuntimeScaleMode.fill, + ), + ); + + expect(transform.x, 0); + expect(transform.y, -280); + expect(transform.width, 1280); + expect(transform.height, 1280); + expect(transform.scaleX, closeTo(1.777777, 0.00001)); + expect(transform.scaleY, closeTo(1.777777, 0.00001)); + }); + + test('stretches design independently on both axes', () { + final transform = RuntimeViewport.compute( + screenSize: Vector2(1440, 720), + config: const RuntimeViewportConfig( + designWidth: 720, + designHeight: 720, + scaleMode: RuntimeScaleMode.stretch, + ), + ); + + expect(transform.x, 0); + expect(transform.y, 0); + expect(transform.width, 1440); + expect(transform.height, 720); + expect(transform.scaleX, 2); + expect(transform.scaleY, 1); + }); + + test('centers design without scaling', () { + final transform = RuntimeViewport.compute( + screenSize: Vector2(1000, 800), + config: const RuntimeViewportConfig( + designWidth: 720, + designHeight: 720, + scaleMode: RuntimeScaleMode.none, + ), + ); + + expect(transform.x, 140); + expect(transform.y, 40); + expect(transform.width, 720); + expect(transform.height, 720); + expect(transform.scaleX, 1); + expect(transform.scaleY, 1); + }); + + test('applies transform to root component', () { + final root = PositionComponent(); + RuntimeViewport.apply( + root, + const RuntimeViewportTransform( + x: 10, + y: 20, + width: 300, + height: 400, + scaleX: 2, + scaleY: 3, + scaleMode: RuntimeScaleMode.stretch, + ), + ); + + expect(root.position, Vector2(10, 20)); + expect(root.size, Vector2(300, 400)); + expect(root.scale, Vector2(2, 3)); + }); + }); +} diff --git a/test/runtime/resources/game_resource_manager_test.dart b/test/runtime/resources/game_resource_manager_test.dart new file mode 100644 index 0000000..5d1cadc --- /dev/null +++ b/test/runtime/resources/game_resource_manager_test.dart @@ -0,0 +1,401 @@ +import 'dart:async' as async; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('GameResourceManager', () { + test( + 'advances generation and clears cache when mounting package', + () async { + final resources = GameResourceManager(); + final first = await _createPackage('first'); + final second = await _createPackage('second'); + + expect(resources.generation, 0); + + await resources.mount(first); + expect(resources.generation, 1); + + await resources.mount(second); + expect(resources.generation, 2); + }, + ); + + test('resolves resource keys from active package', () async { + final resources = GameResourceManager(); + final package = await _createPackage('resources'); + + await resources.mount(package); + + expect(resources.resolve('tile'), endsWith('/assets/tile.png')); + }); + + test( + 'lazy image load records failed state, error and diagnostics', + () async { + final diagnostics = RuntimeDiagnostics(); + final resources = GameResourceManager(diagnostics: diagnostics); + final package = await _createPackage('lazy_failed'); + + await resources.mount(package); + expect(resources.imageState('tile'), GameResourceState.idle); + + final image = await resources.loadImage('tile'); + + expect(image, isNull); + expect(resources.imageState('tile'), GameResourceState.failed); + expect(resources.imageError('tile'), isNotNull); + expect(diagnostics.entries, hasLength(1)); + expect( + diagnostics.entries.single.type, + RuntimeDiagnosticType.resourceLoadError, + ); + }, + ); + + test('exports image debug json and evicts failed records', () async { + final resources = GameResourceManager(); + final package = await _createPackage('debug_json'); + + await resources.mount(package); + + expect(resources.imagesDebugJson(), { + 'generation': 1, + 'hasPackage': true, + 'count': 1, + 'activeLoads': 0, + 'pendingLoads': 0, + 'resources': [ + { + 'key': 'tile', + 'path': endsWith('/assets/tile.png'), + 'type': GameResourceType.image, + 'declared': true, + 'preload': GameResourcePreload.lazy, + 'state': 'idle', + 'loading': false, + 'ready': false, + }, + ], + }); + + await resources.loadImage('tile'); + final failedJson = resources.imagesDebugJson(); + final failedResources = failedJson['resources'] as List; + final failedTile = failedResources.single as Map; + + expect(failedTile['state'], 'failed'); + expect(failedTile['error'], isA()); + + expect(resources.evictImage('tile'), isTrue); + expect(resources.imageState('tile'), GameResourceState.idle); + expect(resources.evictImage('tile'), isFalse); + }); + + test('preloads and evicts image resource groups', () async { + final resources = GameResourceManager(); + final package = await _createMultiImagePackage('image_group'); + + await resources.mount(package); + await resources.preloadGroup('board'); + + expect(resources.imageState('board'), GameResourceState.ready); + expect(resources.imageState('piece'), GameResourceState.ready); + expect(resources.imageState('avatar'), GameResourceState.idle); + expect(resources.evictGroup('board'), 2); + expect(resources.imageState('board'), GameResourceState.idle); + expect(resources.imageState('piece'), GameResourceState.idle); + }); + + test('image LRU evicts least recently used unretained images', () async { + final resources = GameResourceManager(maxCacheEntries: 1); + final package = await _createMultiImagePackage('image_lru'); + + await resources.mount(package); + await resources.loadImage('board'); + expect(resources.imageState('board'), GameResourceState.ready); + + await resources.loadImage('avatar'); + + expect(resources.imageState('board'), GameResourceState.idle); + expect(resources.imageState('avatar'), GameResourceState.ready); + }); + + test('image LRU keeps retained images until released', () async { + final resources = GameResourceManager(maxCacheEntries: 1); + final package = await _createMultiImagePackage('image_lru_retained'); + + await resources.mount(package); + await resources.loadImage('board', retain: true); + await resources.loadImage('avatar'); + + expect(resources.imageState('board'), GameResourceState.ready); + expect(resources.imageState('avatar'), GameResourceState.idle); + + resources.releaseImage('board'); + await resources.loadImage('avatar'); + + expect(resources.imageState('board'), GameResourceState.idle); + expect(resources.imageState('avatar'), GameResourceState.ready); + }); + + test('deduplicates concurrent image load requests', () async { + final resources = GameResourceManager(); + final package = await _createPackage('dedupe'); + final countingPackage = _CountingPackage(package); + + await resources.mount(countingPackage); + + final first = resources.loadImage('tile'); + final second = resources.loadImage('tile'); + + expect(countingPackage.readCount, 1); + countingPackage.releaseReads(); + + await Future.wait([first, second]); + + expect(countingPackage.readCount, 1); + expect(resources.imageState('tile'), GameResourceState.failed); + }); + + test( + 'drops stale image load result after dispose without diagnostics', + () async { + final diagnostics = RuntimeDiagnostics(); + final resources = GameResourceManager(diagnostics: diagnostics); + final package = await _createPackage('stale_image'); + final countingPackage = _CountingPackage(package); + + await resources.mount(countingPackage); + final image = resources.loadImage('tile'); + + resources.dispose(); + countingPackage.releaseReads(); + + expect(await image, isNull); + expect(diagnostics.entries, isEmpty); + }, + ); + + test('optional preload failure does not fail mount', () async { + final resources = GameResourceManager(); + final package = await _createPackage( + 'optional_failed', + preload: GameResourcePreload.optional, + ); + + await resources.mount(package); + + expect(resources.imageState('tile'), GameResourceState.failed); + expect(resources.imageError('tile'), isNotNull); + }); + + test('required preload failure fails mount', () async { + final resources = GameResourceManager(); + final package = await _createPackage( + 'required_failed', + preload: GameResourcePreload.required, + ); + + await expectLater( + resources.mount(package), + throwsA(isA()), + ); + expect(resources.imageState('tile'), GameResourceState.failed); + expect(resources.imageError('tile'), isNotNull); + }); + + test('dispose clears active package and advances generation', () async { + final resources = GameResourceManager(); + final package = await _createPackage('dispose'); + + await resources.mount(package); + expect(resources.generation, 1); + + resources.dispose(); + + expect(resources.generation, 2); + expect(() => resources.package, throwsStateError); + }); + }); +} + +const _pngBytes = [ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1f, + 0x15, + 0xc4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0a, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9c, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0d, + 0x0a, + 0x2d, + 0xb4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4e, + 0x44, + 0xae, + 0x42, + 0x60, + 0x82, +]; + +Future _createPackage( + String name, { + String preload = GameResourcePreload.lazy, +}) async { + final root = await Directory.systemTemp.createTemp('resource_${name}_'); + Directory('${root.path}/assets').createSync(recursive: true); + File('${root.path}/assets/tile.png').writeAsBytesSync(const []); + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return GamePackage.file( + rootPath: root.path, + manifest: GamePackageManifest( + gameId: 'test', + name: 'Test', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: { + 'tile': GameResource( + type: 'image', + path: 'assets/tile.png', + preload: preload, + ), + }, + ), + ); +} + +Future _createMultiImagePackage(String name) async { + final root = await Directory.systemTemp.createTemp('resource_${name}_'); + Directory('${root.path}/assets').createSync(recursive: true); + for (final file in ['board.png', 'piece.png', 'avatar.png']) { + File('${root.path}/assets/$file').writeAsBytesSync(_pngBytes); + } + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return GamePackage.file( + rootPath: root.path, + manifest: GamePackageManifest( + gameId: 'test', + name: 'Test', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: const { + 'board': GameResource( + type: GameResourceType.image, + path: 'assets/board.png', + preload: GameResourcePreload.lazy, + group: 'board', + ), + 'piece': GameResource( + type: GameResourceType.image, + path: 'assets/piece.png', + preload: GameResourcePreload.lazy, + group: 'board', + ), + 'avatar': GameResource( + type: GameResourceType.image, + path: 'assets/avatar.png', + preload: GameResourcePreload.lazy, + group: 'hud', + ), + }, + ), + ); +} + +class _CountingPackage extends GamePackage { + _CountingPackage(GamePackage package) + : _releaseReads = async.Completer(), + super.file(rootPath: package.rootPath, manifest: package.manifest); + + final async.Completer _releaseReads; + int readCount = 0; + + void releaseReads() { + if (!_releaseReads.isCompleted) { + _releaseReads.complete(); + } + } + + @override + Future readBytes(String relativeOrAbsolutePath) async { + readCount++; + await _releaseReads.future; + return ByteData.sublistView(Uint8List(0)); + } +} diff --git a/test/runtime/resources/resource_load_limiter_test.dart b/test/runtime/resources/resource_load_limiter_test.dart new file mode 100644 index 0000000..4e1fbe0 --- /dev/null +++ b/test/runtime/resources/resource_load_limiter_test.dart @@ -0,0 +1,62 @@ +import 'dart:async' as async; + +import 'package:flame_lua_runtime/runtime/resources/resource_load_limiter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ResourceLoadLimiter', () { + test('limits concurrent tasks', () async { + final limiter = ResourceLoadLimiter(2); + var active = 0; + var maxActive = 0; + + Future task(int value) async { + active++; + if (active > maxActive) { + maxActive = active; + } + await Future.delayed(const Duration(milliseconds: 5)); + active--; + return value; + } + + final results = await Future.wait([ + limiter.run(() => task(1)), + limiter.run(() => task(2)), + limiter.run(() => task(3)), + limiter.run(() => task(4)), + ]); + + expect(results, [1, 2, 3, 4]); + expect(maxActive, 2); + expect(limiter.activeCount, 0); + expect(limiter.pendingCount, 0); + }); + + test('clearPending cancels queued tasks', () async { + final limiter = ResourceLoadLimiter(1); + final hold = async.Completer(); + + final first = limiter.run(() => hold.future); + final second = limiter.run(() async => 2); + + expect(limiter.activeCount, 1); + expect(limiter.pendingCount, 1); + + final secondExpectation = expectLater( + second, + throwsA(isA()), + ); + + limiter.clearPending(); + hold.complete(); + + await first; + await secondExpectation; + }); + + test('rejects invalid concurrency', () { + expect(() => ResourceLoadLimiter(0), throwsArgumentError); + }); + }); +} diff --git a/test/runtime/scripting/lua_dardo_script_engine_test.dart b/test/runtime/scripting/lua_dardo_script_engine_test.dart new file mode 100644 index 0000000..94837ae --- /dev/null +++ b/test/runtime/scripting/lua_dardo_script_engine_test.dart @@ -0,0 +1,962 @@ +import 'dart:io'; + +import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; +import 'package:flame_lua_runtime/runtime/packages/game_package_manifest.dart'; +import 'package:flame_lua_runtime/runtime/models/runtime_event.dart'; +import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; +import 'package:flame_lua_runtime/runtime/scripting/lua_dardo_script_engine.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _loadExamplePackage(String gameId) async { + final root = 'example/assets/games/$gameId'; + final manifest = await File('$root/manifest.json').readAsString(); + return GamePackage.file( + rootPath: root, + manifest: GamePackageManifest.fromJsonString(manifest), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LuaDardoScriptEngine runtime.import', () { + test('loads bundled Ludo module graph', () async { + final package = await _loadExamplePackage('ludo'); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue); + final diff = engine.init({'runtimeApiVersion': 1}); + expect(diff.render.creates, isNotEmpty); + expect(diff.ui.creates, isNotEmpty); + expect( + diff.render.creates.map((node) => node.id), + contains('board_panel'), + ); + expect(diff.ui.creates.map((node) => node.id), contains('dice_button')); + final diceButton = diff.ui.creates.singleWhere( + (node) => node.id == 'dice_button', + ); + expect(diceButton.parent, 'top_bar'); + expect(diceButton.x, 540); + expect(diceButton.y, 14); + expect(diceButton.onTap, 'roll_dice'); + + final eventDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'dice_button', + handler: 'roll_dice', + ), + ); + final sound = eventDiff.commands.single; + expect(sound.type, RuntimeCommandType.playSound); + expect(sound.payload['asset'], 'dice'); + }); + + test('loads bundled Ludo with English localization context', () async { + final package = await _loadExamplePackage('ludo'); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + final context = { + 'runtimeApiVersion': 1, + 'locale': { + 'requested': 'en-US', + 'resolved': 'en', + 'default': 'zh-Hans', + 'supported': ['zh-Hans', 'en'], + 'languageCode': 'en', + 'countryCode': 'US', + }, + }; + expect(engine.smokeTest(context), isTrue); + final diff = engine.init(context); + + expect( + diff.render.creates + .singleWhere((node) => node.id == 'board_title') + .text, + 'Lua Ludo', + ); + expect( + diff.ui.creates.singleWhere((node) => node.id == 'dice_button').text, + 'Roll', + ); + expect( + diff.ui.creates.singleWhere((node) => node.id == 'turn_text').text, + 'Current player: Red', + ); + }); + + test('loads bundled Flight module graph and basic dice flow', () async { + final package = await _loadExamplePackage('flight'); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue); + final diff = engine.init({'runtimeApiVersion': 1}); + expect( + diff.render.creates.map((node) => node.id), + contains('board_panel'), + ); + expect(diff.render.creates.map((node) => node.id), contains('red_1')); + expect(diff.ui.creates.map((node) => node.id), contains('dice_button')); + + final rollDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'dice_button', + handler: 'roll_dice', + ), + ); + expect( + rollDiff.render.updates.map((update) => update.id), + contains('red_1'), + ); + + final moveDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'red_1', + handler: 'piece_tap', + ), + ); + expect(moveDiff.commands.single.type, RuntimeCommandType.movePath); + expect(moveDiff.commands.single.target, 'red_1'); + }); + + test( + 'loads bundled Template package as minimal integration starter', + () async { + final package = await _loadExamplePackage('template'); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue); + final diff = engine.init({ + 'runtimeApiVersion': 1, + 'gameId': 'template', + }); + expect( + diff.render.creates.map((node) => node.id), + contains('template_bg'), + ); + expect( + diff.render.creates.map((node) => node.id), + contains('template_start'), + ); + + final tapDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'template_start', + handler: 'template_start', + ), + ); + expect( + tapDiff.ui.updates.map((update) => update.id), + containsAll([ + 'template_start', + 'template_counter', + 'template_status', + ]), + ); + expect(tapDiff.commands.single.type, RuntimeCommandType.toast); + }, + ); + + test('loads bundled Showcase module graph and command examples', () async { + final package = await _loadExamplePackage('showcase'); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue); + final diff = engine.init({'runtimeApiVersion': 1}); + final nodeIds = diff.render.creates.map((node) => node.id); + expect(nodeIds, contains('example_list_panel')); + expect(nodeIds, contains('example_nodes')); + expect(nodeIds, contains('example_text_demo')); + expect(nodeIds, contains('example_buttons')); + expect(nodeIds, contains('example_button_images')); + expect(nodeIds, contains('example_sprites')); + expect(nodeIds, contains('example_radio_group')); + expect(nodeIds, contains('example_list_view')); + expect(nodeIds, contains('example_layout_demo')); + expect(nodeIds, contains('example_commands')); + expect(nodeIds, contains('example_i18n')); + expect(nodeIds, contains('example_responsive')); + expect(nodeIds, contains('detail_panel')); + expect(nodeIds, contains('detail_action_1')); + expect(nodeIds, contains('sample_rect')); + expect(nodeIds, contains('sample_circle')); + expect(nodeIds, contains('sample_image_node')); + expect(nodeIds, contains('sample_sprite_node')); + expect(nodeIds, contains('sample_progress')); + expect(nodeIds, contains('text_plain_title')); + expect(nodeIds, contains('button_primary')); + expect(nodeIds, contains('image_button_normal')); + expect(nodeIds, contains('image_button_toggle')); + expect(nodeIds, contains('image_button_disabled')); + expect(nodeIds, contains('sprite_sprite_demo')); + expect(nodeIds, contains('radio_value_text')); + expect(nodeIds, contains('list_row_1')); + expect(nodeIds, contains('particle_burst')); + expect(nodeIds, contains('particle_trail')); + expect(nodeIds, contains('particle_snow')); + expect(nodeIds, contains('layout_chip_1')); + expect(nodeIds, contains('layout_chip_4')); + final imageButton = diff.render.creates.singleWhere( + (node) => node.id == 'image_button_normal', + ); + expect(imageButton.asset, 'button_normal'); + expect(imageButton.pressedAsset, 'button_pressed'); + expect(imageButton.disabledAsset, 'button_disabled'); + final disabledImageButton = diff.render.creates.singleWhere( + (node) => node.id == 'image_button_disabled', + ); + expect(disabledImageButton.interactive, isFalse); + expect(diff.commands, isEmpty); + + final imageButtonSelectDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_button_images', + handler: 'select_example', + ), + ); + expect( + imageButtonSelectDiff.ui.updates.map((update) => update.id), + containsAll(['detail_title', 'detail_code', 'image_button_normal']), + ); + final imageButtonToggleDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_2', + handler: 'demo_button_image_toggle', + ), + ); + final toggleUpdate = imageButtonToggleDiff.ui.updates.singleWhere( + (update) => update.id == 'image_button_toggle', + ); + expect(toggleUpdate.props, containsPair('interactive', false)); + + final selectDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_commands', + handler: 'select_example', + ), + ); + expect( + selectDiff.ui.updates.map((update) => update.id), + containsAll(['detail_title', 'detail_code', 'detail_action_1']), + ); + + final paramsDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_tab_params', + handler: 'detail_tab_params', + ), + ); + final detailCodeUpdate = paramsDiff.ui.updates.firstWhere( + (update) => update.id == 'detail_code', + ); + final paramsText = detailCodeUpdate.props['text']; + expect(paramsText, contains('参数说明')); + expect( + paramsDiff.ui.updates + .firstWhere( + (update) => + update.id == 'detail_code' && + update.props['textAlign'] != null, + ) + .props['textAlign'], + 'left', + ); + expect( + paramsDiff.ui.updates + .firstWhere((update) => update.id == 'detail_tab_params') + .props['onTap'], + 'detail_tab_params', + ); + + final listPickDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'list_row_3', + handler: 'demo_list_pick_3', + ), + ); + expect( + listPickDiff.ui.updates + .firstWhere((update) => update.id == 'list_panel') + .props['scrollX'], + 0, + ); + expect( + listPickDiff.ui.updates + .firstWhere((update) => update.id == 'list_row_3') + .props['x'], + 8, + ); + expect( + listPickDiff.ui.updates + .firstWhere((update) => update.id == 'list_row_3_text') + .props['textAlign'], + 'left', + ); + + final selectLayoutDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_layout_demo', + handler: 'select_example', + ), + ); + expect( + selectLayoutDiff.ui.updates + .firstWhere((update) => update.id == 'detail_action_3') + .props['onTap'], + 'demo_layout_box', + ); + final boxLayoutDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_3', + handler: 'demo_layout_box', + ), + ); + expect( + boxLayoutDiff.ui.updates + .firstWhere((update) => update.id == 'layout_chip_3') + .props, + containsPair('y', 90), + ); + expect( + boxLayoutDiff.ui.updates + .firstWhere((update) => update.id == 'layout_label') + .props['text'], + contains('2 行 × 2 列'), + ); + + engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_particles', + handler: 'select_example', + ), + ); + final particleParamsDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_tab_params', + handler: 'detail_tab_params', + ), + ); + expect( + particleParamsDiff.ui.updates + .firstWhere( + (update) => + update.id == 'detail_code' && update.props['height'] != null, + ) + .props['height'], + greaterThan(400), + ); + expect( + particleParamsDiff.ui.updates + .firstWhere((update) => update.id == 'code_panel') + .props['contentHeight'], + greaterThan(450), + ); + + final animDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_1', + handler: 'demo_anim', + ), + ); + expect(animDiff.commands.single.type, RuntimeCommandType.sequence); + expect(animDiff.commands.single.payload['commands'], isNotEmpty); + + final dialogDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_1', + handler: 'demo_dialog', + ), + ); + expect( + dialogDiff.ui.creates.map((node) => node.id), + contains('sample_dialog'), + ); + + final soundDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_1', + handler: 'demo_sound', + ), + ); + expect(soundDiff.commands.single.type, RuntimeCommandType.playSound); + expect(soundDiff.commands.single.payload['asset'], 'click'); + + final textSelectDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_text_demo', + handler: 'select_example', + ), + ); + expect( + textSelectDiff.ui.updates.map((update) => update.id), + containsAll(['text_plain_title', 'text_rich_note']), + ); + + final textStyleDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_2', + handler: 'demo_text_style', + ), + ); + expect( + textStyleDiff.ui.updates.map((update) => update.id), + containsAll(['text_plain_title', 'text_style_badge']), + ); + + final buttonSelectDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_buttons', + handler: 'select_example', + ), + ); + expect( + buttonSelectDiff.ui.updates.map((update) => update.id), + containsAll(['button_primary', 'button_state_text']), + ); + + final buttonToggleDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_2', + handler: 'demo_button_toggle', + ), + ); + expect( + buttonToggleDiff.ui.updates.map((update) => update.id), + containsAll(['button_primary', 'button_state_text']), + ); + + final spriteDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_1', + handler: 'demo_sprite_anim', + ), + ); + expect(spriteDiff.commands.single.type, RuntimeCommandType.parallel); + + final radioSelectDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_radio_group', + handler: 'select_example', + ), + ); + expect( + radioSelectDiff.ui.updates.map((update) => update.id), + containsAll(['radio_audio_dot', 'radio_value_text']), + ); + + final radioDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_2', + handler: 'demo_radio_spine', + ), + ); + expect( + radioDiff.ui.updates.map((update) => update.id), + containsAll(['radio_spine_dot', 'radio_value_text']), + ); + + final listSelectDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_list_view', + handler: 'select_example', + ), + ); + expect( + listSelectDiff.ui.updates.map((update) => update.id), + containsAll(['list_row_1', 'list_value_text']), + ); + + final listDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_2', + handler: 'demo_list_next', + ), + ); + expect( + listDiff.ui.updates.map((update) => update.id), + containsAll(['list_row_1', 'list_row_2', 'list_value_text']), + ); + + final layoutDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_2', + handler: 'demo_layout_column', + ), + ); + expect( + layoutDiff.ui.updates.map((update) => update.id), + containsAll(['layout_chip_1', 'layout_chip_2', 'layout_label']), + ); + + final i18nDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'example_i18n', + handler: 'select_example', + ), + ); + expect( + i18nDiff.ui.updates + .singleWhere((update) => update.id == 'detail_title') + .props['text'], + 'Lua 多语言 Showcase', + ); + + final localeDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_1', + handler: 'demo_i18n_toggle', + ), + ); + expect( + localeDiff.ui.updates + .singleWhere((update) => update.id == 'detail_title') + .props['text'], + 'Lua-owned localization', + ); + expect(localeDiff.commands.single.type, RuntimeCommandType.toast); + + final responsiveDiff = engine.dispatchEvent( + const RuntimeEvent( + type: RuntimeEventType.tap, + target: 'detail_action_1', + handler: 'demo_responsive_phone', + ), + ); + expect( + responsiveDiff.ui.updates.map((update) => update.id), + containsAll(['responsive_info', 'responsive_device']), + ); + }); + + test( + 'loads manifest-declared modules and caches returned values', + () async { + final package = await _createPackage( + mainScript: ''' +local theme_a = runtime.import("theme") +local theme_b = runtime.import("theme") + +function smoke_test(ctx) + return theme_a == theme_b and load_count == 1 +end + +function init(ctx) + return { + ui = { + creates = { + { id = "title", type = "text", text = theme_a.title } + } + } + } +end + +function on_event(event) + return {} +end +''', + modules: { + 'theme': ''' +load_count = (load_count or 0) + 1 +return { title = "Imported Theme" } +''', + }, + ); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue); + final diff = engine.init({'runtimeApiVersion': 1}); + expect(diff.ui.creates.single.text, 'Imported Theme'); + }, + ); + + test('runtime_ui supports table-style component options', () async { + final runtimeUi = File( + 'assets/runtime/lua/runtime_ui.lua', + ).readAsStringSync(); + final package = await _createPackage( + mainScript: ''' +local ui = runtime.import("runtime_ui") + +function smoke_test(ctx) + return ui.rect ~= nil and ui.button ~= nil +end + +function init(ctx) + return { + ui = { + creates = { + ui.rect("card", { x = 10, y = 20, w = 120, h = 44, radius = 8 }), + ui.circle("dot", { x = 5, y = 6, size = 18, color = "#ffffffff" }), + ui.button("ok", { text = "OK", x = 1, y = 2, w = 60, h = 24, handler = "submit", asset = "button_normal", pressedAsset = "button_pressed", disabledAsset = "button_disabled" }) + }, + updates = { + ui.update("card", { w = 140, h = 48, onClick = "tap_card" }) + } + } + } +end + +function on_event(event) + return {} +end +''', + modules: {'runtime_ui': runtimeUi}, + ); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue); + final diff = engine.init({'runtimeApiVersion': 1}); + final card = diff.ui.creates.singleWhere((node) => node.id == 'card'); + expect(card.width, 120); + expect(card.height, 44); + final dot = diff.ui.creates.singleWhere((node) => node.id == 'dot'); + expect(dot.width, 18); + expect(dot.height, 18); + final button = diff.ui.creates.singleWhere((node) => node.id == 'ok'); + expect(button.onTap, 'submit'); + expect(button.width, 60); + expect(button.asset, 'button_normal'); + expect(button.pressedAsset, 'button_pressed'); + expect(button.disabledAsset, 'button_disabled'); + final update = diff.ui.updates.single; + expect(update.props, containsPair('width', 140)); + expect(update.props, containsPair('height', 48)); + expect(update.props, containsPair('onTap', 'tap_card')); + expect(update.props.containsKey('w'), isFalse); + expect(update.props.containsKey('h'), isFalse); + expect(update.props.containsKey('onClick'), isFalse); + }); + + test('runtime_widgets exposes common lightweight components', () async { + final runtimeUi = File( + 'assets/runtime/lua/runtime_ui.lua', + ).readAsStringSync(); + final runtimeWidgets = File( + 'assets/runtime/lua/runtime_widgets.lua', + ).readAsStringSync(); + final package = await _createPackage( + mainScript: ''' +local widgets = runtime.import("runtime_widgets") + +widgets.configure({ + primary = "#ff010203", + secondary = "#ff040506", + surface = "#ff070809", + surfaceAlt = "#ff0a0b0c", + text = "#ff0d0e0f", + muted = "#ff101112", + transparent = "#00000000" +}) + +function smoke_test(ctx) + return widgets.label ~= nil and widgets.pill ~= nil and widgets.text_button ~= nil and widgets.tabs ~= nil and widgets.list_item ~= nil and widgets.action_row ~= nil and widgets.panel_header ~= nil +end + +function init(ctx) + local pill = widgets.pill("status", { + text = "Ready", + x = 10, + y = 20, + w = 72, + h = 24, + color = "#ff000000", + textStyle = { color = "#ffffffff" } + }) + local tabs = widgets.tabs("tabs", { + { key = "code", text = "Code", handler = "show_code" }, + { key = "params", text = "Params", handler = "show_params" } + }, { + x = 8, + y = 86, + itemWidth = 70, + itemHeight = 22, + selected = "params", + parent = "toolbar", + layer = 9 + }) + local actions = widgets.action_row("actions", { + { text = "Run", handler = "run" }, + { text = "Stop", handler = "stop", visible = false } + }, { + x = 10, + y = 142, + width = 170, + itemHeight = 24, + gap = 10, + layer = 7 + }) + local header = widgets.panel_header("header", { + eyebrow = "Runtime", + title = "Panel", + summary = "Composable header", + x = 6, + y = 172, + w = 180, + parent = "card", + layer = 6 + }) + return { + ui = { + creates = { + widgets.section_title("title", { text = "Overview", x = 4, y = 6, w = 160, h = 28 }), + pill[1], + pill[2], + widgets.text_button("more", { text = "More", x = 10, y = 52, w = 80, h = 28, handler = "open", variant = "ghost" }), + widgets.list_item("row", { text = "Row", x = 10, y = 112, w = 100, h = 24, handler = "select_row", selected = true }), + tabs[1], + tabs[2], + actions[1], + actions[2], + header[1], + header[2], + header[3] + } + } + } +end + +function on_event(event) + return {} +end +''', + modules: {'runtime_ui': runtimeUi, 'runtime_widgets': runtimeWidgets}, + ); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + expect(engine.smokeTest({'runtimeApiVersion': 1}), isTrue); + final diff = engine.init({'runtimeApiVersion': 1}); + expect( + diff.ui.creates.map((node) => node.id), + containsAll([ + 'title', + 'status', + 'status_text', + 'more', + 'row', + 'tabs_code', + 'tabs_params', + 'actions_1', + 'actions_2', + 'header_eyebrow', + 'header_title', + 'header_summary', + ]), + ); + final title = diff.ui.creates.singleWhere((node) => node.id == 'title'); + expect(title.fontSize, 18); + final pillText = diff.ui.creates.singleWhere( + (node) => node.id == 'status_text', + ); + expect(pillText.parent, 'status'); + final more = diff.ui.creates.singleWhere((node) => node.id == 'more'); + expect(more.onTap, 'open'); + expect(more.color?.toARGB32(), 0x00000000); + final row = diff.ui.creates.singleWhere((node) => node.id == 'row'); + expect(row.onTap, 'select_row'); + expect(row.color?.toARGB32(), 0xff010203); + final codeTab = diff.ui.creates.singleWhere( + (node) => node.id == 'tabs_code', + ); + expect(codeTab.parent, 'toolbar'); + expect(codeTab.x, 8); + expect(codeTab.y, 86); + expect(codeTab.onTap, 'show_code'); + expect(codeTab.color?.toARGB32(), 0xff070809); + final paramsTab = diff.ui.creates.singleWhere( + (node) => node.id == 'tabs_params', + ); + expect(paramsTab.x, 84); + expect(paramsTab.onTap, 'show_params'); + expect(paramsTab.color?.toARGB32(), 0xff010203); + final action1 = diff.ui.creates.singleWhere( + (node) => node.id == 'actions_1', + ); + final action2 = diff.ui.creates.singleWhere( + (node) => node.id == 'actions_2', + ); + expect(action1.width, 80); + expect(action1.onTap, 'run'); + expect(action1.layer, 7); + expect(action2.visible, isFalse); + expect(action2.onTap, 'noop'); + final eyebrow = diff.ui.creates.singleWhere( + (node) => node.id == 'header_eyebrow', + ); + final headerTitle = diff.ui.creates.singleWhere( + (node) => node.id == 'header_title', + ); + final summary = diff.ui.creates.singleWhere( + (node) => node.id == 'header_summary', + ); + expect(eyebrow.parent, 'card'); + expect(eyebrow.y, 172); + expect(headerTitle.y, 196); + expect(summary.y, 228); + }); + + test('layout supports spacing and grid aliases', () async { + final runtimeUi = File( + 'assets/runtime/lua/runtime_ui.lua', + ).readAsStringSync(); + final layout = File('assets/runtime/lua/layout.lua').readAsStringSync(); + final package = await _createPackage( + mainScript: ''' +local ui = runtime.import("runtime_ui") +local layout = runtime.import("layout") + +function smoke_test(ctx) + return layout.box ~= nil and layout.item ~= nil +end + +function init(ctx) + local nodes = layout.box("panel", { + layout.item(ui.rect("a", { w = 20, h = 10 }), { mx = 2, my = 1 }), + ui.rect("b", { w = 20, h = 10 }), + ui.rect("c", { w = 20, h = 10 }) + }, { + x = 10, + y = 20, + cols = 2, + cellW = 30, + cellH = 20, + gap = 5, + padding = 3, + align = "center", + valign = "center" + }) + return { ui = { creates = nodes } } +end + +function on_event(event) + return {} +end +''', + modules: {'runtime_ui': runtimeUi, 'layout': layout}, + ); + final engine = LuaDardoScriptEngine(); + + await engine.loadPackage(package); + + final diff = engine.init({'runtimeApiVersion': 1}); + final a = diff.ui.creates.singleWhere((node) => node.id == 'a'); + final b = diff.ui.creates.singleWhere((node) => node.id == 'b'); + final c = diff.ui.creates.singleWhere((node) => node.id == 'c'); + expect(a.parent, 'panel'); + expect(a.x, 18); + expect(a.y, 28); + expect(b.x, 53); + expect(b.y, 28); + expect(c.x, 18); + expect(c.y, 53); + }); + + test('rejects undeclared module imports', () async { + final package = await _createPackage( + mainScript: ''' +runtime.import("missing") +function smoke_test(ctx) return true end +function init(ctx) return {} end +function on_event(event) return {} end +''', + ); + final engine = LuaDardoScriptEngine(); + + await expectLater(engine.loadPackage(package), throwsStateError); + }); + + test('rejects unsafe module names', () async { + final package = await _createPackage( + mainScript: ''' +runtime.import("../theme") +function smoke_test(ctx) return true end +function init(ctx) return {} end +function on_event(event) return {} end +''', + modules: {'theme': 'return {}'}, + ); + final engine = LuaDardoScriptEngine(); + + await expectLater(engine.loadPackage(package), throwsStateError); + }); + }); +} + +Future _createPackage({ + required String mainScript, + Map modules = const {}, +}) async { + final root = await Directory.systemTemp.createTemp('lua_engine_test_'); + Directory('${root.path}/scripts').createSync(recursive: true); + File('${root.path}/scripts/main.lua').writeAsStringSync(mainScript); + + final manifestModules = {}; + for (final entry in modules.entries) { + final path = 'scripts/${entry.key}.lua'; + File('${root.path}/$path').writeAsStringSync(entry.value); + manifestModules[entry.key] = path; + } + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + return GamePackage.file( + rootPath: root.path, + manifest: GamePackageManifest( + gameId: 'test', + name: 'Test', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + modules: manifestModules, + ), + ); +} diff --git a/tool/generate_lua_runtime_defs.dart b/tool/generate_lua_runtime_defs.dart new file mode 100644 index 0000000..1f49913 --- /dev/null +++ b/tool/generate_lua_runtime_defs.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +const _commonPath = 'tool/lua_runtime_defs_common.lua'; +const _runtimeMarker = 'runtime = runtime'; +const _gamesRoot = 'assets/games'; + +void main(List args) { + final checkOnly = args.contains('--check'); + final commonFile = File(_commonPath); + if (!commonFile.existsSync()) { + stderr.writeln('Missing common Lua runtime defs template: $_commonPath'); + exitCode = 1; + return; + } + + final common = _normalizeCommon(commonFile.readAsStringSync()); + var changed = false; + final targets = _runtimeDefTargets(); + if (targets.isEmpty) { + stderr.writeln('No Lua runtime defs targets found under $_gamesRoot'); + exitCode = 1; + return; + } + + for (final target in targets) { + final targetPath = target.path; + final current = target.existsSync() ? target.readAsStringSync() : ''; + final suffix = current.isEmpty + ? '' + : _gameSpecificSuffix(current, targetPath); + final generated = suffix.isEmpty ? common : '$common\n$suffix'; + + if (current == generated) { + stdout.writeln('up to date: $targetPath'); + continue; + } + + changed = true; + if (checkOnly) { + stderr.writeln('out of date: $targetPath'); + } else { + target.writeAsStringSync(generated); + stdout.writeln('generated: $targetPath'); + } + } + + if (checkOnly && changed) { + stderr.writeln( + 'Lua runtime defs are out of date. Run: dart run tool/generate_lua_runtime_defs.dart', + ); + exitCode = 1; + } +} + +List _runtimeDefTargets() { + final root = Directory(_gamesRoot); + if (!root.existsSync()) { + return const []; + } + final targets = []; + for (final entity in root.listSync()) { + if (entity is! Directory) { + continue; + } + final scripts = Directory('${entity.path}/scripts'); + if (!scripts.existsSync()) { + continue; + } + targets.add(File('${scripts.path}/runtime_defs.lua')); + } + targets.sort((a, b) => a.path.compareTo(b.path)); + return targets; +} + +String _normalizeCommon(String source) { + final text = source.replaceAll('\r\n', '\n').trimRight(); + if (!text.contains(_runtimeMarker)) { + throw const FormatException( + 'Common template must contain runtime = runtime', + ); + } + return '$text\n'; +} + +String _gameSpecificSuffix(String source, String targetPath) { + final text = source.replaceAll('\r\n', '\n'); + final index = text.indexOf(_runtimeMarker); + if (index < 0) { + throw FormatException('$targetPath must contain $_runtimeMarker'); + } + final suffixStart = index + _runtimeMarker.length; + return text.substring(suffixStart).trimLeft(); +} diff --git a/tool/lua_runtime_defs_common.lua b/tool/lua_runtime_defs_common.lua new file mode 100644 index 0000000..c5038c9 --- /dev/null +++ b/tool/lua_runtime_defs_common.lua @@ -0,0 +1,557 @@ +---@meta +--- COMMON RUNTIME TYPES SECTION. +--- Source of truth: tool/lua_runtime_defs_common.lua +--- After editing this common section, run: +--- dart run tool/generate_lua_runtime_defs.dart + + +---@alias RuntimeNodeType +---| 'panel' +---| 'button' +---| 'text' +---| 'circle' +---| 'rect' +---| 'line' +---| 'progress' +---| 'listView' +---| 'sprite' +---| 'image' +---| 'spine' +---| 'particle' + +---@alias RuntimeAnchor +---| 'center' +---| 'topLeft' +---| 'topRight' +---| 'bottomLeft' +---| 'bottomRight' + +---@alias RuntimeTextAlign +---| 'left' +---| 'center' +---| 'right' + +---@alias RuntimeParticlePreset +---| 'burst' +---| 'trail' +---| 'snow' +---| 'confetti' + +---@alias RuntimeCommandType +---| 'move_path' +---| 'move_to' +---| 'fade_to' +---| 'scale_to' +---| 'rotate_to' +---| 'remove_node' +---| 'sequence' +---| 'parallel' +---| 'delay' +---| 'toast' +---| 'play_sound' +---| 'play_bgm' +---| 'pause_bgm' +---| 'resume_bgm' +---| 'stop_bgm' +---| 'preload_resources' +---| 'evict_resources' +---| 'cancel_commands' +---| 'play_spine_animation' +---| 'copy_text' + +---@alias RuntimeEventType +---| 'tap' +---| 'animation_done' +---| 'resize' +---| 'scroll' + +---@alias RuntimeScaleMode +---| 'fit' +---| 'fill' +---| 'stretch' +---| 'none' + +---@alias RuntimeLayoutAlign +---| 'start' +---| 'center' +---| 'end' + +---@alias RuntimeButtonVariant +---| 'primary' +---| 'secondary' +---| 'ghost' + +---@class (exact) RuntimeNode +---@field id string +---@field type RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---@class (exact) RuntimeNodeProps +---@field type? RuntimeNodeType +---@field parent? string +---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field pressedAsset? string Button pressed-state image asset key. +---@field disabledAsset? string Button disabled-state image asset key. +---@field animation? string +---@field skin? string +---@field loop? boolean +---@field text? string +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field paddingLeft? number +---@field paddingTop? number +---@field paddingRight? number +---@field paddingBottom? number +---@field anchor? RuntimeAnchor +---@field layer? integer +---@field visible? boolean +---@field alpha? number +---@field scale? number +---@field rotation? number +---@field color? string +---@field fontSize? number +---@field textAlign? RuntimeTextAlign +---@field radius? number +---@field strokeWidth? number +---@field value? number +---@field scrollX? number +---@field scrollY? number +---@field contentWidth? number +---@field contentHeight? number +---@field virtualized? boolean +---@field cacheExtent? number +---@field inertia? boolean +---@field scrollbarThumbColor? string +---@field scrollbarTrackColor? string +---@field scrollbarThickness? number +---@field scrollbarVisible? boolean +---@field interactive? boolean +---@field onTap? string +---@field onScroll? string +---@field preset? RuntimeParticlePreset +---@field count? integer +---@field duration? number +---@field speedMin? number +---@field speedMax? number +---@field gravityX? number +---@field gravityY? number +---@field spread? number +---@field colorTo? string +---@field radiusTo? number +---@field autoRemove? boolean +---@field fadeOut? boolean + +---Helper-only fields accepted by runtime_ui/runtime_widgets. They are normalized +---before the node/update crosses the Dart Runtime protocol boundary. +---@class RuntimeNodeInit: RuntimeNodeProps +---@field w? number Alias for width. +---@field h? number Alias for height. +---@field size? number Alias for both width and height. +---@field handler? string Alias for onTap. +---@field onClick? string Alias for onTap. + +---@class (exact) RuntimeNodeUpdate +---@field id string +---@field props RuntimeNodeProps + +---@class (exact) RuntimeNodeRemove +---@field id string + +---@class (exact) RuntimeDiffSection +---@field creates? RuntimeNode[] +---@field updates? RuntimeNodeUpdate[] +---@field removes? (string|RuntimeNodeRemove)[] + +---@class (exact) RuntimeDiff +---@field render? RuntimeDiffSection +---@field ui? RuntimeDiffSection +---@field commands? RuntimeCommand[] + +---@class (exact) RuntimeEvent +---@field type RuntimeEventType|string +---@field target? string +---@field handler? string +---@field x? number +---@field y? number +---@field data? table + +---@class (exact) RuntimeCommand +---@field type RuntimeCommandType +---@field target? string +---@field scope? string +---@field id? string +---@field group? string +---@field commandGroup? string +---@field onComplete? string +---@field duration? number +---@field commands? RuntimeCommand[] +---@field path? RuntimePoint[] +---@field x? number +---@field y? number +---@field alpha? number +---@field scale? number +---@field angle? number +---@field text? string +---@field message? string +---@field asset? string +---@field name? string +---@field volume? number +---@field channel? string +---@field loop? boolean +---@field failOnError? boolean +---@field animation? string +---@field track? integer +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeCommandOpts +---@field id? string +---@field group? string +---@field commandGroup? string +---@field scope? string +---@field onComplete? string +---@field duration? number + +---@class (exact) RuntimeAudioCommandOpts: RuntimeCommandOpts +---@field volume? number +---@field name? string + +---@class (exact) RuntimeBgmCommandOpts: RuntimeAudioCommandOpts +---@field channel? string +---@field loop? boolean + +---@class (exact) RuntimeSpineCommandOpts: RuntimeCommandOpts +---@field track? integer +---@field loop? boolean +---@field queue? boolean +---@field delay? number + +---@class (exact) RuntimeResourceCommandOpts: RuntimeCommandOpts +---@field failOnError? boolean + +---@class (exact) RuntimePoint +---@field x number +---@field y number + +---@class (exact) RuntimeLocaleContext +---@field requested string +---@field resolved string +---@field default string +---@field supported string[] +---@field languageCode string +---@field scriptCode? string +---@field countryCode? string + +---@class (exact) RuntimeScreenContext +---@field width number +---@field height number + +---@class (exact) RuntimeDesignContext +---@field width number +---@field height number + +---@class (exact) RuntimeViewportContext +---@field x number +---@field y number +---@field width number +---@field height number +---@field scaleX number +---@field scaleY number +---@field scaleMode RuntimeScaleMode|string + +---@class (exact) RuntimeContext +---@field screen RuntimeScreenContext +---@field design RuntimeDesignContext +---@field viewport RuntimeViewportContext +---@field seed integer +---@field runtimeApiVersion integer +---@field gameId string +---@field gameVersion string +---@field locale? RuntimeLocaleContext + +---@class RuntimeUi +---@field style fun(base?: RuntimeNodeProps, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field with_parent fun(parent: string, opts?: RuntimeNodeProps): RuntimeNodeProps +---@field node fun(node_type: RuntimeNodeType, id: string, opts?: RuntimeNodeInit): RuntimeNode +---@field panel fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field rect fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field circle fun(id: string, x: number|RuntimeNodeInit, y?: number, size?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field line fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field particle fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field text fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field button fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field list_view fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field image fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field sprite fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field spine fun(id: string, asset: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, animation?: string, opts?: RuntimeNodeInit): RuntimeNode +---@field update fun(id: string, props: RuntimeNodeInit): RuntimeNodeUpdate +---@field text_update fun(id: string, text: string): RuntimeNodeUpdate +---@field visible_update fun(id: string, visible: boolean): RuntimeNodeUpdate +---@field alpha_update fun(id: string, alpha: number): RuntimeNodeUpdate +---@field scale_update fun(id: string, scale: number): RuntimeNodeUpdate +---@field position_update fun(id: string, x: number, y: number): RuntimeNodeUpdate +---@field size_update fun(id: string, width: number, height: number): RuntimeNodeUpdate +---@field transform_update fun(id: string, x: number, y: number, scale: number, rotation: number): RuntimeNodeUpdate +---@field batch_update fun(ids: string[], props: RuntimeNodeInit): RuntimeNodeUpdate[] +---@field append fun(nodes: RuntimeNode[], node: RuntimeNode): RuntimeNode[] +---@field append_all fun(nodes: RuntimeNode[], extra_nodes: RuntimeNode[]): RuntimeNode[] + +---@class (exact) RuntimeDialogButton +---@field id? string +---@field text string +---@field handler string +---@field color? string + +---@class (exact) RuntimeDialogOpts +---@field screenWidth? number +---@field screenHeight? number +---@field overlay? boolean +---@field overlayColor? string +---@field blockInput? boolean +---@field layer? integer +---@field color? string +---@field radius? number +---@field panelStyle? RuntimeNodeProps +---@field titleColor? string +---@field titleSize? number +---@field titleStyle? RuntimeNodeProps +---@field messageColor? string +---@field messageSize? number +---@field messageStyle? RuntimeNodeProps +---@field buttons? RuntimeDialogButton[] +---@field buttonGap? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeLabeledProgressOpts: RuntimeNodeInit +---@field labelHeight? number +---@field labelStyle? RuntimeNodeProps + +---@class RuntimePillOpts: RuntimeNodeInit +---@field panelStyle? RuntimeNodeProps +---@field textStyle? RuntimeNodeProps + +---@class RuntimeTextButtonOpts: RuntimeNodeInit +---@field variant? RuntimeButtonVariant + +---@class RuntimeListItemOpts: RuntimeTextButtonOpts +---@field selected? boolean +---@field activeColor? string +---@field inactiveColor? string + +---@class RuntimeTabItem +---@field id? string +---@field key? string +---@field text string +---@field handler? string +---@field selected? boolean + +---@class RuntimeTabsOpts: RuntimeNodeInit +---@field tabs? RuntimeTabItem[] +---@field selected? string +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field activeColor? string +---@field inactiveColor? string +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimeActionItem +---@field id? string +---@field text string +---@field handler? string +---@field visible? boolean +---@field color? string +---@field style? RuntimeNodeProps + +---@class RuntimeActionRowOpts: RuntimeNodeInit +---@field actions? RuntimeActionItem[] +---@field gap? number +---@field itemWidth? number +---@field itemHeight? number +---@field buttonStyle? RuntimeNodeProps + +---@class RuntimePanelHeaderOpts: RuntimeNodeInit +---@field eyebrow? string +---@field title string +---@field summary? string +---@field gap? number +---@field eyebrowId? string +---@field titleId? string +---@field summaryId? string +---@field eyebrowHeight? number +---@field titleHeight? number +---@field summaryHeight? number +---@field eyebrowStyle? RuntimeNodeProps +---@field titleStyle? RuntimeNodeProps +---@field summaryStyle? RuntimeNodeProps + +---@class RuntimeWidgetTheme +---@field primary? string +---@field secondary? string +---@field success? string +---@field overlay? string +---@field surface? string +---@field surfaceAlt? string +---@field card? string +---@field text? string +---@field muted? string +---@field progress? string +---@field transparent? string + +---@class RuntimeWidgets +---@field configure fun(tokens?: RuntimeWidgetTheme): RuntimeWidgets +---@field label fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field section_title fun(id: string, text: string|RuntimeNodeInit, x?: number, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field pill fun(id: string, text: string|RuntimePillOpts, x?: number, y?: number, width?: number, height?: number, opts?: RuntimePillOpts): RuntimeNode[] +---@field text_button fun(id: string, text: string|RuntimeTextButtonOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeTextButtonOpts): RuntimeNode +---@field list_item fun(id: string, text: string|RuntimeListItemOpts, x?: number, y?: number, width?: number, height?: number, handler?: string, opts?: RuntimeListItemOpts): RuntimeNode +---@field tabs fun(id: string, tabs: RuntimeTabItem[]|RuntimeTabsOpts, opts?: RuntimeTabsOpts): RuntimeNode[] +---@field action_row fun(id: string, actions: RuntimeActionItem[]|RuntimeActionRowOpts, opts?: RuntimeActionRowOpts): RuntimeNode[] +---@field panel_header fun(id: string, opts: RuntimePanelHeaderOpts): RuntimeNode[] +---@field overlay fun(id: string, width: number|RuntimeNodeInit, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field card fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field progress_bar fun(id: string, x: number|RuntimeNodeInit, y?: number, width?: number, height?: number, value?: number, opts?: RuntimeNodeInit): RuntimeNode +---@field labeled_progress fun(id: string, label: string, x: number, y: number, width: number, height: number, value: number, opts?: RuntimeLabeledProgressOpts): RuntimeNode[] +---@field button_row fun(parent: string, id: string, buttons: RuntimeDialogButton[], x: number, y: number, width: number, height: number, gap?: number, opts?: RuntimeNodeProps): RuntimeNode[] +---@field dialog fun(id: string, title: string, message: string, x: number, y: number, width: number, height: number, opts?: RuntimeDialogOpts): RuntimeNode[] + +---@class (exact) RuntimeLayoutItem +---@field node RuntimeNode +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLayoutItemOpts +---@field margin? number +---@field mx? number +---@field my? number +---@field ml? number +---@field mr? number +---@field mt? number +---@field mb? number +---@field marginLeft? number +---@field marginRight? number +---@field marginTop? number +---@field marginBottom? number + +---@class RuntimeLinearLayoutOpts +---@field x? number +---@field y? number +---@field width? number +---@field height? number +---@field gap? number +---@field align? RuntimeLayoutAlign +---@field padding? number +---@field paddingX? number +---@field paddingY? number +---@field px? number +---@field py? number +---@field paddingLeft? number +---@field paddingTop? number + +---@class RuntimeBoxLayoutOpts: RuntimeLinearLayoutOpts +---@field rows? integer +---@field columns? integer +---@field cols? integer +---@field cellWidth? number +---@field cellHeight? number +---@field cellW? number +---@field cellH? number +---@field gapX? number +---@field gapY? number +---@field valign? RuntimeLayoutAlign + +---@class RuntimeLayout +---@field item fun(node: RuntimeNode, opts?: RuntimeLayoutItemOpts): RuntimeLayoutItem +---@field local_position fun(origin: RuntimePoint, position: RuntimePoint): RuntimePoint +---@field row fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field column fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field stack fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeLinearLayoutOpts): RuntimeNode[] +---@field box fun(parent?: string, items: (RuntimeNode|RuntimeLayoutItem)[], opts?: RuntimeBoxLayoutOpts): RuntimeNode[] + +---@class RuntimeCommands +---@field toast fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field copy_text fun(text: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field delay fun(duration: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field sequence fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field parallel fun(items: RuntimeCommand[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_path fun(target: string, path: RuntimePoint[], opts?: RuntimeCommandOpts): RuntimeCommand +---@field move_to fun(target: string, x: number, y: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field fade_to fun(target: string, alpha: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field scale_to fun(target: string, scale: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field rotate_to fun(target: string, angle: number, opts?: RuntimeCommandOpts): RuntimeCommand +---@field remove_node fun(target: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field play_spine_animation fun(target: string, animation: string, opts?: RuntimeSpineCommandOpts): RuntimeCommand +---@field play_sound fun(asset: string, opts?: RuntimeAudioCommandOpts): RuntimeCommand +---@field play_bgm fun(asset: string, opts?: RuntimeBgmCommandOpts): RuntimeCommand +---@field pause_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field resume_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field stop_bgm fun(channel?: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field preload_group fun(group: string, opts?: RuntimeResourceCommandOpts): RuntimeCommand +---@field evict_group fun(group: string, opts?: RuntimeCommandOpts): RuntimeCommand +---@field cancel_id fun(id: string): RuntimeCommand +---@field cancel_group fun(group: string): RuntimeCommand +---@field cancel_scope fun(scope: string): RuntimeCommand + +---@class RuntimeImportApi +---@field import fun(moduleName: string): table + +---@type RuntimeImportApi +runtime = runtime