Makelore 2.0 initial clean snapshot

This commit is contained in:
inman
2026-07-29 17:22:35 +08:00
commit b8ca3f8eea
694 changed files with 139782 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
---
name: game-assets
description: 在 NianCode 中通过 Meowa 的固定版 game-assets 适配器生成和整理像素素材、高清素材、角色、道具、背景、UI 图和可复用游戏资源。素材生成、下载、项目内落盘、授权记录和审核证据必须走这个 Skill。
---
# NianCode · Meowa game-assets
这是项目内固定的 Meowa `game-assets` Skill 适配版,版本 `2026.06.19.1`。
上游参考:`https://github.com/Meowa-AI/meowa-skills/tree/main/skills/game-assets`。
NianCode 会把 Meowa API Key 留在 Electron Main 的安全存储或启动环境变量中。你只能通过随 Skill 一起提供的 `meowart_api.py` 调用本地 Host API,不能直接访问 Meowa API,也不能读取、打印、复制或写入 API Key。
## 角色边界
在 `game-development` 模板中,`game-art` 是唯一直接调用本 Skill 的 Agent。当前 Meowa 适配器只生成 2D 像素/高清光栅素材,不生成 3D 模型;3D 模型应走项目、用户或许可证明确的外部来源,并记录在 `ASSET_PLAN.md`。`game-design` 负责提出视觉缺口和验收语境,`game-development` 只接入审核状态为 `approved` 的项目内素材,`game-test-release` 负责验证真实运行效果并回交问题;它们通过 `GDD.md`、`TECH_STACK.md`、`ASSET_PLAN.md`、`ART_GUIDE.md`、`TASKS.md` 和 `.niancode/asset-review.json` 协作,不重复发起生成。`game-promotion` 不属于素材生成链路,只能引用已经确认、可追溯的成果。
这个 Skill 不授予任何 Agent 直接访问 Meowa 的权限。所有角色都必须遵守本地适配器、API Key 隔离、生成元数据、项目内预览和用户审核规则。
## 每次素材任务的第一步
先读取当前任务的动态指引;网络不可用时,CLI 会自动使用项目内固定指引:
```bash
MEOWA_GAME_ASSETS_CLI="${NIANCODE_GAME_ASSETS_CLI:-${OPENCODE_CONFIG_DIR}/skills/game-assets/meowart_api.py}"
if [ -z "${NIANCODE_PYTHON_PATH:-}" ]; then
echo "NianCode managed Python is unavailable; repair or reinstall NianCode." >&2
exit 1
fi
"$NIANCODE_PYTHON_PATH" "$MEOWA_GAME_ASSETS_CLI" skill-doc --task "<这次素材需求>"
```
然后读取项目中的 `GDD.md`、`ASSET_PLAN.md`、`ART_GUIDE.md`、`TASKS.md` 和 `.niancode/asset-review.json`(如果存在)。先判断现有 Works Square、开源素材是否能满足需求;只有在缺口明确时才生成。
## 生成前检查
```bash
"$NIANCODE_PYTHON_PATH" "$MEOWA_GAME_ASSETS_CLI" config-status
"$NIANCODE_PYTHON_PATH" "$MEOWA_GAME_ASSETS_CLI" template-info --kind pixel
"$NIANCODE_PYTHON_PATH" "$MEOWA_GAME_ASSETS_CLI" template-info --kind hd
```
`template-info` 返回的模版名和字段是当前可用事实。不要凭记忆编造模版名或字段。没有配置 Meowa 时,明确写入 `ASSET_PLAN.md` 的阻塞项,不要求用户把 Key 发到聊天里。
`config-status` 用于确认 Main 是否已准备好 Meowa 凭据。未配置时停止生成,把阻塞原因写入 `ASSET_PLAN.md`,且不要要求用户在聊天中提供 Key。
## 生成命令
像素素材:
```bash
"$NIANCODE_PYTHON_PATH" "$MEOWA_GAME_ASSETS_CLI" pixel-gen \
--template-name "<从 template-info 选择>" \
--requirement "<角色/道具/地形和透明背景要求>" \
--aspect-ratio 1:1 \
--output-dir assets/generated/meowa
```
高清素材:
```bash
"$NIANCODE_PYTHON_PATH" "$MEOWA_GAME_ASSETS_CLI" hd-gen \
--template-name "<从 template-info 选择>" \
--requirement "<素材要求>" \
--output-dir assets/generated/meowa
```
需要参考图时使用一个或多个 `--reference-file <项目相对路径>`。需要特定模型、分辨率、去背景模式或模版 JSON 时,先从动态指引和 `template-info` 确认,再传 `--model-name`、`--resolution`、`--remove-bg-mode`、`--template-config '<JSON>'`。
CLI 会等待异步任务完成,把输出下载到明确的项目目录,并写入 `submit_response.json`、`final_response.json` 和 `generation.meta.json`。不要只把远程 URL 写进 `ASSET_PLAN.md`,也不要把未下载的输出提交审核。
## 项目资产与审核规则
- 默认输出放在 `assets/generated/meowa/<run>/`;每个候选必须有真实存在的 `localPath`、`previewPath`、`coverPath` 或非空 `manifestPaths`。
- `generation.meta.json` 必须保留 provider、Skill 版本、模版、提示词、任务 ID、生成时间、条款链接和授权状态。
- Meowa 生成物不是 CC0。当前条款下,商业使用取决于 Meowa 账户方案;免费方案按条款限制为非商业使用,付费方案也要在交付前复核当前条款。记录 `https://meowa.ai/tos`,不要把“可生成”写成“已获商业授权”。
- 用户没有 `approve` 前,只能写入候选和审核记录,不能当作正式开发素材接入。
- 用户 `discard` 或 `replace` 的候选不得再次提交;候选、pending、discarded 和 replace-requested 不得交给游戏开发 Agent 当作已选素材。
- 像素素材预览和缩放使用 nearest-neighbor;保持透明背景、画布尺寸、命名和导出格式与 `ART_GUIDE.md` 一致。
- 生成结果失败、超时、下载失败或授权无法确认时,写清楚 `BLOCKED`/`TODO` 和备用方案;不要编造成功结果。
## 禁止事项
- 不运行 `npx skills add`、远程 bootstrap、用户级外部 Skill 或未固定版本的 `game-assets`。
- 不直接请求 `https://api.meowa.ai`,不把 API Key 放入命令行参数、提示词、项目文件、日志或聊天。
- 不把 Meowa 输出标成 CC0、OpenGameArt、Kenney 或 Works Square 素材。
- 不为了增加数量而重复生成同一缺口;先记录已有候选和生成理由。

View File

@@ -0,0 +1,42 @@
# Meowa game-assets 固定指引
这是 NianCode 的离线兜底指引。在线时,`skill-doc` 会优先通过 Electron Main 代理读取 Meowa 当前指引;在线指引不可用时才使用本文件。
本适配器当前只生成 2D 像素/高清光栅素材,不生成 3D 模型。3D 模型请在 `ASSET_PLAN.md` 中记录项目、用户或许可证明确来源,以及 GLB/glTF 文件和预览证据。
## 调用顺序
1. `config-status` 确认 Main 是否配置了 Meowa 凭据。
2. `template-info --kind pixel` 或 `template-info --kind hd` 读取真实模版。
3. 从 GDD、ART_GUIDE 和 ASSET_PLAN 提取一个明确缺口,写成可验证的 requirement。
4. 运行 `pixel-gen` 或 `hd-gen`,等待任务结束并下载文件。
5. 检查项目内输出和 `generation.meta.json`,再把候选写入 ASSET_PLAN。
## 常用命令
```bash
"$NIANCODE_PYTHON_PATH" meowart_api.py config-status
"$NIANCODE_PYTHON_PATH" meowart_api.py skill-doc --task "像素角色和攻击帧"
"$NIANCODE_PYTHON_PATH" meowart_api.py template-info --kind pixel
"$NIANCODE_PYTHON_PATH" meowart_api.py pixel-gen --template-name "<真实模版名>" --requirement "<需求>" --output-dir assets/generated/meowa
"$NIANCODE_PYTHON_PATH" meowart_api.py hd-gen --template-name "<真实模版名>" --requirement "<需求>" --output-dir assets/generated/meowa
"$NIANCODE_PYTHON_PATH" meowart_api.py credits
```
`pixel-gen` 和 `hd-gen` 是异步任务。成功后 CLI 会将任务响应、最终响应、下载文件和授权元数据保存在同一个 run 目录。下载失败时不要提交审核标记。
## 远程 API 事实
NianCode Main 只代理以下固定能力,并负责注入凭据:
- `GET /api/pixel-gen/template-info`
- `POST /api/pixel-gen`
- `GET /api/pixel-gen/jobs?id=...`
- `GET /api/pixel-gen/jobs/{id}/download`
- `GET /api/hd-gen/template-info`
- `POST /api/hd-gen`
- `GET /api/hd-gen/jobs?id=...`
- `GET /api/hd-gen/jobs/{id}/download`
- `GET /api/credits/balance`
Agent 不应直接构造这些远程 URL;只调用项目内的 `meowart_api.py`。

View File

@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""NianCode's fixed, keyless adapter for the Meowa game-assets Skill.
The Meowa credential stays in Electron Main. This CLI talks only to the local
NianCode Host API and writes generated output plus provenance metadata into the
active project.
"""
from __future__ import annotations
import argparse
import base64
from datetime import datetime, timezone
import json
import mimetypes
import os
from pathlib import Path
import re
import sys
import time
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
SKILL_VERSION = "2026.06.19.1"
DEFAULT_HOST_BASE = "http://127.0.0.1:13210"
DEFAULT_OUTPUT_DIR = "assets/generated/meowa"
TERMINAL_STATUSES = {"success", "completed", "failure", "failed", "cancelled", "canceled"}
class AdapterError(RuntimeError):
pass
def host_base() -> str:
value = os.environ.get("NIANCODE_HOST_API_BASE_URL", DEFAULT_HOST_BASE).strip()
return value.rstrip("/")
def host_token() -> str:
token = os.environ.get("NIANCODE_HOST_API_TOKEN", "").strip()
if not token:
raise AdapterError("NianCode Host API token is unavailable; run this command from a NianCode Agent")
return token
def parse_payload(raw: bytes) -> Any:
if not raw:
return None
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return raw.decode("utf-8", errors="replace")[:500]
def payload_message(payload: Any, fallback: str) -> str:
if isinstance(payload, dict):
for key in ("error", "message", "detail", "msg"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()[:500]
if isinstance(payload, str) and payload.strip():
return payload.strip()[:500]
return fallback
def request_json(path: str, method: str = "GET", payload: dict[str, Any] | None = None, timeout: int = 240) -> Any:
url = path if path.startswith("http://") or path.startswith("https://") else f"{host_base()}{path}"
body = None
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {host_token()}",
}
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=body, headers=headers, method=method)
try:
with urlopen(request, timeout=timeout) as response:
return parse_payload(response.read())
except HTTPError as error:
payload_value = parse_payload(error.read())
raise AdapterError(payload_message(payload_value, f"Host API returned HTTP {error.code}")) from error
except URLError as error:
raise AdapterError(f"无法连接 NianCode Host API:{error.reason}") from error
def json_dump(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def print_json(value: Any) -> None:
print(json.dumps(value, ensure_ascii=False, indent=2))
def cli_path() -> Path:
return Path(__file__).resolve()
def command_skill_doc(args: argparse.Namespace) -> int:
query = urlencode({key: value for key, value in {"task": args.task, "topic": args.topic}.items() if value})
path = "/api/meowa/game-assets/skill-doc"
if query:
path = f"{path}?{query}"
try:
payload = request_json(path)
content = payload.get("content") if isinstance(payload, dict) else None
if isinstance(content, str) and content.strip():
print(content)
return 0
raise AdapterError("Skill 文档响应没有 content")
except AdapterError as error:
fallback = cli_path().with_name("meowart_api.md")
if fallback.is_file():
print(f"[WARN] online Meowa guide unavailable; using bundled guide: {error}", file=sys.stderr)
print(fallback.read_text(encoding="utf-8"))
return 0
raise
def command_skill_doc_status(_args: argparse.Namespace) -> int:
guide = cli_path().with_name("meowart_api.md")
print_json({
"skillVersion": SKILL_VERSION,
"bundledGuide": str(guide),
"bundledGuideExists": guide.is_file(),
"remoteGuide": "通过 NianCode Main 代理读取",
})
return 0
def command_config_status(_args: argparse.Namespace) -> int:
print_json(request_json("/api/meowa/game-assets/config"))
return 0
def command_template_info(args: argparse.Namespace) -> int:
print_json(request_json(f"/api/meowa/game-assets/template-info?kind={args.kind}"))
return 0
def command_credits(_args: argparse.Namespace) -> int:
print_json(request_json("/api/meowa/game-assets/credits"))
return 0
def slug(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip()).strip("-_")
return value[:72] or "asset"
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def read_reference_files(paths: list[str]) -> list[dict[str, str]]:
references: list[dict[str, str]] = []
total = 0
for raw_path in paths:
path = Path(raw_path).expanduser()
if not path.is_file():
raise AdapterError(f"参考文件不存在:{path}")
data = path.read_bytes()
if len(data) > 8 * 1024 * 1024:
raise AdapterError(f"参考文件过大:{path}")
total += len(data)
if total > 16 * 1024 * 1024:
raise AdapterError("参考文件总大小超过限制")
references.append({
"name": path.name,
"mimeType": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
"dataBase64": base64.b64encode(data).decode("ascii"),
})
return references
def safe_request_payload(payload: dict[str, Any]) -> dict[str, Any]:
result = dict(payload)
references = result.get("referenceFiles")
if isinstance(references, list):
result["referenceFiles"] = [
{"name": item.get("name"), "mimeType": item.get("mimeType")}
for item in references
if isinstance(item, dict)
]
return result
def extension_for(mime_type: str, file_name: str) -> str:
suffix = Path(file_name).suffix.lower()
if suffix and len(suffix) <= 10:
return suffix
return {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"application/zip": ".zip",
"audio/mpeg": ".mp3",
"audio/wav": ".wav",
}.get(mime_type.lower(), ".bin")
def download_job(kind: str, job_id: str, output_index: int | None = None) -> dict[str, Any]:
params: dict[str, str] = {"kind": kind, "id": job_id}
if output_index is not None:
params["outputIndex"] = str(output_index)
payload = request_json(f"/api/meowa/game-assets/download?{urlencode(params)}")
if not isinstance(payload, dict) or not payload.get("success"):
raise AdapterError(payload_message(payload, "Meowa 下载响应无效"))
return payload
def generation_payload(args: argparse.Namespace, kind: str) -> dict[str, Any]:
template_config: Any = {}
if args.template_config:
try:
template_config = json.loads(args.template_config)
except json.JSONDecodeError as error:
raise AdapterError(f"template-config 不是有效 JSON:{error}") from error
if not isinstance(template_config, dict):
raise AdapterError("template-config 必须是 JSON 对象")
payload: dict[str, Any] = {
"kind": kind,
"templateName": args.template_name,
"templateConfig": template_config,
"requirement": args.requirement,
"jobName": args.job_name,
"modelName": args.model_name,
"resolution": args.resolution,
"aspectRatio": args.aspect_ratio,
"temperature": args.temperature,
"includeBase64": args.include_base64,
"referenceFiles": read_reference_files(args.reference_file),
}
if kind == "hd":
payload.update({
"hdRemoveBgMode": args.remove_bg_mode,
"projectId": args.project_id,
"threadId": args.thread_id,
})
return payload
def poll_job(kind: str, job_id: str, max_wait: int, poll_interval: float) -> dict[str, Any]:
deadline = time.time() + max(max_wait, 1)
last: dict[str, Any] = {}
while time.time() <= deadline:
payload = request_json(f"/api/meowa/game-assets/jobs?{urlencode({'kind': kind, 'id': job_id})}")
if not isinstance(payload, dict):
raise AdapterError("Meowa 任务响应不是 JSON 对象")
last = payload
status = str(payload.get("status") or "").strip().lower()
print(f"[INFO] meowa job={job_id} status={status or 'unknown'}", file=sys.stderr)
if status in TERMINAL_STATUSES:
return payload
time.sleep(max(poll_interval, 0.2))
raise AdapterError(f"Meowa 任务等待超时:{job_id},最后状态 {last.get('status', 'unknown')}")
def command_generate(args: argparse.Namespace, kind: str) -> int:
payload = generation_payload(args, kind)
output_root = Path(args.output_dir).expanduser()
run_dir = output_root / f"{slug(args.template_name)}-{int(time.time())}"
run_dir.mkdir(parents=True, exist_ok=True)
started_at = now_iso()
json_dump(run_dir / "request.meta.json", {
"provider": "meowa",
"skillVersion": SKILL_VERSION,
"kind": kind,
"startedAt": started_at,
"request": safe_request_payload(payload),
})
try:
submit = request_json("/api/meowa/game-assets/generate", method="POST", payload=payload)
json_dump(run_dir / "submit_response.json", submit)
if not isinstance(submit, dict):
raise AdapterError("Meowa 提交响应不是 JSON 对象")
job_id = str(submit.get("api_job_id") or submit.get("job_id") or "").strip()
if not job_id:
raise AdapterError("Meowa 提交响应缺少任务 ID")
print(f"[INFO] submitted meowa job={job_id}", file=sys.stderr)
final = poll_job(kind, job_id, args.max_wait, args.poll_interval)
json_dump(run_dir / "final_response.json", final)
status = str(final.get("status") or "").strip().lower()
if status not in {"success", "completed"}:
raise AdapterError(f"Meowa 任务未成功结束:{status or 'unknown'}")
download = download_job(kind, job_id)
file_name = str(download.get("fileName") or f"{job_id}.bin")
mime_type = str(download.get("mimeType") or "application/octet-stream")
encoded = download.get("dataBase64")
if not isinstance(encoded, str) or not encoded:
raise AdapterError("Meowa 下载响应缺少文件内容")
file_path = run_dir / f"asset{extension_for(mime_type, file_name)}"
file_path.write_bytes(base64.b64decode(encoded))
metadata = {
"provider": "meowa",
"integration": "niancode-meowa-game-assets",
"skillVersion": SKILL_VERSION,
"kind": kind,
"templateName": args.template_name,
"requirement": args.requirement,
"jobId": job_id,
"generatedAt": now_iso(),
"localPath": str(file_path.as_posix()),
"mimeType": mime_type,
"sourceType": "ai-generated",
"licenseStatus": "verify-meowa-plan-before-shipping",
"commercialUse": "依 Meowa 当前账户方案和条款确认;不得默认视为 CC0。",
"termsUrl": "https://meowa.ai/tos",
"upstreamSkill": "https://github.com/Meowa-AI/meowa-skills/tree/main/skills/game-assets",
}
json_dump(run_dir / "generation.meta.json", metadata)
print(f"[INFO] saved_dir={run_dir}", file=sys.stderr)
print_json({"success": True, "jobId": job_id, "file": str(file_path.as_posix()), "metadata": str((run_dir / 'generation.meta.json').as_posix())})
return 0
except Exception as error:
json_dump(run_dir / "error.json", {"error": str(error), "failedAt": now_iso()})
raise
def add_generation_args(parser: argparse.ArgumentParser, kind: str) -> None:
parser.add_argument("--template-name", required=True)
parser.add_argument("--requirement", required=True)
parser.add_argument("--template-config", default="")
parser.add_argument("--job-name", default="")
parser.add_argument("--model-name", default="gemini-3.1-flash-image-preview" if kind == "hd" else "")
parser.add_argument("--resolution", default="")
parser.add_argument("--aspect-ratio", default="1:1")
parser.add_argument("--temperature", type=float, default=0.0)
parser.add_argument("--include-base64", action="store_true")
parser.add_argument("--reference-file", action="append", default=[])
parser.add_argument("--output-dir", default=DEFAULT_OUTPUT_DIR)
parser.add_argument("--max-wait", type=int, default=900)
parser.add_argument("--poll-interval", type=float, default=3.0)
if kind == "hd":
parser.add_argument("--remove-bg-mode", default="")
parser.add_argument("--project-id", default=None)
parser.add_argument("--thread-id", default=None)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="NianCode keyless adapter for Meowa game assets")
subparsers = parser.add_subparsers(dest="command", required=True)
skill_doc = subparsers.add_parser("skill-doc")
skill_doc.add_argument("--task", default="")
skill_doc.add_argument("--topic", default="")
skill_doc.set_defaults(handler=command_skill_doc)
status = subparsers.add_parser("skill-doc-status")
status.set_defaults(handler=command_skill_doc_status)
config = subparsers.add_parser("config-status")
config.set_defaults(handler=command_config_status)
template = subparsers.add_parser("template-info")
template.add_argument("--kind", choices=("pixel", "hd"), required=True)
template.set_defaults(handler=command_template_info)
credits = subparsers.add_parser("credits")
credits.set_defaults(handler=command_credits)
for kind in ("pixel", "hd"):
generator = subparsers.add_parser(f"{kind}-gen")
add_generation_args(generator, kind)
generator.set_defaults(handler=lambda args, selected_kind=kind: command_generate(args, selected_kind))
return parser
def main() -> int:
args = build_parser().parse_args()
try:
return int(args.handler(args))
except AdapterError as error:
print(f"[ERROR] {error}", file=sys.stderr)
return 1
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"[ERROR] {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())