这批要讲三个文件:扑克牌选中.e(7,019 字节,2016-03-28)、我的第一个动画作品哈哈.e(9,300 字节,2016-03-30)、我的第二个动画作品.e(52,225 字节,2016-04-02)。三个工程挤在六天里,是同一趟学「动画框」控件的产物。为什么那几天连做三个,源码里没有任何证据,我不猜;文件名里那个「哈哈」是他自己写的,保留。

三个工程共用的地基只有一样:易语言扩展支持库 iext2 提供的动画框控件。把它们三个的源码合起来数一遍,用到的动词一共十来个:创建物体、销毁物体、销毁所有物体、置图片、置角度、前进、自动前进、停止自动前进、置类型、置层次、置物体位置、取物体顶边;能听到的回调也只有这几个:碰撞到边界、碰撞到物体、按下某键、物体左键被按下、物体右键被按下、自动前进停止(物体将销毁 在扑克牌那个文件里声明了一个空壳,一行代码都没写)。没有定时器,没有网络,没有文件写入;扑克牌的 52 张 bmp 是运行时从程序外部的文件夹读的,不在 .e 里面。控件没有场景图、没有组件系统、没有"实体"概念——它给你的全部家当就是一个整数标识值、一个角度、一串航点。下面四套手法,全是在这点家当上手搓出来的。

文本源码用 e-packager(MIT 协议)从 .e 容器抽出来,仓库在 text/ 目录下一并给了。先交代两句工具痕迹,读的时候别当原文:抽取文本里的 #e2txt_text#\ 是工具给路径串的转义残留,还原成 \ 来读;匿名局部变量_620822576 是工具给 IDE 里一个没起名字的临时变量临时编的名,同文件里还有 .局部变量 变量 这种只有名字没有类型、以及一整行空白的 .程序集变量——那些才是 IDE 里的原始糙样,工具只是原样还原。一切以易语言 IDE 打开 .e 为准。

一、把方向表示成一个角度整数,反弹就退化成减法

最朴素的那个玩具(我的第一个动画作品哈哈.e)干的事就是往屏幕上贴图、让它们碰壁弹回来。它的碰撞响应值得先看一眼骨架:_动画框1_碰撞到边界 拿到 边界类型(1/2/4/8 对应四条边),然后做四步——置角度 一个朝墙外的角度、前进 一个像素、置角度 换成另一个角度、自动前进 恢复。四组常数摆出来是:类型 1 置 90 再置 0,类型 2 置 180 再置 90,类型 4 置 270 再置 180,类型 8 置 0 再置 270。规律一句话:最终方向 = 脱墙方向 − 90 度(模 360),跟你是从哪个角度撞上去的完全无关。所以这不是物理反射,是"贴墙滑行"——不管怎么撞,都沿墙的另一头溜走。中间那次 前进(1) 的作用是把物体沿刚设的脱墙方向挪开一像素,让下一步不是从边界上起步。

这套写法的可迁移点:方向一旦参数化成角度整数,任何"对边界的响应"就退化成枚举加两次置角度,不需要向量、不需要法线。代价也直说:入射角被整个丢掉了。

两天后的 我的第二个动画作品.e 把这件事做成了真镜像。球的方向状态是程序集变量 上次角度(整数型)——控件不提供"取当前角度",反射需要旧角度,那就自己存一份,每次置角度时同步更新。四根杆子各配一套算式:撞左右两根(竖墙)时 上次角度 = 360 − 上次角度;撞上下两根(横墙)时 上次角度 = 180 − 上次角度,外加一个特判——上次角度 > 270 时走 360 − 上次角度 + 180。这个特判乍看像什么数学上的角落处理,其实不是:拿 θ=300 验一下,180 − 300 = −120,360 − 300 + 180 = 240,模 360 是同一个方向。它唯一的作用是别让减法出负数。动作序列和第一个玩具同构:先 置角度 一个垂直方向 前进(2) 离开碰撞点,再写回算出来的新角度,再 自动前进。

弯路也照原样留着。_动画框1_碰撞到物体 最深一层 否则 里躺着一整段被注释掉的尝试:对 3/6/12/9 这四个边界类型(对角方向)不套公式,直接硬写 上次角度 = 67.5 / 202.5 / 292.5 / 22.5,注释里还留着 如果 (边界类型 = 3)如果 () 这种写到一半就写坏的嵌套残骸。值得记的是它卡在哪:这四个值带小数,而 上次角度 声明成整数型——这段若真启用,67.5 在赋值那一刻就被截成 67;为什么撤掉,注释里一个字没解释,只留了事实:编辑到一半,放弃了,注释了。整数角度这条模型自己的边界,就在这段残骸里露头。

带走的东西:角度即状态;反射即 360−θ / 180−θ;先离墙再转向;特判如果只是保号,就能折叠掉。

二、创建物体返回的那个整数,就是身份系统

动画框不认识"球"也不认识"黑桃 A",创建物体 只还你一个整数标识值。09 和 10 的做法一模一样:把这个整数接住,存进自己的数据结构,回调里拿相等比较把身份找回来。09 里四根杆子存 杆子[1..4] 数组,_动画框1_碰撞到物体 的每个分支条件就是 被碰撞物体标识值 = 杆子[i];10 更进一步,自定义记录类型 扑克类型 的字段是 物体、牌类型、牌花色、牌选中(前三行在 IDE 里都没显式标类型),第一个字段直接把标识值装进记录,_动画框1_物体左键被按下 里循环比 玩家组[j].物体 = 物体标识值,命中就 跳出循环。08 里则是另一种形态:数值[100000](整数型,存标识值)和 图片[100000](字节集)两个平行大数组靠同一个下标配对——第 a 张图就是第 a 个物体,双数组当键值存储用;它的「清空」按钮销毁所有物体后把两个数组逐格清零(图片置 {}、数值置 0)、下标归零,下一轮重建从头写起。

一句必须写诚实的话:09 的创建完毕里确实给五个物体 置类型 打了 1 到 5 的编号(而且给 球 置类型那一刻 球 变量还是 0——球是 F1 按下时才创建的),但通读整个文件,没有任何一个分支拿类型号当比较条件,身份判定全靠比标识值。这五个编号是干什么用的,源码里给不出答案,我只能记录"设了、没用上"。

能带走的:在没有场景图的控件上,handle→实体的映射就是自己那一个数组或一个记录字段;相等比较能用的前提只有一条——标识值的生命周期归你管。顺带一提,扑克牌飞行前 置层次(扑克.物体, 60)、排序时 置层次(玩家组[j].物体, j),叠牌顺序也是拿整数直接排的。

三、状态机不挂定时器,挂在动画事件上;选中态不写变量,写进画面

扑克牌选中.e 全程零定时器。它的时钟就是回调本身:__启动窗口_首次激活 里 发牌() 踢出第一张(注意用的是"首次激活"而不是"创建完毕",创建完毕里做的是把 52 张牌建出来);此后每张牌飞完三个航点,触发一次 _动画框1_自动前进停止,事件处理里那个 游戏进展 变量就是状态机的位:

游戏进展 = 0 时,先调一次 排序扑克(每停一张重排一次);牌堆 取数组成员数(扑克牌组) > 30 就再 发牌() 一张并 返回;牌堆降到 30 张及以下,置 游戏进展 = 1——于是不再发新牌,而左键回调那边的闸门(游戏进展 = 0 就直接 返回)同时打开,手牌进入可点状态。发牌阶段的停止事件负责"发下一张",结束后的停止事件负责"重排序",一个回调两用,全靠那一位状态分流。

选中态的处理更有意思,它存了两处,而两处是同一次写的:牌选中 布尔取反,同时 路径[3] = ±20——选中就往下 20,取消就往上 20——然后拿这三个航点再调一次 自动前进(物体, 3, 1, , , 真, , , 路径),把牌挪那 20 个像素。记录里那个布尔是读取用的,但这份状态的显示形态本身就是存储:视觉上抬起/按下 20 像素,和 牌选中 字段,是同一次点击的两半。布局数学也是纯整数:牌的落位提前用 路径[2] = 成员数 ÷ 2 × 26 + 26 预留(26 是牌间距),排序完 i = 成员数 × 26、x = (动画框1.宽度 − i) ÷ 2 让整排手牌居中,逐张 置物体位置 时 y 用 取物体顶边 原样保留。排序本体是手写选择排序:双层循环(外层 计次循环首、内层 .变量循环首),只按 牌类型 一个键两两比较交换,牌花色 根本不参与——所以同点数的牌互不换序。

可迁移的部分:把控件自带的"到达/碰撞/按键"回调直接当时钟和消息源,状态机的一位变量在事件里分流,整个程序可以一个定时器都不申请;以及,当"屏幕上的位置"和"记录里的字段"可以在同一次交互里一起写时,选中态这种东西放视觉上反而不容易和显示层脱节。

四、dev door:调参控件和正主同在一份工程里,藏在口令门后面

09 的双人对打有个不像游戏的部分。窗体 XML 里,编辑框1、编辑框2、按钮1、按钮2 连同 动画物体1..5(承载内嵌位图的隐藏控件)全部 可视="假";而主窗口标题栏明晃晃写着:“如果您是管理员或管理员授权的人,请按下F5 按下F1开始游戏”。按下某键 回调里 #F5键 触发 载入(窗口1, , 假),弹出一个标题为"管理员登录窗口"的对话框:两个标签"管理员账号/管理员密码",一个登录按钮,按钮事件里一句明文比较——编辑框1.内容 = “admin” 且 编辑框2.内容 = “passXXXXXXXXXX”(口令尾部那 10 位是一个 QQ 号,已打码;用户名 admin 保留原样)。对上,就把主窗口那四件控件 可视 = 真,主窗口标题改成"您是管理员,读取内部功能",销毁登录框;对不上,销毁自己,什么都不留。

显形之后的面板全是直连:两个编辑框 输入方式=7(只收数字),内容被改变 事件里编辑框1 直写 速度、编辑框2 直写 置角度(球, …) 并同步 上次角度(球飞行中改角度,下一次反弹的公式就从新值算起);两个按钮标题是"管理员专用 暂停动画"和"管理员专用 播放动画",分别调 停止自动前进(球) 和 自动前进(球, 速度, 10, …)——注意播放那条把步长写死成 10,玩法里是 1,管理员的按钮球跑得比 F1 开局的快一个数量级。

这就是一个运行时 feature flag:编译开关不用、独立调试版不用,可视 属性就是关闭态,一句口令比较就是开关。它的水平也要说死:口令明文躺在容器里,字符串一扫一个准,防的是误触,不是任何攻击者。

顺带把这篇配套仓库的验收讲完,因为它本身就是这个打码故事:口令尾部那串 10 位数字(QQ 号,已打码),在 .e 的 GBK 字节层做等长替换——10 位数字换成 10 个 X,整个口令串 14 字节换成 14 字节(.e 容器每个字符串前面挂着 little-endian uint32 长度前缀,长度一变整个容器就废)。全文件只有 10 个字节变化(偏移 44449–44458,正好是数字那十位),总长 52,225 B 分毫不动。然后把打完码的文件用 e-packager 重新解包,和解包前的 src/ 逐字节 diff:09 的八个文件里只有 窗口程序集2.txt 变了这 10 个字节,其余文件、包括另外两个工程的每一个文件,全等。仓库 text/ 下的那份 txt 同步做了同样的等长打码,匿名 clone 回扫原始 QQ 号 0 命中。

这个「0 命中」得补一句口径,因为它差点骗过我:那次回扫只覆盖工作树(HEAD)。这个仓库的首个提交里,README 的对照表和那份 txt 都还是原文,git show <first-commit> 直接就能读出来——第二个提交"补漏打码"只改了工作树,旧 blob 照样公开可达。只改 HEAD 不重写历史,等于没抹。 验收标准得收成全历史:匿名 clone 之后 git log -p 全文搜原始号,0 命中才算过;单看工作树干净是假阴性。要真正清掉只有两条路:filter-repo 重写历史,或者删库重推。三个文件按 MZ → e_lfanew 落在 0x40–0x400 → PE\0\0 → 节表的结构法扫了一遍内嵌 PE,检出 0 个——这批只有工程文本和位图资源,没有可执行载荷。

收尾:数字和边界

代码文本一共 15,131 字节:08 的程序集 3,536,09 的 7,592 加 516,10 的 3,495。09 那 52,225 字节的体积大头是五张内嵌位图(上/下/左/右/球,解包还原出 36,942 字节),08 内嵌一张 1,214 字节的默认图,10 则一张图都不带、52 张 bmp 全靠运行时从 取运行目录() + "\新扑克牌图片\" 读外部文件——所以仓库里那三个 .e 拿掉即跑不了扑克牌那个,需要自备 扑克牌_(1).bmp 到 扑克牌_(52).bmp。

能力边界说死:这四套手法出不了上面那份动词表。控件没有物理、没有取角度、没有动画时间轴、没有实体概念,反弹是手写枚举、身份是手写比较、节拍是手写事件分流、面板是手写可视开关。但恰恰因此,四套方法全都带得走,没有一样依赖易语言:角度即状态,handle 即身份,事件即时钟,可视即开关。2016 年那六天里它们被用在了弹图、对打和发牌上;同一段整数算术,换任何一个只有"坐标、角度、回调"这层地基的渲染环境照样成立。

仓库:https://cnb.cool/t1mmoc/tspace/eyuyan/epl-animation-toys (public,三个 .e 原文件加 text/ 文本源码,README 里有逐个程序的机制说明和打码口径)。