diff --git a/studio/app/core/project_session.py b/studio/app/core/project_session.py index e1ebf9c..b74ba44 100644 --- a/studio/app/core/project_session.py +++ b/studio/app/core/project_session.py @@ -18,6 +18,11 @@ *(f"LPT{i}" for i in range(1, 10)), } +PROJECT_TEMPLATE_DIRS = { + "basic": "basic", + "game-ui": "GameUIStarter", +} + @dataclass(slots=True) class ProjectInfo: root: Path @@ -74,13 +79,23 @@ def validate_project_name(name: str) -> str: def project_path(self, name: str) -> Path: return self.default_projects_root / self.validate_project_name(name) - def create_project(self, name: str, metadata: dict | None = None, sdk_metadata: dict | None = None) -> ProjectInfo: + def create_project( + self, + name: str, + metadata: dict | None = None, + sdk_metadata: dict | None = None, + template_id: str = "basic", + ) -> ProjectInfo: name = self.validate_project_name(name) destination = self.project_path(name) if destination.exists(): raise FileExistsError(destination) - template = self.engine_root / "templates" / "basic" + template_key = str(template_id or "basic").strip().lower() + template_dir = PROJECT_TEMPLATE_DIRS.get(template_key) + if not template_dir: + raise ValueError(f"Unknown project template: {template_id}") + template = self.engine_root / "templates" / template_dir if not template.is_dir(): raise FileNotFoundError(f"Project template not found: {template}") @@ -101,6 +116,7 @@ def create_project(self, name: str, metadata: dict | None = None, sdk_metadata: appid = payload.get("appid") payload.update(dict(metadata)) payload["name"] = name + payload["project_template"] = template_key if appid is not None: payload["appid"] = appid descriptor.write_text( @@ -111,8 +127,10 @@ def create_project(self, name: str, metadata: dict | None = None, sdk_metadata: if sdk_metadata: sdk_dir = destination / ".luas30" sdk_dir.mkdir(parents=True, exist_ok=True) + sdk_payload = dict(sdk_metadata) + sdk_payload["project_template"] = template_key (sdk_dir / "mre_sdk.json").write_text( - json.dumps(dict(sdk_metadata), indent=2, ensure_ascii=False) + "\n", + json.dumps(sdk_payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) diff --git a/studio/app/ui/mediatek_mre_dialog.py b/studio/app/ui/mediatek_mre_dialog.py index 6282256..54e8cab 100644 --- a/studio/app/ui/mediatek_mre_dialog.py +++ b/studio/app/ui/mediatek_mre_dialog.py @@ -30,6 +30,8 @@ class MediaTekProjectConfig: ram_label: str compat_profile: str mre_api: str + template_id: str + template_label: str def project_metadata(self) -> dict: return { @@ -48,6 +50,8 @@ def project_metadata(self) -> dict: "mre_api": self.mre_api, "single_vxp": True, "project_wizard": "mediatek-mre-sdk-v1", + "project_template": self.template_id, + "project_template_label": self.template_label, } def sdk_metadata(self) -> dict: @@ -71,9 +75,16 @@ def sdk_metadata(self) -> dict: "heap_label": self.ram_label, "compat_profile": self.compat_profile, "mre_api": self.mre_api, + "project_template": self.template_id, + "project_template_label": self.template_label, } +PROJECT_TEMPLATES = ( + ("basic", "Basic Starter · canvas + input tối giản"), + ("game-ui", "Game UI Starter · splash + menu + HUD + pause"), +) + RESOLUTIONS = ( ("240x320", "240x320 (QVGA - Chuẩn Nokia)", 240, 320), ("320x240", "320x240 (QVGA - Ngang)", 320, 240), @@ -230,6 +241,12 @@ def __init__( self.ram.addItem(label, value) self.ram.setCurrentIndex(self.ram.findData(1024)) + self.template = QComboBox() + self.template.setObjectName("MRECombo") + for template_id, label in PROJECT_TEMPLATES: + self.template.addItem(label, template_id) + self.template.setCurrentIndex(self.template.findData("game-ui")) + self.path_preview = QLabel() self.path_preview.setObjectName("MREPathPreview") self.path_preview.setWordWrap(True) @@ -242,23 +259,26 @@ def __init__( self._label(grid, "Nhà phát triển (VENDOR)", 2, 0, 1, 2) grid.addWidget(self.vendor, 3, 0, 1, 2) - self._label(grid, "Màn hình (Resolution)", 4, 0) - self._label(grid, "Chipset MediaTek", 4, 1) - grid.addWidget(self.resolution, 5, 0) - grid.addWidget(self.chipset, 5, 1) + self._label(grid, "Mẫu dự án (Template)", 4, 0, 1, 2) + grid.addWidget(self.template, 5, 0, 1, 2) + + self._label(grid, "Màn hình (Resolution)", 6, 0) + self._label(grid, "Chipset MediaTek", 6, 1) + grid.addWidget(self.resolution, 7, 0) + grid.addWidget(self.chipset, 7, 1) - self._label(grid, "Dung lượng Heap RAM cấp phát", 6, 0, 1, 2) - grid.addWidget(self.ram, 7, 0, 1, 2) + self._label(grid, "Dung lượng Heap RAM cấp phát", 8, 0, 1, 2) + grid.addWidget(self.ram, 9, 0, 1, 2) hint = QLabel( - "AppID được tạo tự động. MTK6260 mặc định dùng profile Nokia 225 / " - "S30+ native; đường dẫn MRE SDK dùng cấu hình chung trong Settings." + "Game UI Starter có sẵn splash, menu, HUD, pause và game-over. " + "AppID được tạo tự động; đường dẫn MRE SDK dùng cấu hình chung trong Settings." ) hint.setObjectName("MREDialogHint") hint.setWordWrap(True) - grid.addWidget(hint, 8, 0, 1, 2) + grid.addWidget(hint, 10, 0, 1, 2) - grid.addWidget(self.path_preview, 9, 0, 1, 2) + grid.addWidget(self.path_preview, 11, 0, 1, 2) root.addWidget(body) footer = QFrame() @@ -373,4 +393,6 @@ def configuration(self) -> MediaTekProjectConfig: ram_label=self.ram.currentText(), compat_profile=str(compat), mre_api=str(api), + template_id=str(self.template.currentData() or "basic"), + template_label=self.template.currentText(), ) diff --git a/studio/app/vxpui/main_window.py b/studio/app/vxpui/main_window.py index 6a025fc..525c23a 100644 --- a/studio/app/vxpui/main_window.py +++ b/studio/app/vxpui/main_window.py @@ -958,6 +958,7 @@ def create_project_from_template(self) -> None: name, metadata=config.project_metadata(), sdk_metadata=config.sdk_metadata(), + template_id=config.template_id, ) except FileExistsError as exc: NoticeDialog("Dự án đã tồn tại", f"Đã có dự án trùng tên:\n{exc}", self, error=True).exec() @@ -977,6 +978,7 @@ def create_project_from_template(self) -> None: f"Project created: {info.root}" + (f" | AppID {appid}" if appid else "") + f" | {config.chipset_id} | {config.screen_width}x{config.screen_height}" + + f" | {config.template_label}" ) def open_project_dialog(self) -> None: diff --git a/templates/GameUIStarter/.luas30/ui_design.json b/templates/GameUIStarter/.luas30/ui_design.json new file mode 100644 index 0000000..67fd8ec --- /dev/null +++ b/templates/GameUIStarter/.luas30/ui_design.json @@ -0,0 +1,36 @@ +{ + "format": "LuaS30 UI Design", + "version": 2, + "canvas": { + "width": 240, + "height": 320 + }, + "current": "menu", + "screens": [ + { + "id": "splash", + "name": "Splash", + "items": [] + }, + { + "id": "menu", + "name": "Main Menu", + "items": [] + }, + { + "id": "game", + "name": "Game HUD", + "items": [] + }, + { + "id": "pause", + "name": "Pause", + "items": [] + }, + { + "id": "gameover", + "name": "Game Over", + "items": [] + } + ] +} diff --git a/templates/GameUIStarter/README.md b/templates/GameUIStarter/README.md new file mode 100644 index 0000000..6f979e0 --- /dev/null +++ b/templates/GameUIStarter/README.md @@ -0,0 +1,51 @@ +# Game UI Starter + +Mẫu game nhẹ cho LuaS30 IDE / MediaTek MRE, ưu tiên màn hình 240×320 và thiết bị RAM thấp. + +## Có sẵn + +- Splash screen dạng đồ họa procedural, không cần asset ngoài. +- Main Menu: Play, How To Play, Settings, About, Exit. +- HUD trong game: HP, score và mục tiêu thu thập. +- Pause menu và Game Over / Mission Complete. +- Điều khiển keypad Nokia/S30+: 2/4/6/8, 5 và 0. +- Theme riêng trong `src/theme.lua`. +- UI component nhẹ trong `src/ui.lua`. +- Gameplay demo trong `src/game.lua`. +- Không dùng ảnh nền lớn nên phù hợp MRE/MTK6260 và dễ thay bằng asset của game thật. + +## Cấu trúc + +```text +GameUIStarter/ +├── main.lua +├── conf.lua +├── project.json +├── src/ +│ ├── game.lua +│ ├── theme.lua +│ └── ui.lua +└── .luas30/ + └── ui_design.json +``` + +## Điều khiển + +| Phím | Chức năng | +| --- | --- | +| 2 / ↑ | Lên | +| 8 / ↓ | Xuống | +| 4 / ← | Trái | +| 6 / → | Phải | +| 5 / OK | Chọn | +| 0 / Back | Quay lại / Pause | + +## Tùy biến nhanh + +1. Đổi palette trong `src/theme.lua`. +2. Đổi tên game và menu trong `main.lua`. +3. Thay gameplay demo ở `src/game.lua`. +4. Giữ các hàm UI trong `src/ui.lua` để tái sử dụng cho nhiều màn hình. +5. Có thể thêm sprite/audio vào `assets/` sau mà không phải đổi kiến trúc state/menu. + +Mẫu mặc định dùng đồ họa hình học để chạy ngay sau khi tạo project. diff --git a/templates/GameUIStarter/conf.lua b/templates/GameUIStarter/conf.lua new file mode 100644 index 0000000..414a86f --- /dev/null +++ b/templates/GameUIStarter/conf.lua @@ -0,0 +1,7 @@ +config = { + name = "Game UI Starter", + screen_width = 240, + screen_height = 320, + fps = 15 +} +return config diff --git a/templates/GameUIStarter/main.lua b/templates/GameUIStarter/main.lua new file mode 100644 index 0000000..5f27987 --- /dev/null +++ b/templates/GameUIStarter/main.lua @@ -0,0 +1,209 @@ +-- Game UI Starter for LuaS30 IDE / MediaTek MRE. +-- Flow: Splash -> Main Menu -> Game -> Pause/Game Over. +local E = engine + +local UI = require("src.ui") +local Game = require("src.game") + +local state = "splash" +local splash_frames = 0 +local menu_index = 1 +local settings_index = 1 +local pause_index = 1 + +local settings = { + sound = true, + difficulty = 2, +} + +local MENU_ITEMS = { + "PLAY", + "HOW TO PLAY", + "SETTINGS", + "ABOUT", + "EXIT", +} + +local KEYMAP = { + ["2"] = "up", + ["8"] = "down", + ["4"] = "left", + ["6"] = "right", + ["5"] = "ok", + ["#"] = "ok", + ["0"] = "back", + ["clear"] = "back", + ["back"] = "back", + ["enter"] = "ok", + ["return"] = "ok", + ["escape"] = "back", +} + +local function clamp_index(value, count) + if value < 1 then return count end + if value > count then return 1 end + return value +end + +local function start_game() + Game.start(settings.difficulty) + state = "game" +end + +local function open_menu() + state = "menu" + menu_index = 1 +end + +local function activate_menu() + local item = MENU_ITEMS[menu_index] + if item == "PLAY" then + start_game() + elseif item == "HOW TO PLAY" then + state = "guide" + elseif item == "SETTINGS" then + state = "settings" + settings_index = 1 + elseif item == "ABOUT" then + state = "about" + elseif item == "EXIT" then + E.exit() + end +end + +function E.load() + E.set_font(8) + UI.set_settings(settings) +end + +function E.update(dt) + if state == "splash" then + splash_frames = splash_frames + 1 + if splash_frames >= 24 then + open_menu() + end + return + end + + if state == "game" then + Game.update(dt) + if Game.is_over() then + state = "gameover" + end + end +end + +function E.draw() + if state == "splash" then + UI.splash(splash_frames) + elseif state == "menu" then + UI.main_menu(MENU_ITEMS, menu_index) + elseif state == "guide" then + UI.guide() + elseif state == "settings" then + UI.settings(settings_index, settings) + elseif state == "about" then + UI.about() + elseif state == "game" then + Game.draw() + elseif state == "pause" then + Game.draw() + UI.pause(pause_index) + elseif state == "gameover" then + Game.draw() + UI.game_over(Game.did_win(), Game.score()) + end + + if E.flush then E.flush() end +end + +function E.keypressed(raw) + local key = KEYMAP[raw] or raw + + if state == "splash" then + open_menu() + return + end + + if state == "menu" then + if key == "up" then + menu_index = clamp_index(menu_index - 1, #MENU_ITEMS) + elseif key == "down" then + menu_index = clamp_index(menu_index + 1, #MENU_ITEMS) + elseif key == "ok" then + activate_menu() + elseif key == "back" then + E.exit() + end + return + end + + if state == "guide" or state == "about" then + if key == "back" or key == "ok" then open_menu() end + return + end + + if state == "settings" then + if key == "up" then + settings_index = clamp_index(settings_index - 1, 2) + elseif key == "down" then + settings_index = clamp_index(settings_index + 1, 2) + elseif key == "left" or key == "right" or key == "ok" then + if settings_index == 1 then + settings.difficulty = settings.difficulty + (key == "left" and -1 or 1) + if settings.difficulty < 1 then settings.difficulty = 3 end + if settings.difficulty > 3 then settings.difficulty = 1 end + else + settings.sound = not settings.sound + end + UI.set_settings(settings) + elseif key == "back" then + open_menu() + end + return + end + + if state == "game" then + if key == "back" then + pause_index = 1 + state = "pause" + else + Game.keypressed(key) + end + return + end + + if state == "pause" then + if key == "up" then + pause_index = clamp_index(pause_index - 1, 3) + elseif key == "down" then + pause_index = clamp_index(pause_index + 1, 3) + elseif key == "ok" then + if pause_index == 1 then + state = "game" + elseif pause_index == 2 then + start_game() + else + open_menu() + end + elseif key == "back" then + state = "game" + end + return + end + + if state == "gameover" then + if key == "ok" then + start_game() + elseif key == "back" then + open_menu() + end + end +end + +function E.keyreleased(raw) + local key = KEYMAP[raw] or raw + if state == "game" then + Game.keyreleased(key) + end +end diff --git a/templates/GameUIStarter/project.json b/templates/GameUIStarter/project.json new file mode 100644 index 0000000..b5f3e22 --- /dev/null +++ b/templates/GameUIStarter/project.json @@ -0,0 +1,20 @@ +{ + "name": "Game UI Starter", + "vendor": "LuaS30", + "appid": 100001, + "ram_kb": 1024, + "screen_width": 240, + "screen_height": 320, + "fps": 15, + "runtime_target": "mre-s30plus", + "single_vxp": true, + "compat_profile": "nokia225-rm1011", + "mre_api": "Audio File ProMng", + "app_version": "1.0.0", + "mediatek_chipset": "MTK6260", + "mediatek_chipset_label": "MTK6260 (Nokia 220, 225)", + "resolution": "240x320", + "resolution_label": "240x320 (QVGA - Chuẩn Nokia)", + "project_wizard": "mediatek-mre-sdk-v1", + "project_template": "game-ui" +} diff --git a/templates/GameUIStarter/src/game.lua b/templates/GameUIStarter/src/game.lua new file mode 100644 index 0000000..4316d9a --- /dev/null +++ b/templates/GameUIStarter/src/game.lua @@ -0,0 +1,174 @@ +local E = engine +local T = require("src.theme") +local UI = require("src.ui") + +local M = {} + +local keys = {} +local player = { x = 28, y = 170, w = 12, h = 12 } +local enemy = { x = 60, y = 235, w = 20, h = 9, dir = 1 } +local gem_index = 1 +local score_value = 0 +local collected = 0 +local goal = 5 +local hp = 3 +local max_hp = 3 +local invulnerable = 0 +local finished = false +local won = false +local difficulty = 2 + +local gem_positions = { + { 52, 86 }, + { 178, 92 }, + { 112, 135 }, + { 38, 246 }, + { 184, 256 }, + { 120, 206 }, +} + +local function screen() + return E.W or 240, E.H or 320 +end + +local function hit(a, b) + return a.x < b.x + b.w + and a.x + a.w > b.x + and a.y < b.y + b.h + and a.y + a.h > b.y +end + +local function current_gem() + local p = gem_positions[gem_index] + return { x = p[1], y = p[2], w = 10, h = 10 } +end + +function M.start(level) + difficulty = tonumber(level) or 2 + keys = {} + player.x, player.y = 28, 170 + enemy.x, enemy.y, enemy.dir = 60, 235, 1 + gem_index = 1 + score_value = 0 + collected = 0 + hp = difficulty == 3 and 2 or 3 + max_hp = hp + invulnerable = 0 + finished = false + won = false +end + +function M.update(dt) + if finished then return end + + local w, h = screen() + local speed = difficulty == 1 and 3 or 4 + if keys.left then player.x = player.x - speed end + if keys.right then player.x = player.x + speed end + if keys.up then player.y = player.y - speed end + if keys.down then player.y = player.y + speed end + + if player.x < 7 then player.x = 7 end + if player.x > w - player.w - 7 then player.x = w - player.w - 7 end + if player.y < 48 then player.y = 48 end + if player.y > h - player.h - 29 then player.y = h - player.h - 29 end + + local enemy_speed = difficulty + 1 + enemy.x = enemy.x + enemy.dir * enemy_speed + if enemy.x < 10 then + enemy.x = 10 + enemy.dir = 1 + elseif enemy.x > w - enemy.w - 10 then + enemy.x = w - enemy.w - 10 + enemy.dir = -1 + end + + if invulnerable > 0 then invulnerable = invulnerable - 1 end + + local gem = current_gem() + if hit(player, gem) then + collected = collected + 1 + score_value = score_value + 100 + gem_index = (gem_index % #gem_positions) + 1 + if collected >= goal then + finished = true + won = true + end + end + + if invulnerable == 0 and hit(player, enemy) then + hp = hp - 1 + invulnerable = 18 + player.x = 28 + player.y = 170 + if hp <= 0 then + hp = 0 + finished = true + won = false + end + end +end + +function M.draw() + local w, h = screen() + E.clear(T.bg) + UI.hud(score_value, hp, max_hp, collected, goal) + + -- Arena. + E.rect(7, 47, w - 14, h - 76, T.bg2) + E.rect(7, 47, w - 14, 1, T.border) + E.rect(7, h - 30, w - 14, 1, T.border) + + -- Minimal grid gives the project a graphical game feel without assets. + local y = 66 + while y < h - 31 do + E.rect(8, y, w - 16, 1, T.panel) + y = y + 28 + end + local x = 32 + while x < w - 8 do + E.rect(x, 48, 1, h - 78, T.panel) + x = x + 32 + end + + local gem = current_gem() + E.rect(gem.x - 2, gem.y + 3, gem.w + 4, 4, T.accent2) + E.rect(gem.x + 1, gem.y, gem.w - 2, gem.h, T.accent) + + E.rect(enemy.x, enemy.y, enemy.w, enemy.h, T.red) + E.rect(enemy.x + 4, enemy.y - 3, enemy.w - 8, 3, T.accent2) + + local player_color = (invulnerable > 0 and invulnerable % 4 < 2) and T.white or T.cyan + E.rect(player.x, player.y, player.w, player.h, player_color) + E.rect(player.x + 3, player.y + 3, 2, 2, T.bg) + + E.rect(0, h - 22, w, 22, T.bg2) + E.text(7, h - 15, "0 PAUSE", T.muted) + E.text(w - 78, h - 15, "2/4/6/8", T.muted) +end + +function M.keypressed(key) + if key == "left" or key == "right" or key == "up" or key == "down" then + keys[key] = true + end +end + +function M.keyreleased(key) + if key == "left" or key == "right" or key == "up" or key == "down" then + keys[key] = false + end +end + +function M.is_over() + return finished +end + +function M.did_win() + return won +end + +function M.score() + return score_value +end + +return M diff --git a/templates/GameUIStarter/src/theme.lua b/templates/GameUIStarter/src/theme.lua new file mode 100644 index 0000000..8cd1e16 --- /dev/null +++ b/templates/GameUIStarter/src/theme.lua @@ -0,0 +1,24 @@ +local E = engine + +local function c(r, g, b) + return E.color(r, g, b) +end + +local T = { + bg = c(12, 18, 28), + bg2 = c(18, 28, 42), + panel = c(24, 37, 54), + panel2 = c(31, 48, 68), + border = c(58, 82, 108), + text = c(239, 245, 250), + muted = c(150, 169, 188), + accent = c(255, 138, 0), + accent2 = c(255, 188, 84), + green = c(75, 205, 130), + red = c(236, 84, 92), + cyan = c(78, 195, 222), + white = c(255, 255, 255), + black = c(0, 0, 0), +} + +return T diff --git a/templates/GameUIStarter/src/ui.lua b/templates/GameUIStarter/src/ui.lua new file mode 100644 index 0000000..83e0b0f --- /dev/null +++ b/templates/GameUIStarter/src/ui.lua @@ -0,0 +1,235 @@ +local E = engine +local T = require("src.theme") + +local M = {} +local current_settings = { sound = true, difficulty = 2 } + +local function screen() + return E.W or 240, E.H or 320 +end + +local function fill(x, y, w, h, color) + E.rect(math.floor(x), math.floor(y), math.floor(w), math.floor(h), color) +end + +local function outline(x, y, w, h, color) + fill(x, y, w, 1, color) + fill(x, y + h - 1, w, 1, color) + fill(x, y, 1, h, color) + fill(x + w - 1, y, 1, h, color) +end + +local function approx_text_width(text) + return #tostring(text) * 6 +end + +local function center_text(y, text, color) + local w = screen() + local x = math.floor((w - approx_text_width(text)) / 2) + if x < 2 then x = 2 end + E.text(x, y, text, color or T.text) +end + +local function top_bar(title, chip) + local w = screen() + fill(0, 0, w, 31, T.bg2) + fill(0, 30, w, 1, T.border) + E.text(8, 10, title, T.text) + if chip and chip ~= "" then + local cw = approx_text_width(chip) + 10 + fill(w - cw - 7, 7, cw, 16, T.panel2) + outline(w - cw - 7, 7, cw, 16, T.border) + E.text(w - cw - 2, 11, chip, T.accent2) + end +end + +local function softkeys(left, right) + local w, h = screen() + fill(0, h - 22, w, 22, T.bg2) + fill(0, h - 22, w, 1, T.border) + E.text(7, h - 15, left or "", T.muted) + local rt = right or "" + E.text(w - approx_text_width(rt) - 7, h - 15, rt, T.muted) +end + +local function panel(x, y, w, h, title) + fill(x + 2, y + 2, w, h, T.black) + fill(x, y, w, h, T.panel) + outline(x, y, w, h, T.border) + if title then + E.text(x + 8, y + 7, title, T.accent2) + fill(x + 7, y + 20, w - 14, 1, T.border) + end +end + +local function button(x, y, w, h, text, active) + local bg = active and T.accent or T.panel2 + local fg = active and T.black or T.text + fill(x + 2, y + 2, w, h, T.black) + fill(x, y, w, h, bg) + outline(x, y, w, h, active and T.accent2 or T.border) + local tx = x + math.floor((w - approx_text_width(text)) / 2) + E.text(tx, y + math.floor(h / 2) - 4, text, fg) +end + +local function progress(x, y, w, value, max_value, color) + local ratio = 0 + if max_value > 0 then ratio = value / max_value end + if ratio < 0 then ratio = 0 end + if ratio > 1 then ratio = 1 end + fill(x, y, w, 7, T.bg) + outline(x, y, w, 7, T.border) + fill(x + 1, y + 1, math.floor((w - 2) * ratio), 5, color) +end + +function M.set_settings(settings) + current_settings = settings or current_settings +end + +function M.splash(frame) + local w, h = screen() + E.clear(T.bg) + + -- Procedural logo: no external asset required. + local cx = math.floor(w / 2) + local cy = math.floor(h / 2) - 36 + fill(cx - 32, cy - 22, 64, 44, T.panel) + outline(cx - 32, cy - 22, 64, 44, T.border) + fill(cx - 22, cy - 12, 12, 12, T.accent) + fill(cx - 5, cy - 12, 12, 12, T.cyan) + fill(cx + 12, cy - 12, 12, 12, T.green) + fill(cx - 22, cy + 5, 46, 5, T.accent2) + + center_text(cy + 34, "LUA S30", T.text) + center_text(cy + 49, "GAME UI STARTER", T.accent2) + + local dots = (math.floor(frame / 4) % 3) + 1 + center_text(h - 60, "Loading" .. string.rep(".", dots), T.muted) + softkeys("", "SKIP") +end + +function M.main_menu(items, selected) + local w, h = screen() + E.clear(T.bg) + top_bar("POCKET QUEST", "STARTER") + + center_text(44, "READY FOR YOUR GAME", T.muted) + + local button_w = math.min(w - 34, 206) + local x = math.floor((w - button_w) / 2) + local y = 67 + local button_h = 31 + local gap = 7 + + for i, label in ipairs(items) do + button(x, y + (i - 1) * (button_h + gap), button_w, button_h, label, i == selected) + end + + local footer_y = h - 52 + E.text(10, footer_y, "2/8 Navigate", T.muted) + E.text(w - 92, footer_y, "5 Select", T.muted) + softkeys("0 EXIT", "5 SELECT") +end + +function M.guide() + local w, h = screen() + E.clear(T.bg) + top_bar("HOW TO PLAY", "GUIDE") + panel(12, 45, w - 24, h - 92, "CONTROLS") + + E.text(24, 78, "2 / UP Move up", T.text) + E.text(24, 99, "8 / DOWN Move down", T.text) + E.text(24, 120, "4 / LEFT Move left", T.text) + E.text(24, 141, "6 / RIGHT Move right", T.text) + E.text(24, 170, "Collect orange gems.", T.accent2) + E.text(24, 190, "Avoid the red drone.", T.red) + E.text(24, 210, "Fill the goal counter", T.muted) + E.text(24, 225, "to win the demo.", T.muted) + + softkeys("0 BACK", "5 BACK") +end + +function M.settings(selected, settings) + local w, h = screen() + E.clear(T.bg) + top_bar("SETTINGS", "GAME") + panel(12, 47, w - 24, 168, "OPTIONS") + + local names = { "DIFFICULTY", "SOUND" } + local values = { + ({ "EASY", "NORMAL", "HARD" })[settings.difficulty or 2], + settings.sound and "ON" or "OFF", + } + + for i = 1, 2 do + local y = 84 + (i - 1) * 56 + local active = i == selected + fill(22, y, w - 44, 39, active and T.panel2 or T.bg2) + outline(22, y, w - 44, 39, active and T.accent or T.border) + E.text(31, y + 8, names[i], active and T.accent2 or T.muted) + local value = values[i] + E.text(w - approx_text_width(value) - 32, y + 22, value, T.text) + end + + E.text(22, 230, "4/6 or 5 changes value", T.muted) + softkeys("0 BACK", "5 CHANGE") +end + +function M.about() + local w, h = screen() + E.clear(T.bg) + top_bar("ABOUT", "1.0") + panel(12, 48, w - 24, 187, "GAME UI STARTER") + + center_text(83, "LuaS30 IDE", T.accent2) + center_text(104, "MRE / S30+ template", T.text) + center_text(129, "Splash + Menu + HUD", T.muted) + center_text(145, "Pause + Settings + Game Over", T.muted) + + fill(28, 178, w - 56, 1, T.border) + center_text(192, "Replace colors, text and", T.text) + center_text(207, "game logic with your own.", T.text) + + softkeys("0 BACK", "5 BACK") +end + +function M.hud(score, hp, max_hp, collected, goal) + local w = screen() + fill(0, 0, w, 38, T.bg2) + fill(0, 37, w, 1, T.border) + + E.text(7, 7, "HP", T.muted) + progress(25, 8, 67, hp, max_hp, hp > 1 and T.green or T.red) + + E.text(105, 7, "SCORE", T.muted) + E.text(105, 20, tostring(score), T.text) + + local goal_text = tostring(collected) .. "/" .. tostring(goal) + E.text(w - approx_text_width(goal_text) - 8, 20, goal_text, T.accent2) +end + +function M.pause(selected) + local w, h = screen() + local pw, ph = math.min(w - 50, 190), 158 + local x, y = math.floor((w - pw) / 2), math.floor((h - ph) / 2) + panel(x, y, pw, ph, "PAUSED") + + local items = { "RESUME", "RESTART", "MAIN MENU" } + for i, label in ipairs(items) do + button(x + 18, y + 34 + (i - 1) * 34, pw - 36, 27, label, i == selected) + end +end + +function M.game_over(won, score) + local w, h = screen() + local pw, ph = math.min(w - 40, 202), 154 + local x, y = math.floor((w - pw) / 2), math.floor((h - ph) / 2) + panel(x, y, pw, ph, won and "MISSION COMPLETE" or "GAME OVER") + + center_text(y + 43, won and "NICE RUN!" or "TRY AGAIN", won and T.green or T.red) + center_text(y + 67, "SCORE " .. tostring(score), T.text) + center_text(y + 98, "5 RESTART", T.accent2) + center_text(y + 116, "0 MAIN MENU", T.muted) +end + +return M