Phaser Compact Texture Atlas(PCT)格式规范详解:从行格式到加载器实现
2026/9/19 1:30:46 网站建设 项目流程

Phaser Compact Texture Atlas(PCT)格式规范详解:从行格式到加载器实现

【免费下载链接】phaserPhaser is a fun, free and fast 2D game framework for making HTML5 games for desktop and mobile web browsers, supporting Canvas and WebGL rendering.项目地址: https://gitcode.com/gh_mirrors/ph/phaser

导读

Phaser Compact Texture Atlas(简称 PCT)是 Phaser 4 引入的一种紧凑型纹理图集描述格式。它以纯文本、逐行记录的方式描述纹理图集,相比传统的 JSON 图集描述文件体积通常缩小 90%~95%,同时保持运行时可极低成本解析。本文以仓库中的官方规范文档(Phaser Compact Texture Atlas Format Specification)为骨架,结合源码实现与测试用例,完整讲解 PCT 的 8 种记录类型、名称编码规则、解码算法与设计取舍。读完本文,你将具备独立实现 PCT 解析器/加载器的能力,并理解 Phaser 内部如何用atlasPCT加载器把.pct文件与图集图片组装成可用的多源纹理。


一、PCT 格式总览

一个.pct文件是普通的 UTF-8 纯文本文件,每一行是一条记录(record),解析器自上而下单趟扫描。文件中共有 8 种记录类型,通过前缀字符或模式区分:

前缀记录类型用途
PCT:版本头文件标识与格式版本声明(必须是第一行)
P:页头声明一张图集图片及其尺寸
F:文件夹条目在文件夹字典中声明一个文件夹名
#页选择器将后续帧切换到不同的图集页
B:块头声明一个同尺寸精灵组成的网格块
A:别名将重复精灵映射到已存在的帧
name\|单帧一个带显式位置的精灵
names,...块名称行前一块的名称列表(逗号分隔)

记录必须按如下顺序出现:第 1 行是PCT:版本头,之后是全部P:页头,再之后是全部F:文件夹条目,然后是帧数据(可以穿插#B:、块名称行与单帧行),最后是A:别名记录。别名放在最后,是因为它们按名称引用帧,加载器通过复制已存在的条目来解析别名。

实现事实:仓库中的解码器 PCTDecode.js 严格实现了上述顺序约定,且对未知前缀行采取"静默跳过"策略(见 PCTDecode.js 中对不足 3 个|分段的行直接continue的处理)。


二、八种记录类型详解

2.1 版本头(PCT:

每个 PCT 文件的第一行必须是版本头。它同时充当文件标识(magic string)与格式版本声明:

PCT:1.0

版本号采用major.minor的 semver 风格编号:

组件含义
Major破坏性变更。v2 文件无法被 v1 解析器加载。
Minor增量特性。v1.2 文件可以在 v1.0 解析器中加载——未知特性会被安全忽略。

加载器校验逻辑:

const firstLine = lines[0]; if (!firstLine.startsWith('PCT:')) { throw new Error('Not a PCT file'); } const [major, minor] = firstLine.slice(4).split('.').map(Number); if (major > 1) { throw new Error('Unsupported PCT version ' + major); } // Minor features can be checked as needed: // const hasRotation = minor >= 1;

加载器应当拒绝无法识别的主版本号,但接受无法识别的次版本号。未来次版本中引入的未知行前缀应被静默跳过——前缀驱动的解析器天然支持这一点。

源码实现中,PCTDecode.js 对版本校验做了额外的健壮性处理:先剥离首行末尾可能的\r(兼容 CRLF 换行),再校验PCT:前缀,并解析主版本号;当主版本缺失或大于 1 时,通过console.warn输出警告并返回null。这些边界行为都有对应的测试覆盖,见 PCTDecode.test.js:非字符串输入、空字符串、缺失PCT:头、PCT:2.0主版本超限都应返回null,而PCT:1.5这类次版本增量必须能正常加载,CRLF 换行也能被正确处理。

版本历史:

版本变更
1.0初始发布。支持块、文件夹、范围、扩展名、别名、多页。

2.2 页头(P:

声明一张图集页。多图集文件有多个P:行,单图集文件有一个。

P:atlas_0.png,RGBA8888,2048,512,2

字段(逗号分隔):

位置字段类型描述
0filenamestring纹理图片文件名
1formatstring像素格式(当前始终为RGBA8888
2widthint图集宽度(像素)
3heightint图集高度(像素)
4paddingint打包时使用的形状填充(shape padding)值

padding 值仅作信息用途——它告诉加载器精灵之间预留的间隙。帧坐标已经包含了 padding,因此加载器不需要再应用它。

所有P:行出现在文件顶部、任何其他记录类型之前。页按出现顺序从 0 开始编号。

在解码器中,每个P:行被解析为pages数组中的一个对象(filename / format / width / height / padding),见 PCTDecode.js。测试 PCTDecode.test.js 验证了单页与多页两种情况下页数组的解析结果。

2.3 文件夹条目(F:

在文件夹字典中声明一个文件夹名。文件夹条目出现在所有P:页头之后、任何帧数据之前:

F:warrior F:knight/idle F:effects

每个F:行向文件夹字典添加一个条目,按出现顺序从 0 开始编号。帧名称通过该数字索引引用文件夹,而不是重复完整的文件夹字符串。如果没有文件夹(所有精灵都在根层级),则没有F:行。

源码中folders数组按声明顺序压入,见 PCTDecode.js;测试验证了无文件夹时返回空数组、文件夹按顺序解析、以及文件夹名可以包含斜杠(如knight/idle)三种情况(PCTDecode.test.js)。

2.4 页选择器(#

将后续帧数据切换到另一张图集页。仅在存在多个页时出现:

#0 #1

#后的数字是零基页索引,对应P:页头的顺序。跟在#N行之后的所有帧、块和单帧都属于页 N,直到下一个#行或文件结束。

如果只有一个页,则没有#行,所有帧数据隐式属于页 0。

解码器中currentPage变量记录当前页,#行直接更新该变量(PCTDecode.js)。测试验证了块帧和单帧都能被正确路由到目标页,且缺省时默认页为 0(PCTDecode.test.js)。

2.5 块头(B:

声明一个网格块——由同尺寸精灵按网格排列形成的矩形区域。B:头的下一行总是该块的名称行。

未裁剪(untrimmed)块:

B:2,2,8,64,64

已裁剪(trimmed)块:

B:2,2,6,120,108|134,120,4,6

|之前的字段(始终存在):

位置字段类型描述
0xint块在图集中的原点 X(第一个单元格左上角)
1yint块在图集中的原点 Y
2colsint网格列数
3frameWint每个帧的宽度(裁剪/打包后的尺寸)
4frameHint每个帧的高度

|之后的字段(仅裁剪时存在):

位置字段类型描述
0sourceWint裁剪前的原始源图像宽度
1sourceHint裁剪前的原始源图像高度
2trimXint裁剪区域在源图像中的 X 偏移
3trimYint裁剪区域在源图像中的 Y 偏移

对于裁剪块,spriteSourceSize.wspriteSourceSize.h分别等于frameWframeH——它们不会被单独存储。

行数通过推导得出:rows = ceil(spriteCount / cols)

2.6 块名称行

紧跟在B:头之后,包含块内所有精灵的名称,可以使用范围压缩、文件夹索引和扩展名索引:

0/idle#01-24

该行被解析为有序精灵名称列表。每个名称映射到一个网格单元:索引为i的精灵占据列i % cols、行floor(i / cols)的单元。

每个精灵的位置按如下推导:

cellW = frameW + padding * 2 cellH = frameH + padding * 2 sprite[i].x = blockX + (i % cols) * cellW + padding sprite[i].y = blockY + floor(i / cols) * cellH + padding

其中padding是页头中的形状填充值。

实现细节:源码中有一个非常关键的约定——待处理的块(pendingBlock)总是把下一个非空行当作名称行消费,无论该行以什么前缀字符开头(PCTDecode.js)。这意味着即使名称行恰好以#开头也不会被误判为页选择器。测试 PCTDecode.test.js 专门构造了B:0,0,3,10,10后跟#1-3的边界用例,验证展开出的帧名为123而不是被当作页选择器。

2.7 单帧

一个带显式位置和尺寸的独立精灵,用于不属于任何块组的精灵。

未裁剪:

sword|0|726,2,86,42

已裁剪:

shield|2|726,48,72,68|80,80,4,6

字段(以|分隔):

分段字段描述
0name精灵名称(可含文件夹索引与扩展名索引)
1flags位标志:bit 0 = 旋转,bit 1 = 已裁剪
2x,y,w,h图集中的帧矩形
3sw,sh,sx,sy源尺寸与源内裁剪偏移(仅裁剪时存在)

分段 3 存在时,把 4 个裁剪值打包进一个逗号分隔的字段,与B:块头中|分隔的裁剪字段布局保持一致。因此已裁剪的单帧恰好有 4 个|分段;未裁剪的单帧有 3 个。

对于未裁剪帧(flags bit 1 = 0):

  • sourceSize={ w: w, h: h }
  • spriteSourceSize={ x: 0, y: 0, w: w, h: h }

对于已裁剪帧(flags bit 1 = 1):

  • sourceSize={ w: sw, h: sh }
  • spriteSourceSize={ x: sx, y: sy, w: w, h: h }

旋转标志(bit 0)保留给未来使用。目前所有帧都按无旋转方式打包。

源码中 flags 的位运算解析见 PCTDecode.js:isTrimmed = (flags & 2) !== 0rotated = (flags & 1) !== 0。测试覆盖了 flags=0(未裁剪)、flags=1(仅旋转)、flags=2(仅裁剪)、flags=3(裁剪+旋转)四种组合,见 PCTDecode.test.js。值得注意的是,虽然规范说明旋转目前保留未用,但解析器已经实现了旋转位——当src.rotated为真时,PCT.js 会把帧标记为rotated并调用updateUVsInverted()更新 UV 坐标,说明实现层面已经为旋转帧预留了完整能力。

2.8 别名(A:

将重复精灵(打包时检测到的像素完全相同的帧)映射到图集中已有的原始帧。别名行出现在所有帧数据之后:

A:0/idle_01=0/idle_12,0/idle_18

格式:A:originalName=duplicateName1,duplicateName2,...

重复名称列表可以使用范围压缩。

加载器应为每个重复名称创建帧条目,复制原帧的所有属性(位置、尺寸、裁剪数据)。重复名称不出现在纹理图片中——它们共享原帧的图集区域。

源码中别名的处理逻辑见 PCTDecode.js:先解析=两侧的名称(两侧都支持文件夹/扩展名索引),若原帧存在则对每个重复名执行属性复制并覆盖key字段;若原帧不存在或=缺失,则静默跳过。测试 PCTDecode.test.js 验证了:别名帧共享原帧位置、副本 key 为自己的名称、深拷贝(修改副本不影响原帧)、缺失原帧时静默跳过、缺失=时静默跳过、以及=两侧都解析文件夹/扩展名索引等行为。


三、名称编码规则

PCT 格式中的精灵名称包含三层编码:文件夹索引、扩展名索引和顺序范围压缩。解码时按以下顺序应用。

3.1 文件夹索引

名称可以以数字文件夹索引加/作为前缀:

0/idle_01 → folder[0] + "/" + "idle_01" 2/spark_05 → folder[2] + "/" + "spark_05" sword → no folder (root level)

如果名称以数字加/开头,斜杠前的数字是文件夹字典索引。从F:条目中查出文件夹名,并加/分隔符拼成完整 key。没有/前缀的名称是根级精灵。

源码中对"数字"的判定使用了isAllDigits辅助函数(PCTDecode.js),逐字符检查charCodeAt是否落在4857(即 ASCII09)区间。测试专门验证了abc/def这种非数字前缀不会被误判为文件夹索引(PCTDecode.test.js)。

3.2 扩展名索引

名称可以以~N结尾,其中 N 是硬编码的扩展名字典索引:

索引扩展名
1.png
2.webp
3.jpg
4.jpeg
5.gif
sword~1 → "sword.png" frame1~3 → "frame1.jpg" 0/idle_01~2 → folder[0] + "/idle_01.webp" heightmap.tga → "heightmap.tga" (unknown ext stored raw)

如果名称以~加单个数字 1-5 结尾,去掉后缀并追加对应扩展名。如果名称包含.但没有~,说明它带的是字典之外的原始扩展名——原样使用。如果既没有~也没有.,说明名称没有扩展名(打包时被剥离)。

源码中的扩展名字典定义在 PCTDecode.js:EXT = { 1: '.png', 2: '.webp', 3: '.jpg', 4: '.jpeg', 5: '.gif' }。测试验证了 5 个扩展名索引都能正确映射(PCTDecode.test.js),以及heightmap.tga这种原始扩展名原样保留(PCTDecode.test.js)。

3.3 范围压缩

在块名称行中,连续名称可以压缩为范围记号:

frame#1-23 → frame1, frame2, frame3, ..., frame23 walk_#01-08 → walk_01, walk_02, ..., walk_08 idle#000-059 → idle000, idle001, ..., idle059

格式:prefix#start-end

解码规则:

  1. 在逗号处拆分名称行得到分段
  2. 检查每个分段是否包含#
  3. 若包含:在#处拆分为prefixrange
  4. -处拆分range得到startStrendStr
  5. 将两者解析为整数startend
  6. 如果startStr有前导零(长度 > 1 且首字符为0),生成的所有数字按startStr.length补零
  7. 生成名称:对startend(含)的每个i,输出prefix + pad(i)
  8. 如果没有#,该分段是字面名称

范围、字面名称和文件夹/扩展名索引可以组合:

0/frame#1-23~1

展开为文件夹0、帧frame1.pngframe23.png

组合形式的解码顺序:

  1. ,处拆分得到分段
  2. 展开任何#范围为单个名称字符串
  3. 对每个名称:解析文件夹索引(去掉N/前缀)
  4. 对每个名称:解析扩展名索引(去掉~N后缀)

补零逻辑在源码中体现为zeroPad辅助函数(PCTDecode.js)以及padLen的判断(startStr.length > 1 && startStr.charAt(0) === '0')。测试验证了idle_#001-012只生成补零的idle_001idle_012而不会生成idle_12(PCTDecode.test.js),以及frame#1-5不补零(PCTDecode.test.js)。同一行混用字面名称与范围(如intro,frame#1-3,outro)也有测试覆盖(PCTDecode.test.js)。

3.4 带扩展名后缀的范围压缩

当块名称行带扩展名后缀时,它只出现在整行末尾一次,在所有名称和范围之后:

0/frame#1-23~1

~1应用于行内所有名称。解码器应当:

  1. 检查行是否以~N(N 为 1-5)结尾
  2. 若是,剥离并记录该扩展名
  3. 解析剩余字符串中的范围和名称
  4. 将扩展名应用到每个生成的名称

注意优先级:源码实现中,逐名称的~N会覆盖行级后缀resolveFullName先检查名称自身的~N匹配(PCTDecode.js),命中则直接替换扩展名;否则才使用行级extSuffix。测试a,b,c~1验证了行级后缀应用到所有名称(PCTDecode.test.js)。


四、完整解码算法

以下伪代码完整呈现 PCT 文件的解码流程(对应仓库实现 PCTDecode.js):

function decodePCT(text): lines = text.split('\n') pages = [] folders = [] currentPage = 0 frames = {} // key → frame data // Extension dictionary EXT = { 1:'.png', 2:'.webp', 3:'.jpg', 4:'.jpeg', 5:'.gif' } pendingBlock = null for line in lines: if line is empty: continue // A pending block ALWAYS consumes the next non-empty line as its names // line, regardless of what prefix character that line happens to start // with. This must be checked before any prefix-based dispatch below. if pendingBlock is not null: // This line is the names for the pending block block = pendingBlock pendingBlock = null padding = pages[block.page].padding cellW = block.frameW + padding * 2 cellH = block.frameH + padding * 2 names = expandNames(line, folders, EXT) for i, name in enumerate(names): col = i % block.cols row = floor(i / block.cols) frame = { key: name, page: block.page, x: block.x + col * cellW + padding, y: block.y + row * cellH + padding, w: block.frameW, h: block.frameH, trimmed: block.trimmed, rotated: false } if block.trimmed: frame.sourceW = block.sourceW frame.sourceH = block.sourceH frame.trimX = block.trimX frame.trimY = block.trimY else: frame.sourceW = block.frameW frame.sourceH = block.frameH frame.trimX = 0 frame.trimY = 0 frames[name] = frame continue if line starts with 'P:': parts = line[2:].split(',') pages.push({ filename: parts[0], format: parts[1], width: int(parts[2]), height: int(parts[3]), padding: int(parts[4]) }) else if line starts with 'F:': folders.push(line[2:]) else if line starts with '#': currentPage = int(line[1:]) else if line starts with 'B:': // Parse block header trimParts = line[2:].split('|') main = trimParts[0].split(',') block = { page: currentPage, x: int(main[0]), y: int(main[1]), cols: int(main[2]), frameW: int(main[3]), frameH: int(main[4]), trimmed: trimParts.length > 1 } if block.trimmed: trim = trimParts[1].split(',') block.sourceW = int(trim[0]) block.sourceH = int(trim[1]) block.trimX = int(trim[2]) block.trimY = int(trim[3]) pendingBlock = block else if line starts with 'A:': // Parse alias eqIdx = line.indexOf('=', 2) originalName = resolveFullName(line[2:eqIdx], folders, EXT, '') dupNames = expandNames(line[eqIdx+1:], folders, EXT) for name in dupNames: frames[name] = copy(frames[originalName]) frames[name].key = name else: // Individual frame line parts = line.split('|') name = resolveFullName(parts[0], folders, EXT, '') flags = int(parts[1]) trimmed = (flags & 2) != 0 fv = parts[2].split(',') frame = { key: name, page: currentPage, x: int(fv[0]), y: int(fv[1]), w: int(fv[2]), h: int(fv[3]), trimmed: trimmed, rotated: (flags & 1) != 0 } if trimmed: tv = parts[3].split(',') frame.sourceW = int(tv[0]) frame.sourceH = int(tv[1]) frame.trimX = int(tv[2]) frame.trimY = int(tv[3]) else: frame.sourceW = frame.w frame.sourceH = frame.h frame.trimX = 0 frame.trimY = 0 frames[name] = frame return { pages, folders, frames }

辅助函数:expandNames(line, folders, EXT)

function expandNames(line, folders, EXT): // Check for trailing extension suffix: ~N at very end extSuffix = '' match = line.match(/~([1-5])$/) if match: extSuffix = EXT[int(match[1])] line = line[0:-2] // strip ~N results = [] segments = line.split(',') for segment in segments: if '#' in segment: hashIdx = segment.indexOf('#') prefix = segment[0:hashIdx] range = segment[hashIdx+1:] dashIdx = range.indexOf('-') startStr = range[0:dashIdx] endStr = range[dashIdx+1:] start = int(startStr) end = int(endStr) padLen = startStr.length if (startStr.length > 1 and startStr[0] == '0') else 0 for i from start to end inclusive: numStr = padLen > 0 ? zeroPad(i, padLen) : str(i) rawName = prefix + numStr results.push(resolveFullName(rawName, folders, EXT, extSuffix)) else: results.push(resolveFullName(segment, folders, EXT, extSuffix)) return results

辅助函数:resolveFullName(raw, folders, EXT, extSuffix)

function resolveFullName(raw, folders, EXT, extSuffix): name = raw folder = '' // Folder index: "N/rest" slashIdx = name.indexOf('/') if slashIdx > 0 and name[0:slashIdx] is all digits: folderIdx = int(name[0:slashIdx]) folder = folders[folderIdx] name = name[slashIdx+1:] // Extension index: "name~N" (per-name, overrides line-level) match = name.match(/~([1-5])$/) if match: ext = EXT[int(match[1])] name = name[0:-2] + ext else if extSuffix: name = name + extSuffix // else: name has no extension or contains a raw one (e.g. ".tga") if folder: return folder + '/' + name return name

与规范的差异说明:仓库实现与上述伪代码在健壮性上略有增强——解码前会先校验输入是否为非空字符串,并剥离每行末尾的\r以兼容 CRLF 换行文件(PCTDecode.js);校验失败时通过console.warn输出警告而非抛异常,并返回null交由上层处理。测试 PCTDecode.test.js 覆盖了 CRLF 与空行跳过两种边界情况。


五、完整示例

5.1 示例 1:简单块

8 个 64×64 未裁剪精灵打包进单行。

PCT:1.0 P:atlas_0.png,RGBA8888,1024,256,2 B:2,2,8,64,64 frame#1-8

产生 8 个名为frame1frame8的帧,每个 64×64 像素,位置由块网格推导,单元间 2px padding。

解码后的帧位置:

名称XY
frame144
frame2724
frame31404
frame42084
frame52764
frame63444
frame74124
frame84804

单元宽度 = 64 + 2×2 = 68。位置 = blockX + col × 68 + 2。

这个示例与源码测试 PCTDecode.test.js 中的 "spec Example 1" 用例完全一致,测试断言了这 8 个帧的精确坐标(4、72、140、208、276、344、412、480),可直接对照验证。

5.2 示例 2:多页 + 全部特性

两张图集页、三个文件夹、裁剪块、单帧、扩展名索引、范围压缩和别名。

PCT:1.0 P:atlas_0.png,RGBA8888,2048,512,2 P:atlas_1.png,RGBA8888,2048,256,2 F:warrior F:knight F:effects #0 B:2,2,6,120,108|134,120,4,6 0/idle_#01-24~1 B:2,222,6,120,108|134,120,4,6 1/idle_#01-18~1 sword~1|0|726,2,86,42 shield~1|2|726,48,72,68|80,80,4,6 #1 B:2,2,10,48,48 2/spark_#01-30~1 A:0/idle_01~1=0/idle_12,0/idle_18~1 A:1/idle_01~1=1/idle_09~1

解码结果:

页头:

  • 页 0:atlas_0.png,2048×512,padding 2
  • 页 1:atlas_1.png,2048×256,padding 2

文件夹:0=warrior,1=knight,2=effects

页 0 帧:

  • (2,2) 处的块:6 列 120×108 帧,从 134×120 源裁剪,偏移 (4,6)。名称:warrior/idle_01.pngwarrior/idle_24.png(24 帧,4 行 × 6 列)
  • (2,222) 处的块:尺寸相同,名称:knight/idle_01.pngknight/idle_18.png(18 帧,3 行 × 6 列)
  • sword.png:未裁剪单帧,位于 (726,2),尺寸 86×42,页 0
  • shield.png:裁剪单帧,位于 (726,48),帧 72×68,源 80×80,裁剪偏移 (4,6)

页 1 帧:

  • (2,2) 处的块:10 列 48×48 未裁剪帧。名称:effects/spark_01.pngeffects/spark_30.png

别名:

  • warrior/idle_12.pngwarrior/idle_18.pngwarrior/idle_01.png像素相同——共享其图集位置
  • knight/idle_09.pngknight/idle_01.png像素相同

这个完整示例被端到端测试原样采用(PCTDecode.test.js),测试断言了:2 页、3 文件夹、74 个帧(24+18+30+2)、warrior/idle_01.png位于文档所述 (4,4)、warrior/idle_24.png位于 4 行块的最后一个单元 (624,340)(对应 col=5、row=3、cellW=124、cellH=112)、effects/spark_01.png路由到页 1、别名帧共享原帧坐标等细节。这些断言既是测试,也是规范可执行化的最佳佐证。

5.3 示例 3:最小化

单个未裁剪精灵,无文件夹、无块。

PCT:1.0 P:atlas_0.png,RGBA8888,256,256,1 logo|0|1,1,200,180

一个名为logo的帧,位于 (1,1),尺寸 200×180,未裁剪,256×256 图集,1px padding。


六、帧数据结构

解码完成后,每个帧应为渲染引擎提供以下字段:

字段类型描述
keystring唯一帧名称(含文件夹路径与扩展名,若存在)
pageint该帧所在的图集页(pages 数组索引)
xint帧在图集中的 X 位置
yint帧在图集中的 Y 位置
wint帧在图集中的宽度
hint帧在图集中的高度
sourceWint原始源图像宽度
sourceHint原始源图像高度
trimXint帧在原始源中的 X 偏移
trimYint帧在原始源中的 Y 偏移
trimmedbool该帧是否从源中裁剪过
rotatedbool该帧是否在图集中顺时针旋转 90°(保留,当前恒为 false)

未裁剪帧:sourceW = wsourceH = htrimX = 0trimY = 0

按原始源尺寸渲染时,在(sourceW, sourceH)的画布中以偏移(trimX, trimY)绘制图集区域(x, y, w, h)

在 Phaser 中,这个帧结构通过 PCT.js 解析器落到实际的Frame对象上:texture.add(src.key || key, sourceIndex, src.x, src.y, src.w, src.h)创建帧,setTrim(sourceW, sourceH, trimX, trimY, w, h)设置裁剪信息(PCT.js)。该解析器还额外做了一件事:为页 0 源添加__BASE帧(与 JSONArray/JSONHash 解析器行为一致),并把pagesfolders元数据挂到纹理的customData['pct']上,方便运行时检视。


七、在 Phaser 中加载 PCT 图集

7.1 加载器用法

PCT 图集通过LoaderPlugin#atlasPCT方法加载(定义在 PCTAtlasFile.js):

function preload () { this.load.atlasPCT('level1', 'images/Level1.pct'); }

也支持配置对象形式:

this.load.atlasPCT({ key: 'level1', atlasURL: 'images/Level1.pct' });

加载完成后即可按 key 使用帧:

this.add.image(x, y, 'level1', 'background');

解码后的 PCT 数据(含页、文件夹与帧元数据)还可以从 Atlas 缓存中取回:

var data = this.cache.atlas.get('level1');

如果未指定 URL,加载器会取 key 并生成<key>.pct作为文件名(例如 key 为alien时 URL 为alien.pct,扩展名固定为.pct)。

7.2 内部工作流程

从 PCTAtlasFile.js 的实现可以看到完整的内部链路:

  1. 加载数据文件:内部类PCTDataFileresponseType: 'text'请求.pct文件,onProcess阶段调用PCTDecode(this.xhrLoader.responseText)把文本解码为结构化对象(含pagesfoldersframes),解码失败则进入onProcessError(PCTAtlasFile.js)。
  2. 动态排队图片onFileComplete中检查解码出的pages数组,为每一页动态创建并排队一个ImageFile(图片 key 形如PCT{multiKeyIndex}_{filename}),期间会临时覆盖加载器的 baseURL / path / prefix 以支持页图片位于不同路径的场景,处理完再恢复(PCTAtlasFile.js)。
  3. 组装纹理addToCache阶段按文件名把各页图片按页顺序组装为images数组,将解码数据写入 Atlas 缓存,并调用textureManager.addAtlasPCT(key, images, decoded)生成单一的多源纹理(PCTAtlasFile.js)。

TextureManager.addAtlasPCT是最终入口(TextureManager.js),它接收按页顺序排列的图片源与解码数据,交给 PCT.js 解析器逐帧创建Frame

加载器测试 PCTAtlasFile.test.js 验证了这些行为:构造器只创建一个pct类型的子文件并指向 atlas 缓存、无 URL 时默认扩展名为.pctonFileComplete为每页创建ImageFile、处理后恢复加载器原有 baseURL / path / prefix 等。


八、设计说明

8.1 为什么用文本而不是二进制?

在典型图集规模(50-500 帧)下,文本 PCT 文件只有 200-2000 字节。HTTP 标准使用的 Gzip 压缩会让文本与二进制格式的体积差距缩小到几个百分点以内。而文本格式带来的是:人类可读、版本控制中易于 diff、用split()即可平凡解析、没有字节序(endianness)问题。

8.2 块分组标准

当 4 个或更多精灵共享相同的签名(相同的frameWframeH,若裁剪则还需相同的sourceWsourceHtrimXtrimY)时,它们被分组成块。这个阈值在数据节省与打包效率之间取得平衡——更小的分组会过度约束 MaxRects 打包器,却无法获得足够的数据体积收益。

8.3 组感知裁剪

当多个精灵共享相同的源尺寸时,裁剪计算的是所有帧的并集包围盒,而不是各自独立裁剪。这确保了动画序列中的所有帧具有一致的裁剪边界,防止播放期间出现视觉抖动,同时使块分组成为可能。

8.4 列策略

块列数不固定为最大宽度。打包器测试三种策略(近方形、半宽、全宽),选择总图集最紧凑的方案。全宽条带在最后一行不完整时会浪费空间;更方正的块则为其他精灵在旁留出空间。

8.5 行拆分

如果块最后一行未填满,它会被拆分成一个满行的块和一个较小的余数块。这可以防止浪费的单元格占用本可由其他精灵填充的图集空间。


九、PCT 在 Phaser 源码中的完整脉络

如果你希望深入研读 PCT 的实现,仓库中的关键文件如下:

  • 规范文档:Phaser Compact Texture Atlas Format Specification.md(本文依据的原始规范)
  • 核心解码器:PCTDecode.js(PCTDecode(text)返回{ pages, folders, frames },解码失败返回null
  • 纹理解析器:PCT.js(把解码结构转换为 Texture 上的 Frame)
  • 解析器注册表:parsers/index.js(PCTPCTDecode均挂在Phaser.Textures.Parsers命名空间下,从代码注释看两者自 Phaser 4.0.0 起提供)
  • 加载器:PCTAtlasFile.js(atlasPCT加载器,负责数据文件 + 多页图片的组合加载)
  • 纹理管理器入口:TextureManager.js(addAtlasPCT(key, source, data, dataSource),自 4.0.0 起提供)
  • 单元测试:PCTDecode.test.js(解码器全量行为验证,含规范三个示例的端到端断言)、PCTAtlasFile.test.js(加载器组装行为验证)

结语

PCT 是 Phaser 对纹理图集描述格式的一次务实优化:在保持"人类可读、极易解析"的前提下,通过页头 + 文件夹字典 + 网格块 + 范围压缩 + 扩展名索引 + 别名的组合把数据体积压缩 90%~95%。规范文档为每种记录类型、每种编码组合都给出了精确定义,而仓库中的 PCTDecode.js、PCT.js 与两套测试文件则让这份规范有了可执行、可验证的实现参照。无论是想为 PCT 编写第三方打包工具,还是要在自己的引擎中实现兼容解析器,本文所整理的内容都足以作为完整的实现依据。

【免费下载链接】phaserPhaser is a fun, free and fast 2D game framework for making HTML5 games for desktop and mobile web browsers, supporting Canvas and WebGL rendering.项目地址: https://gitcode.com/gh_mirrors/ph/phaser

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询