Files
endorphin-tessera-gui/examples/illustrated-manuscript.endor
T

366 lines
21 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
// 读取故事后截取大范围正文,再预先整形为两套字号/行距;paint 根据可用宽度选择,不在每帧重新读文件加载字体。
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 中调度副作用。
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,先改龙状态,再安排下一次单次任务;这是串行自续约,不是永久后台循环。
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)