From 01b302d7eb078f17b1a6ed4979ab127e6329dc0a Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 3 May 2026 14:01:19 +0800 Subject: [PATCH 1/4] Add resilient Jenkins inbound agent setup --- deploy/systemd/jenkins-agent@.service | 24 ++ .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 39 +++- scripts/build-production-release.sh | 9 +- .../deploy/install-jenkins-inbound-agent.sh | 218 ++++++++++++++++++ .../deploy/jenkins-agent-reverse-tunnel.ps1 | 50 ++++ scripts/deploy/jenkins-inbound-agent-start.sh | 80 +++++++ 6 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 deploy/systemd/jenkins-agent@.service create mode 100644 scripts/deploy/install-jenkins-inbound-agent.sh create mode 100644 scripts/deploy/jenkins-agent-reverse-tunnel.ps1 create mode 100644 scripts/deploy/jenkins-inbound-agent-start.sh diff --git a/deploy/systemd/jenkins-agent@.service b/deploy/systemd/jenkins-agent@.service new file mode 100644 index 00000000..45d16c2a --- /dev/null +++ b/deploy/systemd/jenkins-agent@.service @@ -0,0 +1,24 @@ +[Unit] +Description=Jenkins inbound agent %i +Wants=network-online.target +After=network-online.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +User=root +Group=root +EnvironmentFile=/etc/jenkins-agent/%i.env +WorkingDirectory=/var/lib/jenkins/agent/%i +ExecStart=/usr/local/bin/jenkins-inbound-agent-start %i +Restart=always +RestartSec=10 +KillSignal=SIGINT +TimeoutStopSec=30 + +# 当前生产流水线仍包含服务器初始化、systemd 与 Nginx 写入等特权操作。 +# 后续若将 agent 降权到 jenkins 用户,需要先把流水线命令收敛到精确 sudo 白名单。 +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index 4ba80518..ae8e6639 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -202,10 +202,43 @@ Jenkins 可运行在 Windows 或其他机器上,本机 Windows 只作为人工 - Jenkins Job 参数不暴露真实节点名、IP 或带 IP 的标签。 - 生产机已作为独立 Linux Jenkins agent 接入,节点名使用脱敏名称 `genarrative-release-deploy-01`,调度标签只使用 `linux` 与 `genarrative-release-deploy`。 -- 生产机真实连接地址只允许保存在 Jenkins 节点 SSH launcher 的 `host` 字段中,不能写入节点名、调度标签、Job 参数默认值或文档推荐命令。 +- 生产机 agent 启动方式统一改为 inbound agent + systemd 自守护,不再依赖 Jenkins controller 通过 SSH launcher 长期拉起。SSH 只作为首次登录和安装 systemd 服务的运维通道。 +- 生产机真实连接地址只允许保存在 Jenkins 节点连接配置或人工运维 SSH 配置中,不能写入节点名、调度标签、Job 参数默认值或文档推荐命令。 - 发布 Job 通过 `DEPLOY_TARGET` 选择逻辑部署目标,再在 Jenkinsfile 内部映射到 Linux-only 脱敏调度表达式:`development -> linux && genarrative-build`,`release -> linux && genarrative-release-deploy`。 - 用途:服务器配置、发布静态网站、发布 `api-server`、发布 SpacetimeDB 模块、数据库导入导出、维护模式切换。 +### Jenkins inbound agent 自恢复 + +发布 agent 必须由目标 Linux 机器主动连接 Jenkins controller,并由 systemd 托管: + +- Jenkins 节点 Launch method 使用 inbound agent,优先启用 WebSocket。这样目标机只需要能访问 Jenkins Web 地址,不依赖 controller 每次 SSH 拉起 agent。 +- 目标机安装 `deploy/systemd/jenkins-agent@.service`、`scripts/deploy/jenkins-inbound-agent-start.sh` 与 `scripts/deploy/install-jenkins-inbound-agent.sh`。 +- systemd 服务名采用 `jenkins-agent@.service`,例如 `jenkins-agent@genarrative-release-deploy-01.service`。 +- systemd 自身 `WorkingDirectory` 保持 `/var/lib/jenkins/agent/`;Jenkins remoting `-workDir` 可继续使用旧 SSH agent 的 `/root/jenkins-agent`,避免迁移时 workspace 和缓存路径漂移。 +- inbound secret 只能放在目标机 `/etc/jenkins-agent/.secret` 或等价 Secret Text 注入位置,不能提交到 Git,也不能写入 Jenkinsfile 默认参数。 +- systemd unit 使用 `Restart=always` 和 `RestartSec=10`;agent Java 进程退出、网络短断或机器重启后由 systemd 自动恢复,不需要人工盯着 Jenkins 页面手动重启。 +- 当前 `Genarrative-Server-Provision` 仍负责 systemd、Nginx、`/opt/genarrative`、`/etc/genarrative` 等特权写入,因此 inbound agent 默认仍按现有 root 执行口径迁移。若后续改为 `jenkins` 用户运行 agent,必须先把生产流水线需要的特权命令收敛为精确 `NOPASSWD` sudoers 白名单。 + +如果 Jenkins controller 只运行在本地 Windows,不直接对目标机暴露公网地址,需要在本地控制机启动 `scripts/deploy/jenkins-agent-reverse-tunnel.ps1`。该脚本通过同一条 SSH 会话把远端 `127.0.0.1:18080` 转到本地 Jenkins Web `127.0.0.1:8080`,把远端 `127.0.0.1:50000` 转到本地 Jenkins inbound TCP agent port `127.0.0.1:50000`,并在隧道断开后自动重试。此时远端 agent 的 `JENKINS_URL` 固定写 `http://127.0.0.1:18080/`,不写本地 Windows 的 `127.0.0.1:8080`。 + +本地反向隧道脚本不内置目标机地址;注册 Windows 计划任务时必须显式传入 `-RemoteHost `,真实 IP 或主机名只保存在本地计划任务配置中,不提交到 Git。 + +首次迁移示例: + +```bash +sudo install -m 0600 /tmp/genarrative-release-deploy-01.secret /etc/jenkins-agent/genarrative-release-deploy-01.secret +sudo scripts/deploy/install-jenkins-inbound-agent.sh \ + --agent-name genarrative-release-deploy-01 \ + --jenkins-url http://127.0.0.1:18080/ \ + --secret-file /etc/jenkins-agent/genarrative-release-deploy-01.secret \ + --workdir /root/jenkins-agent \ + --java-bin /usr/bin/java +sudo systemctl status jenkins-agent@genarrative-release-deploy-01.service --no-pager -l +journalctl -u jenkins-agent@genarrative-release-deploy-01.service -f +``` + +如果 Jenkins controller 暂时仍配置为 SSH launcher,只能作为过渡方案使用:需要把 SSH launch timeout 拉长、增加 retry 和 retry wait、固定 Java 路径,并确认 `ssh user@host 'java -version'` 稳定返回。最终仍要切到 inbound + systemd,避免 SSH 连接卡住时阻塞发布队列。 + ### Git 仓库访问 Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置: @@ -538,6 +571,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - [x] `deploy/systemd/spacetimedb.service` - [x] `deploy/systemd/genarrative-api.service` +- [x] `deploy/systemd/jenkins-agent@.service` - [x] `deploy/nginx/genarrative.conf` - [x] `deploy/nginx/genarrative-dev-http.conf` - [x] `deploy/nginx/snippets/genarrative-maintenance.conf` @@ -545,6 +579,9 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - [x] `scripts/deploy/maintenance-on.sh` - [x] `scripts/deploy/maintenance-off.sh` - [x] `scripts/deploy/maintenance-status.sh` +- [x] `scripts/deploy/jenkins-agent-reverse-tunnel.ps1` +- [x] `scripts/deploy/jenkins-inbound-agent-start.sh` +- [x] `scripts/deploy/install-jenkins-inbound-agent.sh` - [x] `scripts/build-production-release.sh` - [x] `scripts/jenkins-checkout-source.sh` - [x] `scripts/deploy/production-web-deploy.sh` diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index ce6f3c19..adf46c33 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -370,10 +370,15 @@ mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/deploy" cp "${SCRIPT_DIR}/deploy/maintenance-on.sh" "${TARGET_DIR}/scripts/maintenance-on.sh" cp "${SCRIPT_DIR}/deploy/maintenance-off.sh" "${TARGET_DIR}/scripts/maintenance-off.sh" cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenance-status.sh" +cp "${SCRIPT_DIR}/deploy/jenkins-inbound-agent-start.sh" "${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh" +cp "${SCRIPT_DIR}/deploy/install-jenkins-inbound-agent.sh" "${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh" +cp "${SCRIPT_DIR}/deploy/jenkins-agent-reverse-tunnel.ps1" "${TARGET_DIR}/scripts/jenkins-agent-reverse-tunnel.ps1" chmod +x \ "${TARGET_DIR}/scripts/maintenance-on.sh" \ "${TARGET_DIR}/scripts/maintenance-off.sh" \ - "${TARGET_DIR}/scripts/maintenance-status.sh" + "${TARGET_DIR}/scripts/maintenance-status.sh" \ + "${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh" \ + "${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh" copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET_DIR}/scripts/database-export.mjs" "数据库导出脚本" copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本" @@ -398,7 +403,7 @@ cat >"${TARGET_DIR}/README.md" <&2 <<'EOF' +用法: + sudo scripts/deploy/install-jenkins-inbound-agent.sh \ + --agent-name genarrative-release-deploy-01 \ + --jenkins-url http://:8080/ \ + --secret-file /path/to/inbound-agent.secret + +可选参数: + --run-user systemd 运行用户,默认 root;当前生产流水线仍需要特权操作。 + --run-group systemd 运行用户组,默认跟随 --run-user。 + --workdir agent 工作目录,默认 /var/lib/jenkins/agent/。 + --jar-path agent.jar 落盘路径,默认 /opt/jenkins-agent/agent.jar。 + --java-bin Java 命令路径,默认 java;需要固定 JDK 时传绝对路径。 + --no-websocket 不使用 WebSocket inbound 连接。 + --no-enable 只安装 unit,不执行 systemctl enable。 + --no-start 只安装 unit,不立即启动服务。 + --dry-run 只打印操作,不写入系统。 + +密钥来源: + 优先使用 --secret-file;如果未传入,则读取环境变量 JENKINS_AGENT_SECRET; + 如果目标机已存在 /etc/jenkins-agent/.secret,则保留原密钥。 +EOF +} + +AGENT_NAME="" +JENKINS_URL_VALUE="" +SECRET_FILE="" +RUN_USER="root" +RUN_GROUP="" +WORKDIR="" +JAR_PATH="/opt/jenkins-agent/agent.jar" +JAVA_BIN="java" +USE_WEBSOCKET="true" +ENABLE_SERVICE="true" +START_SERVICE="true" +DRY_RUN="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --agent-name) + AGENT_NAME="${2:?缺少 --agent-name 的值}" + shift 2 + ;; + --jenkins-url) + JENKINS_URL_VALUE="${2:?缺少 --jenkins-url 的值}" + shift 2 + ;; + --secret-file) + SECRET_FILE="${2:?缺少 --secret-file 的值}" + shift 2 + ;; + --run-user) + RUN_USER="${2:?缺少 --run-user 的值}" + shift 2 + ;; + --run-group) + RUN_GROUP="${2:?缺少 --run-group 的值}" + shift 2 + ;; + --workdir) + WORKDIR="${2:?缺少 --workdir 的值}" + shift 2 + ;; + --jar-path) + JAR_PATH="${2:?缺少 --jar-path 的值}" + shift 2 + ;; + --java-bin) + JAVA_BIN="${2:?缺少 --java-bin 的值}" + shift 2 + ;; + --no-websocket) + USE_WEBSOCKET="false" + shift + ;; + --no-enable) + ENABLE_SERVICE="false" + shift + ;; + --no-start) + START_SERVICE="false" + shift + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[jenkins-agent-install] 未知参数: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "${AGENT_NAME}" || -z "${JENKINS_URL_VALUE}" ]]; then + usage + exit 2 +fi + +if [[ -z "${RUN_GROUP}" ]]; then + RUN_GROUP="${RUN_USER}" +fi + +if [[ -z "${WORKDIR}" ]]; then + WORKDIR="/var/lib/jenkins/agent/${AGENT_NAME}" +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +START_SOURCE="${SCRIPT_DIR}/jenkins-inbound-agent-start.sh" +UNIT_SOURCE="${REPO_ROOT}/deploy/systemd/jenkins-agent@.service" +CONFIG_DIR="/etc/jenkins-agent" +CONFIG_FILE="${CONFIG_DIR}/${AGENT_NAME}.env" +SECRET_TARGET="${CONFIG_DIR}/${AGENT_NAME}.secret" +SERVICE_NAME="jenkins-agent@${AGENT_NAME}.service" + +run_cmd() { + echo "+ $*" + if [[ "${DRY_RUN}" != "true" ]]; then + "$@" + fi +} + +write_file() { + local target="$1" + local mode="$2" + local owner="$3" + local group="$4" + local temp_file + + temp_file="$(mktemp)" + cat >"${temp_file}" + echo "+ install -m ${mode} ${temp_file} ${target}" + if [[ "${DRY_RUN}" != "true" ]]; then + install -m "${mode}" -o "${owner}" -g "${group}" "${temp_file}" "${target}" + fi + rm -f "${temp_file}" +} + +if [[ ! -f "${START_SOURCE}" ]]; then + echo "[jenkins-agent-install] 缺少启动脚本: ${START_SOURCE}" >&2 + exit 1 +fi + +if [[ ! -f "${UNIT_SOURCE}" ]]; then + echo "[jenkins-agent-install] 缺少 systemd 模板: ${UNIT_SOURCE}" >&2 + exit 1 +fi + +if [[ "${RUN_USER}" != "root" ]] && ! id "${RUN_USER}" >/dev/null 2>&1; then + run_cmd useradd --system --create-home --home-dir "/var/lib/${RUN_USER}" --shell /bin/bash "${RUN_USER}" +fi + +run_cmd mkdir -p "${CONFIG_DIR}" "$(dirname "${JAR_PATH}")" "${WORKDIR}" +run_cmd chmod 0755 "${CONFIG_DIR}" "$(dirname "${JAR_PATH}")" + +if [[ "${DRY_RUN}" != "true" ]]; then + chown -R "${RUN_USER}:${RUN_GROUP}" "$(dirname "${JAR_PATH}")" "${WORKDIR}" +fi + +run_cmd install -m 0755 "${START_SOURCE}" /usr/local/bin/jenkins-inbound-agent-start + +UNIT_TMP="$(mktemp)" +sed \ + -e "s|^User=.*|User=${RUN_USER}|" \ + -e "s|^Group=.*|Group=${RUN_GROUP}|" \ + "${UNIT_SOURCE}" >"${UNIT_TMP}" +run_cmd install -m 0644 "${UNIT_TMP}" /etc/systemd/system/jenkins-agent@.service +rm -f "${UNIT_TMP}" + +write_file "${CONFIG_FILE}" 0644 root root <&2 + exit 1 + fi + run_cmd install -m 0600 -o "${RUN_USER}" -g "${RUN_GROUP}" "${SECRET_FILE}" "${SECRET_TARGET}" +elif [[ -n "${JENKINS_AGENT_SECRET:-}" ]]; then + write_file "${SECRET_TARGET}" 0600 "${RUN_USER}" "${RUN_GROUP}" <&2 + exit 1 +fi + +run_cmd systemctl daemon-reload + +if [[ "${ENABLE_SERVICE}" == "true" ]]; then + run_cmd systemctl enable "${SERVICE_NAME}" +fi + +if [[ "${START_SERVICE}" == "true" ]]; then + run_cmd systemctl restart "${SERVICE_NAME}" + run_cmd systemctl status "${SERVICE_NAME}" --no-pager -l +fi + +echo "[jenkins-agent-install] 完成: ${SERVICE_NAME}" diff --git a/scripts/deploy/jenkins-agent-reverse-tunnel.ps1 b/scripts/deploy/jenkins-agent-reverse-tunnel.ps1 new file mode 100644 index 00000000..c62a95c5 --- /dev/null +++ b/scripts/deploy/jenkins-agent-reverse-tunnel.ps1 @@ -0,0 +1,50 @@ +param( + [string]$RemoteHost = "", + [string]$RemoteUser = "root", + [string]$SshKeyPath = "$env:USERPROFILE\.ssh\dsk.pem", + [string]$LocalJenkinsHost = "127.0.0.1", + [int]$LocalJenkinsPort = 8080, + [int]$LocalAgentPort = 50000, + [int]$RemoteJenkinsPort = 18080, + [int]$RemoteAgentPort = 50000, + [int]$RestartDelaySeconds = 10 +) + +$ErrorActionPreference = "Stop" + +function Write-Log { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Write-Output "[$timestamp] $Message" +} + +$ssh = (Get-Command ssh.exe -ErrorAction Stop).Source +$remote = "$RemoteUser@$RemoteHost" + +if (-not $RemoteHost) { + throw "RemoteHost is required." +} + +if (-not (Test-Path -LiteralPath $SshKeyPath)) { + throw "SSH key not found: $SshKeyPath" +} + +while ($true) { + $args = @( + "-i", $SshKeyPath, + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ExitOnForwardFailure=yes", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-N", + "-R", "127.0.0.1:${RemoteJenkinsPort}:${LocalJenkinsHost}:${LocalJenkinsPort}", + "-R", "127.0.0.1:${RemoteAgentPort}:${LocalJenkinsHost}:${LocalAgentPort}", + $remote + ) + + Write-Log "Starting Jenkins agent reverse tunnel: $remote" + & $ssh @args + $exitCode = $LASTEXITCODE + Write-Log "Reverse tunnel exited, exitCode=$exitCode; retrying in ${RestartDelaySeconds}s." + Start-Sleep -Seconds $RestartDelaySeconds +} diff --git a/scripts/deploy/jenkins-inbound-agent-start.sh b/scripts/deploy/jenkins-inbound-agent-start.sh new file mode 100644 index 00000000..fecc6f17 --- /dev/null +++ b/scripts/deploy/jenkins-inbound-agent-start.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +用法: + jenkins-inbound-agent-start + +说明: + 该脚本由 systemd 调用,读取 /etc/jenkins-agent/.env, + 下载 Jenkins agent.jar,并通过 inbound WebSocket 连接 Jenkins controller。 +EOF +} + +AGENT_INSTANCE="${1:-}" +if [[ -z "${AGENT_INSTANCE}" ]]; then + usage + exit 2 +fi + +CONFIG_FILE="${JENKINS_AGENT_CONFIG_FILE:-/etc/jenkins-agent/${AGENT_INSTANCE}.env}" +if [[ ! -r "${CONFIG_FILE}" ]]; then + echo "[jenkins-agent] 配置文件不可读: ${CONFIG_FILE}" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "${CONFIG_FILE}" +set +a + +JENKINS_AGENT_NAME="${JENKINS_AGENT_NAME:-${AGENT_INSTANCE}}" +JENKINS_AGENT_WORKDIR="${JENKINS_AGENT_WORKDIR:-/var/lib/jenkins/agent/${JENKINS_AGENT_NAME}}" +JENKINS_AGENT_JAR="${JENKINS_AGENT_JAR:-/opt/jenkins-agent/agent.jar}" +JENKINS_AGENT_SECRET_FILE="${JENKINS_AGENT_SECRET_FILE:-/etc/jenkins-agent/${JENKINS_AGENT_NAME}.secret}" +JENKINS_AGENT_USE_WEBSOCKET="${JENKINS_AGENT_USE_WEBSOCKET:-true}" +JENKINS_AGENT_JAVA_BIN="${JENKINS_AGENT_JAVA_BIN:-java}" + +if [[ -z "${JENKINS_URL:-}" ]]; then + echo "[jenkins-agent] JENKINS_URL 不能为空。" >&2 + exit 1 +fi + +if [[ -z "${JENKINS_AGENT_SECRET:-}" ]]; then + if [[ ! -r "${JENKINS_AGENT_SECRET_FILE}" ]]; then + echo "[jenkins-agent] 未提供 JENKINS_AGENT_SECRET,且密钥文件不可读: ${JENKINS_AGENT_SECRET_FILE}" >&2 + exit 1 + fi + JENKINS_AGENT_SECRET="$(tr -d '\r\n' <"${JENKINS_AGENT_SECRET_FILE}")" +fi + +if [[ -z "${JENKINS_AGENT_SECRET}" ]]; then + echo "[jenkins-agent] Jenkins inbound agent secret 不能为空。" >&2 + exit 1 +fi + +mkdir -p "$(dirname "${JENKINS_AGENT_JAR}")" "${JENKINS_AGENT_WORKDIR}" + +AGENT_JAR_URL="${JENKINS_URL%/}/jnlpJars/agent.jar" +AGENT_JAR_TMP="${JENKINS_AGENT_JAR}.tmp" + +echo "[jenkins-agent] 下载 agent.jar: ${AGENT_JAR_URL}" +curl -fsSL --retry 5 --retry-delay 5 "${AGENT_JAR_URL}" -o "${AGENT_JAR_TMP}" +mv "${AGENT_JAR_TMP}" "${JENKINS_AGENT_JAR}" + +agent_args=( + "${JENKINS_AGENT_JAVA_BIN}" + -jar "${JENKINS_AGENT_JAR}" + -url "${JENKINS_URL}" + -secret "${JENKINS_AGENT_SECRET}" + -name "${JENKINS_AGENT_NAME}" + -workDir "${JENKINS_AGENT_WORKDIR}" +) + +if [[ "${JENKINS_AGENT_USE_WEBSOCKET}" == "true" ]]; then + agent_args+=(-webSocket) +fi + +echo "[jenkins-agent] 启动 inbound agent: ${JENKINS_AGENT_NAME}" +exec "${agent_args[@]}" From 49aad7311c68459d11de30cb4d1c90b4c6201a5e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 3 May 2026 14:18:27 +0800 Subject: [PATCH 2/4] Add local Jenkins controller watchdog --- .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 3 + scripts/build-production-release.sh | 1 + .../jenkins-local-controller-watchdog.ps1 | 95 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 scripts/deploy/jenkins-local-controller-watchdog.ps1 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index ae8e6639..d4c6ad3e 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -223,6 +223,8 @@ Jenkins 可运行在 Windows 或其他机器上,本机 Windows 只作为人工 本地反向隧道脚本不内置目标机地址;注册 Windows 计划任务时必须显式传入 `-RemoteHost `,真实 IP 或主机名只保存在本地计划任务配置中,不提交到 Git。 +当 Jenkins controller 以本地 Windows `java -jar jenkins.war` 方式运行时,使用 `scripts/deploy/jenkins-local-controller-watchdog.ps1` 作为本地守护脚本。该脚本只保存本机 Java、`jenkins.war`、`JENKINS_HOME` 和端口路径,不保存 Jenkins 账号、密码、token 或 agent secret;注册 Windows 计划任务后,脚本会在登录后检查 `8080` 是否已有 Jenkins 监听,若已有则监控现有 PID,若进程退出或端口空闲则重新启动 Jenkins,并固定 `--agentPort=50000` 供远端 inbound agent 连接。 + 首次迁移示例: ```bash @@ -579,6 +581,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module - [x] `scripts/deploy/maintenance-on.sh` - [x] `scripts/deploy/maintenance-off.sh` - [x] `scripts/deploy/maintenance-status.sh` +- [x] `scripts/deploy/jenkins-local-controller-watchdog.ps1` - [x] `scripts/deploy/jenkins-agent-reverse-tunnel.ps1` - [x] `scripts/deploy/jenkins-inbound-agent-start.sh` - [x] `scripts/deploy/install-jenkins-inbound-agent.sh` diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index adf46c33..03cd25cb 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -373,6 +373,7 @@ cp "${SCRIPT_DIR}/deploy/maintenance-status.sh" "${TARGET_DIR}/scripts/maintenan cp "${SCRIPT_DIR}/deploy/jenkins-inbound-agent-start.sh" "${TARGET_DIR}/scripts/jenkins-inbound-agent-start.sh" cp "${SCRIPT_DIR}/deploy/install-jenkins-inbound-agent.sh" "${TARGET_DIR}/scripts/install-jenkins-inbound-agent.sh" cp "${SCRIPT_DIR}/deploy/jenkins-agent-reverse-tunnel.ps1" "${TARGET_DIR}/scripts/jenkins-agent-reverse-tunnel.ps1" +cp "${SCRIPT_DIR}/deploy/jenkins-local-controller-watchdog.ps1" "${TARGET_DIR}/scripts/jenkins-local-controller-watchdog.ps1" chmod +x \ "${TARGET_DIR}/scripts/maintenance-on.sh" \ "${TARGET_DIR}/scripts/maintenance-off.sh" \ diff --git a/scripts/deploy/jenkins-local-controller-watchdog.ps1 b/scripts/deploy/jenkins-local-controller-watchdog.ps1 new file mode 100644 index 00000000..7b9ff16b --- /dev/null +++ b/scripts/deploy/jenkins-local-controller-watchdog.ps1 @@ -0,0 +1,95 @@ +param( + [string]$JavaPath = "$env:USERPROFILE\jenkins-local\jdk-21\jdk-21.0.11+10\bin\java.exe", + [string]$JenkinsWar = "$env:USERPROFILE\jenkins-local\jenkins.war", + [string]$JenkinsHome = "$env:USERPROFILE\.jenkins", + [int]$HttpPort = 8080, + [int]$AgentPort = 50000, + [int]$RestartDelaySeconds = 10 +) + +$ErrorActionPreference = "Stop" + +function Write-Log { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Write-Output "[$timestamp] $Message" +} + +function Get-ListeningProcessId { + param([int]$Port) + $line = netstat -ano | Select-String -Pattern "LISTENING\s+(\d+)$" | Where-Object { + $_.Line -match "[:.]$Port\s+" + } | Select-Object -First 1 + + if (-not $line) { + return $null + } + + if ($line.Line -match "LISTENING\s+(\d+)$") { + return [int]$Matches[1] + } + + return $null +} + +function Test-JenkinsProcess { + param([int]$ProcessId) + if (-not $ProcessId) { + return $false + } + + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction SilentlyContinue + if (-not $process) { + return $false + } + + return ($process.CommandLine -like "*jenkins.war*") +} + +if (-not (Test-Path -LiteralPath $JavaPath)) { + throw "Java path not found: $JavaPath" +} + +if (-not (Test-Path -LiteralPath $JenkinsWar)) { + throw "Jenkins war not found: $JenkinsWar" +} + +New-Item -ItemType Directory -Force -Path $JenkinsHome | Out-Null + +while ($true) { + $listeningPid = Get-ListeningProcessId -Port $HttpPort + + if ($listeningPid -and (Test-JenkinsProcess -ProcessId $listeningPid)) { + Write-Log "Jenkins is already listening on port $HttpPort with pid $listeningPid; monitoring it." + try { + Wait-Process -Id $listeningPid + } catch { + Write-Log "Existing Jenkins process wait failed: $($_.Exception.Message)" + } + } elseif ($listeningPid) { + Write-Log "Port $HttpPort is occupied by pid $listeningPid, but it is not Jenkins. Retrying in ${RestartDelaySeconds}s." + Start-Sleep -Seconds $RestartDelaySeconds + continue + } else { + $arguments = @( + "-Djenkins.install.runSetupWizard=false", + "-jar", $JenkinsWar, + "--httpPort=$HttpPort", + "--agentPort=$AgentPort" + ) + + Write-Log "Starting local Jenkins controller on port $HttpPort." + $previousJenkinsHome = $env:JENKINS_HOME + $env:JENKINS_HOME = $JenkinsHome + $process = Start-Process -FilePath $JavaPath -ArgumentList $arguments -WorkingDirectory (Split-Path -Parent $JenkinsWar) -NoNewWindow -PassThru + $env:JENKINS_HOME = $previousJenkinsHome + try { + Wait-Process -Id $process.Id + } catch { + Write-Log "Started Jenkins process wait failed: $($_.Exception.Message)" + } + } + + Write-Log "Jenkins controller stopped; retrying in ${RestartDelaySeconds}s." + Start-Sleep -Seconds $RestartDelaySeconds +} From f1e86a88dabb38b221c67499f8521123c9a624a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Sun, 3 May 2026 23:26:08 +0800 Subject: [PATCH 3/4] feat: refine match3d brick runtime assets --- ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md | 25 +- ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md | 29 +- ...NTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md | 95 ++- .../crates/module-match3d/src/application.rs | 344 ++++++--- server-rs/crates/module-match3d/src/domain.rs | 56 +- src/Match3DPlaygroundApp.tsx | 8 +- .../match3d-runtime/Match3DPhysicsBoard.tsx | 650 +++++++++++++++--- .../Match3DRuntimeShell.test.tsx | 295 ++++++-- .../match3d-runtime/Match3DRuntimeShell.tsx | 65 +- .../match3d-runtime/match3dVisualAssets.tsx | 296 ++++---- .../match3d-runtime/match3dLocalRuntime.ts | 287 +++++--- 11 files changed, 1580 insertions(+), 570 deletions(-) diff --git a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md index ad4b7664..2d321ea4 100644 --- a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md +++ b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md @@ -151,9 +151,9 @@ Agent 的职责是帮助用户确认可以直接编译 demo 的最小配置: 题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。 -首版 demo 不接入真实图片生成。运行态可消除物统一使用纯色几何体表现,不使用透明气泡,也不在图案上放文字标识。题材仍决定后端生成的 `visualKey` 和尺寸比例,但前端首版用差异化颜色与几何造型表现可消除物,例如圆形、三角形、菱形、五角星、梯形、平行四边形等,避免玩家在堆叠状态下难以辨认。 +首版 demo 不接入真实图片生成。当前运行态可消除物统一使用参考图方向的 25 个积木件类型表现,不使用透明气泡,也不在图案上放文字标识。前端首版用差异化颜色、积木造型和 3D 程序化模型表现可消除物,避免玩家在堆叠状态下难以辨认。 -水果图形资产需要具备常识可感知的相对大小关系,但不要求真实比例绝对精准。首版固定规则为:西瓜明显大于苹果;苹果、橙子、梨、桃子为中等尺寸;葡萄、李子、青柠等小型水果略小。该尺寸由后端运行态物品 `radius` 下发,前端只按快照表现。 +可消除物尺寸使用五档相对体积规则:XL 型相对体积为 `1.60~2.30`,L 型为 `1.25~1.60`,M 型为 `1.00`,XS 型为 `0.65~0.85`,S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。 ### 需要消除次数 @@ -265,6 +265,16 @@ totalItemCount = clearCount * 3 每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。 +生成的消除物类型数由用户填写的需要消除次数决定: + +```text +itemTypeCount = clearCount <= 25 ? clearCount : 25 +``` + +当 `clearCount <= 25` 时,本局生成的 `itemTypeId` 数量等于 `clearCount`,每种类型默认生成 `3` 件;当 `clearCount > 25` 时,本局最多生成 `25` 种 `itemTypeId`,后续消除组按这 `25` 种类型轮转补齐,且每种类型最终数量仍必须保持 `3` 的倍数。 + +同一局内这些类型必须分别使用不同的形状和颜色组合,不能出现两个组看起来像同一种物体的情况。 + ## 8.4 阶段陆续生成 每局物品允许阶段陆续生成。 @@ -277,8 +287,8 @@ totalItemCount = clearCount * 3 首版 demo 使用 2D 图案素材。 -1. demo 至少提供 `10` 种颜色与几何造型组合素材。 -2. 当题材为水果时,后端仍可切换到 `10` 种水果视觉键和尺寸比例,但前端首版必须把这些视觉键映射为无文字的纯色几何体,不能显示为水果图、透明气泡或文字标记。 +1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限。 +2. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。 3. 后续可以尝试替换为伪 3D 或 3D 模型。 4. 用户题材主题后续会映射为符合常识预期的物品集合。 @@ -310,6 +320,8 @@ totalItemCount = clearCount * 3 飞行动画过程中,物品不再与其他物品产生碰撞。 +当前 3D 实验模式下,物品进入备选栏后必须从圆形空间的物理世界移除;备选栏只展示该物品同款 3D 模型的独立预览,固定为斜 `45` 度便于识别,不再参与场内碰撞、重力或堆叠。 + 前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。 ## 8.9 备选栏 @@ -318,8 +330,9 @@ totalItemCount = clearCount * 3 1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。 2. 备选栏中每出现 `3` 个相同物品 id,前端立即播放自动消除效果并腾出格子。 -3. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。 -4. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。 +3. 3D 模式下,备选栏格子展示从场内取出的同款 3D 模型预览,视角固定斜 `45` 度,不使用另一套不一致的 UI 图标;托盘预览必须共享一个 WebGL renderer,不能因多个预览上下文导致中心场地模型不可见;WebGL 回退或 `2D` 模式下才使用保留的 2D 图标。 +4. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。 +5. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。 ## 8.10 胜利 diff --git a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md index d8816907..d1c1d470 100644 --- a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md +++ b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md @@ -518,15 +518,25 @@ totalItemCount = clearCount * 3 每种 `itemTypeId` 的数量必须是 `3` 的倍数。 +消除物类型数按创作输入的 `clearCount` 计算: + +```text +itemTypeCount = clearCount <= 25 ? clearCount : 25 +``` + +当 `clearCount <= 25` 时,运行态快照中的不同 `itemTypeId` 数量必须等于 `clearCount`;当 `clearCount > 25` 时,不同 `itemTypeId` 数量必须等于 `25`。超过 `25` 组的消除目标按这 `25` 种类型轮转生成,确保每种类型的最终数量仍是 `3` 的倍数。 + +这 `25` 组在同一局内还必须对应 25 套不同的形状和颜色签名,不能有两组视觉上撞型。 + ## 9.3 demo 视觉素材 -首版使用内置视觉键和前端内置几何图形资产,不接真实图片生成。 +首版使用 25 个内置积木件视觉键和前端内置几何图形资产,不接真实图片生成。 -1. 水果题材必须使用 `watermelon-green / apple-red / banana-yellow / grape-purple / melon-green / berry-blue / peach-pink / plum-indigo / lime-lime / orange-orange` 这组内置水果视觉键;前端首版将其映射为纯色几何体,不渲染水果写实图,也不能显示为带文字或透明气泡的小球。 -2. 非水果题材暂使用 `red_circle / yellow_triangle / purple_diamond / green_square / blue_star / orange_hexagon / cyan_capsule / pink_heart / lime_leaf / white_moon` 这组兜底颜色形状视觉键。 -3. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品。 -4. 运行态图案必须使用实心、高饱和、无文字的几何 SVG,至少覆盖圆形、三角形、菱形、方形、五角星、六边形、胶囊、心形、梯形、平行四边形等多种轮廓;外层命中按钮不得再显示半透明气泡底。 -5. 水果题材的相对尺寸由后端权威半径决定,首版要求西瓜明显大于苹果,苹果、橙子、桃子等中型水果大于葡萄、李子、青柠等小型水果;前端不得自行改写规则半径,只负责按快照表现。 +1. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版将其映射为无文字的 2D 图标和程序化 3D 积木模型,不渲染写实图,也不能显示为带文字或透明气泡的小球。 +2. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品。 +3. 运行态图案必须使用实心、高饱和、无文字的几何 SVG,并保持与 3D 模型同一批 `visualKey` 对应关系;外层命中按钮不得再显示半透明气泡底。 +4. 每局按使用类型数量分配五档相对体积:XL 型 `1.60~2.30` 占 `20%`,L 型 `1.25~1.60` 占 `30%`,M 型固定 `1.00` 占 `30%`,XS 型 `0.65~0.85` 占 `15%`,S 型 `0.35~0.50` 占 `5%`。非整数配额按最大余数补齐,总数必须等于本局使用类型数量。 +5. 同一局内同一个颜色和造型的 `visualKey` 只能对应一个尺寸档位和一个半径,不能出现同一物品类型三件副本大小不同,也不能出现同一视觉键在复用时被分配到两种大小。前端不得自行改写规则半径,只负责按快照表现。 6. 后续接入真实题材图片素材前,必须另补资产生成方案。 ## 9.4 难度 @@ -646,9 +656,10 @@ src/components/match3d-runtime/ 1. 圆形空间占据主要区域。 2. 备选栏固定 `7` 格。 -3. 倒计时清晰但不遮挡物品。 -4. 物品点击区域稳定,不因动画造成布局跳动。 -5. 胜利/失败结算使用独立面板,不在当前面板下方展开。 +3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer,不能每格创建独立 renderer;仅 WebGL 回退或 `2D` 模式使用 2D 图标。 +4. 倒计时清晰但不遮挡物品。 +5. 物品点击区域稳定,不因动画造成布局跳动。 +6. 胜利/失败结算使用独立面板,不在当前面板下方展开。 ## 11.5 本地 mock 口径 diff --git a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md index e9c49cfa..9f36e223 100644 --- a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md +++ b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md @@ -19,7 +19,7 @@ 1. 现有 `Match3DVisualIcon`、`Match3DToken` 和托盘 2D 图案渲染代码必须保留。 2. 新增 3D 表现层只作为运行态棋盘的可选渲染分支。 3. 当浏览器不支持 WebGL、3D 依赖加载失败或实验开关关闭时,运行态必须自动回到现有 2D 图案表现。 -4. 托盘继续使用当前 2D 图标,便于玩家识别已选物品,也便于实验失败时快速回滚。 +4. 3D 模式下,托盘直接复用场内同一套程序化 3D 模型,以固定斜 `45` 度识别视角展示已选物品;托盘内物品不进入物理世界,不参与碰撞。WebGL 不可用或实验回退时,托盘继续使用当前 2D 图标。 ## 3. 工程落点 @@ -50,7 +50,7 @@ cannon-es 3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。 -`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。 +`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘在 3D 模式下通过 `Match3DTrayPreviewBoard` 使用单个共享 WebGL 预览层复用 `createMatch3DItemMesh` 生成同款 3D 模型,不能为每个托盘格单独创建 `WebGLRenderer`。WebGL 不可用或 2D 回退时继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。 ## 4. 验收口径 @@ -58,8 +58,10 @@ cannon-es 2. 3D 几何体保持在圆形区域内,不被圆形边界裁切到不可点。 3. 物体进入场景后有轻微物理碰撞和堆叠稳定过程。 4. 点击 3D 物体后仍执行原有乐观入槽、后端确认、三消反馈和结算。 -5. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除。 -6. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见。 +5. 被取出的 3D 物体必须立即从棋盘物理世界移除;备选栏展示的是无碰撞、固定角度的独立预览模型,不允许继续受场内碰撞、重力或堆叠影响。 +6. 托盘 3D 预览必须共享一个 renderer,避免多个 WebGL 上下文导致中心棋盘上下文被浏览器回收;中心棋盘监听 `webglcontextlost`,丢失时自动回退 2D 表现,禁止出现模型不可见但仍可点击的状态。 +7. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除。 +8. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见。 ## 5. 锅型容器优化 @@ -72,3 +74,88 @@ cannon-es 3. 物理世界使用同一个锅内半径作为水平活动边界,所有可消除物体的初始位置和运行中位置都必须被约束在圆形锅内。 4. 物体受到重力后只允许在锅内碰撞、滑动、翻滚和向上堆叠,不能因为碰撞或初始坐标散落到圆形区域外。 5. 该优化仍只属于前端 3D 表现层,不改变后端运行态坐标、点击权威判定、备选栏、消除和胜负规则。 + +## 6. 中心引力优化 + +2026-05-02 追加中心引力,用来解决高消除次数下 3D 物体过于松散、贴边后被圆形场地裁切的问题。体验后发现默认向心力会让模型过度挤压成团,因此当前先关闭默认引力,只保留代码开关,后续如需再尝试可重新调参。 + +编码口径: + +1. 中心引力默认系数为 `0`,默认不对物理 body 施加水平向心力。 +2. 引力只作用在 X/Z 平面,不改变垂直重力,物体仍会自然落到锅底或堆叠在其他物体上。 +3. 引力在越靠近锅边时越明显,避免大量物体碰撞后形成稀疏外环;靠近中心时力度收敛,避免所有物体被吸成单点。 +4. 锅内活动边界继续作为硬约束;高数量物体应被锅边挡住并向上堆叠,不允许散落到圆形场地外。 +5. `/match3d?clearCount=100` 可作为本地直达压力测试入口,用于验证 300 个物体时仍在锅内聚拢。 + +## 7. 正交俯视与真实场地边界 + +2026-05-02 针对高堆叠时 3D 物体被 DOM 圆形裁切的问题,明确中心圆形区域不是裁切蒙版,而是游戏实际游玩场地。 + +编码口径: + +1. 3D 棋盘使用正交俯视相机,避免高处物体因为透视放大而投影到圆形场地外。 +2. 圆形场地的内圈圆环对应 3D 世界里的锅内空气墙,物体范围由物理约束控制,不再依赖 DOM `overflow-hidden` 裁切。 +3. 外层圆形 UI 只负责显示锅沿和场地外观,不能把物体裁成半截;如果物体看起来越界,优先修正相机、物理半径和空气墙。 +4. 高数量压力测试以 `/match3d?clearCount=100` 为基准,物体可以在场地内向上堆叠,但不能被圆形边缘压住或切掉。 + +## 8. 类型数量与样式池历史口径 + +2026-05-03 曾调整消除物类型生成规则,解决 3D 关卡中可消除物类型和样式过少的问题。该节为历史口径,后续实际实现以第 11 节 25 个积木件资源池为准。 + +编码口径: + +1. 历史版本曾使用 20 类形状颜色组合。 +2. 当前版本已替换为 25 个积木件,旧 20 类上限不再作为编码依据。 +3. 3D 与 2D 回退仍共用视觉键映射,新增样式不能破坏 `?match3dRender=2d` 回退路径。 + +## 9. 特殊形状 3D 可读性修正 + +2026-05-03 针对 20 组关卡中看不到十字、圆环、盾形、闪电、月牙、箭头等新形状的问题,补充 3D 几何体渲染口径。 + +编码口径: + +1. 数据层仍使用 `visualKey` 决定类型,不新增贴图素材或文本标识。 +2. 十字、心形、星形、圆环、盾形、闪电、月牙、箭头、V 形等特殊形状不能继续使用普通盒子、球体或锥体代理,必须生成俯视角可辨认的 3D 轮廓。 +3. 特殊形状使用 Three.js 程序化轮廓挤出生成,保持当前 3D 实验可快速回退,不影响现有 2D 图案分支。 +4. 特殊形状的物理碰撞可以继续使用近似碰撞体,但显示网格需要固定为俯视可读姿态,避免落地翻滚后又变成长方块或普通三角体。 +5. 当前特殊形状已被 25 个积木件资源池替换;不能为了让玩家开局肉眼看到全部类型而改动初始层级、物理堆叠、遮挡、边界或可点击规则。 + +## 10. 15 组中档局面的类型唯一性修正 + +2026-05-03 针对 `clearCount=15` 时可消除物类型不足 15 种的问题,补充中档局面的规则验收口径。 + +编码口径: + +1. `clearCount=15` 时,运行态数据中必须生成 `15` 种不同 `itemTypeId`,且首个 `15` 个 `visualKey` 必须分别对应不同几何形状。 +2. 每种 `itemTypeId` 在 `clearCount=15` 时只对应 1 次消除目标,即恰好生成 `3` 件物体;同一种视觉模型在同局中不应出现超过 3 件。 +3. 不为了展示 15 种而修改初始层级、物理堆叠、遮挡、边界或可点击规则;被盖住、堆叠和局部不可见是正常玩法效果。 +4. 当前版本已改为第 11 节的 `itemTypeCount = clearCount <= 25 ? clearCount : 25` 规则。 + +## 11. 25 个积木件资源池替换 + +2026-05-03 根据新的参考图,把可消除物体替换为 25 个积木件类型,并调整本局类型抽取规则。 + +编码口径: + +1. 默认 `visualKey` 资源池改为 25 个积木件,覆盖长条、短条、2x2、2x3、2x4、1x1、光板、斜坡、圆柱、透明圆环、拱门和锥形件等差异化模型。 +2. 前端 3D 表现继续使用 Three.js 程序化几何体生成,不引入外部贴图或 GLB;托盘和 2D 回退继续使用同一批 `visualKey` 的简化图标。 +3. `clearCount <= 25` 时,本局从 25 个类型中按确定性随机顺序抽取 `clearCount` 种类型,不允许同局刷新重复类型。 +4. `clearCount > 25` 时,本局最多使用 25 种类型,额外消除组在这 25 种中轮转复用;每种类型最终数量仍必须是 3 的倍数。 +5. 该随机抽取只决定本局使用哪些类型和使用顺序,不改变物理堆叠、遮挡、边界、可点击判定、备选栏和胜负规则。 +6. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套 `itemTypeCount = clearCount <= 25 ? clearCount : 25` 口径。 + +## 12. 五档体积规则 + +2026-05-03 追加可消除物模型大小规则,把每局可消除物按五档相对体积分配。 + +编码口径: + +1. M 型作为标准体积 `1.00`。 +2. XL 型相对体积范围为 `1.60~2.30`,占本局可消除类型数的 `20%`。 +3. L 型相对体积范围为 `1.25~1.60`,占本局可消除类型数的 `30%`。 +4. M 型相对体积固定为 `1.00`,占本局可消除类型数的 `30%`。 +5. XS 型相对体积范围为 `0.65~0.85`,占本局可消除类型数的 `15%`。 +6. S 型相对体积范围为 `0.35~0.50`,占本局可消除类型数的 `5%`。 +7. 本局使用类型数仍按第 11 节计算,即 `clearCount <= 25 ? clearCount : 25`。比例遇到非整数时按最大余数补齐,确保五档数量之和等于本局使用类型数。 +8. 体积档位分配绑定到本局选中的 `visualKey`,同一局内同一个颜色和造型只能有一个尺寸档位和一个半径;当 `clearCount > 25` 轮转复用类型时,复用的同一 `visualKey` 继续沿用同一尺寸。 +9. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套五档体积分配口径。 diff --git a/server-rs/crates/module-match3d/src/application.rs b/server-rs/crates/module-match3d/src/application.rs index 0cb34c10..80517a9f 100644 --- a/server-rs/crates/module-match3d/src/application.rs +++ b/server-rs/crates/module-match3d/src/application.rs @@ -2,15 +2,56 @@ use shared_kernel::{normalize_optional_string, normalize_required_string, normal use crate::commands::{default_tags_for_theme, validate_result_publish_fields}; use crate::{ - MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BOARD_SAFE_MARGIN, - MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_FRUIT_VISUAL_KEYS, MATCH3D_ITEMS_PER_CLEAR, - MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, MATCH3D_SHAPE_VISUAL_KEYS, - MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason, - Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot, - Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot, - Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, + MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, + MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR, + MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY, + MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, + Match3DClickRejectReason, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, + Match3DItemSnapshot, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, + Match3DRunSnapshot, Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, }; +#[derive(Clone, Copy)] +struct Match3DSizeTierRule { + ratio: f32, + radius_scale: f32, + relative_volume: f32, + tier: &'static str, +} + +const MATCH3D_SIZE_TIER_RULES: [Match3DSizeTierRule; 5] = [ + Match3DSizeTierRule { + tier: "XL", + ratio: 0.20, + relative_volume: 1.86, + radius_scale: 1.23, + }, + Match3DSizeTierRule { + tier: "L", + ratio: 0.30, + relative_volume: 1.40, + radius_scale: 1.12, + }, + Match3DSizeTierRule { + tier: "M", + ratio: 0.30, + relative_volume: 1.00, + radius_scale: 1.00, + }, + Match3DSizeTierRule { + tier: "XS", + ratio: 0.15, + relative_volume: 0.73, + radius_scale: 0.90, + }, + Match3DSizeTierRule { + tier: "S", + ratio: 0.05, + relative_volume: 0.44, + radius_scale: 0.76, + }, +]; + pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft { let game_name = format!("{}抓大鹅", config.theme_text); let summary = format!( @@ -268,17 +309,18 @@ fn build_initial_items( ) -> Vec { let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); let base_radius = resolve_item_radius(difficulty); - let visual_keys = visual_keys_for_theme(theme_text); + let selected_visual_keys = select_visual_keys(&mut rng, theme_text, clear_count); + let item_type_count = resolve_item_type_count(clear_count); + let size_tier_plan = resolve_size_tier_plan(item_type_count); let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize); for clear_index in 0..clear_count { - let visual_index = (clear_index as usize) % visual_keys.len(); + let visual_index = (clear_index as usize) % item_type_count; let item_type_id = format!("match3d-type-{:02}", visual_index + 1); - let visual_key = visual_keys[visual_index].to_string(); + let visual_key = selected_visual_keys[visual_index].to_string(); + let radius = resolve_item_radius_variant(base_radius, size_tier_plan[visual_index]); for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR { - let radius = - resolve_item_radius_variant(base_radius, &visual_key, visual_index, copy_index); let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius)); let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index; items.push(Match3DItemSnapshot { @@ -308,22 +350,57 @@ fn build_initial_items( items } -fn visual_keys_for_theme(theme_text: &str) -> &'static [&'static str; 10] { - if is_fruit_theme(theme_text) { - &MATCH3D_FRUIT_VISUAL_KEYS - } else { - &MATCH3D_SHAPE_VISUAL_KEYS +fn resolve_size_tier_plan(item_type_count: usize) -> Vec { + let mut plans = MATCH3D_SIZE_TIER_RULES + .iter() + .map(|rule| { + let exact_count = item_type_count as f32 * rule.ratio; + (exact_count.floor() as usize, exact_count.fract(), *rule) + }) + .collect::>(); + let mut assigned_count = plans + .iter() + .map(|(count, _, _)| *count) + .sum::(); + let mut remainder_order = (0..plans.len()).collect::>(); + remainder_order.sort_by(|left, right| { + plans[*right] + .1 + .partial_cmp(&plans[*left].1) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let mut cursor = 0; + while assigned_count < item_type_count { + let plan_index = remainder_order[cursor % remainder_order.len()]; + plans[plan_index].0 += 1; + assigned_count += 1; + cursor += 1; } + + plans + .into_iter() + .flat_map(|(count, _, rule)| std::iter::repeat(rule).take(count)) + .collect() } -fn is_fruit_theme(theme_text: &str) -> bool { - let normalized = theme_text.trim().to_lowercase(); - [ - "水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "桃", - "李", "柠", "橙", "梨", - ] - .iter() - .any(|marker| normalized.contains(marker)) +fn resolve_item_type_count(clear_count: u32) -> usize { + clear_count.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize +} + +fn select_visual_keys( + rng: &mut DeterministicRng, + _theme_text: &str, + clear_count: u32, +) -> Vec<&'static str> { + let item_type_count = resolve_item_type_count(clear_count); + let mut visual_keys = MATCH3D_BLOCK_VISUAL_KEYS.to_vec(); + // 中文注释:只打乱类型池顺序,不改变每个类型三件一组的可通关结构。 + for index in (1..visual_keys.len()).rev() { + let swap_index = (rng.next_u32() as usize) % (index + 1); + visual_keys.swap(index, swap_index); + } + visual_keys.truncate(item_type_count); + visual_keys } fn resolve_item_radius(difficulty: u32) -> f32 { @@ -332,48 +409,10 @@ fn resolve_item_radius(difficulty: u32) -> f32 { radius.max(0.052) } -fn resolve_item_radius_variant( - base_radius: f32, - visual_key: &str, - visual_index: usize, - copy_index: u32, -) -> f32 { - let copy_delta = (copy_index as f32 - 1.0) * 0.002; - if is_fruit_visual_key(visual_key) { - return (base_radius * fruit_visual_size_scale(visual_key) + copy_delta).clamp(0.04, 0.13); - } - - let type_delta = ((visual_index % 5) as f32 - 2.0) * 0.004; - (base_radius + type_delta + copy_delta).clamp(0.045, 0.12) -} - -fn is_fruit_visual_key(visual_key: &str) -> bool { - matches!( - visual_key, - "watermelon-green" - | "apple-red" - | "banana-yellow" - | "grape-purple" - | "melon-green" - | "berry-blue" - | "peach-pink" - | "plum-indigo" - | "lime-lime" - | "orange-orange" - | "pear-cyan" - ) -} - -fn fruit_visual_size_scale(visual_key: &str) -> f32 { - match visual_key { - "watermelon-green" => 1.24, - "melon-green" => 1.12, - "banana-yellow" => 1.04, - "apple-red" | "orange-orange" | "peach-pink" | "pear-cyan" => 1.0, - "plum-indigo" | "lime-lime" => 0.86, - "grape-purple" | "berry-blue" => 0.78, - _ => 1.0, - } +fn resolve_item_radius_variant(base_radius: f32, size_tier: Match3DSizeTierRule) -> f32 { + debug_assert!(!size_tier.tier.is_empty()); + debug_assert!(size_tier.relative_volume > 0.0); + (base_radius * size_tier.radius_scale).clamp(0.045, 0.13) } fn max_spawn_offset(radius: f32) -> f32 { @@ -623,6 +662,79 @@ mod tests { assert!(counts.values().all(|count| count % 3 == 0)); } + #[test] + fn item_type_count_follows_clear_count_until_twenty_five() { + let run = start_run_with_seed_at( + "run-types-small".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(12), + 42, + 1_000, + ) + .expect("run should start"); + + let mut counts = BTreeMap::::new(); + for item in &run.items { + *counts.entry(item.item_type_id.clone()).or_default() += 1; + } + + assert_eq!(counts.len(), 12); + assert!(counts.values().all(|count| *count == 3)); + } + + #[test] + fn visual_key_count_follows_fifteen_clear_count() { + let run = start_run_with_seed_at( + "run-types-fifteen".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(15), + 42, + 1_000, + ) + .expect("run should start"); + + let mut counts = BTreeMap::::new(); + let mut item_types_by_visual_key = BTreeMap::>::new(); + for item in &run.items { + *counts.entry(item.visual_key.clone()).or_default() += 1; + item_types_by_visual_key + .entry(item.visual_key.clone()) + .or_default() + .push(item.item_type_id.clone()); + } + + assert_eq!(counts.len(), 15); + assert!(counts.values().all(|count| *count == 3)); + assert!(item_types_by_visual_key.values().all(|item_type_ids| { + item_type_ids + .iter() + .all(|item_type_id| item_type_id == &item_type_ids[0]) + })); + } + + #[test] + fn item_type_count_is_capped_at_twenty_five() { + let run = start_run_with_seed_at( + "run-types-large".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(100), + 42, + 1_000, + ) + .expect("run should start"); + + let mut counts = BTreeMap::::new(); + for item in &run.items { + *counts.entry(item.item_type_id.clone()).or_default() += 1; + } + + assert_eq!(counts.len(), 25); + assert!(counts.values().all(|count| count % 3 == 0)); + } + #[test] fn initial_run_uses_slightly_different_item_sizes() { let run = start_run_with_seed_at( @@ -647,9 +759,58 @@ mod tests { } #[test] - fn fruit_theme_generates_fruit_visuals_inside_board() { + fn size_tier_plan_follows_ratio_for_twenty_five_types() { + let plan = resolve_size_tier_plan(25); + let mut counts = BTreeMap::<&str, usize>::new(); + for rule in plan { + *counts.entry(rule.tier).or_default() += 1; + match rule.tier { + "XL" => assert!((1.60..=2.30).contains(&rule.relative_volume)), + "L" => assert!((1.25..=1.60).contains(&rule.relative_volume)), + "M" => assert_eq!(rule.relative_volume, 1.00), + "XS" => assert!((0.65..=0.85).contains(&rule.relative_volume)), + "S" => assert!((0.35..=0.50).contains(&rule.relative_volume)), + _ => panic!("unknown size tier"), + } + } + + assert_eq!(counts.get("XL"), Some(&5)); + assert_eq!(counts.get("L"), Some(&8)); + assert_eq!(counts.get("M"), Some(&7)); + assert_eq!(counts.get("XS"), Some(&4)); + assert_eq!(counts.get("S"), Some(&1)); + } + + #[test] + fn same_visual_key_keeps_one_size_in_run() { let run = start_run_with_seed_at( - "run-fruit".to_string(), + "run-size-unique".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(30), + 42, + 1_000, + ) + .expect("run should start"); + + let mut radii_by_visual_key = BTreeMap::>::new(); + for item in &run.items { + radii_by_visual_key + .entry(item.visual_key.clone()) + .or_default() + .push((item.radius * 10_000.0).round() as u32); + } + + assert_eq!(radii_by_visual_key.len(), 25); + assert!(radii_by_visual_key.values().all(|radii| { + radii.iter().all(|radius| radius == &radii[0]) + })); + } + + #[test] + fn block_visuals_stay_inside_board() { + let run = start_run_with_seed_at( + "run-blocks".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(10), @@ -663,10 +824,7 @@ mod tests { .iter() .map(|item| item.visual_key.as_str()) .collect::>(); - assert!(visual_keys.contains(&"watermelon-green")); - assert!(visual_keys.contains(&"apple-red")); - assert!(visual_keys.contains(&"banana-yellow")); - assert!(!visual_keys.contains(&"red_circle")); + assert!(visual_keys.iter().all(|visual_key| visual_key.starts_with("block-"))); for item in &run.items { let dx = item.x - MATCH3D_BOARD_CENTER; @@ -684,38 +842,31 @@ mod tests { } #[test] - fn fruit_theme_uses_common_sense_relative_sizes() { + fn twenty_five_or_less_does_not_repeat_visual_keys() { let run = start_run_with_seed_at( - "run-fruit-size".to_string(), + "run-block-unique".to_string(), "user-1".to_string(), "profile-1".to_string(), - &test_config(10), + &test_config(25), 27, 1_000, ) .expect("run should start"); - let max_radius_for_visual = |visual_key: &str| { - run.items - .iter() - .filter(|item| item.visual_key == visual_key) - .map(|item| item.radius) - .fold(0.0, f32::max) - }; + let mut counts = BTreeMap::::new(); + for item in &run.items { + *counts.entry(item.visual_key.clone()).or_default() += 1; + } - let watermelon = max_radius_for_visual("watermelon-green"); - let apple = max_radius_for_visual("apple-red"); - let grape = max_radius_for_visual("grape-purple"); - - assert!(watermelon > apple); - assert!(apple > grape); + assert_eq!(counts.len(), 25); + assert!(counts.values().all(|count| *count == 3)); } #[test] - fn non_fruit_theme_generates_shape_visuals() { + fn block_visuals_have_different_relative_sizes() { let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid"); let run = start_run_with_seed_at( - "run-shapes".to_string(), + "run-block-size".to_string(), "user-1".to_string(), "profile-1".to_string(), &config, @@ -724,14 +875,15 @@ mod tests { ) .expect("run should start"); - let visual_keys = run + let mut radii = run .items .iter() - .map(|item| item.visual_key.as_str()) + .map(|item| (item.radius * 1_000.0).round() as u32) .collect::>(); - assert!(visual_keys.contains(&"red_circle")); - assert!(visual_keys.contains(&"yellow_triangle")); - assert!(!visual_keys.contains(&"apple-red")); + radii.sort(); + radii.dedup(); + + assert!(radii.len() > 1); } #[test] diff --git a/server-rs/crates/module-match3d/src/domain.rs b/server-rs/crates/module-match3d/src/domain.rs index 890e5501..53e50b08 100644 --- a/server-rs/crates/module-match3d/src/domain.rs +++ b/server-rs/crates/module-match3d/src/domain.rs @@ -9,6 +9,8 @@ pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-"; pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-"; pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3; +pub const MATCH3D_MAX_ITEM_TYPE_COUNT: u32 = 25; +pub(crate) const MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE: usize = 25; pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000; @@ -16,32 +18,34 @@ pub const MATCH3D_BOARD_CENTER: f32 = 0.5; pub const MATCH3D_BOARD_RADIUS: f32 = 0.5; pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035; -// 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键。 -pub(crate) const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [ - "watermelon-green", - "apple-red", - "banana-yellow", - "grape-purple", - "melon-green", - "berry-blue", - "peach-pink", - "plum-indigo", - "lime-lime", - "orange-orange", -]; - -// 中文注释:非水果题材使用颜色形状兜底 key;前端必须逐个渲染,不能统一兜成同一图案。 -pub(crate) const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [ - "red_circle", - "yellow_triangle", - "purple_diamond", - "green_square", - "blue_star", - "orange_hexagon", - "cyan_capsule", - "pink_heart", - "lime_leaf", - "white_moon", +// 中文注释:首版 demo 不接真实图片生成,当前先用程序化积木件作为稳定可辨认的默认素材。 +// 中文注释:当前 demo 使用 25 个积木件作为默认可消除物资源池,前端据 visual_key 程序化生成 3D 模型。 +pub(crate) const MATCH3D_BLOCK_VISUAL_KEYS: [&str; MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE] = [ + "block-red-2x4", + "block-blue-1x2", + "block-yellow-2x2", + "block-green-1x4", + "block-orange-1x6", + "block-white-1x1", + "block-black-1x8", + "block-tan-2x3", + "block-lime-1x2", + "block-darkred-2x2", + "block-blue-1x4", + "block-pink-2x4", + "block-gray-1x6", + "block-lavender-tile-2x2", + "block-teal-tile-1x3", + "block-mint-tile-1x4", + "block-magenta-tile-2x2", + "block-orange-tile-2x2-stud", + "block-purple-slope-1x2", + "block-brown-slope-1x2", + "block-sky-slope-2x2", + "block-green-cylinder", + "block-clear-ring", + "block-mint-arch", + "block-gold-cone", ]; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/src/Match3DPlaygroundApp.tsx b/src/Match3DPlaygroundApp.tsx index 68a56989..ba37e6a8 100644 --- a/src/Match3DPlaygroundApp.tsx +++ b/src/Match3DPlaygroundApp.tsx @@ -12,7 +12,13 @@ import { } from './services/match3d-runtime'; function buildInitialRun() { - return startLocalMatch3DRun(12); + const params = new URLSearchParams(window.location.search); + const clearCountParam = params.get('clearCount') ?? params.get('count'); + const clearCount = + clearCountParam === null ? 12 : Number.parseInt(clearCountParam, 10); + return startLocalMatch3DRun( + Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12, + ); } export default function Match3DPlaygroundApp() { diff --git a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx index c4e1078e..c59c0f8a 100644 --- a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx +++ b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx @@ -8,7 +8,11 @@ import { isItemState, resolveRenderableItemFrame, } from './match3dRuntimePresentation'; -import { resolveGeometryAsset } from './match3dVisualAssets'; +import { + resolveGeometryAsset, + type Match3DGeometryAsset, + type Match3DGeometryShape, +} from './match3dVisualAssets'; type Match3DPhysicsBoardProps = { run: Match3DRunSnapshot; @@ -21,15 +25,17 @@ type ThreeModule = typeof import('three'); type CannonModule = typeof import('cannon-es'); type PhysicsBody = import('cannon-es').Body; type PhysicsWorld = import('cannon-es').World; -type ThreeMesh = import('three').Mesh; +type ThreeObject3D = import('three').Object3D; type ThreeScene = import('three').Scene; type ThreeRenderer = import('three').WebGLRenderer; -type ThreeCamera = import('three').PerspectiveCamera; +type ThreeCamera = import('three').OrthographicCamera; type PhysicsEntry = { item: Match3DItemSnapshot; body: PhysicsBody; - mesh: ThreeMesh; + lockReadableTop: boolean; + mesh: ThreeObject3D; + topRotationY: number; }; type PhysicsRuntime = { @@ -48,11 +54,19 @@ const MATCH3D_POT_FLOOR_RADIUS = 4.75; const MATCH3D_POT_INNER_RADIUS = 4.52; const MATCH3D_POT_OUTER_RADIUS = 5.18; const MATCH3D_POT_WALL_HEIGHT = 2.15; -const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.82; -const MATCH3D_ITEM_POSITION_RADIUS = 3.64; -const MATCH3D_ITEM_SPAWN_HEIGHT = 1.85; +const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58; +const MATCH3D_ITEM_POSITION_RADIUS = 3.34; +const MATCH3D_ITEM_SPAWN_HEIGHT = 1.25; +const MATCH3D_ITEM_STACK_HEIGHT_STEP = 0.024; +const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0; const MATCH3D_BOARD_CENTER = 0.5; const MATCH3D_PHYSICS_STEP = 1 / 60; +const MATCH3D_CAMERA_HALF_SIZE = 6.15; +export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet = + new Set([ + 'ring', + 'arch', + ]); function hasWebGLSupport() { try { @@ -67,7 +81,7 @@ function hasWebGLSupport() { function toWorldPosition(item: Match3DItemSnapshot) { const frame = resolveRenderableItemFrame(item); - const radius = Math.max(0.32, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.32); + const radius = Math.max(0.28, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.02); let x = (frame.x - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2; let z = (frame.y - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2; const horizontalDistance = Math.hypot(x, z); @@ -112,92 +126,330 @@ function constrainBodyInsidePot(entry: PhysicsEntry) { } } +function applyCenterGravity(entry: PhysicsEntry) { + if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) { + return; + } + + const horizontalDistance = Math.hypot( + entry.body.position.x, + entry.body.position.z, + ); + if (horizontalDistance <= 0.08) { + return; + } + + const visualRadius = toWorldPosition(entry.item).radius; + const maxDistance = Math.max( + 0.1, + MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05, + ); + const edgePressure = Math.min(1, horizontalDistance / maxDistance); + const centerFalloff = Math.min(1, Math.max(0, (horizontalDistance - 1.15) / maxDistance)); + const forceStrength = + MATCH3D_CENTER_GRAVITY_COEFFICIENT * + entry.body.mass * + (10.5 + edgePressure * 13) * + centerFalloff; + + // 中文注释:中心引力只拉水平面,垂直方向仍交给锅底重力和物体堆叠处理。 + entry.body.force.x += + (-entry.body.position.x / horizontalDistance) * forceStrength; + entry.body.force.z += + (-entry.body.position.z / horizontalDistance) * forceStrength; +} + function createCannonShape( cannon: CannonModule, shape: ReturnType['shape'], radius: number, ) { switch (shape) { - case 'circle': - case 'heart': - return new cannon.Sphere(radius); - case 'square': - return new cannon.Box(new cannon.Vec3(radius, radius, radius)); - case 'triangle': - return new cannon.Cylinder(radius * 0.55, radius, radius * 1.5, 3); - case 'diamond': - return new cannon.Sphere(radius * 0.92); - case 'star': - return new cannon.Sphere(radius * 0.88); - case 'hexagon': - return new cannon.Cylinder(radius, radius, radius * 1.2, 6); - case 'capsule': - return new cannon.Box(new cannon.Vec3(radius * 1.28, radius * 0.68, radius * 0.68)); - case 'trapezoid': - return new cannon.Box(new cannon.Vec3(radius * 1.02, radius * 0.78, radius * 0.78)); - case 'parallelogram': - return new cannon.Box(new cannon.Vec3(radius * 1.12, radius * 0.72, radius * 0.72)); + case 'ring': + case 'cylinder': + case 'cone': + return new cannon.Cylinder(radius * 0.82, radius * 0.82, radius * 1.1, 18); + case 'slope': + return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.66)); + case 'arch': + return new cannon.Box(new cannon.Vec3(radius * 1.35, radius * 0.92, radius * 0.56)); + case 'tile': + return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.36, radius * 0.72)); + case 'brick': default: - return new cannon.Sphere(radius); + return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.72)); } } -function createThreeGeometry( +function buildPointShape( three: ThreeModule, - shape: ReturnType['shape'], + radius: number, + points: Array<[number, number]>, +) { + const shape = new three.Shape(); + points.forEach(([x, y], index) => { + if (index === 0) { + shape.moveTo(x * radius, y * radius); + } else { + shape.lineTo(x * radius, y * radius); + } + }); + shape.closePath(); + return shape; +} + +function buildRingShape(three: ThreeModule, radius: number) { + const shape = new three.Shape(); + shape.absarc(0, 0, radius * 0.92, 0, Math.PI * 2, false); + const hole = new three.Path(); + hole.absarc(0, 0, radius * 0.43, 0, Math.PI * 2, true); + shape.holes.push(hole); + return shape; +} + +function buildReadableShape( + three: ThreeModule, + shape: Match3DGeometryShape, radius: number, ) { switch (shape) { - case 'circle': - return new three.SphereGeometry(radius, 28, 18); - case 'square': - return new three.BoxGeometry(radius * 1.65, radius * 1.65, radius * 1.65); - case 'triangle': - return new three.ConeGeometry(radius, radius * 1.9, 3); - case 'diamond': - return new three.OctahedronGeometry(radius * 1.04, 1); - case 'star': - return new three.IcosahedronGeometry(radius * 0.96, 0); - case 'hexagon': - return new three.CylinderGeometry(radius, radius, radius * 1.35, 6); - case 'capsule': - return new three.CapsuleGeometry(radius * 0.62, radius * 1.18, 6, 14); - case 'heart': - return new three.SphereGeometry(radius, 24, 16); - case 'trapezoid': - return new three.CylinderGeometry(radius * 0.78, radius * 1.12, radius * 1.1, 4); - case 'parallelogram': - return new three.BoxGeometry(radius * 1.9, radius * 1.05, radius * 1.05); + case 'ring': + return buildRingShape(three, radius); + case 'arch': + return buildPointShape(three, radius, [ + [-1, 0.8], + [1, 0.8], + [1, -0.7], + [0.42, -0.7], + [0.42, 0.24], + [-0.42, 0.24], + [-0.42, -0.7], + [-1, -0.7], + ]); default: - return new three.SphereGeometry(radius, 28, 18); + return null; } } +function createExtrudedReadableGeometry( + three: ThreeModule, + shape: Match3DGeometryShape, + radius: number, +) { + const path = buildReadableShape(three, shape, radius); + if (!path) { + return null; + } + const geometry = new three.ExtrudeGeometry(path, { + bevelEnabled: true, + bevelSegments: 2, + bevelSize: radius * 0.045, + bevelThickness: radius * 0.04, + depth: radius * 0.42, + steps: 1, + }); + geometry.center(); + geometry.rotateX(-Math.PI / 2); + return geometry; +} + +export function createMatch3DThreeGeometry( + three: ThreeModule, + shape: Match3DGeometryShape, + radius: number, +) { + const readableGeometry = createExtrudedReadableGeometry(three, shape, radius); + if (readableGeometry) { + return readableGeometry; + } + + switch (shape) { + case 'cylinder': + return new three.CylinderGeometry(radius * 0.72, radius * 0.72, radius * 1.35, 26); + case 'cone': + return new three.ConeGeometry(radius * 0.78, radius * 1.62, 28); + case 'tile': + case 'brick': + case 'slope': + case 'arch': + default: + return new three.BoxGeometry(radius * 1.8, radius * 0.9, radius * 1.2); + } +} + +function createRoundedBlockBase( + three: ThreeModule, + asset: Match3DGeometryAsset, + radius: number, +) { + const width = radius * (0.9 + asset.studsX * 0.62); + const depth = radius * (0.9 + asset.studsY * 0.62); + const height = Math.max(radius * 0.24, radius * asset.heightScale); + return new three.BoxGeometry(width, height, depth); +} + +function createStudGeometry(three: ThreeModule, radius: number) { + return new three.CylinderGeometry(radius * 0.18, radius * 0.18, radius * 0.12, 20); +} + +function createSlopeGeometry( + three: ThreeModule, + asset: Match3DGeometryAsset, + radius: number, +) { + const width = radius * (1 + asset.studsX * 0.66); + const depth = radius * (0.95 + asset.studsY * 0.62); + const height = radius * asset.heightScale; + const halfW = width / 2; + const halfD = depth / 2; + const halfH = height / 2; + const vertices = new Float32Array([ + -halfW, -halfH, -halfD, + halfW, -halfH, -halfD, + halfW, -halfH, halfD, + -halfW, -halfH, halfD, + halfW, halfH, -halfD, + halfW, halfH, halfD, + ]); + const indices = [ + 0, 1, 2, 0, 2, 3, + 1, 4, 5, 1, 5, 2, + 3, 2, 5, 3, 5, 0, + 0, 5, 4, 0, 4, 1, + ]; + const geometry = new three.BufferGeometry(); + geometry.setAttribute('position', new three.BufferAttribute(vertices, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +function addBrickStuds( + three: ThreeModule, + group: import('three').Group, + asset: Match3DGeometryAsset, + radius: number, + material: import('three').Material, +) { + if (asset.shape === 'tile') { + return; + } + const studGeometry = createStudGeometry(three, radius); + const width = radius * (0.9 + asset.studsX * 0.62); + const depth = radius * (0.9 + asset.studsY * 0.62); + const y = Math.max(radius * 0.24, radius * asset.heightScale) / 2 + radius * 0.06; + for (let row = 0; row < asset.studsY; row += 1) { + for (let column = 0; column < asset.studsX; column += 1) { + const stud = new three.Mesh(studGeometry.clone(), material); + stud.position.set( + ((column + 0.5) / asset.studsX - 0.5) * width * 0.74, + y, + ((row + 0.5) / asset.studsY - 0.5) * depth * 0.72, + ); + group.add(stud); + } + } +} + +function createBlockMesh( + three: ThreeModule, + asset: Match3DGeometryAsset, + radius: number, + material: import('three').Material, +) { + const group = new three.Group(); + let baseGeometry: import('three').BufferGeometry; + if (asset.shape === 'slope') { + baseGeometry = createSlopeGeometry(three, asset, radius); + } else if (asset.shape === 'cylinder') { + baseGeometry = new three.CylinderGeometry(radius * 0.58, radius * 0.58, radius * 1.18, 28); + } else if (asset.shape === 'cone') { + baseGeometry = new three.ConeGeometry(radius * 0.68, radius * 1.48, 30); + } else if (MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)) { + baseGeometry = createMatch3DThreeGeometry(three, asset.shape, radius); + } else { + baseGeometry = createRoundedBlockBase(three, asset, radius); + } + const base = new three.Mesh(baseGeometry, material); + group.add(base); + + if (asset.shape === 'brick' || asset.shape === 'slope') { + addBrickStuds(three, group, asset, radius, material); + } + if (asset.shape === 'cylinder') { + const topStud = new three.Mesh(createStudGeometry(three, radius * 1.2), material); + topStud.position.y = radius * 0.65; + group.add(topStud); + } + if (asset.shape === 'cone') { + const lip = new three.Mesh( + new three.TorusGeometry(radius * 0.38, radius * 0.07, 8, 24), + material, + ); + lip.rotation.x = Math.PI / 2; + lip.position.y = radius * 0.52; + group.add(lip); + } + return group; +} + +function markObjectForItem(object: ThreeObject3D, itemInstanceId: string) { + object.userData.itemInstanceId = itemInstanceId; + object.traverse((child) => { + child.userData.itemInstanceId = itemInstanceId; + child.castShadow = true; + child.receiveShadow = true; + }); +} + +function disposeThreeObject(object: ThreeObject3D) { + object.traverse((child) => { + const maybeMesh = child as import('three').Mesh; + maybeMesh.geometry?.dispose(); + const material = maybeMesh.material; + if (Array.isArray(material)) { + material.forEach((item) => item.dispose()); + } else { + material?.dispose(); + } + }); +} + +export function createMatch3DItemMesh( + three: ThreeModule, + item: Match3DItemSnapshot, +) { + const asset = resolveGeometryAsset(item.visualKey); + const position = toWorldPosition(item); + const material = new three.MeshStandardMaterial({ + color: asset.fill, + emissive: asset.fill, + emissiveIntensity: 0.08, + metalness: 0.16, + opacity: asset.transparent ? 0.58 : 1, + roughness: 0.46, + transparent: Boolean(asset.transparent), + side: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape) + ? three.DoubleSide + : three.FrontSide, + }); + const mesh = createBlockMesh(three, asset, position.radius, material); + markObjectForItem(mesh, item.itemInstanceId); + return { + lockReadableTop: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape), + mesh, + radius: position.radius, + shape: asset.shape, + topRotationY: ((item.layer % 12) / 12) * Math.PI * 2, + position, + }; +} + function createItemMesh( three: ThreeModule, item: Match3DItemSnapshot, ) { - const asset = resolveGeometryAsset(item.visualKey); - const position = toWorldPosition(item); - const geometry = createThreeGeometry(three, asset.shape, position.radius); - if (asset.shape === 'parallelogram') { - geometry.applyMatrix4(new three.Matrix4().makeShear(0.28, 0, 0, 0, 0, 0)); - } - if (asset.shape === 'heart') { - geometry.scale(1, 0.92, 0.82); - } - const material = new three.MeshStandardMaterial({ - color: asset.fill, - emissive: asset.fill, - emissiveIntensity: 0.08, - metalness: 0.16, - roughness: 0.46, - }); - const mesh = new three.Mesh(geometry, material); - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.userData.itemInstanceId = item.itemInstanceId; - return { mesh, shape: asset.shape, radius: position.radius, position }; + return createMatch3DItemMesh(three, item); } function disposeRuntime(runtime: PhysicsRuntime | null) { @@ -208,18 +460,182 @@ function disposeRuntime(runtime: PhysicsRuntime | null) { window.cancelAnimationFrame(runtime.animationId); } runtime.entries.forEach((entry) => { - entry.mesh.geometry.dispose(); - const material = entry.mesh.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material.dispose(); - } + disposeThreeObject(entry.mesh); }); runtime.renderer.dispose(); runtime.renderer.domElement.remove(); } +type TrayPreviewRuntime = { + animationId: number | null; + camera: ThreeCamera; + entries: Map; + renderer: ThreeRenderer; + scene: ThreeScene; + three: ThreeModule; +}; + +function disposeTrayPreview(runtime: TrayPreviewRuntime | null) { + if (!runtime) { + return; + } + if (runtime.animationId !== null) { + window.cancelAnimationFrame(runtime.animationId); + } + runtime.entries.forEach((mesh) => { + disposeThreeObject(mesh); + }); + runtime.entries.clear(); + runtime.renderer.dispose(); + runtime.renderer.domElement.remove(); +} + +export function Match3DTrayPreviewBoard({ + slotItems, +}: { + slotItems: Array; +}) { + const containerRef = useRef(null); + const runtimeRef = useRef(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + let cleanupResize: (() => void) | undefined; + + async function setup() { + const container = containerRef.current; + if (!container || !hasWebGLSupport()) { + return; + } + + const three = await import('three'); + if (cancelled || !containerRef.current) { + return; + } + + const renderer = new three.WebGLRenderer({ + alpha: true, + antialias: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); + renderer.outputColorSpace = three.SRGBColorSpace; + container.appendChild(renderer.domElement); + + const scene = new three.Scene(); + scene.background = null; + const camera = new three.OrthographicCamera(-3.7, 3.7, 1.1, -1.1, 0.1, 40); + camera.position.set(4.2, 3.2, 4.2); + camera.lookAt(0, 0, 0); + + scene.add(new three.AmbientLight(0xffffff, 1.55)); + const keyLight = new three.DirectionalLight(0xffffff, 2.2); + keyLight.position.set(-2.6, 4.4, 3.2); + scene.add(keyLight); + const fillLight = new three.DirectionalLight(0xfef3c7, 0.95); + fillLight.position.set(3.2, 2.8, -2.8); + scene.add(fillLight); + + const resize = () => { + const rect = container.getBoundingClientRect(); + const width = Math.max(1, rect.width); + const height = Math.max(1, rect.height); + renderer.setSize(width, height, false); + renderer.render(scene, camera); + }; + resize(); + + const ro = new ResizeObserver(resize); + ro.observe(container); + + const animate = () => { + const activeRuntime = runtimeRef.current; + if (!activeRuntime) { + return; + } + renderer.render(scene, camera); + activeRuntime.animationId = window.requestAnimationFrame(animate); + }; + runtimeRef.current = { + animationId: window.requestAnimationFrame(animate), + camera, + entries: new Map(), + renderer, + scene, + three, + }; + setReady(true); + + cleanupResize = () => ro.disconnect(); + } + + void setup(); + + return () => { + cancelled = true; + cleanupResize?.(); + disposeTrayPreview(runtimeRef.current); + runtimeRef.current = null; + setReady(false); + }; + }, []); + + useEffect(() => { + const runtime = runtimeRef.current; + if (!runtime) { + return; + } + const activeIds = new Set( + slotItems + .filter((item): item is Match3DItemSnapshot => Boolean(item)) + .map((item) => item.itemInstanceId), + ); + + runtime.entries.forEach((mesh, itemInstanceId) => { + if (!activeIds.has(itemInstanceId)) { + runtime.scene.remove(mesh); + disposeThreeObject(mesh); + runtime.entries.delete(itemInstanceId); + } + }); + + slotItems.forEach((item, slotIndex) => { + if (!item) { + return; + } + let mesh = runtime.entries.get(item.itemInstanceId); + if (!mesh) { + const preview = createMatch3DItemMesh(runtime.three, item); + mesh = preview.mesh; + mesh.rotation.set(-0.12, Math.PI / 4, 0.08); + + const bounds = new runtime.three.Box3().setFromObject(mesh); + const size = bounds.getSize(new runtime.three.Vector3()); + const maxDimension = Math.max(size.x, size.y, size.z, 0.001); + mesh.scale.multiplyScalar(0.82 / maxDimension); + const centeredBounds = new runtime.three.Box3().setFromObject(mesh); + const center = centeredBounds.getCenter(new runtime.three.Vector3()); + mesh.position.sub(center); + runtime.scene.add(mesh); + runtime.entries.set(item.itemInstanceId, mesh); + } + mesh.position.x = (slotIndex - 3) * 1.03; + mesh.position.y = 0; + mesh.position.z = 0; + }); + + runtime.renderer.render(runtime.scene, runtime.camera); + }, [ready, slotItems]); + + return ( +
+ ); +} + export function Match3DPhysicsBoard({ run, disabled, @@ -272,13 +688,29 @@ export function Match3DPhysicsBoard({ renderer.shadowMap.enabled = true; renderer.outputColorSpace = three.SRGBColorSpace; container.appendChild(renderer.domElement); + const handleContextLost = (event: Event) => { + event.preventDefault(); + fallbackRef.current(); + }; + renderer.domElement.addEventListener( + 'webglcontextlost', + handleContextLost, + false, + ); const scene = new three.Scene(); scene.background = null; - const camera = new three.PerspectiveCamera(32, 1, 0.1, 80); - camera.position.set(0, 14.8, 2.3); - camera.lookAt(0, 0.48, 0); + const camera = new three.OrthographicCamera( + -MATCH3D_CAMERA_HALF_SIZE, + MATCH3D_CAMERA_HALF_SIZE, + MATCH3D_CAMERA_HALF_SIZE, + -MATCH3D_CAMERA_HALF_SIZE, + 0.1, + 80, + ); + camera.position.set(0, 17.5, 0.01); + camera.lookAt(0, 0, 0); const ambient = new three.AmbientLight(0xffffff, 1.28); scene.add(ambient); @@ -407,7 +839,10 @@ export function Match3DPhysicsBoard({ const rect = container.getBoundingClientRect(); const size = Math.max(1, Math.min(rect.width, rect.height)); renderer.setSize(size, size, false); - camera.aspect = 1; + camera.left = -MATCH3D_CAMERA_HALF_SIZE; + camera.right = MATCH3D_CAMERA_HALF_SIZE; + camera.top = MATCH3D_CAMERA_HALF_SIZE; + camera.bottom = -MATCH3D_CAMERA_HALF_SIZE; camera.updateProjectionMatrix(); }; resize(); @@ -423,9 +858,13 @@ export function Match3DPhysicsBoard({ } const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000)); lastTime = now; + activeRuntime.entries.forEach((entry) => { + applyCenterGravity(entry); + }); activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3); activeRuntime.entries.forEach((entry) => { + applyCenterGravity(entry); constrainBodyInsidePot(entry); entry.mesh.position.set( entry.body.position.x, @@ -433,11 +872,14 @@ export function Match3DPhysicsBoard({ entry.body.position.z, ); entry.mesh.quaternion.set( - entry.body.quaternion.x, - entry.body.quaternion.y, - entry.body.quaternion.z, - entry.body.quaternion.w, + entry.lockReadableTop ? 0 : entry.body.quaternion.x, + entry.lockReadableTop ? 0 : entry.body.quaternion.y, + entry.lockReadableTop ? 0 : entry.body.quaternion.z, + entry.lockReadableTop ? 1 : entry.body.quaternion.w, ); + if (entry.lockReadableTop) { + entry.mesh.rotation.y = entry.topRotationY; + } }); activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera); @@ -447,6 +889,11 @@ export function Match3DPhysicsBoard({ setReady(true); return () => { + renderer.domElement.removeEventListener( + 'webglcontextlost', + handleContextLost, + false, + ); ro.disconnect(); }; } catch { @@ -475,11 +922,7 @@ export function Match3DPhysicsBoard({ const activeItemIds = new Set( run.items - .filter( - (item) => - isItemState(item.state, 'in_board') || - isItemState(item.state, 'flying'), - ) + .filter((item) => isItemState(item.state, 'in_board')) .map((item) => item.itemInstanceId), ); @@ -487,29 +930,20 @@ export function Match3DPhysicsBoard({ if (!activeItemIds.has(itemInstanceId)) { runtime.scene.remove(entry.mesh); runtime.world.removeBody(entry.body); - entry.mesh.geometry.dispose(); - const material = entry.mesh.material; - if (Array.isArray(material)) { - material.forEach((item) => item.dispose()); - } else { - material.dispose(); - } + disposeThreeObject(entry.mesh); runtime.entries.delete(itemInstanceId); } }); run.items.forEach((item) => { - if ( - !isItemState(item.state, 'in_board') && - !isItemState(item.state, 'flying') - ) { + if (!isItemState(item.state, 'in_board')) { return; } const existing = runtime.entries.get(item.itemInstanceId); if (existing) { existing.item = item; - existing.mesh.visible = isItemState(item.state, 'in_board'); + existing.mesh.visible = true; return; } @@ -521,7 +955,7 @@ export function Match3DPhysicsBoard({ shape: createCannonShape(runtime.cannon, visual.shape, visual.radius), position: new runtime.cannon.Vec3( visual.position.x, - MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * 0.055, + MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP, visual.position.z, ), }); @@ -541,7 +975,9 @@ export function Match3DPhysicsBoard({ runtime.entries.set(item.itemInstanceId, { body, item, + lockReadableTop: visual.lockReadableTop, mesh: visual.mesh, + topRotationY: visual.topRotationY, }); }); }, [ready, run.items, run.snapshotVersion]); @@ -568,7 +1004,7 @@ export function Match3DPhysicsBoard({ entry.mesh.visible, ) .map((entry) => entry.mesh); - const hit = runtime.raycaster.intersectObjects(meshes, false)[0]; + const hit = runtime.raycaster.intersectObjects(meshes, true)[0]; const itemInstanceId = typeof hit?.object.userData.itemInstanceId === 'string' ? hit.object.userData.itemInstanceId @@ -587,7 +1023,7 @@ export function Match3DPhysicsBoard({ return (
diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index a77d4969..23ac3537 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useEffect } from 'react'; -import { expect, test, vi } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import type { Match3DClickItemRequest, @@ -12,16 +12,42 @@ import { confirmLocalMatch3DClick, startLocalMatch3DRun, } from '../../services/match3d-runtime'; +import { + MATCH3D_EXTRUDED_READABLE_SHAPES, + createMatch3DThreeGeometry, +} from './Match3DPhysicsBoard'; +import { resolveGeometryAsset } from './match3dVisualAssets'; import { Match3DRuntimeShell } from './Match3DRuntimeShell'; -vi.mock('./Match3DPhysicsBoard', () => ({ - Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => { - useEffect(() => { - onFallback(); - }, [onFallback]); - return
; - }, -})); +vi.mock('./Match3DPhysicsBoard', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => { + useEffect(() => { + const shouldKeep3D = + ( + globalThis as typeof globalThis & { + __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean; + } + ).__MATCH3D_KEEP_3D_TEST_RENDER__ === true; + if (!shouldKeep3D) { + onFallback(); + } + }, [onFallback]); + return
; + }, + }; +}); + +afterEach(() => { + delete ( + globalThis as typeof globalThis & { + __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean; + } + ).__MATCH3D_KEEP_3D_TEST_RENDER__; +}); function renderRuntime(run: Match3DRunSnapshot) { let currentRun = run; @@ -79,13 +105,204 @@ test('点击可见物品后先乐观入槽再等待确认', async () => { await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1)); }); -test('后端形状视觉键不会被统一兜底成红色苹字', () => { +test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => { + ( + globalThis as typeof globalThis & { + __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean; + } + ).__MATCH3D_KEEP_3D_TEST_RENDER__ = true; + const run = startLocalMatch3DRun(1); + const selectedItem = run.items[0]!; + const nextRun: Match3DRunSnapshot = { + ...run, + items: run.items.map((item, index) => + index === 0 + ? { + ...item, + state: 'InTray' as const, + clickable: false, + traySlotIndex: 0, + } + : item, + ), + traySlots: run.traySlots.map((slot) => + slot.slotIndex === 0 + ? { + slotIndex: 0, + itemInstanceId: selectedItem.itemInstanceId, + itemTypeId: selectedItem.itemTypeId, + visualKey: selectedItem.visualKey, + } + : slot, + ), + }; + + renderRuntime(nextRun); + + expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy(); +}); + +test('本地试玩按消除次数生成类型并在 25 类封顶', () => { + const smallRun = startLocalMatch3DRun(12); + const largeRun = startLocalMatch3DRun(100); + const countTypes = (run: Match3DRunSnapshot) => + new Set(run.items.map((item) => item.itemTypeId)).size; + + expect(countTypes(smallRun)).toBe(12); + expect(countTypes(largeRun)).toBe(25); + expect(largeRun.items).toHaveLength(300); +}); + +test('25 次以内生成不重复积木视觉签名', () => { + const run = startLocalMatch3DRun(25); + const firstItemByType = new Map( + run.items.map((item) => [item.itemTypeId, item]), + ); + const visualKeys = new Set( + [...firstItemByType.values()].map((item) => item.visualKey), + ); + const signatures = new Set( + [...firstItemByType.values()].map( + (item) => { + const asset = resolveGeometryAsset(item.visualKey); + return `${asset.shape}-${asset.fill}-${asset.studsX}x${asset.studsY}-${asset.heightScale}`; + }, + ), + ); + + expect(firstItemByType.size).toBe(25); + expect(visualKeys.size).toBe(25); + expect(signatures.size).toBe(25); +}); + +test('积木池覆盖参考图里的特殊件', () => { + const shapes = new Set( + startLocalMatch3DRun(25).items.map((item) => + resolveGeometryAsset(item.visualKey).shape, + ), + ); + + expect(shapes).toContain('brick'); + expect(shapes).toContain('tile'); + expect(shapes).toContain('slope'); + expect(shapes).toContain('cylinder'); + expect(shapes).toContain('ring'); + expect(shapes).toContain('arch'); + expect(shapes).toContain('cone'); +}); + +test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', async () => { + const three = await import('three'); + + for (const shape of MATCH3D_EXTRUDED_READABLE_SHAPES) { + const geometry = createMatch3DThreeGeometry(three, shape, 1); + + expect(geometry.type).toBe('ExtrudeGeometry'); + } +}); + +test('15 次消除时每种视觉模型只对应一次消除目标', () => { + const run = startLocalMatch3DRun(15); + const countByVisualKey = new Map(); + const typeByVisualKey = new Map>(); + + for (const item of run.items) { + countByVisualKey.set( + item.visualKey, + (countByVisualKey.get(item.visualKey) ?? 0) + 1, + ); + typeByVisualKey.set(item.visualKey, typeByVisualKey.get(item.visualKey) ?? new Set()); + typeByVisualKey.get(item.visualKey)!.add(item.itemTypeId); + } + + expect(countByVisualKey.size).toBe(15); + expect([...countByVisualKey.values()]).toEqual(Array(15).fill(3)); + expect( + [...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1), + ).toBe(true); +}); + +test('25 次以内的随机抽取不会刷新重复物品', () => { + for (const clearCount of [1, 12, 15, 24, 25]) { + const run = startLocalMatch3DRun(clearCount); + const visualKeys = new Set(run.items.map((item) => item.visualKey)); + + expect(visualKeys.size).toBe(clearCount); + } +}); + +test('25 类型局面按五档体积比例生成尺寸', () => { + const run = startLocalMatch3DRun(25); + const radiusByVisualKey = new Map(); + for (const item of run.items) { + radiusByVisualKey.set(item.visualKey, item.radius); + } + + const baseRadius = [...radiusByVisualKey.values()].find( + (radius) => Math.abs(radius / 0.072 - 1) < 0.01, + ); + expect(baseRadius).toBeTruthy(); + + const tierCounts = new Map(); + for (const radius of radiusByVisualKey.values()) { + const relativeVolume = Math.pow(radius / baseRadius!, 3); + const tier = + relativeVolume >= 1.6 + ? 'XL' + : relativeVolume >= 1.25 + ? 'L' + : relativeVolume >= 0.65 && relativeVolume <= 0.85 + ? 'XS' + : relativeVolume <= 0.5 + ? 'S' + : 'M'; + tierCounts.set(tier, (tierCounts.get(tier) ?? 0) + 1); + } + + expect(tierCounts.get('XL')).toBe(5); + expect(tierCounts.get('L')).toBe(8); + expect(tierCounts.get('M')).toBe(7); + expect(tierCounts.get('XS')).toBe(4); + expect(tierCounts.get('S')).toBe(1); +}); + +test('同一视觉模型在复用时保持唯一尺寸', () => { + const run = startLocalMatch3DRun(30); + const radiiByVisualKey = new Map>(); + + for (const item of run.items) { + const radii = radiiByVisualKey.get(item.visualKey) ?? new Set(); + radii.add(Math.round(item.radius * 10_000)); + radiiByVisualKey.set(item.visualKey, radii); + } + + expect(radiiByVisualKey.size).toBe(25); + expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true); +}); + +test('积木 3D 资源可以为本局类型创建几何体', async () => { + const three = await import('three'); + const run = startLocalMatch3DRun(15); + const firstItemByType = new Map( + run.items.map((item) => [item.itemTypeId, item]), + ); + + expect(firstItemByType.size).toBe(15); + for (const item of firstItemByType.values()) { + const shape = resolveGeometryAsset(item.visualKey).shape; + const geometry = createMatch3DThreeGeometry(three, shape, 1); + + expect(geometry).toBeTruthy(); + } +}); + +test('积木视觉键不会被统一兜底成红色苹字', () => { const run = startLocalMatch3DRun(2); run.items = run.items.slice(0, 2).map((item, index) => ({ ...item, - itemInstanceId: `shape-${index}`, - itemTypeId: `shape-type-${index}`, - visualKey: index === 0 ? 'red_circle' : 'yellow_triangle', + itemInstanceId: `block-${index}`, + itemTypeId: `block-type-${index}`, + visualKey: index === 0 ? 'block-red-2x4' : 'block-blue-1x2', x: 0.42 + index * 0.16, y: 0.5, layer: index, @@ -93,23 +310,23 @@ test('后端形状视觉键不会被统一兜底成红色苹字', () => { })); renderRuntime(run); - expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy(); - expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy(); + expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy(); + expect(screen.getByTestId('match3d-visual-block-blue-1x2')).toBeTruthy(); expect(screen.queryAllByText('苹')).toHaveLength(0); }); -test('水果题材视觉键也渲染为无文字纯色几何体', () => { +test('积木视觉键渲染为无文字纯色图标', () => { const run = startLocalMatch3DRun(3); run.items = run.items.slice(0, 3).map((item, index) => ({ ...item, - itemInstanceId: `fruit-${index}`, - itemTypeId: `fruit-type-${index}`, + itemInstanceId: `block-icon-${index}`, + itemTypeId: `block-icon-type-${index}`, visualKey: index === 0 - ? 'watermelon-green' + ? 'block-red-2x4' : index === 1 - ? 'apple-red' - : 'grape-purple', + ? 'block-clear-ring' + : 'block-mint-arch', x: 0.35 + index * 0.15, y: 0.5, radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07, @@ -118,31 +335,31 @@ test('水果题材视觉键也渲染为无文字纯色几何体', () => { })); renderRuntime(run); - expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy(); + expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy(); expect( - screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'), - ).toBe('heart'); + screen.getByTestId('match3d-visual-block-clear-ring').getAttribute('data-shape'), + ).toBe('ring'); expect( screen - .getByTestId('match3d-visual-grape-purple') + .getByTestId('match3d-visual-block-mint-arch') .getAttribute('data-shape'), - ).toBe('star'); + ).toBe('arch'); expect(screen.queryByText('苹果')).toBeNull(); expect(screen.queryByText('苹')).toBeNull(); }); -test('运行态支持梯形和平行四边形等差异化几何造型', () => { +test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => { const run = startLocalMatch3DRun(3); run.items = run.items.slice(0, 3).map((item, index) => ({ ...item, - itemInstanceId: `geometry-${index}`, - itemTypeId: `geometry-type-${index}`, + itemInstanceId: `block-geometry-${index}`, + itemTypeId: `block-geometry-type-${index}`, visualKey: index === 0 - ? 'peach-pink' + ? 'block-black-1x8' : index === 1 - ? 'banana-yellow' - : 'orange_hexagon', + ? 'block-purple-slope-1x2' + : 'block-green-cylinder', x: 0.35 + index * 0.15, y: 0.5, layer: index, @@ -151,18 +368,18 @@ test('运行态支持梯形和平行四边形等差异化几何造型', () => { renderRuntime(run); expect( - screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'), - ).toBe('trapezoid'); + screen.getByTestId('match3d-visual-block-black-1x8').getAttribute('data-shape'), + ).toBe('brick'); expect( screen - .getByTestId('match3d-visual-banana-yellow') + .getByTestId('match3d-visual-block-purple-slope-1x2') .getAttribute('data-shape'), - ).toBe('parallelogram'); + ).toBe('slope'); expect( screen - .getByTestId('match3d-visual-orange_hexagon') + .getByTestId('match3d-visual-block-green-cylinder') .getAttribute('data-shape'), - ).toBe('hexagon'); + ).toBe('cylinder'); }); test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => { @@ -172,7 +389,7 @@ test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () { ...item, itemInstanceId: 'legacy-outside', - visualKey: 'apple-red', + visualKey: 'block-red-2x4', x: -0.4, y: 0.5, radius: 0.1, diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index c7c9972d..99dbe30f 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -19,7 +19,10 @@ import { Match3DVisualIcon, resolveVisualSeed, } from './match3dVisualAssets'; -import { Match3DPhysicsBoard } from './Match3DPhysicsBoard'; +import { + Match3DPhysicsBoard, + Match3DTrayPreviewBoard, +} from './Match3DPhysicsBoard'; import { isItemState, isRunState, @@ -178,19 +181,28 @@ function Match3DToken({ ); } -function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) { +function Match3DTrayToken({ + slot, + use3DPreview, +}: { + slot: Match3DTraySlot; + use3DPreview: boolean; +}) { if (!slot.visualKey) { return ( ); } const visualSeed = resolveVisualSeed(slot.visualKey); + const fallback = ; return ( - + + {fallback} + ); } @@ -321,6 +333,18 @@ export function Match3DRuntimeShell({ }, [run]); const shouldUse3DRender = !force2DRender; + const trayPreviewItems = useMemo(() => { + if (!run) { + return []; + } + return run.traySlots.map((slot) => + slot.itemInstanceId + ? (run.items.find( + (item) => item.itemInstanceId === slot.itemInstanceId, + ) ?? null) + : null, + ); + }, [run]); const handleItemClick = async (item: Match3DItemSnapshot) => { if (!run || !isRunState(run.status, 'running') || pendingClick) { @@ -436,7 +460,9 @@ export function Match3DRuntimeShell({
-
- {run.traySlots.map((slot) => ( -
- -
- ))} +
+ {shouldUse3DRender ? ( + + ) : null} + {run.traySlots.map((slot) => { + return ( +
+ +
+ ); + })}
diff --git a/src/components/match3d-runtime/match3dVisualAssets.tsx b/src/components/match3d-runtime/match3dVisualAssets.tsx index 35773f46..c93990d9 100644 --- a/src/components/match3d-runtime/match3dVisualAssets.tsx +++ b/src/components/match3d-runtime/match3dVisualAssets.tsx @@ -2,139 +2,63 @@ import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime'; type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number]; -export type Match3DGeometryShape = - | 'circle' - | 'triangle' - | 'diamond' - | 'square' - | 'star' - | 'hexagon' - | 'capsule' - | 'heart' - | 'trapezoid' - | 'parallelogram'; +export type Match3DBlockShape = + | 'brick' + | 'tile' + | 'slope' + | 'cylinder' + | 'ring' + | 'arch' + | 'cone'; + +export type Match3DGeometryShape = Match3DBlockShape; export type Match3DGeometryAsset = { - shape: Match3DGeometryShape; + shape: Match3DBlockShape; fill: string; stroke: string; + studsX: number; + studsY: number; + heightScale: number; + transparent?: boolean; }; const MATCH3D_GEOMETRY_ASSETS: Record = { - 'watermelon-green': { - shape: 'circle', - fill: '#16a34a', - stroke: '#14532d', - }, - 'apple-red': { - shape: 'heart', - fill: '#ef4444', - stroke: '#991b1b', - }, - 'banana-yellow': { - shape: 'parallelogram', - fill: '#facc15', - stroke: '#a16207', - }, - 'grape-purple': { - shape: 'star', - fill: '#8b5cf6', - stroke: '#5b21b6', - }, - 'melon-green': { - shape: 'hexagon', - fill: '#84cc16', - stroke: '#3f6212', - }, - 'berry-blue': { - shape: 'diamond', - fill: '#2563eb', - stroke: '#1e3a8a', - }, - 'peach-pink': { - shape: 'trapezoid', - fill: '#fb7185', - stroke: '#be123c', - }, - 'plum-indigo': { - shape: 'capsule', - fill: '#4f46e5', - stroke: '#312e81', - }, - 'lime-lime': { - shape: 'square', - fill: '#65a30d', - stroke: '#365314', - }, - 'orange-orange': { - shape: 'triangle', - fill: '#f97316', - stroke: '#9a3412', - }, - 'pear-cyan': { - shape: 'parallelogram', - fill: '#06b6d4', - stroke: '#155e75', - }, - red_circle: { - shape: 'circle', - fill: '#ef4444', - stroke: '#991b1b', - }, - yellow_triangle: { - shape: 'triangle', - fill: '#facc15', - stroke: '#a16207', - }, - purple_diamond: { - shape: 'diamond', - fill: '#7c3aed', - stroke: '#4c1d95', - }, - green_square: { - shape: 'square', - fill: '#16a34a', - stroke: '#14532d', - }, - blue_star: { - shape: 'star', - fill: '#0ea5e9', - stroke: '#075985', - }, - orange_hexagon: { - shape: 'hexagon', - fill: '#f97316', - stroke: '#9a3412', - }, - cyan_capsule: { - shape: 'capsule', - fill: '#06b6d4', - stroke: '#155e75', - }, - pink_heart: { - shape: 'heart', - fill: '#ec4899', - stroke: '#9d174d', - }, - lime_leaf: { - shape: 'trapezoid', - fill: '#84cc16', - stroke: '#3f6212', - }, - white_moon: { - shape: 'parallelogram', - fill: '#e2e8f0', - stroke: '#64748b', + 'block-red-2x4': blockAsset('brick', '#e31818', '#8f1111', 4, 2, 0.72), + 'block-blue-1x2': blockAsset('brick', '#1478d4', '#0b4f91', 2, 1, 0.82), + 'block-yellow-2x2': blockAsset('brick', '#f7c51d', '#a66f00', 2, 2, 0.76), + 'block-green-1x4': blockAsset('brick', '#079447', '#055c2f', 4, 1, 0.72), + 'block-orange-1x6': blockAsset('brick', '#ff7a12', '#b84708', 6, 1, 0.64), + 'block-white-1x1': blockAsset('brick', '#f3f2ec', '#b7b8b2', 1, 1, 0.86), + 'block-black-1x8': blockAsset('brick', '#101214', '#030405', 8, 1, 0.54), + 'block-tan-2x3': blockAsset('brick', '#d8bd72', '#9b7a35', 3, 2, 0.68), + 'block-lime-1x2': blockAsset('brick', '#a5df18', '#6d990b', 2, 1, 0.58), + 'block-darkred-2x2': blockAsset('brick', '#b51217', '#76090d', 2, 2, 0.7), + 'block-blue-1x4': blockAsset('brick', '#1688df', '#0b5c9e', 4, 1, 0.58), + 'block-pink-2x4': blockAsset('brick', '#f66bb5', '#ba2e7e', 4, 2, 0.56), + 'block-gray-1x6': blockAsset('brick', '#4c5456', '#232829', 6, 1, 0.5), + 'block-lavender-tile-2x2': blockAsset('tile', '#c99fe6', '#8b63ad', 2, 2, 0.28), + 'block-teal-tile-1x3': blockAsset('tile', '#11adb0', '#087377', 3, 1, 0.26), + 'block-mint-tile-1x4': blockAsset('tile', '#a7c6ac', '#6e9275', 4, 1, 0.24), + 'block-magenta-tile-2x2': blockAsset('tile', '#cf0f68', '#8e0644', 2, 2, 0.28), + 'block-orange-tile-2x2-stud': blockAsset('tile', '#ff970f', '#b65b05', 2, 2, 0.3), + 'block-purple-slope-1x2': blockAsset('slope', '#5e42b6', '#342070', 2, 1, 0.82), + 'block-brown-slope-1x2': blockAsset('slope', '#8b421f', '#552414', 2, 1, 0.94), + 'block-sky-slope-2x2': blockAsset('slope', '#4db3f2', '#1f78b7', 2, 2, 0.9), + 'block-green-cylinder': blockAsset('cylinder', '#159554', '#076236', 1, 1, 1.08), + 'block-clear-ring': { + ...blockAsset('ring', '#d9e1df', '#aebbbb', 2, 2, 0.38), + transparent: true, }, + 'block-mint-arch': blockAsset('arch', '#c4ded2', '#83a996', 4, 1, 1.0), + 'block-gold-cone': blockAsset('cone', '#d39a10', '#8c6105', 1, 1, 1.18), }; const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [ - { shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' }, - { shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' }, - { shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' }, - { shape: 'star', fill: '#10b981', stroke: '#065f46' }, - { shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' }, - { shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' }, + blockAsset('brick', '#e11d48', '#9f1239', 2, 2, 0.68), + blockAsset('tile', '#f59e0b', '#92400e', 3, 1, 0.28), + blockAsset('slope', '#8b5cf6', '#5b21b6', 2, 1, 0.86), + blockAsset('cylinder', '#10b981', '#065f46', 1, 1, 1.0), ]; const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [ @@ -162,14 +86,26 @@ const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [ colorClassName: 'from-emerald-300 to-green-600', label: '四', }, - { - itemTypeId: 'unknown-sky', - visualKey: 'unknown-sky', - colorClassName: 'from-sky-300 to-blue-600', - label: '五', - }, ]; +function blockAsset( + shape: Match3DBlockShape, + fill: string, + stroke: string, + studsX: number, + studsY: number, + heightScale: number, +): Match3DGeometryAsset { + return { + shape, + fill, + stroke, + studsX, + studsY, + heightScale, + }; +} + export function hashVisualKey(visualKey: string) { let hash = 0; for (const char of visualKey) { @@ -199,48 +135,80 @@ export function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset { ); } -function renderGeometryShape(asset: Match3DGeometryAsset) { +function renderBlockIcon(asset: Match3DGeometryAsset) { const shapeProps = { fill: asset.fill, stroke: asset.stroke, - strokeWidth: 6, + strokeWidth: 5, strokeLinejoin: 'round' as const, + opacity: asset.transparent ? 0.72 : 1, }; - switch (asset.shape) { - case 'circle': - return ; - case 'triangle': - return ; - case 'diamond': - return ; - case 'square': - return ; - case 'star': - return ( - - ); - case 'hexagon': - return ; - case 'capsule': - return ; - case 'heart': - return ( - - ); - case 'trapezoid': - return ; - case 'parallelogram': - return ; - default: - return ; + if (asset.shape === 'cylinder') { + return ( + <> + + + + ); } + + if (asset.shape === 'ring') { + return ( + <> + + + + ); + } + + if (asset.shape === 'arch') { + return ( + + ); + } + + if (asset.shape === 'cone') { + return ( + + ); + } + + if (asset.shape === 'slope') { + return ; + } + + const width = Math.min(76, 16 + asset.studsX * 14); + const height = Math.min(54, 18 + asset.studsY * 13); + const x = 50 - width / 2; + const y = 54 - height / 2; + const studRadius = asset.shape === 'tile' ? 0 : 5; + return ( + <> + + {Array.from({ length: asset.studsX * asset.studsY }, (_, index) => { + if (studRadius <= 0) { + return null; + } + const column = index % asset.studsX; + const row = Math.floor(index / asset.studsX); + return ( + + ); + })} + + ); } export function Match3DVisualIcon({ @@ -261,7 +229,7 @@ export function Match3DVisualIcon({ data-testid={`match3d-visual-${visualKey}`} data-shape={asset.shape} > - {renderGeometryShape(asset)} + {renderBlockIcon(asset)} ); } diff --git a/src/services/match3d-runtime/match3dLocalRuntime.ts b/src/services/match3d-runtime/match3dLocalRuntime.ts index bbe3efd1..18d2e088 100644 --- a/src/services/match3d-runtime/match3dLocalRuntime.ts +++ b/src/services/match3d-runtime/match3dLocalRuntime.ts @@ -8,156 +8,237 @@ import type { const MATCH3D_TRAY_SLOT_COUNT = 7; const MATCH3D_LOCAL_DURATION_MS = 600_000; +const MATCH3D_MAX_ITEM_TYPE_COUNT = 25; +const MATCH3D_LOCAL_BASE_RADIUS = 0.072; + +type Match3DSizeTier = 'XL' | 'L' | 'M' | 'XS' | 'S'; type Match3DVisualSeed = { itemTypeId: string; visualKey: string; colorClassName: string; label: string; - sizeScale?: number; }; +type Match3DSelectedVisualSeed = Match3DVisualSeed & { + radiusScale: number; + relativeVolume: number; + sizeTier: Match3DSizeTier; +}; + +const MATCH3D_SIZE_TIER_RULES: Array<{ + radiusScale: number; + ratio: number; + relativeVolume: number; + sizeTier: Match3DSizeTier; +}> = [ + { sizeTier: 'XL', ratio: 0.2, relativeVolume: 1.86, radiusScale: 1.23 }, + { sizeTier: 'L', ratio: 0.3, relativeVolume: 1.4, radiusScale: 1.12 }, + { sizeTier: 'M', ratio: 0.3, relativeVolume: 1, radiusScale: 1 }, + { sizeTier: 'XS', ratio: 0.15, relativeVolume: 0.73, radiusScale: 0.9 }, + { sizeTier: 'S', ratio: 0.05, relativeVolume: 0.44, radiusScale: 0.76 }, +]; + export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [ - // 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案。 + // 中文注释:默认 25 类使用参考图中的积木件,形状、尺寸和颜色都要能区分。 { - itemTypeId: 'watermelon', - visualKey: 'watermelon-green', - colorClassName: 'from-emerald-500 to-green-800', - label: '西瓜', - sizeScale: 1.24, + itemTypeId: 'block-red-2x4', + visualKey: 'block-red-2x4', + colorClassName: 'from-rose-400 to-red-700', + label: '红色二乘四', }, { - itemTypeId: 'apple', - visualKey: 'apple-red', - colorClassName: 'from-rose-400 to-red-600', - label: '苹果', - sizeScale: 1, + itemTypeId: 'block-blue-1x2', + visualKey: 'block-blue-1x2', + colorClassName: 'from-blue-300 to-blue-700', + label: '蓝色一乘二', }, { - itemTypeId: 'banana', - visualKey: 'banana-yellow', - colorClassName: 'from-yellow-300 to-amber-500', - label: '香蕉', - sizeScale: 1.04, + itemTypeId: 'block-yellow-2x2', + visualKey: 'block-yellow-2x2', + colorClassName: 'from-yellow-300 to-yellow-600', + label: '黄色二乘二', }, { - itemTypeId: 'grape', - visualKey: 'grape-purple', - colorClassName: 'from-violet-400 to-purple-700', - label: '葡萄', - sizeScale: 0.78, + itemTypeId: 'block-green-1x4', + visualKey: 'block-green-1x4', + colorClassName: 'from-emerald-300 to-green-700', + label: '绿色一乘四', }, { - itemTypeId: 'melon', - visualKey: 'melon-green', - colorClassName: 'from-emerald-300 to-green-600', - label: '甜瓜', - sizeScale: 1.12, + itemTypeId: 'block-orange-1x6', + visualKey: 'block-orange-1x6', + colorClassName: 'from-orange-300 to-orange-700', + label: '橙色一乘六', }, { - itemTypeId: 'berry', - visualKey: 'berry-blue', - colorClassName: 'from-sky-300 to-blue-600', - label: '蓝莓', - sizeScale: 0.78, + itemTypeId: 'block-white-1x1', + visualKey: 'block-white-1x1', + colorClassName: 'from-slate-50 to-slate-300', + label: '白色一乘一', }, { - itemTypeId: 'peach', - visualKey: 'peach-pink', - colorClassName: 'from-pink-300 to-orange-400', - label: '桃子', - sizeScale: 1, + itemTypeId: 'block-black-1x8', + visualKey: 'block-black-1x8', + colorClassName: 'from-zinc-700 to-black', + label: '黑色一乘八', }, { - itemTypeId: 'plum', - visualKey: 'plum-indigo', - colorClassName: 'from-indigo-300 to-indigo-700', - label: '李子', - sizeScale: 0.86, + itemTypeId: 'block-tan-2x3', + visualKey: 'block-tan-2x3', + colorClassName: 'from-amber-100 to-yellow-600', + label: '米色二乘三', }, { - itemTypeId: 'lime', - visualKey: 'lime-lime', - colorClassName: 'from-lime-300 to-lime-600', - label: '青柠', - sizeScale: 0.86, + itemTypeId: 'block-lime-1x2', + visualKey: 'block-lime-1x2', + colorClassName: 'from-lime-300 to-lime-700', + label: '青柠一乘二', }, { - itemTypeId: 'orange', - visualKey: 'orange-orange', - colorClassName: 'from-orange-300 to-orange-600', - label: '橙子', - sizeScale: 1, + itemTypeId: 'block-darkred-2x2', + visualKey: 'block-darkred-2x2', + colorClassName: 'from-red-700 to-red-950', + label: '深红二乘二', }, { - itemTypeId: 'pear', - visualKey: 'pear-cyan', - colorClassName: 'from-cyan-300 to-teal-600', - label: '梨', - sizeScale: 1, + itemTypeId: 'block-blue-1x4', + visualKey: 'block-blue-1x4', + colorClassName: 'from-sky-300 to-blue-700', + label: '蓝色一乘四', }, { - itemTypeId: 'red-circle', - visualKey: 'red_circle', - colorClassName: 'from-rose-400 to-red-600', - label: '圆', + itemTypeId: 'block-pink-2x4', + visualKey: 'block-pink-2x4', + colorClassName: 'from-pink-300 to-pink-600', + label: '粉色二乘四', }, { - itemTypeId: 'yellow-triangle', - visualKey: 'yellow_triangle', - colorClassName: 'from-yellow-300 to-amber-500', - label: '三', + itemTypeId: 'block-gray-1x6', + visualKey: 'block-gray-1x6', + colorClassName: 'from-zinc-400 to-zinc-700', + label: '灰色一乘六', }, { - itemTypeId: 'purple-diamond', - visualKey: 'purple_diamond', - colorClassName: 'from-violet-400 to-purple-700', - label: '菱', + itemTypeId: 'block-lavender-tile-2x2', + visualKey: 'block-lavender-tile-2x2', + colorClassName: 'from-purple-200 to-purple-500', + label: '薰衣草光板', }, { - itemTypeId: 'green-square', - visualKey: 'green_square', - colorClassName: 'from-emerald-300 to-green-600', - label: '方', + itemTypeId: 'block-teal-tile-1x3', + visualKey: 'block-teal-tile-1x3', + colorClassName: 'from-teal-300 to-teal-700', + label: '青色长光板', }, { - itemTypeId: 'blue-star', - visualKey: 'blue_star', - colorClassName: 'from-sky-300 to-blue-600', - label: '星', + itemTypeId: 'block-mint-tile-1x4', + visualKey: 'block-mint-tile-1x4', + colorClassName: 'from-emerald-100 to-emerald-400', + label: '薄荷长光板', }, { - itemTypeId: 'orange-hexagon', - visualKey: 'orange_hexagon', - colorClassName: 'from-orange-300 to-orange-600', - label: '六', + itemTypeId: 'block-magenta-tile-2x2', + visualKey: 'block-magenta-tile-2x2', + colorClassName: 'from-fuchsia-500 to-pink-800', + label: '洋红光板', }, { - itemTypeId: 'cyan-capsule', - visualKey: 'cyan_capsule', - colorClassName: 'from-cyan-300 to-teal-600', - label: '胶', + itemTypeId: 'block-orange-tile-2x2-stud', + visualKey: 'block-orange-tile-2x2-stud', + colorClassName: 'from-orange-300 to-amber-700', + label: '橙色单钉板', }, { - itemTypeId: 'pink-heart', - visualKey: 'pink_heart', - colorClassName: 'from-pink-300 to-rose-500', - label: '心', + itemTypeId: 'block-purple-slope-1x2', + visualKey: 'block-purple-slope-1x2', + colorClassName: 'from-violet-400 to-violet-900', + label: '紫色斜坡', }, { - itemTypeId: 'lime-leaf', - visualKey: 'lime_leaf', - colorClassName: 'from-lime-300 to-lime-600', - label: '叶', + itemTypeId: 'block-brown-slope-1x2', + visualKey: 'block-brown-slope-1x2', + colorClassName: 'from-orange-900 to-stone-700', + label: '棕色斜坡', }, { - itemTypeId: 'white-moon', - visualKey: 'white_moon', - colorClassName: 'from-slate-100 to-slate-400', - label: '月', + itemTypeId: 'block-sky-slope-2x2', + visualKey: 'block-sky-slope-2x2', + colorClassName: 'from-sky-300 to-sky-600', + label: '天蓝斜坡', + }, + { + itemTypeId: 'block-green-cylinder', + visualKey: 'block-green-cylinder', + colorClassName: 'from-green-400 to-green-800', + label: '绿色圆柱', + }, + { + itemTypeId: 'block-clear-ring', + visualKey: 'block-clear-ring', + colorClassName: 'from-slate-50 to-slate-300', + label: '透明圆环', + }, + { + itemTypeId: 'block-mint-arch', + visualKey: 'block-mint-arch', + colorClassName: 'from-emerald-100 to-emerald-300', + label: '薄荷拱门', + }, + { + itemTypeId: 'block-gold-cone', + visualKey: 'block-gold-cone', + colorClassName: 'from-yellow-300 to-amber-700', + label: '金色锥形件', }, ]; +function hashNumber(value: number) { + let state = Math.max(1, value >>> 0); + state ^= state << 13; + state ^= state >>> 7; + state ^= state << 17; + return state >>> 0; +} + +function resolveSizeTierPlan(typeCount: number) { + const baseCounts = MATCH3D_SIZE_TIER_RULES.map((rule) => ({ + ...rule, + count: Math.floor(typeCount * rule.ratio), + remainder: typeCount * rule.ratio - Math.floor(typeCount * rule.ratio), + })); + let assignedCount = baseCounts.reduce((sum, rule) => sum + rule.count, 0); + const remainderOrder = [...baseCounts].sort( + (left, right) => right.remainder - left.remainder, + ); + let cursor = 0; + while (assignedCount < typeCount) { + remainderOrder[cursor % remainderOrder.length]!.count += 1; + assignedCount += 1; + cursor += 1; + } + + return baseCounts.flatMap((rule) => Array(rule.count).fill(rule)); +} + +function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] { + const typeCount = Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, clearCount); + const seeds = [...MATCH3D_VISUAL_SEEDS]; + let state = hashNumber(clearCount * 2_654_435_761); + for (let index = seeds.length - 1; index > 0; index -= 1) { + state = hashNumber(state + index); + const swapIndex = state % (index + 1); + [seeds[index], seeds[swapIndex]] = [seeds[swapIndex]!, seeds[index]!]; + } + const sizeTierPlan = resolveSizeTierPlan(typeCount); + return seeds.slice(0, typeCount).map((seed, index) => ({ + ...seed, + radiusScale: sizeTierPlan[index]!.radiusScale, + relativeVolume: sizeTierPlan[index]!.relativeVolume, + sizeTier: sizeTierPlan[index]!.sizeTier, + })); +} + function createEmptyTray(): Match3DTraySlot[] { return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({ slotIndex, @@ -188,7 +269,7 @@ function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) { } function buildItem( - seed: Match3DVisualSeed, + seed: Match3DSelectedVisualSeed, index: number, copyIndex: number, ): Match3DItemSnapshot { @@ -198,9 +279,7 @@ function buildItem( const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026; const y = 0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02; - const baseRadius = - 0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004; - const radius = baseRadius * (seed.sizeScale ?? 1); + const radius = MATCH3D_LOCAL_BASE_RADIUS * seed.radiusScale; return { itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`, itemTypeId: seed.itemTypeId, @@ -332,12 +411,12 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) { export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot { const normalizedClearCount = Math.max(1, Math.round(clearCount)); - const typeCount = Math.min(10, normalizedClearCount); + const selectedSeeds = selectVisualSeeds(normalizedClearCount); const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) => Array.from({ length: 3 }, (_, copyOffset) => { const seed = - MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? - MATCH3D_VISUAL_SEEDS[0]!; + selectedSeeds[clearIndex % selectedSeeds.length] ?? + selectedSeeds[0]!; return buildItem( seed, clearIndex * 3 + copyOffset, From 34aecdddf1e018d9a6dab7af1e2921ca27c9f1f4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 4 May 2026 02:32:38 +0800 Subject: [PATCH 4/4] Add skill for gameplay entry type workflows --- .../agents/openai.yaml | 4 + README.md | 3 +- apps/admin-web/src/api/adminApiClient.ts | 52 ++ apps/admin-web/src/api/adminApiTypes.ts | 48 ++ apps/admin-web/src/app/AdminApp.tsx | 14 + apps/admin-web/src/app/AdminShell.tsx | 2 + apps/admin-web/src/app/adminRoutes.ts | 3 +- .../src/config/trackingEventDefinitions.ts | 45 + .../src/pages/AdminInviteCodePage.tsx | 176 +++- .../src/pages/AdminRedeemCodePage.tsx | 119 ++- .../src/pages/AdminTaskConfigPage.tsx | 545 ++++++++++++ apps/admin-web/src/styles/admin.css | 120 ++- apps/admin-web/vite.config.ts | 2 + docs/README.md | 4 + .../PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md | 33 + docs/operations/README.md | 5 + .../MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md | 17 +- ...B_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md | 86 +- ...ILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md | 78 ++ docs/technical/README.md | 1 + docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 66 +- docs/tracking/README.md | 7 + .../TRACKING_QUERY_PLAYBOOK_2026-05-03.md | 50 ++ packages/shared/src/contracts/runtime.ts | 117 ++- scripts/dev-rust-stack.sh | 34 +- server-rs/crates/api-server/src/app.rs | 54 +- .../crates/api-server/src/runtime_profile.rs | 496 ++++++++++- .../crates/module-runtime/src/application.rs | 177 ++++ .../crates/module-runtime/src/commands.rs | 148 ++++ server-rs/crates/module-runtime/src/domain.rs | 315 +++++++ server-rs/crates/module-runtime/src/errors.rs | 24 + server-rs/crates/module-runtime/src/lib.rs | 75 ++ .../crates/shared-contracts/src/runtime.rs | 169 ++++ server-rs/crates/spacetime-client/src/lib.rs | 28 +- .../crates/spacetime-client/src/mapper.rs | 332 ++++++++ ...n_disable_profile_task_config_procedure.rs | 59 ++ ...min_list_profile_invite_codes_procedure.rs | 59 ++ ...min_list_profile_redeem_codes_procedure.rs | 59 ++ ...min_list_profile_task_configs_procedure.rs | 59 ++ ...in_upsert_profile_task_config_procedure.rs | 59 ++ ...rofile_task_reward_and_return_procedure.rs | 59 ++ .../get_profile_task_center_procedure.rs | 59 ++ .../src/module_bindings/mod.rs | 64 ++ .../profile_task_config_type.rs | 91 ++ .../profile_task_progress_type.rs | 74 ++ .../profile_task_reward_claim_type.rs | 69 ++ ...ofile_invite_code_admin_list_input_type.rs | 15 + ...e_code_admin_list_procedure_result_type.rs | 19 + ...ofile_redeem_code_admin_list_input_type.rs | 15 + ...m_code_admin_list_procedure_result_type.rs | 19 + ...time_profile_task_center_get_input_type.rs | 15 + ...ofile_task_center_procedure_result_type.rs | 19 + ...ntime_profile_task_center_snapshot_type.rs | 21 + .../runtime_profile_task_claim_input_type.rs | 16 + ...rofile_task_claim_procedure_result_type.rs | 19 + ...untime_profile_task_claim_snapshot_type.rs | 24 + ...le_task_config_admin_disable_input_type.rs | 17 + ...ofile_task_config_admin_list_input_type.rs | 15 + ...config_admin_list_procedure_result_type.rs | 19 + ...task_config_admin_procedure_result_type.rs | 19 + ...ile_task_config_admin_upsert_input_type.rs | 29 + ...ntime_profile_task_config_snapshot_type.rs | 31 + .../runtime_profile_task_cycle_type.rs | 16 + ...runtime_profile_task_item_snapshot_type.rs | 29 + .../runtime_profile_task_status_type.rs | 22 + ..._profile_wallet_ledger_source_type_type.rs | 2 + .../runtime_tracking_scope_kind_type.rs | 22 + .../tracking_daily_stat_type.rs | 75 ++ .../module_bindings/tracking_event_type.rs | 83 ++ .../crates/spacetime-client/src/runtime.rs | 179 ++++ .../crates/spacetime-module/src/migration.rs | 5 + .../spacetime-module/src/runtime/profile.rs | 796 ++++++++++++++++++ .../RpgEntryHomeView.recharge.test.tsx | 130 ++- src/components/rpg-entry/RpgEntryHomeView.tsx | 225 +++++ src/config/viteProxyConfig.test.ts | 18 + src/services/rpg-entry/rpgProfileClient.ts | 25 + vite.config.ts | 8 + 77 files changed, 5997 insertions(+), 110 deletions(-) create mode 100644 .codex/skills/genarrative-gameplay-entry-type/agents/openai.yaml create mode 100644 apps/admin-web/src/config/trackingEventDefinitions.ts create mode 100644 apps/admin-web/src/pages/AdminTaskConfigPage.tsx create mode 100644 docs/operations/PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md create mode 100644 docs/operations/README.md create mode 100644 docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md create mode 100644 docs/tracking/README.md create mode 100644 docs/tracking/TRACKING_QUERY_PLAYBOOK_2026-05-03.md create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_disable_profile_task_config_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_invite_codes_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_redeem_codes_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_task_configs_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_task_config_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/claim_profile_task_reward_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_task_center_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_task_config_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_task_progress_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_task_reward_claim_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_center_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_center_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_center_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_claim_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_claim_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_claim_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_config_admin_disable_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_config_admin_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_config_admin_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_config_admin_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_config_admin_upsert_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_config_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_cycle_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_item_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_task_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_scope_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/tracking_daily_stat_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/tracking_event_type.rs diff --git a/.codex/skills/genarrative-gameplay-entry-type/agents/openai.yaml b/.codex/skills/genarrative-gameplay-entry-type/agents/openai.yaml new file mode 100644 index 00000000..1fb1aa12 --- /dev/null +++ b/.codex/skills/genarrative-gameplay-entry-type/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "新增玩法入口" + short_description: "把新增玩法入口的文档、配置、路由和验证流程一次收口" + default_prompt: "Use $genarrative-gameplay-entry-type to add a new gameplay entry type end to end in Genarrative." diff --git a/README.md b/README.md index 3f07df16..411670a0 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ npm run dev 补充说明: -- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server` 与 Vite 前端,适合完整联调。 +- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调。 +- 主站默认地址是 `http://127.0.0.1:3000`,后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`。 - 如果只想单独启动前端页面,可使用 `npm run dev:web`,默认代理到本地 Rust `api-server`。 构建生产包: diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index c4203bfd..94d4fce1 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -2,16 +2,22 @@ import type { AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDisableProfileRedeemCodeRequest, + AdminDisableProfileTaskConfigRequest, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest, + AdminUpsertProfileTaskConfigRequest, ApiErrorEnvelope, ApiMeta, ApiSuccessEnvelope, + ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, + ProfileRedeemCodeAdminListResponse, ProfileRedeemCodeAdminResponse, + ProfileTaskConfigAdminListResponse, + ProfileTaskConfigAdminResponse, } from './adminApiTypes'; const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; @@ -129,6 +135,13 @@ export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) { }); } +export function listProfileRedeemCodes(token: string) { + return request( + '/admin/api/profile/redeem-codes', + {token}, + ); +} + export function upsertProfileRedeemCode( token: string, payload: AdminUpsertProfileRedeemCodeRequest, @@ -143,6 +156,13 @@ export function upsertProfileRedeemCode( ); } +export function listProfileInviteCodes(token: string) { + return request( + '/admin/api/profile/invite-codes', + {token}, + ); +} + export function upsertProfileInviteCode( token: string, payload: AdminUpsertProfileInviteCodeRequest, @@ -171,6 +191,38 @@ export function disableProfileRedeemCode( ); } +export function listProfileTaskConfigs(token: string) { + return request( + '/admin/api/profile/tasks', + {token}, + ); +} + +export function upsertProfileTaskConfig( + token: string, + payload: AdminUpsertProfileTaskConfigRequest, +) { + return request('/admin/api/profile/tasks', { + method: 'POST', + token, + body: payload, + }); +} + +export function disableProfileTaskConfig( + token: string, + payload: AdminDisableProfileTaskConfigRequest, +) { + return request( + '/admin/api/profile/tasks/disable', + { + method: 'POST', + token, + body: payload, + }, + ); +} + function normalizeBaseUrl(value: string) { return value.trim().replace(/\/+$/, ''); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index a9201f87..cd7bc74c 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -106,6 +106,8 @@ export interface AdminDebugHttpResponse { } export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; +export type ProfileTaskCycle = 'daily'; +export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; export interface AdminUpsertProfileRedeemCodeRequest { code: string; @@ -126,6 +128,23 @@ export interface AdminDisableProfileRedeemCodeRequest { code: string; } +export interface AdminUpsertProfileTaskConfigRequest { + taskId: string; + title: string; + description?: string | null; + eventKey: string; + cycle: ProfileTaskCycle; + scopeKind: TrackingScopeKind; + threshold: number; + rewardPoints: number; + enabled: boolean; + sortOrder: number; +} + +export interface AdminDisableProfileTaskConfigRequest { + taskId: string; +} + export interface ProfileRedeemCodeAdminResponse { code: string; mode: ProfileRedeemCodeMode; @@ -139,6 +158,10 @@ export interface ProfileRedeemCodeAdminResponse { updatedAt: string; } +export interface ProfileRedeemCodeAdminListResponse { + entries: ProfileRedeemCodeAdminResponse[]; +} + export interface ProfileInviteCodeAdminResponse { userId: string; inviteCode: string; @@ -146,3 +169,28 @@ export interface ProfileInviteCodeAdminResponse { createdAt: string; updatedAt: string; } + +export interface ProfileInviteCodeAdminListResponse { + entries: ProfileInviteCodeAdminResponse[]; +} + +export interface ProfileTaskConfigAdminResponse { + taskId: string; + title: string; + description: string; + eventKey: string; + cycle: ProfileTaskCycle; + scopeKind: TrackingScopeKind; + threshold: number; + rewardPoints: number; + enabled: boolean; + sortOrder: number; + createdBy: string; + createdAt: string; + updatedBy: string; + updatedAt: string; +} + +export interface ProfileTaskConfigAdminListResponse { + entries: ProfileTaskConfigAdminResponse[]; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 498db96f..a200d35d 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -10,6 +10,7 @@ import type { AdminSessionPayload, ProfileInviteCodeAdminResponse, ProfileRedeemCodeAdminResponse, + ProfileTaskConfigAdminResponse, } from '../api/adminApiTypes'; import { clearStoredAdminToken, @@ -21,6 +22,7 @@ import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; +import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; import {AdminShell} from './AdminShell'; import type {AdminRouteId} from './adminRoutes'; import {resolveAdminRoute, routeHash} from './adminRoutes'; @@ -40,6 +42,8 @@ export function AdminApp() { useState(null); const [inviteResult, setInviteResult] = useState(null); + const [taskConfigResult, setTaskConfigResult] = + useState(null); const clearSession = useCallback((message = '') => { clearStoredAdminToken(); @@ -47,6 +51,7 @@ export function AdminApp() { setAdmin(null); setRedeemResult(null); setInviteResult(null); + setTaskConfigResult(null); setStatus('guest'); setLoginNotice(message); }, []); @@ -115,6 +120,7 @@ export function AdminApp() { setAdmin(response.admin); setRedeemResult(null); setInviteResult(null); + setTaskConfigResult(null); setLoginNotice(''); setStatus('authenticated'); }, []); @@ -172,6 +178,14 @@ export function AdminApp() { onResultChange={setInviteResult} /> ) : null} + {routeId === 'tasks' ? ( + + ) : null} ); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 47a7c0d0..d5295039 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -3,6 +3,7 @@ import { LayoutDashboard, LogOut, ShieldCheck, + ListChecks, TicketCheck, TicketPercent, } from 'lucide-react'; @@ -25,6 +26,7 @@ const routeIcons = { debug: Bug, redeem: TicketPercent, invite: TicketCheck, + tasks: ListChecks, } satisfies Record; export function AdminShell({ diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index ee9ba760..a73c44db 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -1,4 +1,4 @@ -export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite'; +export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite' | 'tasks'; export interface AdminRouteDefinition { id: AdminRouteId; @@ -11,6 +11,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'debug', label: 'API 调试', hash: '#debug'}, {id: 'redeem', label: '兑换码', hash: '#redeem'}, {id: 'invite', label: '邀请码', hash: '#invite'}, + {id: 'tasks', label: '任务配置', hash: '#tasks'}, ]; export function resolveAdminRoute(hash: string): AdminRouteId { diff --git a/apps/admin-web/src/config/trackingEventDefinitions.ts b/apps/admin-web/src/config/trackingEventDefinitions.ts new file mode 100644 index 00000000..2e068809 --- /dev/null +++ b/apps/admin-web/src/config/trackingEventDefinitions.ts @@ -0,0 +1,45 @@ +import type {TrackingScopeKind} from '../api/adminApiTypes'; + +export interface AdminTrackingEventDefinition { + key: string; + title: string; + scopeKind: TrackingScopeKind; + remark: string; +} + +export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [ + { + key: 'daily_login', + title: '每日登录', + scopeKind: 'user', + remark: '用户打开任务中心时由后端幂等记录,用于每日登录任务进度校验。', + }, +]; + +export function findAdminTrackingEventDefinition(eventKey: string) { + const normalizedEventKey = eventKey.trim(); + return ( + adminTrackingEventDefinitions.find( + (definition) => definition.key === normalizedEventKey, + ) ?? null + ); +} + +export function filterAdminTrackingEventDefinitions(query: string) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return adminTrackingEventDefinitions; + } + + return adminTrackingEventDefinitions.filter((definition) => { + const haystack = [ + definition.key, + definition.title, + definition.scopeKind, + definition.remark, + ] + .join(' ') + .toLowerCase(); + return haystack.includes(normalizedQuery); + }); +} diff --git a/apps/admin-web/src/pages/AdminInviteCodePage.tsx b/apps/admin-web/src/pages/AdminInviteCodePage.tsx index ce927c6d..a507ef4d 100644 --- a/apps/admin-web/src/pages/AdminInviteCodePage.tsx +++ b/apps/admin-web/src/pages/AdminInviteCodePage.tsx @@ -1,7 +1,10 @@ -import {Save} from 'lucide-react'; -import {FormEvent, useState} from 'react'; +import {RefreshCcw, Save} from 'lucide-react'; +import {FormEvent, useEffect, useState} from 'react'; -import {upsertProfileInviteCode} from '../api/adminApiClient'; +import { + listProfileInviteCodes, + upsertProfileInviteCode, +} from '../api/adminApiClient'; import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes'; import {handlePageError} from './pageUtils'; @@ -21,7 +24,28 @@ export function AdminInviteCodePage({ const [inviteCode, setInviteCode] = useState(''); const [metadataText, setMetadataText] = useState('{}'); const [errorMessage, setErrorMessage] = useState(''); + const [listErrorMessage, setListErrorMessage] = useState(''); + const [entries, setEntries] = useState([]); const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + void refreshInviteCodes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshInviteCodes() { + setIsLoading(true); + setListErrorMessage(''); + try { + const response = await listProfileInviteCodes(token); + setEntries(response.entries); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } async function handleSave(event: FormEvent) { event.preventDefault(); @@ -37,6 +61,8 @@ export function AdminInviteCodePage({ metadata: parseMetadata(metadataText), }); onResultChange(response); + upsertEntry(response); + fillForm(response); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -44,6 +70,28 @@ export function AdminInviteCodePage({ } } + function upsertEntry(next: ProfileInviteCodeAdminResponse) { + setEntries((current) => { + const rest = current.filter((entry) => entry.inviteCode !== next.inviteCode); + return [...rest, next].sort((left, right) => { + const leftUpdatedAt = Date.parse(left.updatedAt); + const rightUpdatedAt = Date.parse(right.updatedAt); + if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) { + const updatedCompare = rightUpdatedAt - leftUpdatedAt; + if (updatedCompare !== 0) { + return updatedCompare; + } + } + return left.inviteCode.localeCompare(right.inviteCode); + }); + }); + } + + function fillForm(entry: ProfileInviteCodeAdminResponse) { + setInviteCode(entry.inviteCode); + setMetadataText(JSON.stringify(entry.metadata, null, 2)); + } + return (
@@ -51,8 +99,23 @@ export function AdminInviteCodePage({

邀请码

注册链路预置码

+
+ {listErrorMessage ? ( +
+ {listErrorMessage} +
+ ) : null} +
); diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index a346c8f1..373634e9 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -1,8 +1,9 @@ -import {PowerOff, Save} from 'lucide-react'; -import {FormEvent, useState} from 'react'; +import {PowerOff, RefreshCcw, Save} from 'lucide-react'; +import {FormEvent, useEffect, useState} from 'react'; import { disableProfileRedeemCode, + listProfileRedeemCodes, upsertProfileRedeemCode, } from '../api/adminApiClient'; import type { @@ -40,8 +41,29 @@ export function AdminRedeemCodePage({ const [disableCode, setDisableCode] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [disableErrorMessage, setDisableErrorMessage] = useState(''); + const [listErrorMessage, setListErrorMessage] = useState(''); + const [entries, setEntries] = useState([]); const [isSaving, setIsSaving] = useState(false); const [isDisabling, setIsDisabling] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + void refreshRedeemCodes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshRedeemCodes() { + setIsLoading(true); + setListErrorMessage(''); + try { + const response = await listProfileRedeemCodes(token); + setEntries(response.entries); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } async function handleSave(event: FormEvent) { event.preventDefault(); @@ -63,6 +85,8 @@ export function AdminRedeemCodePage({ mode === 'private' ? splitLines(allowedPublicUserCodes) : [], }); onResultChange(response); + upsertEntry(response); + fillForm(response); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -83,6 +107,8 @@ export function AdminRedeemCodePage({ code: disableCode.trim(), }); onResultChange(response); + upsertEntry(response); + fillForm(response); } catch (error: unknown) { handlePageError(error, onUnauthorized, setDisableErrorMessage); } finally { @@ -90,6 +116,34 @@ export function AdminRedeemCodePage({ } } + function upsertEntry(next: ProfileRedeemCodeAdminResponse) { + setEntries((current) => { + const rest = current.filter((entry) => entry.code !== next.code); + return [...rest, next].sort((left, right) => { + const leftUpdatedAt = Date.parse(left.updatedAt); + const rightUpdatedAt = Date.parse(right.updatedAt); + if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) { + const updatedCompare = rightUpdatedAt - leftUpdatedAt; + if (updatedCompare !== 0) { + return updatedCompare; + } + } + return left.code.localeCompare(right.code); + }); + }); + } + + function fillForm(entry: ProfileRedeemCodeAdminResponse) { + setCode(entry.code); + setMode(entry.mode); + setRewardPoints(String(entry.rewardPoints)); + setMaxUses(String(entry.maxUses)); + setEnabled(entry.enabled); + setAllowedUserIds(entry.allowedUserIds.join('\n')); + setAllowedPublicUserCodes(''); + setDisableCode(entry.code); + } + return (
@@ -97,8 +151,23 @@ export function AdminRedeemCodePage({

兑换码

创建、更新与停用

+
+ {listErrorMessage ? ( +
+ {listErrorMessage} +
+ ) : null} +
@@ -200,6 +269,48 @@ export function AdminRedeemCodePage({
+
+
+

兑换码列表

+ {entries.length} +
+ {entries.length ? ( +
+ + + + + + + + + + {entries.map((entry) => ( + + + + + + ))} + +
Code奖励状态
+ + {redeemModeLabel(entry.mode)} + {entry.rewardPoints}{entry.enabled ? '启用' : '停用'}
+
+ ) : ( +
+ {isLoading ? '加载中' : '暂无兑换码'} +
+ )} +
+