369 lines
21 KiB
Plaintext
369 lines
21 KiB
Plaintext
// All application state, motion, fire, hit reactions and scheduling live here.
|
||
// Tessera only supplies generic graphics/text/geometry, input and owned Tasks.
|
||
// 整个文件作为一次源码输入加载:应用状态、跟随运动、火焰与定时重绘均由源方法实现,宿主提供通用图形与任务能力。
|
||
// graphics 是通用图形入口,math 提供数学/时钟函数;assets 为相对于运行工作目录的素材路径,不是网络地址。
|
||
g = ui.graphics
|
||
math = g.math
|
||
assets = "examples/assets/manuscript/"
|
||
font = g.loadFont(assets + "IMFellEnglish-Regular.ttf", "IM FELL English")
|
||
// 同步 graphics.readText 先按 16 KiB UTF-8 资源预算读故事,再按 Unicode 标量索引截取;1000000 不会扩大读取预算。
|
||
// 两套字号/行距在顶层预整形,paint 按宽度选择;字体注册属于进程级文字引擎,不因这个 VM 关闭自动撤销。
|
||
story = g.textSlice(g.readText(assets + "story.txt"), 1, 1000000)
|
||
bodyText = g.prepareText(story, font, 21, 34, "#2A1A0A", 2)
|
||
smallText = g.prepareText(story, font, 16, 26, "#2A1A0A", 2)
|
||
hint = g.prepareText("Move the pointer. Hold to breathe fire.", font, 14, 22, "#705B44", 2)
|
||
|
||
// artwork 调用的精灵包装:image 是共享图片资源,返回新的绘制命令;位置/锚点/旋转不写回图片对象。
|
||
function sprite(image: Object, x: Number, y: Number, scale: Number, angle: Number, ax: Number, ay: Number): Object {
|
||
return g.sprite(image, x, y, image.width * scale, image.height * scale, angle, ax, ay, 1)
|
||
}
|
||
function wrappedAngle(angle: Number): Number {
|
||
// step 的姿态插值调用,把差值归一化到 [-π,π],跨越一圈边界时仍选择短方向;循环不修改传入业务姿态。
|
||
result = angle
|
||
// 大于正半圈就逐圈减去 2π,允许输入差值超过一圈,不只处理一次越界。
|
||
while result > math.pi { result = result - math.pi * 2 }
|
||
// 小于负半圈则反向逐圈加回,与上面共同覆盖任意有限角度差。
|
||
while result < -math.pi { result = result + math.pi * 2 }
|
||
return result
|
||
}
|
||
class Segment {
|
||
// 龙身体的单节状态,位置为手稿设计坐标,angle 为弧度;step 修改姿态,artwork 只读它生成本轮命令。
|
||
x: Number
|
||
y: Number
|
||
angle: Number
|
||
image: Object
|
||
}
|
||
// Asset orientation is application data: angles are radians, clockwise in
|
||
// screen coordinates. Each PNG is calibrated against its own long axis.
|
||
class FireArt {
|
||
// image 为火焰图片资源,axis 为该图片长轴方向校准,artwork 用速度方向减它以正确旋转素材。
|
||
image: Object
|
||
axis: Number
|
||
}
|
||
// 顶层 fireDirections 的轻量记录,loadFire 逐一取 angle,与 Layer 2..11 的图片顺序对应。
|
||
class Direction { angle: Number }
|
||
class Flame {
|
||
// x/y、vx/vy 为设计坐标中的粒子位置与每步速度;age 按 step 次数递增,size/art 控制绘制,不使用独立后台线程。
|
||
x: Number
|
||
y: Number
|
||
vx: Number
|
||
vy: Number
|
||
size: Number
|
||
age: Number
|
||
art: FireArt
|
||
}
|
||
class Dragon {
|
||
// 三个列表分别保存龙节、活跃粒子与校准素材;图片字段共享资源,目标/按压/时间/seed 则为可变应用状态。
|
||
segments: Object
|
||
flames: Object
|
||
fireImages: Object
|
||
tongue: Object
|
||
frontWing: Object
|
||
backWing: Object
|
||
targetX: Number
|
||
targetY: Number
|
||
pressed: Number
|
||
time: Number
|
||
lastMove: Number
|
||
seed: Number
|
||
function random(self: Dragon): Number {
|
||
// step 产生火焰时调用的确定性伪随机序列;先推进 seed,再取正弦混合值的小数部分,返回 [0,1)。
|
||
self.seed = self.seed + 1
|
||
value = math.sin(self.seed * 12.9898) * 43758.5453
|
||
return value - math.floor(value)
|
||
}
|
||
function pointer(self: Dragon, event: Event): Unit {
|
||
// pointermove 和 down 调用;event 为当前出现局部指针坐标,bounds 给出可用宽度,反变换与 paint 的缩放/留白保持一致。
|
||
bounds = event.mount.bounds()
|
||
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
|
||
self.targetX = (event.x - (bounds.width - 700 * scale) / 2) / scale
|
||
self.targetY = (event.y - 30) / scale
|
||
self.lastMove = math.now()
|
||
}
|
||
function down(self: Dragon, event: Event): Unit {
|
||
// pointerdown 先更新目标和最近移动时刻,再开启喷火;这里只改状态,粒子生成留到定时 step。
|
||
self.pointer(event)
|
||
self.pressed = 1
|
||
}
|
||
// pointerup 关闭新粒子生成,已有火焰仍按后续 step 继续移动并到龄移除。
|
||
function up(self: Dragon, event: Event): Unit { self.pressed = 0 }
|
||
function leave(self: Dragon, event: Event): Unit {
|
||
// pointerleave/pointercancel 共用入口,清按压并把最近移动时间置为过期值,使龙回到待机姿态。
|
||
self.pressed = 0
|
||
self.lastMove = -10
|
||
}
|
||
function step(self: Dragon): Unit {
|
||
// Manuscript.tick 每次定时恢复调用一次;这是可写业务阶段,更新姿态/粒子供下一轮只读 paint 使用。
|
||
self.time = math.now()
|
||
// 超过两秒没有指针移动时,用平滑插值回到预设弧形,不继续追踪旧目标。
|
||
if self.time - self.lastMove > 2 {
|
||
// x/y 为本轮待机目标游标,i 从头节开始;float 明确提供可进行浮点运算的起始值。
|
||
x = math.float(150)
|
||
y = math.float(110)
|
||
i = 0
|
||
while i < self.segments.count {
|
||
// 顺序访问每一节,以索引定义弧度;每轮只修改这一节的姿态,再推进游标。
|
||
s = self.segments.at(i)
|
||
angle = -i / 19 * math.pi * 0.7
|
||
if i > 0 {
|
||
// 头部使用固定起点,后续节沿目标角度后退 30 设计单位,形成待机弧线。
|
||
x = x - math.cos(angle) * 30
|
||
y = y - math.sin(angle) * 30
|
||
}
|
||
s.x = s.x + (x - s.x) * 0.12
|
||
// 位置与角度都只靠近目标的 12%,wrappedAngle 防止跨 ±π 时绕远路。
|
||
s.y = s.y + (y - s.y) * 0.12
|
||
s.angle = s.angle + wrappedAngle(angle - s.angle) * 0.12
|
||
i = i + 1
|
||
}
|
||
} else {
|
||
// 最近有输入时,头部追逐指针目标,其余节依次根据已经更新的前一节重建位置。
|
||
head = self.segments.at(0)
|
||
dx = self.targetX - head.x
|
||
dy = self.targetY - head.y
|
||
distance = math.sqrt(dx * dx + dy * dy)
|
||
if distance > 4 {
|
||
// 距离足够才归一化方向,避免近零除法和微小抖动;速度下限 12,但不会超过剩余距离。
|
||
speed = math.min(distance, math.max(12, distance * 0.15))
|
||
head.x = head.x + dx / distance * speed
|
||
head.y = head.y + dy / distance * speed
|
||
head.angle = math.atan2(dy, dx)
|
||
}
|
||
i = 1
|
||
while i < self.segments.count {
|
||
// 身体从第二节向尾部遍历,限制相对前节弯曲到 ±0.25 弧度,再保持 30 单位节距。
|
||
previous = self.segments.at(i - 1)
|
||
s = self.segments.at(i)
|
||
angle = math.atan2(previous.y - s.y, previous.x - s.x)
|
||
s.angle = previous.angle + math.clamp(wrappedAngle(angle - previous.angle), -0.25, 0.25)
|
||
s.x = previous.x - math.cos(s.angle) * 30
|
||
s.y = previous.y - math.sin(s.angle) * 30
|
||
i = i + 1
|
||
}
|
||
}
|
||
i = self.flames.count
|
||
// 从列表尾部向前更新粒子,删除当前索引后不会跳过尚未处理的前项。
|
||
while i > 0 {
|
||
i = i - 1
|
||
p = self.flames.at(i)
|
||
p.age = p.age + 1
|
||
p.x = p.x + p.vx
|
||
p.y = p.y + p.vy
|
||
p.vx = p.vx * 0.95
|
||
// 速度衰减模拟阻力,vy 再减 0.6 令屏幕坐标中的火焰向上飘;年龄是步数,不是秒。
|
||
p.vy = p.vy * 0.95 - 0.6
|
||
// 存活 12 步后删除,artwork 的透明度使用相同寿命常量,避免留下不可见粒子。
|
||
if p.age >= 12 { self.flames.remove(i) }
|
||
}
|
||
// 按压状态才发射;先更新/清理旧粒子,再检查总数上限。
|
||
if self.pressed > 0 {
|
||
head = self.segments.at(0)
|
||
i = 0
|
||
while i < 1 {
|
||
// 当前循环上界为 1,即每个 step 至多尝试产生一个粒子;并非一次生成十二个。
|
||
if self.flames.count < 12 {
|
||
// 仍有容量时随机扰动喷射方向/速度/大小,按素材列表长度选一个合法图片索引。
|
||
angle = head.angle + (self.random() - 0.5) * 0.25
|
||
speed = 20 + self.random() * 12
|
||
size = 12 + self.random() * 8
|
||
art = self.fireImages.at(math.floor(self.random() * self.fireImages.count))
|
||
// 从头部前方 30 单位发射,位置和速度都在设计坐标里;缩放只在 artwork 转绘制命令时应用。
|
||
self.flames.append(Flame(head.x + math.cos(head.angle) * 30, head.y + math.sin(head.angle) * 30, math.cos(angle) * speed, math.sin(angle) * speed, size, 0, art))
|
||
}
|
||
i = i + 1
|
||
}
|
||
}
|
||
}
|
||
function artwork(self: Dragon, ox: Number, oy: Number, scale: Number): Object {
|
||
// Manuscript.paint 的只读调用,commands 是本次新列表;允许填写新命令,不能在这里推进 seed 或粒子年龄。
|
||
commands = ObjectList()
|
||
wing = self.segments.at(5)
|
||
commands.append(sprite(self.backWing, ox + wing.x * scale, oy + wing.y * scale, scale * 0.24, wing.angle + math.sin(self.time * 3) * 0.4, 1, 1))
|
||
// 后翼先画,再从尾到头画龙节,让头部覆盖后面的身体;索引 5 对应的前翼在该节之后追加形成前景层。
|
||
i = self.segments.count
|
||
while i > 0 {
|
||
// 与 step 的头到尾更新顺序不同,这里反向遍历仅为控制绘制遮盖,不改 segments 顺序。
|
||
i = i - 1
|
||
s = self.segments.at(i)
|
||
ax = math.float(0.5)
|
||
if i == 0 {
|
||
// 头节使用校准锚点,先追加舌头再追加头图,舌根被头部覆盖。
|
||
ax = 0.45
|
||
commands.append(sprite(self.tongue, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, -s.image.width * 0.3 / self.tongue.width, 0.5))
|
||
}
|
||
commands.append(sprite(s.image, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, ax, 0.5))
|
||
// 前翼只在固定身体节追加,时间正弦控制摆动;图像资源自身仍不发生旋转字段写入。
|
||
if i == 5 { commands.append(sprite(self.frontWing, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle - math.sin(self.time * 3 + 0.5) * 0.4, 1, 1)) }
|
||
}
|
||
i = 0
|
||
while i < self.flames.count {
|
||
// 每个活跃粒子产生一条新精灵命令;按图片最大边等比缩放,以粒子速度角减素材轴角校准朝向。
|
||
p = self.flames.at(i)
|
||
image = p.art.image
|
||
imageScale = p.size * scale / math.max(image.width, image.height)
|
||
angle = math.atan2(p.vy, p.vx) - p.art.axis
|
||
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
|
||
drawing.opacity = 1 - p.age / 12
|
||
// 随年龄分别跨过两个色调阈值,第二个 if 可覆盖第一个 tint;二者不是互斥分支。
|
||
if p.age > 4 { drawing.tint = "#E08A30" }
|
||
if p.age > 8 { drawing.tint = "#F0C030" }
|
||
commands.append(drawing)
|
||
i = i + 1
|
||
}
|
||
return commands
|
||
}
|
||
function heat(self: Dragon, commands: ObjectList, ox: Number, oy: Number, scale: Number): Unit {
|
||
// paint 只把本轮 flowText 新命令传入;允许改这些临时字形命令,不改 prepared 资源或 Dragon 原状态。
|
||
i = 0
|
||
while i < commands.count {
|
||
// 外循环逐字形命令处理,内循环逐火焰累加热扰动,多个粒子可共同影响同一字形。
|
||
c = commands.at(i)
|
||
j = 0
|
||
while j < self.flames.count {
|
||
p = self.flames.at(j)
|
||
dx = c.x - ox - p.x * scale
|
||
if math.abs(dx) < 60 * scale {
|
||
// 先做横轴范围排除,命中后才计算纵轴差,减少远处粒子的距离计算。
|
||
dy = c.y - oy - p.y * scale
|
||
if math.abs(dy) < 60 * scale {
|
||
// 通过轴向粗筛后求欧氏距离,以下两个条件分别排除近零除法和圆形作用范围外区域。
|
||
distance = math.sqrt(dx * dx + dy * dy)
|
||
if distance > 0.1 {
|
||
if distance < 60 * scale {
|
||
// 距离越近、粒子越年轻权重越大;位置向外推开,轻微转动并降低透明度但至少保留 0.3。
|
||
weight = (1 - distance / (60 * scale)) * (1 - p.age / 12)
|
||
c.x = c.x + dx / distance * weight * 8 * scale
|
||
c.y = c.y + dy / distance * weight * 8 * scale
|
||
c.angle = c.angle + weight * 0.12
|
||
c.opacity = math.max(0.3, c.opacity - weight * 0.2)
|
||
c.tint = "#C4402A"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
j = j + 1
|
||
}
|
||
i = i + 1
|
||
}
|
||
}
|
||
}
|
||
class Manuscript: RectBlock {
|
||
// dragon 保存动态业务状态;page 是动画任务 owner;prepared/small/initial 是共享资源,timer 保存最近一次 after 返回的 Task。
|
||
dragon: Dragon
|
||
page: Object
|
||
prepared: Object
|
||
small: Object
|
||
initial: Object
|
||
timer: Object
|
||
function measure(self: Manuscript, mount: Mount, limits: Constraints): MeasureResult {
|
||
// source_layout 调用的只读测量,给出不超过 1000×900 的期望大小,宿主仍按完整 Constraints 校验/钳制。
|
||
return MeasureResult(math.min(limits.maxWidth, 1000), math.min(limits.maxHeight, 900))
|
||
}
|
||
function onMount(self: Manuscript, mount: Mount): Unit {
|
||
// 宿主挂载通知启动首个 80ms 单次任务,任务归 page、键为 animation;不会在 paint 中调度副作用。
|
||
// 本例 view 只有一个有效出现;若复用它产生多个 Mount,仍共用同一 page/key 和 timer 字段,不会各自拥有独立动画时钟。
|
||
self.timer = ui.after(self.page, "animation", 80, self.tick)
|
||
}
|
||
// 宿主卸载通知取消最近安排的任务,防止隐藏/退役视图继续无意义地推进动画。
|
||
function onUnmount(self: Manuscript, mount: Mount): Unit { self.timer.cancel() }
|
||
function tick(self: Manuscript): Unit {
|
||
// after 到期且获得交付后调用 tick,先改龙状态,再安排下一次 80ms 单次任务;不是固定帧率或补齐错过帧。
|
||
// 位置插值与粒子年龄按实际 step 次数更新;worker 忙时回调延后,不按经过秒数补做相同数量的步。
|
||
self.dragon.step()
|
||
self.timer = ui.after(self.page, "animation", 80, self.tick)
|
||
}
|
||
function paint(self: Manuscript, mount: Mount, bounds: Bounds): DisplayList {
|
||
// source_paint 用本轮出现 Bounds 调用,设计画布宽 700,缩放限制为 0.2..1,ox/oy 把设计坐标映射到布局坐标。
|
||
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
|
||
ox = bounds.x + (bounds.width - 700 * scale) / 2
|
||
oy = bounds.y + 30
|
||
margin = 45 * scale
|
||
text = self.prepared
|
||
line = 34
|
||
// 窄于 540 时切到预整形的小字和 26 行距,否则用正文字体与 34 行距,不改任何资源对象。
|
||
if bounds.width < 540 {
|
||
text = self.small
|
||
line = 26
|
||
}
|
||
initialHeight = line * 7
|
||
// 首字母图高占七行,宽按素材比例计算;单独矩形排除区给正文留下首字母和右侧间距。
|
||
initialWidth = self.initial.width / self.initial.height * initialHeight
|
||
art = self.dragon.artwork(ox, oy, scale)
|
||
exclusions = ObjectList(g.rectExclusion(ox + margin, oy + margin, initialWidth + 12, initialHeight))
|
||
i = 0
|
||
while i < art.count {
|
||
// 每条龙/火焰精灵按图像形状生成绕排排除区,额外留白随画布缩放,不只用整体外接矩形挡文字。
|
||
exclusions.append(g.imageExclusion(art.at(i), 6 * scale))
|
||
i = i + 1
|
||
}
|
||
letters = 0
|
||
// 有火焰时请求可逐字母扰动的输出;无火焰时使用普通输出,避免总是拆成大量命令。
|
||
if self.dragon.flames.count > 0 { letters = 1 }
|
||
// 正文在给定矩形内绕开首字母与所有精灵;可用高度至少为零,避免小窗口产生负尺寸。
|
||
drawing = g.flowText(text, ox + margin, oy + margin, 700 * scale - margin * 2, math.max(0, bounds.height - 90 - margin), exclusions, letters)
|
||
// 只对新生成的正文命令应用热扰动,原文字符串和预整形资源保持原值。
|
||
if self.dragon.flames.count > 0 { self.dragon.heat(drawing.commands, ox, oy, scale) }
|
||
// 命令层次为矩形底色 → 首字母 → 正文 → 龙/火焰 → 页脚,列表追加顺序就是绘制顺序。
|
||
commands = RectBlock.paint(self, mount, bounds).commands
|
||
commands.append(g.sprite(self.initial, ox + margin, oy + margin, initialWidth, initialHeight, 0, 0, 0, 1))
|
||
commands.extend(drawing.commands)
|
||
commands.extend(art)
|
||
footer = g.flowText(hint, bounds.x + 20, bounds.y + bounds.height - 28, math.max(0, bounds.width - 40), 24, ObjectList(), 0)
|
||
// 页脚不使用正文排除区,也不应用 heat;把这批命令接到末尾再交宿主统一验证和绘制。
|
||
commands.extend(footer.commands)
|
||
return DisplayList(commands)
|
||
}
|
||
}
|
||
|
||
// 顶层先创建头节,再调用 makeBody 添加十九个身体节;图片只在加载示例时读取,不在动画步中加载。
|
||
segments = ObjectList()
|
||
headImage = g.loadImage(assets + "dragon-sprites/head.png")
|
||
segments.append(Segment(math.float(150), math.float(110), math.float(0), headImage))
|
||
function makeBody(): Unit {
|
||
// 顶层唯一一次调用,游标沿与待机姿态相同的弧线前进;i=1..19 对应 body-1.png 到 body-19.png。
|
||
i = 1
|
||
x = math.float(150)
|
||
y = math.float(110)
|
||
while i < 20 {
|
||
// 计算当前节相对于前节的后退位置,加载对应素材并保留在 Segment,随后递增索引。
|
||
angle = -i / 19 * math.pi * 0.7
|
||
x = x - math.cos(angle) * 30
|
||
y = y - math.sin(angle) * 30
|
||
image = g.loadImage(assets + "dragon-sprites/body-" + g.string(i) + ".png")
|
||
segments.append(Segment(x, y, angle, image))
|
||
i = i + 1
|
||
}
|
||
}
|
||
makeBody()
|
||
fireImages = ObjectList()
|
||
// Layer 2..11: baseline directions differ; these values belong to this artwork.
|
||
fireDirections = ObjectList(Direction(-0.50), Direction(-0.30), Direction(-0.55), Direction(0.40), Direction(-1.35), Direction(-1.57), Direction(-1.57), Direction(0.05), Direction(-1.40), Direction(1.12))
|
||
function loadFire(): Unit {
|
||
// 顶层调用一次,按图片编号 2..11 加载十张火焰;校准表索引用 i-2 转成从零开始。
|
||
i = 2
|
||
while i <= 11 {
|
||
// FireArt 同时保留图片和其自身轴角,之后随机选择整个记录,避免图片和校准值错位。
|
||
image = g.loadImage(assets + "fire-sprites/Layer " + g.string(i) + ".png")
|
||
fireImages.append(FireArt(image, fireDirections.at(i - 2).angle))
|
||
i = i + 1
|
||
}
|
||
}
|
||
loadFire()
|
||
// 构造唯一 Dragon,空 flames 列表稍后由 step 填充;初始 lastMove=-10 进入待机路径,seed=42 固定伪随机起点。
|
||
dragon = Dragon(segments, ObjectList(), fireImages, g.loadImage(assets + "dragon-sprites/tongue.png"), g.loadImage(assets + "dragon-sprites/wing-front.png"), g.loadImage(assets + "dragon-sprites/wing-back.png"), math.float(150), math.float(110), 0, math.now(), -10, math.float(42))
|
||
page = Page()
|
||
page.title = "Illustrated Manuscript · Endor"
|
||
view = Manuscript(700, 800, 0, "#F4EEE0", dragon, page, bodyText, smallText, g.loadImage(assets + "dropcap.png"), ObjectList())
|
||
// 原生 RectBlock 构造参数之后追加六个源字段;timer 的临时初值在挂载通知中换成真正 Task,随后才能 cancel。
|
||
view.flex = 1
|
||
view.boxWidth = "fill"
|
||
page.crossAlign = "stretch"
|
||
// 五个连接共用 dragon 的绑定方法,取消与离开走同一清理入口;只注册输入,不在顶层启动 step 循环。
|
||
moveConnection = view.on("pointermove", dragon.pointer)
|
||
downConnection = view.on("pointerdown", dragon.down)
|
||
upConnection = view.on("pointerup", dragon.up)
|
||
leaveConnection = view.on("pointerleave", dragon.leave)
|
||
cancelConnection = view.on("pointercancel", dragon.leave)
|
||
// render 后建立出现,onMount 才启动动画任务;paint 由宿主刷新调用,tick 的状态变化使后续帧重新计算。
|
||
page.render(view)
|