12.1 内容与版权的地区差异

同一份素材能不能发,取决于它买的是哪一档授权。本节把每份内容的授权区间、署名要求、二创许可整理成一份可机读登记表,用 D 层脚本扫成 content × region 可用性矩阵,算出可发对数、被阻断区与死资产,再用 P 层 TypeScript 内核跨语言对拍,证明判定逐位一致。

站内有两篇长文要先划清边界:一篇是游戏本地化与多语言指南 ,讲的是本地化行业与语言分工;另一篇是出海合规与税务风险清单 ,讲的是法务与税务口径。本节都不重写。它要回答的是一个更窄、也更工程的问题:手上这批素材,哪些地区能发、哪些不能发。这个问题之所以必须工程化,是因为它每天都在变——今天买的一首背景音乐,可能只授权北美与欧洲;一个看起来无害的名称,可能在巴西已被别人注册成商标。靠人脑记「哪张图能发哪国」,迟早出事。

前面第 6 章我们把文化禁忌做成了规则表,但禁忌只回答「这张图在文化上会不会冒犯」。本节补上另一半:这张图在法律上有没有授权。两者合起来,才是「一份内容能不能进某个市场」的完整判据。

版权不是一个状态,是一张地区矩阵

一个常见误判是把版权当成一个布尔值:「这图我们买了,能用」。真实的授权是有地域范围的。同一份素材,可能有三种档位:全球授权(allows 含 "*")、地区白名单(只在少数市场有效)、地区黑名单(全球可用,但个别市场被排除,通常是商标冲突或当地法规)。再叠上两个开关:是否要求署名(attributionRequired)和是否允许二次创作(derivativeAllowed)——后者直接决定这份素材能不能被裁成地区变体。

把这三个维度写成一条公式,就得到了本节的核心判据:

available(content, region) = ((region ∈ allows) 或 ("*" ∈ allows)) 且 (region ∉ denies)

denies 的优先级高于 allows:哪怕一份素材是「全球授权」,只要它在某地区的 denies 里,那个地区就不发。这条规则听起来简单,却能把「我们明明买了全球授权」这种想当然挡在门外。

三个维度:授权区间、署名、二创

登记表是纯数据,每份内容登记 kind(音乐 / 音效 / 美术 / 字体 / 名称 / 贴图 / 徽标 / 照片)、source(自制 / 委托 / 买断 / 授权 / 商标)、allows / denies 与两个开关:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "内容与版权的地区差异登记表。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):把每份素材的授权来源、是否要求署名、是否允许二次创作,以及授权允许 / 拒绝发行的地区整理成可机读的登记表,不是任何版权方或平台的逐条法律结论,也不构成法律意见。allows 里的 \"*\" 表示全球授权;denies 优先级高于 allows。available(content, region) = ((region ∈ allows) 或 (\"*\" ∈ allows)) 且 (region ∉ denies)。真实授权合同与真实商标检索需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "regions": [
    { "id": "us", "name": "北美" },
    { "id": "eu", "name": "欧洲" },
    { "id": "cn", "name": "中国大陆" },
    { "id": "jp", "name": "日本" },
    { "id": "kr", "name": "韩国" },
    { "id": "tw", "name": "台港澳" },
    { "id": "br", "name": "巴西" },
    { "id": "in", "name": "印度" },
    { "id": "sea", "name": "东南亚" },
    { "id": "mena", "name": "中东" }
  ],
  "contents": [
    { "id": "bgm_island", "name": "背景音乐·小岛", "kind": "music", "source": "stock-royalty-free", "attributionRequired": 1, "derivativeAllowed": 1, "allows": ["*"], "denies": [] },
    { "id": "sfx_merge", "name": "音效·合成", "kind": "sfx", "source": "self-made", "attributionRequired": 0, "derivativeAllowed": 1, "allows": ["*"], "denies": [] },
    { "id": "art_hero", "name": "主视觉·小岛", "kind": "art", "source": "commissioned", "attributionRequired": 0, "derivativeAllowed": 1, "allows": ["*"], "denies": [] },
    { "id": "font_cjk", "name": "字体·汉字", "kind": "font", "source": "licensed", "attributionRequired": 0, "derivativeAllowed": 0, "allows": ["cn", "jp", "kr", "tw"], "denies": [] },
    { "id": "name_island", "name": "名称·合成小岛", "kind": "name", "source": "trademark", "attributionRequired": 0, "derivativeAllowed": 0, "allows": ["*"], "denies": ["br"] },
    { "id": "stock_texture", "name": "贴图·沙滩", "kind": "texture", "source": "stock-royalty-free", "attributionRequired": 0, "derivativeAllowed": 1, "allows": ["us", "eu"], "denies": [] },
    { "id": "partner_logo", "name": "合作方 Logo", "kind": "logo", "source": "partner-license", "attributionRequired": 1, "derivativeAllowed": 0, "allows": ["*"], "denies": ["cn", "mena"] },
    { "id": "music_pop", "name": "授权流行曲", "kind": "music", "source": "licensed", "attributionRequired": 0, "derivativeAllowed": 0, "allows": ["us", "eu", "jp"], "denies": [] },
    { "id": "photo_beach", "name": "照片·海滩", "kind": "photo", "source": "stock-royalty-free", "attributionRequired": 0, "derivativeAllowed": 0, "allows": ["*"], "denies": [] }
  ]
}

D 层:把授权登记表扫成可用性矩阵

下面这段是 D 层校验脚本 sim/g12_content_rights.py。它读登记表,把「内容授权」与「发行地区」叉乘成一张可用性矩阵,统计可发对数、被阻断区、每个内容可发行的地区数,并单独列出死资产(授权区间与发行地区零交集,等于白买)与两个开关的清单:

#!/usr/bin/env python3
# file: sim/g12_content_rights.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g12_content_rights.py --config config/ --out out/
"""内容与版权的地区可用性矩阵校验(D 层:清单结构 / lint 校验)。

读 config/g12_content_rights.json,把「内容授权」与「发行地区」做笛卡尔匹配,
得到一张 content × region 的可用性矩阵:

    available(content, region) = ((region ∈ allows) 或 ("*" ∈ allows)) 且 (region ∉ denies)
    availablePairs   = available==1 的 (content, region) 对数
    blockedPairs     = available==0 的 (content, region) 对数
    shipRegions(c)   = 该内容可发行的地区
    deadAssets       = shipCount==0 的内容(授权区间与发行地区零交集,等于白买)
    needsAttribution = attributionRequired==1 的内容(须在关于页署名)
    noDerivative     = derivativeAllowed==0 的内容(不可裁剪 / 改色,地区变体受限)

口径:授权登记表是「公开可观察机制」的建模假设,不是版权方的逐条法律结论,
也不构成法律意见。真实授权合同与商标检索需真实业务环境,命令行不可复现(X)。
产出 out/g12_content_rights.json + .csv + .svg。全整数,无浮点,无随机。
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def is_available(content: dict, region: str) -> int:
    """内容在指定地区的可用性:allows 命中且不在 denies。denies 优先。"""
    allows = content["allows"]
    if region in content["denies"]:
        return 0
    return 1 if ("*" in allows or region in allows) else 0


def scan(cfg: dict):
    regions = [r["id"] for r in cfg["regions"]]
    pairs, content_rows = [], []
    for c in cfg["contents"]:
        ship = [reg for reg in regions if is_available(c, reg) == 1]
        for reg in regions:
            pairs.append({"content": c["id"], "region": reg, "available": is_available(c, reg)})
        content_rows.append({"id": c["id"], "name": c["name"], "kind": c["kind"],
                             "source": c["source"], "attributionRequired": c["attributionRequired"],
                             "derivativeAllowed": c["derivativeAllowed"], "shipRegions": ship,
                             "shipCount": len(ship), "blockedCount": len(regions) - len(ship)})
    return pairs, content_rows


def draw_svg(path: str, rows: list[dict], regions: list[str]) -> int:
    """纯 Python SVG:content × region 可用性热力格(绿=可发、红=不可发)。"""
    CW, CH, LEFT, TOP = 52, 30, 220, 74
    W = LEFT + CW * len(regions) + 20
    H = TOP + CH * len(rows) + 20
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15",
                                "fill": "#111827"}).text = "内容 × 地区可用性矩阵(绿=可发 / 红=不可发)"
    for k, reg in enumerate(regions):
        ET.SubElement(svg, "text", {"x": str(LEFT + k * CW + 14), "y": str(TOP - 10),
                                    "font-size": "11", "fill": "#6b7280"}).text = reg
    for i, r in enumerate(rows):
        y = TOP + i * CH
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 19), "font-size": "11",
                                    "fill": "#374151"}).text = f'{r["name"]}({r["shipCount"]}/{len(regions)})'
        for k, reg in enumerate(regions):
            avail = 1 if reg in r["shipRegions"] else 0
            ET.SubElement(svg, "rect", {"x": str(LEFT + k * CW + 2), "y": str(y + 4),
                                        "width": str(CW - 4), "height": str(CH - 8), "rx": "3",
                                        "fill": "#16a34a" if avail else "#ef4444"})
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "g12_content_rights.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    regions = [r["id"] for r in cfg["regions"]]
    pairs, content_rows = scan(cfg)
    available_pairs = sum(1 for p in pairs if p["available"] == 1)
    blocked_pairs = len(pairs) - available_pairs
    dead = [r["id"] for r in content_rows if r["shipCount"] == 0]
    needs_attr = [r["id"] for r in content_rows if r["attributionRequired"] == 1]
    no_deriv = [r["id"] for r in content_rows if r["derivativeAllowed"] == 0]
    region_rows = [{"region": reg, "availableContents": sum(1 for p in pairs if p["region"] == reg and p["available"] == 1)}
                   for reg in regions]

    digest = (f"contents={len(content_rows)},regions={len(regions)},available={available_pairs},"
              f"blocked={blocked_pairs},needsAttribution={len(needs_attr)},noDerivative={len(no_deriv)}")

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "g12_content_rights.svg"), content_rows, regions)
    with open(os.path.join(a.out, "g12_content_rights.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["content", "region", "available"])
        for p in pairs:
            w.writerow([p["content"], p["region"], p["available"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "regionCount": len(regions), "contentCount": len(content_rows),
           "availablePairs": available_pairs, "blockedPairs": blocked_pairs,
           "deadAssets": dead, "needsAttribution": needs_attr, "noDerivative": no_deriv,
           "contentSummary": content_rows, "regionSummary": region_rows,
           "digest": digest, "svg": os.path.join(a.out, "g12_content_rights.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "g12_content_rights.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "availablePairs": available_pairs, "blockedPairs": blocked_pairs,
                      "deadAssets": dead, "needsAttribution": needs_attr, "noDerivative": no_deriv,
                      "contentSummary": [{"id": r["id"], "shipCount": r["shipCount"],
                                          "blockedCount": r["blockedCount"]} for r in content_rows]},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑 /opt/homebrew/bin/python3 sim/g12_content_rights.py --config config/ --out out/,9 份内容 × 10 个地区 = 90 对的结果是(同机相对量级,绝对数值不可移植):

digest = contents=9,regions=10,available=66,blocked=24,needsAttribution=2,noDerivative=5

90 对里 66 对可发、24 对被阻断。逐内容看(shipCount 是可发行地区数):

内容授权来源可发行被阻断读法
背景音乐·小岛免版税素材100全球授权,但须署名
音效·合成自制100干净,全区可发
主视觉·小岛委托100干净,全区可发
字体·汉字授权46只在 4 个 CJK 市场有效
名称·合成小岛商标91巴西商标冲突
贴图·沙滩免版税素材28只在欧美授权
合作方 Logo合作授权82中国大陆与中东被排除
授权流行曲授权37只在美国 / 欧盟 / 日本
照片·海滩免版税素材100全球授权,但禁止二创

这张表最有价值的一行是贴图·沙滩:它只有 2 个市场可发。它不是「某个地区要换图」,而是这张贴图在当前授权下只能进欧美——如果发行组合里有日韩,就必须换素材或补授权。另一个信号是 deadAssets 为空:没有哪份素材的授权区间与发行地区零交集(那种情况等于白买),说明当前素材池的授权都至少覆盖一个目标市场。

P 层内核:跨语言对拍,证明判定没写错

一张表看不出「是判定对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/g12/content_rights.ts 复算同一套匹配:同样的 denies 优先、同样的 "*" 展开、同样的地区计数。它读同一份 config/g12_content_rights.json,产出与 Python 逐字符一致的摘要。

// file: core/src/g12/content_rights.ts
// tier: P
// verify: node --test 'core/test/g12_*.test.ts'
//
// 内容与版权的地区可用性内核:与 sim/g12_content_rights.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g12_content_rights.json;全整数、无浮点、无随机、禁 Math.random。
// available(content, region) = ((region ∈ allows) 或 ("*" ∈ allows)) 且 (region ∉ denies)。

export interface RightsRegion { readonly id: string; readonly name: string }
export interface ContentRights {
  readonly id: string; readonly name: string; readonly kind: string; readonly source: string;
  readonly attributionRequired: number; readonly derivativeAllowed: number;
  readonly allows: readonly string[]; readonly denies: readonly string[];
}
export interface RightsConfig {
  readonly version: number; readonly game: string; readonly observedAt: string;
  readonly regions: readonly RightsRegion[]; readonly contents: readonly ContentRights[];
}
export interface ContentRow {
  readonly id: string; readonly name: string; readonly kind: string; readonly source: string;
  readonly attributionRequired: number; readonly derivativeAllowed: number;
  readonly shipRegions: readonly string[]; readonly shipCount: number; readonly blockedCount: number;
}
export interface ScanResult { readonly pairs: readonly { content: string; region: string; available: number }[];
  readonly contents: readonly ContentRow[] }

/** 内容在指定地区的可用性:allows 命中且不在 denies;denies 优先。 */
export function isAvailable(content: ContentRights, region: string): number {
  if (content.denies.includes(region)) return 0;
  return content.allows.includes("*") || content.allows.includes(region) ? 1 : 0;
}

/** 扫描整张矩阵:逐内容 × 逐地区求可用性、可发行地区、被阻断地区。 */
export function scan(cfg: RightsConfig): ScanResult {
  const pairs: { content: string; region: string; available: number }[] = [];
  const contents: ContentRow[] = [];
  for (const c of cfg.contents) {
    const ship = cfg.regions.filter((r) => isAvailable(c, r.id) === 1).map((r) => r.id);
    for (const r of cfg.regions) pairs.push({ content: c.id, region: r.id, available: isAvailable(c, r.id) });
    contents.push({ id: c.id, name: c.name, kind: c.kind, source: c.source,
      attributionRequired: c.attributionRequired, derivativeAllowed: c.derivativeAllowed,
      shipRegions: ship, shipCount: ship.length, blockedCount: cfg.regions.length - ship.length });
  }
  return { pairs, contents };
}

/** 跨语言对拍摘要:与 sim/g12_content_rights.py 的 digest 逐字符一致。 */
export function digest(cfg: RightsConfig): string {
  const { pairs, contents } = scan(cfg);
  const available = pairs.filter((p) => p.available === 1).length;
  const blocked = pairs.length - available;
  const needsAttr = contents.filter((c) => c.attributionRequired === 1).length;
  const noDeriv = contents.filter((c) => c.derivativeAllowed === 0).length;
  return `contents=${contents.length},regions=${cfg.regions.length},available=${available},`
    + `blocked=${blocked},needsAttribution=${needsAttr},noDerivative=${noDeriv}`;
}

/** 授权区间与发行地区零交集的内容(白买),工程上应在上架前拦下。 */
export function deadAssets(cfg: RightsConfig): string[] {
  return scan(cfg).contents.filter((c) => c.shipCount === 0).map((c) => c.id);
}

/** 某内容可发行的地区数。 */
export function shipCount(cfg: RightsConfig, contentId: string): number {
  const c = cfg.contents.find((x) => x.id === contentId);
  if (c === undefined) return 0;
  return cfg.regions.filter((r) => isAvailable(c, r.id) === 1).length;
}

配套测试 core/test/g12_content_rights.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验 denies 优先、全球授权展开、地区受限与死资产。在本机跑 node --test 'core/test/g12_*.test.ts',本节 6 个用例全过:

✔ 跨语言一致:TS 可用性摘要 == Python 金标准
✔ 矩阵规模:9 内容 × 10 地区 = 90 对,可发 66 对
✔ denies 优先:名称在巴西被商标冲突挡下,其余 9 区可发
✔ 地区受限:字体只在 4 个 CJK 市场、贴图只在 2 个欧美市场可发
✔ 全球授权:自制音效在全部 10 个地区可发,无死资产
✔ 署名与二创:2 份素材须署名、5 份禁止二创(地区变体受限)
ℹ tests 6 / pass 6 / fail 0

只要有一边的 denies 优先级、"*" 展开或地区计数写错,摘要立刻分叉。这与第 6 章「文化禁忌与符号审查」 是同一个信念:同一张登记表,在 Python 校验器与 TS 内核里必须判得一模一样。而版权侧比文化侧更容易被忽视——文化禁忌是「会冒犯谁」,版权是「能不能合法用」,后者一旦出错是法律风险,不是体验问题。

X 层:真实授权与商标检索不可复现

必须把边界划清。上面算的是规则侧:给定一张授权登记表与一批地区,可用性矩阵是什么。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 各地区的真实商标检索结果(同名商标是否存在、在哪个类别注册,需逐国检索,且状态随时变动);
  • 各版权方的真实授权条款(allows / denies 是登记表的建模值,与合同原文的偏差靠法务复核兜住);
  • 各平台的真实版权审核结论(同一素材在不同平台的判定可能不同)。

工程上正确的做法是:把可复现的规则层与不可复现的授权层分开——规则层保证「买了哪档授权就只发哪些地区」这条纪律被自动执行;授权层靠合同与检索结果反标定登记表(补一份授权就改一行 allows,矩阵立刻更新)。这也是本节与「上架资料、审核与驳回」 的接口:版权可用性直接决定上架时能勾选哪些地区。至于「名称 / 商标该不该注册」这类判断,属于版号与资质办理 的口径,本节只做素材侧的工程落点。

自检表

检查项合格标准
三维登记授权区间、署名、二创三个维度都进表,不只看「买没买」
denies 优先黑名单高于白名单,全球授权也能被单点排除
死资产可查授权区间与发行地区零交集的内容被单独列出
跨语言一致Python 校验器与 TS 内核摘要逐字符相同
边界清晰真实商标 / 授权条款标 X;不写成「实测」,不提供法律意见

到这里,内容侧的地区差异就立起来了:先有授权矩阵,才谈得上多市场发行。下一节换一个维度——不是「素材能不能发」,而是「这个地区本身能不能进」:把制裁清单与地区限制变成一份可维护的地区表与一组工程开关。

阅读导航:上一节:11.3 税务表单与合规 · 下一节:12.2 制裁与地区可用性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制