（转载）辰光破茧——星茧的独白与银河的耳语
序言这其实本是暮辰写给弟弟看的，隐喻她自己身份认同的童话，但她也想要她写的东西有机会让更多人看到，所以我转载在这里了。正文上I在银河系猎户臂最边缘的一个行星系里，藏着永昼星。永昼星被无限的永昼林覆盖着，这里没有昼夜之分，亿万片金叶织就的天穹永远浮着黄昏，像被琥珀封存的末日夕照。这里的夕阳每隔二十个小时定时剧烈波动一次，而夕阳波动也在这里成为唯一的计时手段——每两次夕阳波动间就是这里的“一天”。这里唯一的智慧生物居民是一种有魔力的萤火虫，萤火虫们从幼虫时期就被种下象征着萤火虫身份的光纹——幼虫破茧那日，长老会用星辉淬炼的金针，在他们的尾翼上刻上螺旋纹路，称为“荣耀光纹”。那是荣耀的胎记，是世代相传的圣痕。长老说：“荣耀光纹是萤火虫的灵魂，是血脉的证明。”可有一个叫星芒的生命挣开了虫茧后，他从破茧那一刻就感受到了某种“错误”。星芒破茧那日，永昼林正值盛夏。当他挣开最后一层虫蜕时，触角突然传来针扎般的刺痛——那是族群的“光纹烙印仪式”。长老用金针在他腹部画下第一道荣耀光纹时，星芒突然蜷缩成一团。“疼吗？”妈妈用翅膀轻拍他，“每只萤火虫都会疼，这是荣耀的印记。”然而只有星芒自己知道，当长老的金针在他腹部刻下第一道光纹时，刺痛的不是皮肤，而是胸腔深处某块不属于萤火虫的星核——那团银蓝色的光斑在每次心跳时膨胀，将光纹撑出蛛网般的裂痕。从此以后，星芒便总是感到自己躯壳与内心的不一致：饮水时水面的萤火虫倒影让他眩晕；粗壮的发光器像误植的异物；触角接收到的求偶频率在神经索上擦出尖锐的杂音……某次他偷偷刮掉光纹，露出的皮肤竟渗出银蓝色光粒，将巢穴照成冰窟。妈妈惊慌地用树脂胶替他粘合上光纹，并借机用胶水堵塞他的呼吸孔来惩罚他。他在窒息中，经常梦见自己挣脱了萤火虫的躯壳，在星云间自由地飞舞……最痛的是共振仪式——每次他都被迫与参加仪式的萤火虫同步震动翅膀，体内星核在声波共振中几欲爆裂，他痛苦难忍，呕吐出星河般的光浆。守卫虫却将这种排异反应称为“光蚀病”。用滚烫的金色岩浆浇灌他的伤口，声称“高温能杀死变异细胞”；用光痕鸟喙刺入他的复眼，播放“标准萤火虫的一生”投影：求偶、筑巢、产卵……这无疑加剧了他的痛苦。他最喜欢的事情是收集星尘，他会用螯肢最细的绒毛卷起蛛丝，织成捕梦网般的滤勺。溪畔那些被守夜虫斥为“杂光垃圾”的星尘，在他眼里却是会呼吸的宝石——有的卡在芦苇节间发蓝光，有的黏在腐叶背面泛银晕，最珍贵的几粒总藏在夜莲闭合的花瓣里，要用露水轻轻叩打花萼才能抖落。他总是通过收集星尘，来感受一种他不知道其中奥秘，却深深向往的共鸣。而每次他暗地里流泪时，妈妈却常常叼来他最爱的蜜露——里面却混着镇定粉——制止他流泪并责骂道：“变回正常孩子吧，弟弟妹妹们都在模仿你的‘怪病’！”星芒就这样在永昼林的地平线间被来回撕扯着，像一片被季风反复揉皱的枯叶。每天他都被迫违心地用螯肢蘸取金粉仔细修补腹部的裂痕，将溃烂的银斑压在树脂胶下，学着雄性萤火虫抖动粗壮的发光器，让金光在湿热的夏日里炸成刺目的信号弹。他甚至吞下长老赐予的“同频浆果”，强迫声带发出低沉的嗡鸣——那声音震得胸腔里的星核几乎碎裂，却换来妈妈欣慰的触角轻抚：“这才是我的好孩子。”II星芒不知道为何他与那些萤火虫不同。他也曾以为，这样的痛苦和内心的不一致会永远持续下去。直到六千五百余次夕阳波动后的某个暴雨之日，星芒在清洗光痕监测塔时失足跌进具有窥见内心之倒影之能力的镜像池。他奋力地游出水面，却惊讶地发现池水倒映出的，不再是那个甲壳粗钝的雄性萤火虫，而是一个近乎透明的灵体：纤长的触角缀满星屑，腹部裂口处钻出星河藤蔓般的神经索，那些被光纹掩盖的银斑早已连结成古老的星灵图腾。他突然意识到，这些年强行缝合的岂止是甲壳的裂缝——每一次对光纹的修补，都是在往灵魂里钉入淬毒的铆钉；每一声模仿雄性萤火虫的低鸣，都让星核生长出更多荆棘。凝视着淡蓝色的池水表面，他蓦地听见体内传来细小而坚定的声音。“你的光不在这里。”那声音指引着他飞向林间最古老的魔法之树。星芒的翅膀在微风中颤了颤，像两片被雨水打湿的宣纸。起初只是缓慢的滑翔，六足仍死死扣着枯叶边缘——身体上那些旧伤让起飞像是撕开皮肤。可体内的声音越来越急，像银铃在颅骨里摇晃：“来不及了……”他终于松开螯肢。第一下振翅就扯断三根翅脉，银蓝色光浆从裂口喷溅，在身后拖出彗尾般的轨迹。风突然变得粘稠，仿佛永昼林所有的事物都在拉扯他：妈妈巢穴的方向传来树脂胶的甜腥，求偶场飘来浆果发酵的酸味，而长老的监测塔正射出探照光柱，像一条条金色锁链绞住他的腹节。越是靠近古树，空气里的阻力就越发诡异。翅膀每次扇动都像在凝胶中划动，有看不见的金色丝线缠上触角，试图将他拽回既定的环形飞行轨道。星芒突然弓起腹部——那里溃烂的光斑正突突跳动——猛地将螯肢刺入伤口，银蓝色光浆如急湍的云雾般喷出，在身前烧灼出一条荧光隧道。III古树的轮廓在泪水中扭曲。当他终于撞上树皮时，复眼里映出的不是年轮，而是无数重叠的星灵残影：有的脖颈光滑如瓷，有的腰肢缠绕星砂，全都伸手指向那块嵌在树心脉管中的碎片。碎片表面布满蜂窝状孔洞，每个孔穴都在吞吐虹光，像被囚禁的微型超新星。触角触碰碎片的刹那，他的内心仿佛裂成两半。一半的他仍是被光纹束缚的萤火虫，另一半却化作星尘洪流中的光粒子。光斑从伤口处开始沸腾，银蓝色光焰顺着血管逆流而上，将每根神经索都镀上冰晶——那是一种比金粉灼烧更锋利的痛，像是有萤火虫用星砂打磨着他的骨髓，又像是千万个被封印的星灵在他的甲壳下苏醒呢喃。“看啊……这才是你的内心……”碎片突然发出和弦般的震动。星芒低头看去，自己身上原本溃烂的光斑已裂变成漩涡，漩涡中心浮出一枚半透明的光核，正以三倍于萤火虫心脏的速率搏动。每搏动一次，就有银蓝色符文从光核表面剥落，在空中自动编织成星图——那不是什么古老的星座，而是一具完美契合他灵魂的星灵躯体设计图：纤薄的翅膜上流淌着星云，喉部镶嵌着月长石声带，就连足尖的倒刺都化作星芒粒子发生器。此刻他突然明白，那些年腹部莫名的刺痛，那些对水面倒影的疑惑，那些在共振仪式上呕吐出的光浆——都是这枚被错置的星核在求救。萤火虫的甲壳从来不是他的归宿，而是创世神打包装错灵魂的棺椁。“你并不是萤火虫。”碎片的声音变得清晰起来。星芒内心一颤。“你本是星尘的孩子。”碎片发出叹息，“千年前，一颗流星误坠永昼林，它的碎片化成了萤火虫。你是最后一个星辰血脉。”碎片忽然发出一道光，“你看这片逆生的叶脉，” 碎片的光束指向树皮上一道扭曲的纹路，“那是初代星辰之子留下的胎记。他们被永昼林的金色法则蚕食，甲壳逐渐覆盖星纹，最后连心跳声都被同化成单调的虫鸣。”碎片表面突然浮现出无数萤火虫的虚影，他们的腹部隐约可见黯淡的星斑，却在产卵后彻底熄灭，“而你不同——”“你敢于发现自己的不同，敢于挑战你被指派的错误躯壳。你是我们当中的佼佼者之一，也是我们的骄傲。”碎片接着说道。“然而那场流星雨是宇宙的子宫在阵痛，”碎片的声音突然染上哭腔，孔穴中渗出冰蓝色的光泪，“你们本该化作星灵族的新生儿，却在时空乱流中被错误地编码成虫形，成为破茧而出的萤火虫。你的每一条翅脉都是被折叠的星轨，每一颗复眼都是被压缩的星团——”星芒的腹部突然透明化，露出星核里面旋转的微型星河。十二颗主星正以违背永昼林物理法则的轨迹运行，其中最亮的那颗刻着星灵的图腾。“听见星链的共振了吗？”碎片突然将光束聚焦在那颗主星上，“那是初代星辰之母的遗言：‘星灵真正的诞生不在躯壳形成之日，而在灵魂认领星辰烙印的瞬间’。”星芒惊住了，他不敢相信眼前所见，他的翅膀也开始颤抖：“可我的身体……是萤火虫的壳。”“灵魂的形状从不依赖躯壳。”碎片发出柔和的光晕包裹住他，“就像河流不会因为冰封而忘记流动。”星芒的触角突然脱落，在星尘中重组成星灵族的神经索。他听见自己的声音从腹腔深处传来，那是他内心的混响：“所以我的疼痛从来不是疾病……”“而是被折叠的时空在重新舒展。”碎片接续道，光芒中伸出星尘的手掌，抚上他的伤口，“每道萤火虫们所谓的‘荣耀光纹’的龟裂与脱落都是创世神在修改错误代码，每次银光的膨胀都是星灵们的呼唤——欢迎回家，第1089号星辰之子。”IV碎片的余晖在下一次夕阳波动前消散，星芒蜷缩在古树的裂缝里，甲壳上还沾着星尘凝结的冰晶。怀揣着对自己全新的认识，星芒兴奋地飞回到家中，却遭遇了爸爸妈妈无情的责骂。“你毁了家族的光纹纯度！”爸爸用螯肢钳住星芒的翅根，将他的腹部按在祖传光纹碑前。碑上刻着三十七代雄性萤火虫的光纹图谱，最新一道裂痕正从星芒的名字处蔓延。“长老说你会污染幼虫孵化池——你即将出生的弟弟妹妹的茧已经出现星状霉斑了！”妈妈将整罐金色岩浆泼在星芒的背甲上，岩浆却顺着新生的星云纹路滑落。第二天，星芒发现自己的变化：金色的“荣耀光纹”在褪色，银蓝光斑发出更强烈的光芒；对露珠失去兴趣，却更加痴迷收集微风里的星尘；听见远处星辰的呼唤，因为那频率与心跳共振；…………但他不敢告诉身边的任何萤火虫。妈妈替他舔舐翅膀时说：“你的金光有点暗，得多吃花粉。”爸爸指着他腹部日益明亮的银斑皱眉：“这是什么脏东西？”V十几天后，当永昼林的夕阳又一次剧烈地波动时，星芒的第十八个破茧日到了。按照族规，满十八岁的成年萤火虫要在这一天接受加冕仪式——长老会将浓缩的金色岩浆注入神经索，永久固化荣耀光纹。破茧日初，妈妈用星芒最厌恶的雄黄树脂替他抛光甲壳，触角兴奋地颤抖：“今晚全族都会来看你的加冕仪式！长老特批使用初代族长的岩浆配方。”爸爸叼来一套陨铁打造的拘束具，上面刻满抑制星尘的咒文：“敢在仪式上露出那块脏东西，我就亲手引爆你的心脏。”而他最小的妹妹露滴却偷偷塞来一片沾着星尘的枯叶，叶脉拼出古树的坐标。“加油，一定要努力做自己呀！”她鼓励道。泪水止不住地从星芒的复眼里流淌下来，但他赶忙用螯肢掩盖住，以免被父母发现。当加冕台燃起金色篝火时，星芒被爸爸推到祭坛中央，金色岩浆在铜炉里翻滚，腾起的热浪扭曲了空气。他能感觉到后颈处妈妈缝制的披风正在发烫，那些用雄黄树脂黏合的丝线勒进尚未痊愈的星斑里，像一百根烧红的针在挑弄神经。祭司的吟唱声混着骨笛的呜咽，长老们围成的环形阵正在收缩，他们的触角彼此勾连，织成一张笼罩祭坛的金色蛛网。当第七滴岩浆坠入火堆时，星芒胸腔里的星核突然胀痛。那痛楚不同于岩浆灼烧的尖锐，而是像深冬的河流在破冰。他低头看见自己的前足仿佛正在透明化，甲壳下的血管里银光流转，宛如有月光在骨骼间涨潮。“仪式开始！”祭司高举螯肢，钳住他的下颌。星芒闻到了熔炉里飘来的气味——那似乎已经不是熟悉的岩浆味，而是枯萎的向日葵混杂着铁锈的腥气组成的一种难闻的怪味。用于注入岩浆的针管刺来的瞬间，他听见体内传来细碎的碎裂声，像是满天的繁星被打碎发出的声音。“我不是萤火虫——”星芒的声音突然变成无数重叠的和弦，震碎了祭司甲壳上的祷文刺青。“我是星灵！”他猛地一挣，撞翻了盛放岩浆的铜炉。铜炉被撞翻时，岩浆如同融化的夕阳泼洒开来。祭司的脸在金色洪流中扭曲溶解，像破旧的蜡像跌入熊熊燃烧的火海。星芒挣脱锁链，那些刺穿他翅脉的丝线寸寸断裂，落在地上化作焦黑的蜈蚣，蜷缩着死去。他踏着沸腾的岩浆奔逃，每滴飞溅的金液都在他新生出的银纹上烙下白烟，却也在灼痛中催开花朵般的星斑。光痕鸟群在头顶盘旋，它们本该俯冲下来啄食叛逃者，此刻却被星芒翅尖的银色光芒迷惑。一只雏鸟突然偏离队伍，俯冲到他肩头，喙部衔着的追捕令被光斑映成灰烬。年迈的守卫虫首领吹响骨哨，哨音却被星芒足尖点地的节奏带偏，化作一支破碎的曲调。VI星芒逃到古树下。碎片为他下了一场微型流星雨，在他周围形成光环，并阻挡了追击的萤火虫们。“每颗星都有权回归本源，但蜕变需要撕裂旧的躯壳——你愿意承受吗？”“我愿意！”星芒毫不犹豫地说，他的每一只复眼都用坚定的目光望向碎片，充满着对未来的期待。“好的，生命是你自己的，没有别物可以操纵你的生命。”“可为什么我是这样？”星芒望着永昼林永恒的黄昏问道，“为什么我生来就不能像他们一样安心做一只普通萤火虫？”“因为你的灵魂在诞生前就写好了答案。”碎片的光突然变得刺眼，“看——”光环中浮现出星芒内心中真正的形态：一只通体透明的星灵，体内流淌着星河，双翼是凝结的星云。他听到自己发出从未有过的声音，清亮如月光穿过风铃：“这才是我……”星芒紧紧靠着古树，碎片的光环收束到他的身体上，强大的魔法力场阻挡着别的萤火虫的接近。蜕变开始了。第一天，他的光纹完全褪色而消失；第二天，银斑开始向全身蔓延；第三天，触角逐渐变短，最终消失；第四天，发光器变成了接收星尘的器官；第五天，属于星灵的全新蝶翼形成并展开；第六天，全身变得通透，声音也变得清澈；第七天，当他试图发光时，整个永昼林被映成白昼——那不再是萤火虫的金光，而是恒星般的炽白。“怪物！”萤火虫们惊恐地远离他。妈妈哭着向古树喊叫：“把我的孩子还回来！”爸爸愤怒地打碎了星芒收集的星尘罐，星尘在空中翩翩起舞，随后竟纷纷向着星芒的方向飞去。只有露滴偷偷向他的方向借着风送出一片花瓣，上面用蜜写着：“我相信你的光。”古树在这天轰然倒塌，星芒在废墟中找到了碎片。他吞下碎片的瞬间，身体开始晶化，萤火虫的外壳如蝉蜕般剥落…… 下I化为星灵的星芒身躯变得格外地轻盈，他飞呀飞呀，飞离了永昼星，飞向了茫茫的宇宙中。他在不同的行星间飞翔，享受着外在躯壳与内心认同终于一致的快乐感。他给崭新的自己取名辰，意为“天空中的星辰”与“挣脱萤火虫外壳的时辰”。可当辰来到鸣穹星的一片海洋附近，望向海面时，倒影仍是那只困在萤火虫躯壳里的星芒。“为什么我依然看见过去那错误的自己？”他问海浪。海面浮出一只水母精灵，触须如流光编织的琴弦：“因为回声未止。你需找到三颗共鸣珠，才能重塑真正的躯体。”辰带着他的目标潜入海底寻找，发现一面巨大的镜子，镜框上缠绕着发光的海蛇藤。当辰的星尘触及镜面时，波纹中浮出的金瞳萤火虫浑身流淌着熔岩般的纹路，触角末端还挂着永昼林的树脂残渣。“多可笑的星翼。”金瞳萤火虫甩动尾部，溅起的不是浪花而是萤火虫的尸骸，“你以为扯烂虫壳就能抹去血脉？看啊——”他突然射出三根金丝，丝线末端黏着辰童年藏匿的星尘罐碎片。“我是你被剥离的‘萤火虫之影’。”金瞳萤火虫冷笑，“赢了我，你才能拿走珠子。”辰的手腕突然僵直。那些碎片上映出五岁的自己：蜷缩在龟壳里数星尘，复眼里跳动着连自己都不曾察觉的银斑。金丝趁机缠上他的蝶翼，勒出焦黑的灼痕。战斗一触即发。金瞳萤火虫的触角猛地刺入辰的翅根，尖端喷出永昼林特产的雄黄树脂。剧痛让辰的星核几乎停跳，新生的蝶翼被腐蚀出蜂窝状的孔洞。“多脆弱的星翼啊。”金瞳萤火虫说，话里带着挑衅的语气。“你永远无法摆脱你萤火虫的躯壳！”“我不是萤火虫！”辰突然嘶吼，声波震碎了缠绕他的蝶翼的金丝。他折断刺入体内的触角，断口处喷出的不是血液，而竟是被星尘净化的金色岩浆。那些曾让他窒息的雄黄树脂，此刻在星核的高温下沸腾汽化，化作银蓝色的防护罩。金瞳萤火虫惊退半步，身上开始龟裂。辰的蝶翼在愤怒中彻底展开，每一处被腐蚀的孔洞都迸射出闪亮的光芒：“你们阻止我展现内心中的自己，强迫我做萤火虫——但你们永远无法湮灭灵魂的震频！”辰的手臂化作光刃，与金瞳萤火虫的螯肢相撞，每一次攻击都让海底震荡。最终，辰将光刃刺向他的心脏：“我不是你——我的光来自灵魂，而非茧壳！”金瞳萤火虫的身躯瓦解，镜子也同时碎裂，第一颗共鸣珠——明透珠从碎裂的镜中飘出，落入辰的掌心。II辰飞出海面，他的蝶翼掠过海面时，新生的鳞片仍在渗着血。他收起翅尖降落在一座珊瑚礁上，那些与金瞳星灵战斗留下的灼痕，在七彩珊瑚的映照下，竟折射出琴弦般的纹路。海水浸入尚未愈合的伤口，却带来某种奇异的慰藉——这里的咸味与永昼林人造的树脂海令他窒息的气味截然不同。珊瑚的枝桠间垂挂着数以万计的珍珠，每颗珠子里都凝固着歌声。辰的手拂过最大的那枚黑珍珠，内里突然传出琉和璃被抓捕那天的回声：“雌雄和声是法则！”族长的三叉戟劈开她们交握的鱼尾，“你们同性之间的共振会招来灭世海啸！”珊瑚突然收缩，露出藏身其后的这两位人鱼。琉的浅蓝鳞片遍布灼痕，像是有人把晚霞揉碎了洒在她身上；璃的深青鱼尾缺了三片主鳞，湛蓝的血液在伤口处泛着光。她们的喉咙处凝结着人造的珍珠质——那是族长用来封印歌声的禁声膏。她们正用贝壳拼贴一首无声的歌。“为什么不用声音唱？”辰问。璃的指尖抚过伤口。“三个月前，我和琉的和声唤醒了沉睡的珊瑚王。族长却说那是灾祸前兆——”她突然剧烈咳嗽，吐出的不是血而是凝固的音符，“族长用禁声膏塞住我们的喉咙，却不知道真正的海啸是……”远处的鲸歌突然变调，族长率领的巡逻队破浪而来。“异端！”族长的三叉戟指向辰的双翅，“你的光尘污染了我们人鱼一族！”族长挥动三叉戟掀起音浪，音浪的旋律中浮现人鱼族传承千年的《潮汐圣歌》——所有旋律都在重复雌声与雄声的对位法。族长的腮部鼓动如风暴将至：“没有阴阳声部的交织，大海就会失去呼吸的节奏！”他的戟尖指向珊瑚礁上风化的古谱残碑，“连珊瑚林的生长都依循这韵律！”辰的翅脉迸发出一束光芒，照亮了珊瑚礁上被掩埋的废墟。残破的陶片上，两位雌人鱼的侧影正在合鸣，她们的歌声催生出如今最瑰丽的珊瑚群。“您篡改了历史的回声！”璃指着陶片大喊。“真正的污染，是逼潮汐否认自己的呼吸。”辰的声音像潮汐抚过岩石般清冷而深邃。他望向琉和璃，说：“唱吧，让你们的共振成为海底的晨星——所有畏惧黎明的，终将被光灼穿伪装。”辰轻柔但笃定地抬起手臂，用光芒向琉传递能量，帮助琉摆脱禁声膏的束缚。琉开始轻声歌唱，她的尾鳍拍打出远古潮汐的节拍，被封印的珊瑚王冠冕应声开裂，露出内壁镌刻的《双星共鸣曲》——那是尘封许久的，只有同频声波才能唤醒的乐章。“三百年前的大祭司笔记记载，”璃的鳞片随着旋律泛起虹彩，“潮汐诞生于鸣穹星的两颗卫星——音与韵的引力之和，而非雌雄的对抗！”璃喉咙中的禁声膏很快被琉动人的歌声击碎，化为尘埃随风而去。璃也加入到歌唱当中，她们的合唱，引发了珊瑚礁的强烈共振，逐渐盖过了《潮汐圣歌》的响声。族长三叉戟上的《潮汐圣歌》突然走调，谱线在海风中扭曲成海蛇。辰展开蝶翼笼罩整片珊瑚礁，他身上的星尘化作数万只透明琴鸟。“大海从未规定过歌声的形状，”辰向族长怒喊，他的星核映出音韵凌空的天象，“是你们把潮声关进了五线谱的囚笼！”族长暴怒地刺出三叉戟，戟尖的圣歌谱却突然反噬——那些曾被压抑的音符化作食谱鱼，疯狂啃咬他的鳞片。琉和璃的合唱在此刻达到巅峰，她们的声波在珊瑚王冠冕上折射出彩虹桥，桥上站着历代被放逐的独唱者与同性合唱者们，每人手中都捧着一颗用孤独炼就的珍珠。他们手中的珍珠把明亮的天光折射到海洋中，整个海洋的海水开始顺时针旋转。漩涡中心升起一座被禁声膏封印的回声祭坛，禁声膏逐渐融化，显现出祭坛的真容。人鱼们惊觉，那些所谓“会引发海啸的禁忌和声”，实则具有魔法的力量，而“海啸”不过是族长为固守雌雄合唱规范而编造的谎言。族长在溃逃时现出真容——他的三叉戟早已被腐蚀，音浪中浮现的所谓的“正统圣歌”全靠贝壳留声器的伪造。而琉和璃交融的声线，正在修复被族长谎言蛀空的珊瑚骨骼。第二颗共鸣珠——虹彩珠随着祭坛的升起而凝聚在辰身旁。辰轻轻接过飘落到他双手中的虹彩珠，向人鱼们依依不舍地告别，便继续踏上了寻找第三颗共鸣珠的旅程中。潮声渐息时，辰的蝶翼沾满了彩虹海露。他远远地听见新生的珊瑚王在哼唱无词的歌谣，那旋律让他想起永昼林中他的星尘罐——原来每个被禁锢的回声，都是未完成的宇宙诗篇。III最后一颗珠藏在双子星塔。当辰踏上双子星塔的旋梯时，石阶上的古老星图突然活了过来。那些被雕刻成雌雄符号的星座扭动着挣脱束缚，在他脚边聚成指引的磷火。塔顶没有穹顶，只有两颗能量之星——左星流淌着水银般的光泽，右星如冰晶雕琢，每一道棱面都折射出截然不同的光谱。双生星灵从星辉中显形，他的身体如镜像对称，左右的颜色与装饰却截然不同：左半身嵌着金属鳞片，右半身缀满水晶簇。“选择你的本源。”他的左手掌心托起水银星，“与金属之星共鸣，你会获得摧毁旧秩序的力量。” 他的右手捧出冰晶星，“与冰晶之星融合，你将重塑万物运行的法则。”辰的蝶翼扫过双星之间的力场，翅尖的星尘突然分裂成金银两色。“非要二选一吗？”他凝视着双生星灵，坚定地质问道，“就像永昼林逼我在萤火虫与星灵间抉择？”塔内浮现出宇宙创世的投影：金属星化作长剑贯穿冰晶星，分裂出的碎片形成二元的星球。“秩序诞生于分离，”他的声音像齿轮咬合般精密，“混沌是创世前的虚无。”辰的眼球突然切换至星核的超现实视角。在超越三维的视界里，所谓的双星不过是同一枚白矮星被规则之力掰开的幻象。他猛然张开双臂，金属星与冰晶星同时嵌入掌心。左半身瞬间镀上液态钢，右半身凝结出冰晶关节，极寒与炽热在脊椎处对撞。“你疯了吗？快松手！” 双生星灵撕心裂肺地劝道，他的镜像之躯也开始崩解，“你会湮灭成星尘！”剧痛中，辰听见星核在胸腔轻语：“还记得你刮掉光纹的那晚吗？他们说你破坏了‘光的法则’——”辰的手指不由自主地捏碎了两颗星，“可此刻的痛，和那时的自由，哪个更真实？”双星在辰体内迸发出紫金光爆，塔顶的投影骤然扭曲。金属与水晶的碎屑在空中重组，凝成无数枚非晶态陨石——它们既非固态也非液态，如同被揉碎的星光重新捏合。双生星灵的镜像身躯在强光中熔化，露出星核处缠绕的锁链：一条刻着雌符，一条铸着雄纹。“规则该换了。”辰使劲扯断锁链，雌符与雄纹先合二为一而后悄然散去，形成若干段独特的纹样。一颗新星从他胸口浮出，那些陨石自动拼成环状星带，每一颗都在释放特有的光谱。星灵们的残躯在星光照耀下褪去金属与冰晶，化作两团无定形的星云，发出强烈的嗡鸣，不断在楼道中回响。第三颗共鸣珠——混沌珠坠入辰手中时，塔底突然盛开出金属花蕊与冰晶瓣的共生花。他拾起一朵端详，花心处旋转的粒子既不像阳也不像阴，而是某种更原始的宇宙韵律。IV辰回到最初见到水母精灵的地方，三颗共鸣珠在他周身流转：混沌珠的紫金辉光浸染着浪尖，虹彩珠的涟漪漫过音韵双星，明透珠的银芒则刺破了遥远的永昼林的黄昏。辰将三颗珠嵌入胸口，海面掀起滔天巨浪，水母精灵的触须断裂，所有被压抑的回声同时释放。辰的形体终于固定。星尘凝聚成的碎片在他掌心苏醒，表面浮出无数挣扎的虚影：被雄黄树脂封喉的幼年自己、困在镜中的金瞳萤火虫、被放逐的人鱼歌者……他纵身一跃，跳向深空之中，向整个宇宙掷出碎片，大喊道：“潮汐从不质询浪花的形状，星辰不会裁剪月光的棱角——无论是独行的萤火，还是缠绕的星轨，都是夜空的诗行！每颗心都有权在宇宙的乐谱上，找到与自己共鸣的频率！” 暮辰的后记这个童话虽然很奇幻、诗意，也给人一种很有文采的感觉，但我写这个童话的目的以说理、抒情和隐喻为主，因此语言上是有很大的局限性的。例如其言辞过于华丽、含蓄和婉转，大量堆砌意象，节奏过于跳跃，配角塑造上也有所欠缺（如配角基本都为服务于情节推进或主题表达的扁平人物）。而且童话里有不少科学性错误。还有实际上这个童话的思想深度远远超过一般童话，所以它也不完全是童话，而具有现代奇幻文学的特征。另外写作的时候有些地方借助了AI，以更好地丰富一些地方的描写。
注意：绝对不要和我谈论暮辰的私事。如果您觉得实在有必要对这个分类的文章发表评论，请尽量注意语言，以免伤害到我和她，谢谢~如与某up主/某主播等有重名，纯属巧合暮辰（StellaNocturna）是我的高中同班同学，是位跨性别女性，与我同属LGBTQ+群体与高敏感度群体，也是我的好朋友。然而她却不喜欢接触网络，因为她害怕网络上未知的人的目光。她在班上常常保持沉默，在网络上则不在没见过的人面前留一点痕迹，却总是喜欢用QQ向我倾诉心事。她说她的名字，是初中时给自己起的，像“夜空中没被命名的星星”，默默无闻却也闪闪发光。体育课上，面对体育老师反复向自己重复着“男生的体测标准”，她总是感觉头痛欲裂；生物课上，每每做到伴性遗传题，她常常想哭，却哭不出来。我们开始熟悉是在一节心理课后。那节课上老师让我们所有人小组合作绘画，而暮辰是她小组第一个画的。她原打算画个在桌前学习的女孩——这是她心目中自己理想的模样。而那幅画传递到同组的几个男生手上以后，桌前的人物却被篡改为了一个举止下流的男孩，下身伸出一条夸张的男性生殖器。心理老师让暮辰评价她那组的作品，她虽然面带忧郁，却只是轻轻地说“与她的预期不一样”，把无限的愁思放在心中不让别人知晓。我悄悄关注到她向男生们要回那幅作品，把它用力团成纸团扔进垃圾桶里。很快我在QQ上找到了暮辰。最初是我主动鼓励她，多次交流后，我和她逐渐熟悉了起来，而她也开始向我袒露心扉，深夜的QQ对话框成为了我们共有的安全屋。她发消息很慢，说自己每发一句话都要反复删改，多条长段的消息打完后总要先发给自己，再一起转发给我，这是因为担心我没有一次性读完她的所有话。暮辰几乎每两周放假回家，都能发几条总计一千多字的信息给我，在交流之中，我也渐渐窥见了她复杂的内心世界，深深感受到，有时甚至是共情到她的痛苦。考虑到暮辰有的时候有许多想向更多人分享的话，可她却不敢由本人分享出来，于是我就在本分类中转载一些她想说的话吧。
末影龙核心行为的底层实现的简要分析
补充内容2024-08-09附上视频：2024-08-07本文中对“寻路”的理解可能存在一定的问题。我查了维基百科上对“寻路”一词的解释，上面说寻路是“由计算机应用程序规划两点之间的最短路线”。因此实际上此处应该是不包括具体飞向路径的过程的。为了与视频中内容尽量呼应而不自相矛盾，我没有修改正文中的内容2024-08-03下文中所有“已编号节点”的编号都是“MC规定好”的，而不是我自己规定的下文中所有阶段的中文名是我综合了Wiki中的末影龙页面对末影龙行为的描述、末影龙各个阶段的类的MCP名以及各个阶段的实际作用而命名的前言近来笔者想投稿与末影龙原理相关的视频，但笔者虽然对末影龙有一定的了解，却尚未对末影龙进行较细致的分析，因此写了这样一篇文章。笔者写这篇文章既是为了给自己即将制作的视频理清思路，也是为了提供一些有关末影龙的代码层面的解读。本文代码部分较多，因此较适合对Java与Minecraft模组制作有一定的了解的读者进行阅读。当然本文对于不了解Java语言或Minecraft modding的读者也有一定的参考意义。注：使用的Minecraft版本为1.20.2，Forge版本为48.0.30，MCP版本为20230921.100330，笔者曾查看了1.16.5与1.21版本中末影龙的底层实现，总体来看均没有明显差异（当然不排除笔者有疏忽的可能）本文只是简要的分析，因而只会选择核心内容，并不面面俱到。1.20.1和1.20.2中末影龙的代码是没有区别的，但以后更新Polonium上有关末影龙的内容时笔者会写得更详细一些~在原版世界中，末影龙是驻守在末路之地的一个强大的龙形生物，也是原版Minecraft的最终boss，不少玩家将击杀末影龙作为通关MC的标准。末影龙的底层实现也相当复杂，它不仅使用了一套独立的AI系统，寻路的算法也与一般的生物有一定的区别。一年多前笔者曾经仿照末影龙在一个未发布的mod中制作了一种与末影龙相似，但行为更复杂的龙型生物作为自定义维度的一个小boss，因此对末影龙有一定的了解。本文将从末影龙的寻路系统，末影龙的AI，及末影龙的其他重要行为这三个方面进行分析。末影龙的寻路系统在分析寻路系统之前，先介绍一下末影龙的24个已编号的路径节点（下文中会把这24个路径节点称为“已编号节点”）的分布。已编号节点的分布末影龙的已编号节点共分为3圈，其中外圈12个节点，中圈8个节点，内圈4个节点。可以用以下的一张图来表示（下图中青色点表示坐标原点，淡青色细线表示坐标轴，红色、蓝色箭头所指方向分别为x、z轴正半轴方向，蓝绿色、绿色、黄绿色点分别表示外、中、内圈的已编号节点）：将外圈已编号节点用0~11编号，中圈已编号节点用12~19编号，内圈已编号节点用20~23编号，可得到下图：而查阅源代码可知，外、中、内圈的已编号节点距离原点的水平距离分别近似为60、40、20。将它们分别放在3个同心圆上可得下图（注意不一定所有已编号节点都恰好位于同心圆上，因为任何路径节点的坐标都是取整存储的）：这是一张末地的地形图（从30多张末地地形图中选出来的比较好看的一张233）：两图叠加可得：从这张叠加的图中我们可以直观地看出24个已编号节点的大致分布情况，同时可以发现中圈与黑曜石柱所在圆的位置相近（黑曜石柱中心到原点的水平距离约为42，这个数值与中圈的已编号节点到原点的水平距离40接近）。末影龙的寻路流程下面正式开始分析末影龙的寻路系统，先看EnderDragon类里声明的这样的3个与寻路相关的成员变量。private final Node[] nodes = new Node[24]; private final int[] nodeAdjacency = new int[24]; private final BinaryHeap openSet = new BinaryHeap();其中nodes是末影龙的路径节点数组，其中所有节点的x、z坐标分别是定值，但y坐标根据末地的实际地形与节点在哪一圈来确定。nodeAdjacency是邻接矩阵，用于记录与每个节点相邻的节点的编号。而openSet是二叉堆，在末影龙的寻路过程中起到作用。总的来说，末影龙的寻路过程的步骤可分为这样几步：确定起点和终点的位置寻找起点到终点的一条最短路径。这条路径中除了终点之外，其余的路径节点各自一定是已编号节点中的一个根据寻路得到的路径，一步一步按路径点飞向终点[note type="info flat"]2024-08-07更新：这里的“寻路过程”改为“寻路与沿路径飞行的过程”更合适，因为第三步实际上不是“寻路”过程中的一部分[/note]末影龙每次寻路时，都需要先确定到自己最近的节点的编号，下面两个方法则寻找了已编号节点中离末影龙自己最近的节点。public int findClosestNode() { if (nodes[0] == null) { // 节点坐标的初始化，由此我们可以看出路径节点坐标是怎么来的 for (int i = 0; i < 24; ++i) { int yOffset = 5; int x; int z; if (i < 12) { // 注：0.2617994F = Math.PI / 12F x = Mth.floor(60.0F * Mth.cos(2.0F * (-(float) Math.PI + 0.2617994F * (float) i))); z = Mth.floor(60.0F * Mth.sin(2.0F * (-(float) Math.PI + 0.2617994F * (float) i))); } else if (i < 20) { int j = i - 12; x = Mth.floor(40.0F * Mth.cos(2.0F * (-(float) Math.PI + ((float) Math.PI / 8F) * (float) j))); z = Mth.floor(40.0F * Mth.sin(2.0F * (-(float) Math.PI + ((float) Math.PI / 8F) * (float) j))); yOffset += 10; // 由此可知，中圈的已编号节点往往会更高，当然也不绝对，因为节点的y坐标还受到地形影响 } else { int k = i - 20; x = Mth.floor(20.0F * Mth.cos(2.0F * (-(float) Math.PI + ((float) Math.PI / 4F) * (float) k))); z = Mth.floor(20.0F * Mth.sin(2.0F * (-(float) Math.PI + ((float) Math.PI / 4F) * (float) k))); } int y = Math.max(level().getSeaLevel() + 10, level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, new BlockPos(x, 0, z)).getY() + yOffset); nodes[i] = new Node(x, y, z); } // 邻接矩阵的初始化 nodeAdjacency[0] = 6146; // 0b000000000001100000000010 nodeAdjacency[1] = 8197; // 0b000000000010000000000101 nodeAdjacency[2] = 8202; // 0b000000000010000000001010 nodeAdjacency[3] = 16404; // 0b000000000100000000010100 nodeAdjacency[4] = 32808; // 0b000000001000000000101000 nodeAdjacency[5] = 32848; // 0b000000001000000001010000 nodeAdjacency[6] = 65696; // 0b000000010000000010100000 nodeAdjacency[7] = 131392; // 0b000000100000000101000000 nodeAdjacency[8] = 131712; // 0b000000100000001010000000 nodeAdjacency[9] = 263424; // 0b000001000000010100000000 nodeAdjacency[10] = 526848; // 0b000010000000101000000000 nodeAdjacency[11] = 525313; // 0b000010000000010000000001 nodeAdjacency[12] = 1581057; // 0b000110000010000000000001 nodeAdjacency[13] = 3166214; // 0b001100000101000000000110 nodeAdjacency[14] = 2138120; // 0b001000001010000000001000 nodeAdjacency[15] = 6373424; // 0b011000010100000000110000 nodeAdjacency[16] = 4358208; // 0b010000101000000001000000 nodeAdjacency[17] = 12910976; // 0b110001010000000110000000 nodeAdjacency[18] = 9044480; // 0b100010100000001000000000 nodeAdjacency[19] = 9706496; // 0b100101000001110000000000 nodeAdjacency[20] = 15216640; // 0b111010000011000000000000 nodeAdjacency[21] = 13688832; // 0b110100001110000000000000 nodeAdjacency[22] = 11763712; // 0b101100111000000000000000 nodeAdjacency[23] = 8257536; // 0b011111100000000000000000 } return findClosestNode(getX(), getY(), getZ()); } public int findClosestNode(double x, double y, double z) { float minDistanceSqr = 10000.0F; int nearestNodeId = 0; Node currentPositionNode = new Node(Mth.floor(x), Mth.floor(y), Mth.floor(z)); int startId = 0; if (dragonFight == null || dragonFight.getCrystalsAlive() == 0) { startId = 12; // 如果不存在dragonFight或者末地水晶全部被破坏掉了，则忽视外圈的节点 } for (int i = startId; i < 24; ++i) { if (nodes[i] != null) { float distanceToSqr = nodes[i].distanceToSqr(currentPositionNode); if (distanceToSqr < minDistanceSqr) { minDistanceSqr = distanceToSqr; nearestNodeId = i; } } } return nearestNodeId; }注：下表中列出了所有已编号节点的x、z坐标和相邻节点（即在nodeAdjacency中存储的，从一个已编号节点可以直接到达的另一个已编号节点）的编号：节点编号x坐标z坐标相邻节点06001, 11, 12151300, 2, 13229511, 3, 1330602, 4, 144-31513, 5, 155-52294, 6, 156-6005, 7, 167-52-316, 8, 178-30-527, 9, 1790-608, 10, 181029-529, 11, 191151-300, 10, 19124000, 13, 19, 201328281, 2, 12, 14, 20, 21140403, 13, 15, 2115-29284, 5, 14, 16, 21, 2216-4006, 15, 17, 2217-29-297, 8, 16, 18, 22, 23180-409, 17, 19, 231928-2910, 11, 12, 18, 20, 232020012, 13, 19, 21, 22, 232102013, 14, 15, 20, 22, 2322-20015, 16, 17, 20, 21, 23230-2017, 18, 19, 20, 21, 22寻找完路径终点后，下一步是构建最短路径。这两个方法与路径的构建有关。下文中“希望经过的节点”指除已编号节点外的，末影龙想要到达的目标节点，比如末影龙攻击目标附近的一个位置等。注意末影龙寻路至任何未编号的节点时，其所得路径上倒数第二个节点一定是已编号节点。@Nullable public Path findPath(int startId, int endId, @Nullable Node expectedToPass) { // 初始化节点的f（综合优先级）, g（距离起点的代价）, h（距离终点的预计代价）值等 for (int i = 0; i < 24; ++i) { Node node = nodes[i]; node.closed = false; node.f = 0; node.g = 0; node.h = 0; node.cameFrom = null; node.heapIdx = -1; } Node start = nodes[startId]; Node end = nodes[endId]; start.g = 0; start.h = start.distanceTo(end); start.f = start.h; openSet.clear(); openSet.insert(start); // “当前”的路径终点，如果未成功到达想要到达的终点则会返回到这个节点的路径 Node currentEnd = start; int startCheckingId = 0; if (dragonFight == null || dragonFight.getCrystalsAlive() == 0) { startCheckingId = 12; } // 以下内容是用A*算法寻路的核心过程 while (!openSet.isEmpty()) { Node top = openSet.pop(); // 堆顶节点为终点，则返回路径 if (top.equals(end)) { if (expectedToPass != null) { // 如果末影龙有“希望经过的节点”，则把终点调整为这个“希望经过的节点” expectedToPass.cameFrom = end; end = expectedToPass; } return reconstructPath(start, end); } if (top.distanceTo(end) < currentEnd.distanceTo(end)) { currentEnd = top; } top.closed = true; int topId = 0; for (int i = 0; i < 24; ++i) { if (nodes[i] == top) { topId = i; break; } } for (int i = startCheckingId; i < 24; ++i) { if ((nodeAdjacency[topId] & 1 << i) > 0) { Node node = nodes[i]; if (!node.closed) { // 如果遍历到的节点可以直接到达，且不在closeSet中，则更新节点的f值 float g = top.g + top.distanceTo(node); if (!node.inOpenSet() || g < node.g) { node.cameFrom = top; node.g = g; node.h = node.distanceTo(end); if (node.inOpenSet()) { openSet.changeCost(node, node.g + node.h); } else { node.f = node.g + node.h; openSet.insert(node); } } } } } } if (currentEnd == start) { return null; } else { LOGGER.debug("Failed to find path from {} to {}", startId, endId); if (expectedToPass != null) { expectedToPass.cameFrom = currentEnd; currentEnd = expectedToPass; } return reconstructPath(start, currentEnd); } } private Path reconstructPath(Node start, Node end) { List<Node> pathNodes = Lists.newArrayList(); Node node = end; pathNodes.add(0, end); while (node.cameFrom != null) { node = node.cameFrom; pathNodes.add(0, node); } return new Path(pathNodes, new BlockPos(end.x, end.y, end.z), true);这样我们就获得了一条具体的路径，接下来末影龙要做的就是沿着这条路径飞行至终点即可。飞行的具体过程与寻路系统无关，此处不做讲述。下面举个例子来形象地说明整个寻路过程：末影龙现在位于点(5, 70, 42)，它想要飞到点(-23, 75, 4)附近，且它没有任何其他“希望经过的节点”。已知场上还有5个末地水晶未被破坏，假设场上所有已编号节点的y坐标均为73。第一步是选择寻找距离自身最近的已编号节点。此处因为末地水晶有剩余，所以也要检查外圈节点。遍历所有已编号节点可知14号节点(0, 73, 40)离它最近，然后寻找距离(-23, 75, 4)最近的已编号节点，可知这个节点为22号节点(-20, 73, 0)。接着进行路径的构建，首先根据邻接矩阵获取与14号节点相邻的已编号节点，结果为3、13、15、21号节点。计算得3号节点的f值约为20.0+63.2=83.2，13号节点的f值约为30.5+55.6=86.1，15号节点的f值约为31.4+29.4=60.8，21号节点的f值约为20.0+28.3=48.3，因此21号节点优先级最高。接下来取出21号节点，获取与21号节点相邻的已编号节点，计算得22号节点优先级最高。下一步发现22号节点已经是终点了，因此停止A*算法流程，返回路径14->21->22。最后按照14->21->22的顺序飞向终点，从而完成了一次寻路与沿路径飞行的过程。末影龙的AIMC中有两个主要的AI系统，一个是早就有的目标（Goal）系统，另一个是1.14后加入的Brain（大脑系统？感觉不太好听，就不翻译了233）系统。而末影龙使用的是这两个AI系统中的其中一个吗？不幸的是，末影龙拥有独属于自己的复杂AI系统，因此这给分析末影龙的行为带来了一定的挑战性。下文中笔者将末影龙使用的这套AI系统称为阶段（Phase）系统。阶段系统的运作方式阶段系统的的核心为EnderDragonPhaseManager类，末影龙当前处于的阶段的数据在该类中进行管理。public class EnderDragonPhaseManager { private static final Logger LOGGER = LogUtils.getLogger(); private final EnderDragon dragon; private final DragonPhaseInstance[] phases = new DragonPhaseInstance[EnderDragonPhase.getCount()]; @Nullable private DragonPhaseInstance currentPhase; public EnderDragonPhaseManager(EnderDragon dragon) { this.dragon = dragon; setPhase(EnderDragonPhase.HOVERING); } public void setPhase(EnderDragonPhase<?> phase) { if (currentPhase == null || phase != currentPhase.getPhase()) { if (currentPhase != null) { currentPhase.end(); } currentPhase = getPhase(phase); if (!dragon.level().isClientSide) { dragon.getEntityData().set(EnderDragon.DATA_PHASE, phase.getId()); } LOGGER.debug("Dragon is now in phase {} on the {}", phase, dragon.level().isClientSide ? "client" : "server"); currentPhase.begin(); } } public DragonPhaseInstance getCurrentPhase() { return currentPhase; } // 根据传入的阶段返回该阶段的阶段实例（DragonPhaseInstance） public <T extends DragonPhaseInstance> T getPhase(EnderDragonPhase<T> phase) { int phaseId = phase.getId(); if (phases[phaseId] == null) { phases[phaseId] = phase.createInstance(this.dragon); } return (T) phases[phaseId]; } } 这个类中的setPhase方法则更是控制末影龙不同阶段之间的切换的关键，下面仔细看一下这个方法。public void setPhase(EnderDragonPhase<?> phase) { if (currentPhase == null || phase != currentPhase.getPhase()) { if (currentPhase != null) { // 结束当前阶段 currentPhase.end(); } // 开始下一阶段 currentPhase = getPhase(phase); if (!dragon.level().isClientSide) { dragon.getEntityData().set(EnderDragon.DATA_PHASE, phase.getId()); } LOGGER.debug("Dragon is now in phase {} on the {}", phase, dragon.level().isClientSide ? "client" : "server"); currentPhase.begin(); } }在这个方法中，先收尾了旧的阶段，然后切换为新的阶段，并执行了新的阶段开始时的一些行为。末影龙阶段每刻的更新则在aiStep方法中，由于aiStep方法很长，所以下面只展示与阶段的更新有关的代码。if (level().isClientSide) { phaseManager.getCurrentPhase().doClientTick(); } else { DragonPhaseInstance phase = phaseManager.getCurrentPhase(); phase.doServerTick(); // 这儿有个细节，因为每个阶段更新后可能会改变currentPhase的值，所以要检查现在 // currentPhase的新值，如果该变量的值改变了则需要更新新的阶段 if (phaseManager.getCurrentPhase() != phase) { phase = phaseManager.getCurrentPhase(); phase.doServerTick(); } }不同阶段的特点介绍说完了阶段系统的运作方式，那么末影龙具体有哪些阶段呢？其实各阶段的主要行为在Wiki中的末影龙页面上有简单的说明和定性的分析，以下内容中将给出更详细的阐述。下表中列出了所有阶段的名称和ID，马上将会对每个阶段的特点进行介绍。.tg {border-collapse:collapse;border-spacing:0;}.tg td{border-color:black;border-style:solid;border-width:1px; overflow:hidden;padding:10px 5px;word-break:normal;}.tg th{border-color:black;border-style:solid;border-width:1px; ;overflow:hidden;padding:10px 5px;word-break:normal;}.tg .tg-9wq8{border-color:inherit;text-align:center;vertical-align:middle}.tg .tg-nrix{text-align:center;vertical-align:middle} 阶段类型 阶段名 阶段ID 飞行类阶段 盘旋阶段（DragonHoldingPatternPhase） 0 扫射阶段（DragonStrafePlayerPhase） 1 准备降落阶段（DragonLandingApproachPhase） 2 降落/起飞类阶段 降落阶段（DragonLandingPhase） 3 起飞阶段（DragonTakeoffPhase） 4 栖息类阶段 吐息阶段（DragonSittingFlamingPhase） 5 面向玩家阶段（DragonSittingScanningPhase） 6 咆哮阶段（DragonSittingAttackingPhase） 7 其他类型阶段 俯冲阶段（DragonChargePlayerPhase） 8 死亡阶段（DragonDeathPhase） 9 悬停阶段（DragonHoverPhase） 10 盘旋阶段盘旋阶段是末影龙处于的总时长最长的一个阶段。在这一阶段中，末影龙的行为主要是环绕相邻的黑曜石柱飞行，并有概率切换到扫射阶段及准备降落阶段。以下是盘旋阶段末影龙的服务端每刻的更新部分的代码：@Override public void doServerTick() { double distanceToTargetSqr = targetLocation == null ? 0.0D : targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ()); if (distanceToTargetSqr < 100.0D || distanceToTargetSqr > 22500.0D || dragon.horizontalCollision || dragon.verticalCollision) { findNewTarget(); } }可以看出，当末影龙距离当前飞行目标（Wiki上相关内容上写的是“攻击目标”，笔者觉得称为“飞行目标”更合适）近于10格/远于150格，或是任意方向上撞到方块或世界边界时会尝试选择新的飞行目标。而findNewTarget内容又是怎样的呢？该方法大致可以分为“阶段的切换”和“下一飞行目标的确定”两部分。private void findNewTarget() { // 阶段的切换 if (currentPath != null && currentPath.isDone()) { BlockPos origin = dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, new BlockPos(EndPodiumFeature.getLocation(dragon.getFightOrigin()))); int crystalsAlive = dragon.getDragonFight() == null ? 0 : dragon.getDragonFight().getCrystalsAlive(); if (dragon.getRandom().nextInt(crystalsAlive + 3) == 0) { dragon.getPhaseManager().setPhase(EnderDragonPhase.LANDING_APPROACH); return; } Player targetPlayer = dragon.level().getNearestPlayer(NEW_TARGET_TARGETING, dragon, (double) origin.getX(), (double) origin.getY(), (double) origin.getZ()); double m; // 不知道怎样给这个变量重命名最好……暂且称它为m吧。m的值越小，末影龙扫射玩家的概率越大 if (targetPlayer != null) { m = origin.distToCenterSqr(targetPlayer.position()) / 512.0D; } else { m = 64.0D; } if (targetPlayer != null && (dragon.getRandom().nextInt((int) (m + 2.0D)) == 0 || dragon.getRandom().nextInt(crystalsAlive + 2) == 0)) { strafePlayer(targetPlayer); return; } } // 下一飞行目标的确定 if (currentPath == null || currentPath.isDone()) { int closestNodeId = dragon.findClosestNode(); int endNodeId = closestNodeId; if (dragon.getRandom().nextInt(8) == 0) { clockwise = !clockwise; endNodeId = closestNodeId + 6; } if (clockwise) { ++endNodeId; } else { --endNodeId; } if (dragon.getDragonFight() != null && dragon.getDragonFight().getCrystalsAlive() >= 0) { endNodeId %= 12; if (endNodeId < 0) { endNodeId += 12; } } else { endNodeId -= 12; endNodeId &= 7; endNodeId += 12; } currentPath = dragon.findPath(closestNodeId, endNodeId, (Node) null); if (currentPath != null) { currentPath.advance(); } } navigateToNextPathNode(); } private void strafePlayer(Player player) { dragon.getPhaseManager().setPhase(EnderDragonPhase.STRAFE_PLAYER); dragon.getPhaseManager().getPhase(EnderDragonPhase.STRAFE_PLAYER).setTarget(player); } private void navigateToNextPathNode() { if (currentPath != null && !currentPath.isDone()) { Vec3i nextNodePos = currentPath.getNextNodePos(); currentPath.advance(); double targetX = nextNodePos.getX(); double targetZ = nextNodePos.getZ(); double targetY; do { targetY = (double) ((float) nextNodePos.getY() + dragon.getRandom().nextFloat() * 20.0F); } while (targetY < (double) nextNodePos.getY()); targetLocation = new Vec3(targetX, targetY, targetZ); } }由此可知，末影龙在寻找新的飞行目标时，如果当前飞行路径非空且末影龙已经飞完了整条路径，令$c$为剩余末地水晶的数量，则首先有$\frac{1}{c+3}$的概率准备降落在返回传送门上。如果末影龙未准备降落，则有一定的概率进入扫射阶段，攻击玩家。这个概率的计算较为复杂，令$d$为离x=0，z=0位置最高的固体方块正上方的方块最近的玩家到该方块的坐标的距离（如果玩家不存在则d取$128\sqrt{2}$，顺带提一下上述代码中的m值等于$\frac{d^2}{512}$），则该概率为（式子未化简）：$$ 1-\left(1-\frac{1}{\lfloor\frac{d^2}{512}+2\rfloor}\right)\left(1-\frac{1}{c+2}\right) $$如果末影龙的阶段没有被切换，且当前飞行路径为空/已经飞完了整条路径，那么末影龙将会寻找并飞向另一个已编号路径节点。这个节点的编号是怎样被计算出来的呢？首先末影龙在末地水晶有剩余的时候只围绕外圈节点盘旋，否则只围绕中圈节点盘旋。同时末影龙有$\frac{1}{8}$的概率“横穿”【稍微解释一下何为“横穿”：如果末影龙在外圈盘旋，则下一个路径节点变为沿顺/逆时针方向旋转的第6个节点，如果末影龙在中圈盘旋，则下一个路径节点变为沿逆时针方向旋转的第2个节点。例如如果末影龙在外圈盘旋且此时在8号节点，则下一个路径节点为2号节点（正好与8号节点关于原点对称），而如果末影龙在中圈盘旋且此时在16号节点，则下一个路径节点为14号节点】末地主岛并进行反向盘旋（原本顺时针盘旋变为逆时针盘旋，反之亦然），剩下$\frac{7}{8}$的概率这一步不进行任何操作。再根据末影龙现在的盘旋方向，进一步沿这个方向把上一步确定的路径节点调整为沿这个方向旋转的下一个节点。这样我们就得到了将要飞向的下一个已编号节点的位置。确定好下一个已编号节点后，具体飞向的坐标点则在navigateToNextPathNode方法中被确定。将这个已编号节点的坐标记为$\left(x_0,y_0,z_0\right)$，则这个坐标点的x、z坐标分别等于$x_0$，$z_0$，而y坐标在区间$[y_0, y_0+20)$中。该类还重写了onCrystalDestroyed方法，使得末影龙在盘旋状态下如果一个可被攻击的玩家破坏了末地水晶，则一定会进入扫射状态，开始攻击这个玩家。@Override public void onCrystalDestroyed(EndCrystal crystal, BlockPos pos, DamageSource source, @Nullable Player player) { if (player != null && dragon.canAttack(player)) { strafePlayer(player); } }此外，该类重写的begin方法中初始化了targetLocation和currentPath，确保了末影龙的行为正常。其余阶段重写的begin方法中亦有此类初始化操作。多测不清空，爆零两行泪扫射阶段扫射阶段每刻的更新内容较多，且不同的部分间基本相互独立，因此我们把doServerTick方法拆开来分析。先看最外层的条件判断语句。@Override public void doServerTick() { if (attackTarget == null) { LOGGER.warn("Skipping player strafe phase because no player was found"); dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN); } else { // ... } }可见如果attackTarget为null，这代表着没有可攻击的玩家，就把末影龙的阶段切换到盘旋阶段，否则执行扫射的逻辑。然后寻找下一个飞行目标。if (currentPath != null && currentPath.isDone()) { double targetX = attackTarget.getX(); double targetZ = attackTarget.getZ(); // 这里dx、dz分别指攻击目标的x、z值与末影龙位置的x、z值的差值 double dx = targetX - dragon.getX(); double dz = targetZ - dragon.getZ(); double horizontalDistance = Math.sqrt(dx * dx + dz * dz); double yOffset = Math.min((double) 0.4F + horizontalDistance / 80.0D - 1.0D, 10.0D); // 将飞行目标设置在攻击目标正上方的某一点处 targetLocation = new Vec3(targetX, attackTarget.getY() + yOffset, targetZ); } double distanceToTargetSqr = targetLocation == null ? 0.0D : targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ()); if (distanceToTargetSqr < 100.0D || distanceToTargetSqr > 22500.0D) { // 这里findNewTarget方法的具体作用与DragonHoldingPatternPhase类的同名方法基本一致， // 但只有“下一飞行目标的确定”部分，而没有“阶段的切换”，所以不再展开 findNewTarget(); }由此可知末影龙在扫射阶段下会尝试先根据攻击目标确定飞行目标，如果失败了再使用DragonHoldingPatternPhase中的逻辑，环绕路径点飞行。 第三步是生成并发射火球。if (attackTarget.distanceToSqr(dragon) < 4096.0D) { if (dragon.hasLineOfSight(attackTarget)) { ++fireballCharge; Vec3 horizontalUnitVectorToTarget = (new Vec3(attackTarget.getX() - dragon.getX(), 0.0D, attackTarget.getZ() - dragon.getZ())).normalize(); Vec3 horizontalViewUnitVector = (new Vec3(Mth.sin(dragon.getYRot() * ((float) Math.PI / 180F)), 0.0D, -Mth.cos(dragon.getYRot() * ((float) Math.PI / 180F)))).normalize(); float dotProduct = (float) horizontalViewUnitVector.dot(horizontalUnitVectorToTarget); float includedAngle = (float) (Math.acos(dotProduct) * (double) (180F / (float) Math.PI)); includedAngle += 0.5F; if (fireballCharge >= 5 && includedAngle >= 0.0F && includedAngle < 10.0F) { Vec3 viewVector = dragon.getViewVector(1.0F); double x = dragon.head.getX() - viewVector.x; double y = dragon.head.getY(0.5D) + 0.5D; double z = dragon.head.getZ() - viewVector.z; double dx = attackTarget.getX() - x; double dy = attackTarget.getY(0.5D) - y; double dz = attackTarget.getZ() - z; if (!dragon.isSilent()) { // 播放发射末影龙火球的声音 dragon.level().levelEvent(null, 1017, dragon.blockPosition(), 0); } DragonFireball dragonFireball = new DragonFireball(dragon.level(), dragon, dx, dy, dz); dragonFireball.moveTo(x, y, z, 0.0F, 0.0F); dragon.level().addFreshEntity(dragonFireball); fireballCharge = 0; // （强制）完成当前路径 if (currentPath != null) { while (!currentPath.isDone()) { currentPath.advance(); } } dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN); } } else if (fireballCharge > 0) { --fireballCharge; } } else if (fireballCharge > 0) { --fireballCharge; }末影龙火球有一定的“装填时间”，并且要“装填”5个游戏刻（0.25s）才能发射，期间攻击目标与末影龙的距离不近于64或者末影龙看不见攻击目标（即末影龙与攻击目标间有障碍物）时这个“装填时间”会自减。如果已经“装填完成”，则会检查horizontalUnitVectorToTarget与horizontalViewUnitVector的夹角，也就是水平方向上方向由末影龙指向目标的单位向量与方向为末影龙看向的方向的单位向量的夹角。如果这个夹角小于9.5°，那么末影龙会发动攻击，将“装填时间”重置为0并切换到盘旋阶段。还有个看似简单实则暗藏玄机的setTarget方法值得关注。public void setTarget(LivingEntity attackTarget) { this.attackTarget = attackTarget; int startNodeId = dragon.findClosestNode(); int endNodeId = dragon.findClosestNode(attackTarget.getX(), attackTarget.getY(), attackTarget.getZ()); int targetX = attackTarget.getBlockX(); int targetZ = attackTarget.getBlockZ(); double dx = (double) targetX - dragon.getX(); double dz = (double) targetZ - dragon.getZ(); double horizontalDistance = Math.sqrt(dx * dx + dz * dz); double yOffset = Math.min((double) 0.4F + horizontalDistance / 80.0D - 1.0D, 10.0D); int targetY = Mth.floor(attackTarget.getY() + yOffset); Node targetNode = new Node(targetX, targetY, targetZ); currentPath = dragon.findPath(startNodeId, endNodeId, targetNode); // 这里将精确的飞行目标作为了“希望经过的节点” if (currentPath != null) { currentPath.advance(); // 这里的navigateToNextPathNode方法与DragonHoldingPatternPhase类的同名方法功能完全相同 // 下文中出现的任何navigateToNextPathNode方法若不做额外说明，则同理 navigateToNextPathNode(); } }这个方法中，不仅修改了attackTarget的值，还为末影龙设置了飞行路径，使它能够先飞到离攻击目标最近的已编号节点处，再飞到攻击目标正上方。准备降落阶段准备降落阶段整体不复杂，这一阶段每刻的更新逻辑与盘旋阶段是完全一样的，但由于begin方法中会初始化targetLocation，所以刚刚进入该阶段时必定会寻找新的飞行目标。这一阶段中飞行目标的选择是这样的：// 这个目标选择器是忽视视线的，并且只会选择可攻击的玩家 private static final TargetingConditions NEAR_EGG_TARGETING = TargetingConditions.forCombat().ignoreLineOfSight(); private void findNewTarget() { if (currentPath == null || currentPath.isDone()) { int startNodeId = dragon.findClosestNode(); BlockPos origin = dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, EndPodiumFeature.getLocation(dragon.getFightOrigin())); Player player = dragon.level().getNearestPlayer(NEAR_EGG_TARGETING, dragon, (double) origin.getX(), (double) origin.getY(), (double) origin.getZ()); int endNodeId; if (player != null) { Vec3 unitHorizontalVectorFromZeroToPlayer = (new Vec3(player.getX(), 0.0D, player.getZ())).normalize(); endNodeId = dragon.findClosestNode(-unitHorizontalVectorFromZeroToPlayer.x * 40.0D, 105.0D, -unitHorizontalVectorFromZeroToPlayer.z * 40.0D); } else { endNodeId = dragon.findClosestNode(40.0D, (double) origin.getY(), 0.0D); // 即12号节点 } Node node = new Node(origin.getX(), origin.getY(), origin.getZ()); currentPath = dragon.findPath(startNodeId, endNodeId, node); if (currentPath != null) { currentPath.advance(); } } navigateToNextPathNode(); // 在飞行完成当前路径后，进入（正式的）降落阶段 if (currentPath != null && currentPath.isDone()) { dragon.getPhaseManager().setPhase(EnderDragonPhase.LANDING); } }令选择的玩家的坐标为$\left(x_0,y_0,z_0\right)$，则末影龙将会选择距离$\left(-\frac{40x_0}{\sqrt{x_0^2+z_0^2}},105,-\frac{40z_0}{\sqrt{x_0^2+z_0^2}}\right)$最近的已编号节点（也就往往是选择水平面上距离原点与玩家坐标连线的反向延长线与中圈的交点最近的已编号节点，若玩家不存在则默认选择12号节点）作为飞行目标。构造该坐标的方式的特殊性导致了这一步选择的飞行目标节点一定位于中圈，而这也是为了下一阶段的降落做准备。降落阶段在降落状态下，不但服务端每刻会更新，而且客户端每刻也有更新的内容。先看客户端每刻更新的部分：@Override public void doClientTick() { Vec3 lookVector = dragon.getHeadLookVector(1.0F).normalize(); lookVector.yRot(-(float) Math.PI / 4F); // 绕y轴逆时针旋转45度 double headX = dragon.head.getX(); double headY = dragon.head.getY(0.5D); double headZ = dragon.head.getZ(); for (int i = 0; i < 8; ++i) { RandomSource random = dragon.getRandom(); double x = headX + random.nextGaussian() / 2.0D; double y = headY + random.nextGaussian() / 2.0D; double z = headZ + random.nextGaussian() / 2.0D; Vec3 movement = dragon.getDeltaMovement(); dragon.level().addParticle(ParticleTypes.DRAGON_BREATH, x, y, z, -lookVector.x * (double) 0.08F + movement.x, -lookVector.y * (double) 0.3F + movement.y, -lookVector.z * (double) 0.08F + movement.z); lookVector.yRot(0.19634955F); // 注：0.19634955F = Math.PI / 16F } }这部分内容生成了龙息的粒子效果，但也仅仅生成了粒子效果，不能对玩家造成实际的伤害。再看服务端每刻更新的部分：@Override public void doServerTick() { if (targetLocation == null) { targetLocation = Vec3.atBottomCenterOf(dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, EndPodiumFeature.getLocation(dragon.getFightOrigin()))); } if (targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ()) < 1.0D) { // 将吐息阶段中的flameCount变量重置为0 dragon.getPhaseManager().getPhase(EnderDragonPhase.SITTING_FLAMING).resetFlameCount(); dragon.getPhaseManager().setPhase(EnderDragonPhase.SITTING_SCANNING); } }这个部分将末影龙的飞行目标调整为了返回传送门正上方，并且当末影龙距离飞行目标足够近时切换到面向玩家阶段。有个细节需要注意，这一阶段下末影龙的飞行速度提升了150%，且转向速度也大大提高。@Override public float getFlySpeed() { return 1.5F; // 父类中返回0.6F } @Override public float getTurnSpeed() { float movementLen = (float) dragon.getDeltaMovement().horizontalDistance() + 1.0F; float movementLen1 = Math.min(movementLen, 40.0F); // 父类中返回0.7F / movementLen1 / movementLen，而实际情况下movementLen远远小于40，所以 // movementLen1 / movementLen的值几乎总是1.0F return movementLen1 / movementLen; }起飞阶段注意到起飞阶段引入了一个布尔值firstTick，用来区分这个阶段是不是首次被更新。由于起飞阶段只需要寻找一次飞行目标，所以用这个变量来标记是否寻找过飞行目标。同时如果末影龙成功起飞了（与返回传送门正上方的距离不小于10），则进入盘旋阶段。private boolean firstTick; public void doServerTick() { if (!firstTick && currentPath != null) { BlockPos origin = dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, EndPodiumFeature.getLocation(dragon.getFightOrigin())); if (!origin.closerToCenterThan(dragon.position(), 10.0D)) { dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN); } } else { firstTick = false; findNewTarget(); } } public void begin() { firstTick = true; currentPath = null; targetLocation = null; }而这个飞行目标的寻找方式有一点特别：private void findNewTarget() { int startNodeId = dragon.findClosestNode(); Vec3 headLookVector = dragon.getHeadLookVector(1.0F); // getHeadLookVector返回值在误差允许范围内可以看作单位向量 int endNodeId = dragon.findClosestNode(-headLookVector.x * 40.0D, 105.0D, -headLookVector.z * 40.0D); // 下面这一部分则与盘旋阶段是相近的（末影龙在末地水晶有剩余的时候只围绕外圈节点盘旋，否则只围绕中圈节点盘旋） if (dragon.getDragonFight() != null && dragon.getDragonFight().getCrystalsAlive() > 0) { endNodeId %= 12; if (endNodeId < 0) { endNodeId += 12; } } else { endNodeId -= 12; endNodeId &= 7; endNodeId += 12; } currentPath = dragon.findPath(startNodeId, endNodeId, null); navigateToNextPathNode(); }在第一步中，选择了水平面上距离过原点，且以离末影龙看向的方向向量的相反向量为自身方向向量的射线与中圈的交点最近的已编号节点作为飞行目标。然后和盘旋阶段一样，通过取模操作变换飞行目标到正确的一圈。栖息类阶段概述栖息类阶段指继承了AbstractDragonSittingPhase类的3个阶段。在栖息类阶段下，末影龙会停在返回传送门上方，免疫所有来自继承了AbstractArrow的实体（也就是各种箭及掷出的三叉戟等实体）的直接伤害并点燃该实体1s。@Override public float onHurt(DamageSource source, float amount) { if (source.getDirectEntity() instanceof AbstractArrow) { source.getDirectEntity().setSecondsOnFire(1); return 0.0F; } else { return super.onHurt(source, amount); } }在栖息类阶段及悬停阶段下，末影龙有这样一些特殊的行为：末影龙不会随机咆哮末影龙扇动翅膀的频率会提高末影龙不会伤害撞上它翅膀的实体当末影龙生命值归零时，末影龙会直接死亡而不是进入（死亡前飞向返回传送门的）死亡阶段末影龙在这类阶段累计受到相当于最大生命值的0.25倍（Wiki上写的是50，但当末影龙的最大生命值不是200时，50这个数值是错误的）的伤害时，末影龙会进入起飞阶段并清零累计伤害（注意在悬停阶段下末影龙也有此行为，因此可以通过对用/summon指令召唤出的末影龙造成足量的伤害来使它获得“正常末影龙的行为”）接下来调一下讲解顺序，不按阶段的ID顺序讲这3个栖息类阶段，而按阶段执行的先后顺序讲，这样理解起来可能更容易一些。面向玩家阶段面向玩家阶段的重点就是服务端每刻的更新了，该阶段主要起到“承上启下”的作用，承接了降落阶段的着陆，调整了末影龙的旋转角度，并为接下来末影龙的攻击做准备。// 俯冲阶段的目标选择器 private static final TargetingConditions CHARGE_TARGETING = TargetingConditions.forCombat().range(150.0D); public DragonSittingScanningPhase(EnderDragon dragon) { super(dragon); // 咆哮阶段的目标选择器 this.scanTargeting = TargetingConditions.forCombat().range(20.0D).selector(entity -> { return Math.abs(entity .getY() - dragon.getY()) <= 10.0D; }); } @Override public void doServerTick() { ++scanningTime; LivingEntity nearestPlayer = dragon.level().getNearestPlayer(scanTargeting, dragon, dragon.getX(), dragon.getY(), dragon.getZ()); if (nearestPlayer != null) { if (scanningTime > 25) { dragon.getPhaseManager().setPhase(EnderDragonPhase.SITTING_ATTACKING); } else { Vec3 horizontalUnitVectorToPlayer = new Vec3(nearestPlayer.getX() - dragon.getX(), 0.0D, nearestPlayer.getZ() - dragon.getZ()).normalize(); Vec3 horizontalViewUnitVector = new Vec3(Mth.sin(dragon.getYRot() * ((float) Math.PI / 180F)), 0.0D, -Mth.cos(dragon.getYRot() * ((float) Math.PI / 180F))).normalize(); float dotProduct = (float) horizontalViewUnitVector.dot(horizontalUnitVectorToPlayer); float includedAngle = (float) (Math.acos(dotProduct) * (double) (180F / (float) Math.PI)) + 0.5F; // 夹角过大时进行调整 if (includedAngle < 0.0F || includedAngle > 10.0F) { double dx = nearestPlayer.getX() - dragon.head.getX(); double dz = nearestPlayer.getZ() - dragon.head.getZ(); double dyRotA = Mth.clamp(Mth.wrapDegrees(180.0D - Mth.atan2(dx, dz) * (double) (180F / (float)Math.PI) - dragon.getYRot()), -100.0D, 100.0D); // “A”应该可以理解为acceleration dragon.yRotA *= 0.8F; float length = (float) Math.sqrt(dx * dx + dz * dz) + 1.0F; float length1 = length; if (length > 40.0F) { length = 40.0F; } dragon.yRotA += (float) dyRotA * (0.7F / length / length1); dragon.setYRot(dragon.getYRot() + dragon.yRotA); } } } else if (scanningTime >= 100) { nearestPlayer = dragon.level().getNearestPlayer(CHARGE_TARGETING, dragon, dragon.getX(), dragon.getY(), dragon.getZ()); dragon.getPhaseManager().setPhase(EnderDragonPhase.TAKEOFF); if (nearestPlayer != null) { dragon.getPhaseManager().setPhase(EnderDragonPhase.CHARGING_PLAYER); dragon.getPhaseManager().getPhase(EnderDragonPhase.CHARGING_PLAYER).setTarget(new Vec3(nearestPlayer.getX(), nearestPlayer.getY(), nearestPlayer.getZ())); } } }这一阶段基本上是对末影龙绕y轴旋转角度的调整，如果在20格的范围内发现了与自身垂直距离不大于10的可被攻击的玩家，且该阶段持续了超过1.25s，则会进入咆哮阶段。如果第一个要求未满足，但5s后在150格的范围内发现了可被攻击的玩家，且玩家与末影龙的距离大于20或垂直距离大于10，则会进入俯冲阶段。如果仍未找到玩家，则会进入起飞阶段。咆哮阶段咆哮阶段非常简单，需要关注的只有双端的更新。在客户端播放了末影龙咆哮的声音，而在服务端实现了对吐息阶段的调控。@Override public void doClientTick() { dragon.level().playLocalSound(dragon.getX(), dragon.getY(), dragon.getZ(), SoundEvents.ENDER_DRAGON_GROWL, dragon.getSoundSource(), 2.5F, 0.8F + dragon.getRandom().nextFloat() * 0.3F, false); } @Override public void doServerTick() { // 等待2s后进入吐息阶段 if (attackingTicks++ >= 40) { dragon.getPhaseManager().setPhase(EnderDragonPhase.SITTING_FLAMING); } }吐息阶段吐息阶段相对复杂一些，重要的方法也较多，我们来分解后研究。先看客户端的更新。@Override public void doClientTick() { ++flameTicks; if (flameTicks % 2 == 0 && flameTicks < 10) { Vec3 headLookVector = dragon.getHeadLookVector(1.0F).normalize(); headLookVector.yRot(-(float) Math.PI / 4F); double headX = dragon.head.getX(); double headY = dragon.head.getY(0.5D); double headZ = dragon.head.getZ(); for (int i = 0; i < 8; ++i) { double x = headX + dragon.getRandom().nextGaussian() / 2.0D; double y = headY + dragon.getRandom().nextGaussian() / 2.0D; double z = headZ + dragon.getRandom().nextGaussian() / 2.0D; for (int j = 0; j < 6; ++j) { dragon.level().addParticle(ParticleTypes.DRAGON_BREATH, x, y, z, -headLookVector.x * (double) 0.08F * (double) j, -headLookVector.y * (double) 0.6F, -headLookVector.z * (double) 0.08F * (double) j); } headLookVector.yRot(0.19634955F); // 注：0.19634955F = Math.PI / 16F } } }这个方法实现了在末影龙的头部的位置喷出龙息粒子效果，这里给龙息粒子指定了运动的方向向量，使龙息粒子可以沿着一条射线运动，看上去好像是从末影龙嘴里高速喷出的一样。再看服务端的更新。@Override public void doServerTick() { ++flameTicks; if (flameTicks >= 200) { if (flameCount >= 4) { dragon.getPhaseManager().setPhase(EnderDragonPhase.TAKEOFF); } else { dragon.getPhaseManager().setPhase(EnderDragonPhase.SITTING_SCANNING); } } else if (flameTicks == 10) { Vec3 unitVectorToHead = new Vec3(dragon.head.getX() - dragon.getX(), 0.0D, dragon.head.getZ() - dragon.getZ()).normalize(); double x = dragon.head.getX() + unitVectorToHead.x * 5.0D / 2.0D; double z = dragon.head.getZ() + unitVectorToHead.z * 5.0D / 2.0D; double headY = dragon.head.getY(0.5D); double y = headY; BlockPos.MutableBlockPos mutablePos = new BlockPos.MutableBlockPos(x, headY, z); while (dragon.level().isEmptyBlock(mutablePos)) { --y; if (y < 0.0D) { y = headY; break; } mutablePos.set(x, y, z); } y = Mth.floor(y) + 1; flame = new AreaEffectCloud(dragon.level(), x, y, z); flame.setOwner(dragon); flame.setRadius(5.0F); flame.setDuration(200); flame.setParticle(ParticleTypes.DRAGON_BREATH); flame.addEffect(new MobEffectInstance(MobEffects.HARM)); dragon.level().addFreshEntity(flame); } }这个方法则实现了龙息“药水云”的生成，让龙息“药水云”生成在向头部方向继续额外延伸一段距离（2.5格）的位置。吐息阶段还同时重写了begin和end方法，实现了喷出的龙息“药水云”的累计数量的统计，并且让龙息“药水云”在本阶段结束时自动消失。@Override public void begin() { flameTicks = 0; ++flameCount; } @Override public void end() { if (flame != null) { flame.discard(); flame = null; } }俯冲阶段俯冲阶段下末影龙的飞行速度非常快，达到了盘旋阶段的5倍，降落阶段的2倍。但俯冲阶段的重要方法不多，以下是服务端的更新部分。@Override public void doServerTick() { if (targetLocation == null) { LOGGER.warn("Aborting charge player as no target was set."); dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN); } else if (timeSinceCharge > 0 && timeSinceCharge++ >= 10) { dragon.getPhaseManager().setPhase(EnderDragonPhase.HOLDING_PATTERN); } else { double distanceToTargetSqr = targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ()); if (distanceToTargetSqr < 100.0D || distanceToTargetSqr > 22500.0D || dragon.horizontalCollision || dragon.verticalCollision) { ++timeSinceCharge; } } }俯冲阶段维护了一个变量timeSinceCharge，当这个变量的值大于10，即“激活”俯冲阶段超过0.5s时，会进入盘旋阶段。此处的“激活”指末影龙与俯冲目标的距离小于10/大于150或末影龙任意方向上撞到方块或世界边界。俯冲阶段的其余方法不是特别重要，也较容易理解，此处省略不讲。死亡阶段易错点：死亡阶段指末影龙濒死时“飞向返回传送门”的过程，而非末影龙“在返回传送门上缓慢升起”的死亡动画在死亡阶段下，末影龙每0.5s在四周生成大量爆炸粒子效果，这一点通过重写doClientTick方法实现。@Override public void doClientTick() { if (time++ % 10 == 0) { float xOffset = (dragon.getRandom().nextFloat() - 0.5F) * 8.0F; float yOffset = (dragon.getRandom().nextFloat() - 0.5F) * 4.0F; float zOffset = (dragon.getRandom().nextFloat() - 0.5F) * 8.0F; dragon.level().addParticle(ParticleTypes.EXPLOSION_EMITTER, dragon.getX() + (double) xOffset, dragon.getY() + 2.0D + (double) yOffset, dragon.getZ() + (double) zOffset, 0.0D, 0.0D, 0.0D); } }死亡阶段下，末影龙会飞到返回传送门上方再开始死亡过程，这个过程在服务端的更新中完成。public void doServerTick() { ++time; if (targetLocation == null) { BlockPos origin = dragon.level().getHeightmapPos(Heightmap.Types.MOTION_BLOCKING, EndPodiumFeature.getLocation(dragon.getFightOrigin())); targetLocation = Vec3.atBottomCenterOf(origin); } double distanceToTargetSqr = targetLocation.distanceToSqr(dragon.getX(), dragon.getY(), dragon.getZ()); if (!(distanceToTargetSqr < 100.0D) && !(distanceToTargetSqr > 22500.0D) && !dragon.horizontalCollision && !dragon.verticalCollision) { dragon.setHealth(1.0F); } else { dragon.setHealth(0.0F); } }由此还可以看出，如果末影龙距离返回传送门超过150格，或者撞到了东西，则会跳过飞到返回传送门上方的过程而直接死亡。悬停阶段民间有一种观点认为用/summon指令召唤出来的末影龙是“没有AI”的，其实这是错误的观点。这样召唤出的末影龙仍然是有AI的，只不过它的阶段被设置为了悬停阶段而已，而悬停阶段的末影龙只会把它的当前坐标作为飞行目标。@Override public void doServerTick() { if (targetLocation == null) { targetLocation = dragon.position(); } } 前文中提到可以通过对用/summon指令召唤出的末影龙造成足量的伤害来使它获得“正常末影龙的行为”，这是悬停阶段中重写了isSitting方法所致。@Override public boolean isSitting() { return true; }另外，悬停状态下末影龙的飞行速度为盘旋状态下的$\frac{5}{3}$。全阶段总结下图说明了各个阶段之间的联系，可供参考。其中箭头代表由一个阶段可以转到另一个阶段，不同颜色则代表了不同类型的阶段。注：3个栖息类阶段以及悬停阶段不能直接转到死亡阶段末影龙的其他重要行为末影龙战斗末影龙战斗（EndDragonFight）是一个比较重要且复杂的类，细细分析很需要时间与精力，而且本文以讲末影龙自身的行为为主，所以这一块笔者只稍稍提一下。末影龙战斗实现了对末地水晶数量、末影龙Boss栏的更新，以及返回传送门与末地折跃门的构建，并处理了末影龙的生成和复活。在与末影龙的战斗过程中起到“背后支撑”的作用。末影龙与末地水晶的交互末影龙通过末地水晶来治疗自身，如果末影龙正被一末地水晶治疗，则每0.5秒会回复1的生命值。同时末影龙每刻有0.1的概率检查并更新离自己最近的末地水晶。这一逻辑的实现位于checkCrystals方法中。private void checkCrystals() { // nearesrCrystal也是与末影龙通过白色光柱相连的末地水晶 if (nearestCrystal != null) { if (nearestCrystal.isRemoved()) { nearestCrystal = null; } else if (tickCount % 10 == 0 && getHealth() < getMaxHealth()) { setHealth(getHealth() + 1.0F); } } if (random.nextInt(10) == 0) { List<EndCrystal> nearbyCrystals = level().getEntitiesOfClass(EndCrystal.class, getBoundingBox().inflate(32.0D)); EndCrystal newNearestCrystal = null; double minDistanceSqr = Double.MAX_VALUE; for (EndCrystal nearbyCrystal : nearbyCrystals) { double distanceToCrystalSqr = nearbyCrystal.distanceToSqr(this); if (distanceToCrystalSqr < minDistanceSqr) { minDistanceSqr = distanceToCrystalSqr; newNearestCrystal = nearbyCrystal; } } // 注意newNearestCrystal可能是null，这意味着检查并更新离自己最近的末地水晶时 // 有可能将nearestCrystal赋值为null nearestCrystal = newNearestCrystal; } }末影龙在onCrystalDestroyed方法中实现了当正在治疗末影龙的末地水晶被摧毁时，使末影龙受到10来自某位玩家的爆炸伤害。这个玩家是怎样被确定的呢？private static final TargetingConditions CRYSTAL_DESTROY_TARGETING = TargetingConditions.forCombat().range(64.0D); // 这个方法会在末地水晶被破坏时被调用 public void onCrystalDestroyed(EndCrystal crystal, BlockPos pos, DamageSource source) { Player player; if (source.getEntity() instanceof Player) { player = (Player) source.getEntity(); } else { player = level().getNearestPlayer(CRYSTAL_DESTROY_TARGETING, (double) pos.getX(), (double) pos.getY(), (double) pos.getZ()); } if (crystal == nearestCrystal) { hurt(head, damageSources().explosion(crystal, player), 10.0F); } phaseManager.getCurrentPhase().onCrystalDestroyed(crystal, pos, source, player); }如果破坏末地水晶的伤害来源是一个玩家，则会选择这个玩家。而如果破坏末地水晶的伤害来源是其他东西，则会选择64格内离该末地水晶位置最近的可攻击玩家。末影龙的死亡易错点：这是指末影龙在返回传送门正上方缓慢升起的过程，而非前文中的“死亡阶段”末影龙死亡的逻辑在aiStep和tickDeath方法中均有涉及，主要则是在tickDeath方法中。aiStep（节选）：if (isDeadOrDying()) { float xOffset = (random.nextFloat() - 0.5F) * 8.0F; float yOffset = (random.nextFloat() - 0.5F) * 4.0F; float zOffset = (random.nextFloat() - 0.5F) * 8.0F; level().addParticle(ParticleTypes.EXPLOSION, getX() + (double) xOffset, getY() + 2.0D + (double) yOffset, getZ() + (double) zOffset, 0.0D, 0.0D, 0.0D); }tickDeath：@Override protected void tickDeath() { if (dragonFight != null) { dragonFight.updateDragon(this); } ++dragonDeathTime; // 死亡的最后1s生成更多的爆炸粒子效果 if (dragonDeathTime >= 180 && dragonDeathTime <= 200) { float xOffset = (random.nextFloat() - 0.5F) * 8.0F; float yOffset = (random.nextFloat() - 0.5F) * 4.0F; float zOffset = (random.nextFloat() - 0.5F) * 8.0F; level().addParticle(ParticleTypes.EXPLOSION_EMITTER, getX() + (double) xOffset, getY() + 2.0D + (double) yOffset, getZ() + (double) zOffset, 0.0D, 0.0D, 0.0D); } boolean loot = level().getGameRules().getBoolean(GameRules.RULE_DOMOBLOOT); int xpAmount = 500; if (dragonFight != null && !dragonFight.hasPreviouslyKilledDragon()) { xpAmount = 12000; } if (level() instanceof ServerLevel) { // 死亡7.5s后分批掉落经验 if (dragonDeathTime > 150 && dragonDeathTime % 5 == 0 && loot) { int award = ForgeEventFactory.getExperienceDrop(this, unlimitedLastHurtByPlayer, Mth.floor((float) xpAmount * 0.08F)); ExperienceOrb.award((ServerLevel) level(), position(), award); } if (dragonDeathTime == 1 && !isSilent()) { level().globalLevelEvent(1028, blockPosition(), 0); } } // 死亡过程中每刻向上移动0.1格 move(MoverType.SELF, new Vec3(0.0D, 0.1F, 0.0D)); // 当死亡过程结束时 if (dragonDeathTime == 200 && level() instanceof ServerLevel) { if (loot) { // 掉落完剩余的经验 int award = ForgeEventFactory.getExperienceDrop(this, unlimitedLastHurtByPlayer, Mth.floor((float) xpAmount * 0.2F)); ExperienceOrb.award((ServerLevel) level(), position(), award); } if (dragonFight != null) { dragonFight.setDragonKilled(this); } remove(Entity.RemovalReason.KILLED); gameEvent(GameEvent.ENTITY_DIE); } }末影龙在死亡过程中，每刻上升0.1格，并在四周不断生成爆炸粒子效果，且最后1s生成更多粒子。死亡7.5s后，末影龙每隔0.25s掉落8%的应该掉落的经验值总量，最后掉落20%应该掉落的经验，这里应该掉落的经验与是否是第一次击杀末影龙有关，如果是，则掉落总计12000的经验值，否则只掉落500经验值。当末影龙彻底死亡时，末影龙战斗会移除Boss栏，激活返回传送门，如果末地折跃门总数小于20则会生成新的末地折跃门。末影龙被/kill时的行为EnderDragon类重写了kill方法，直接移除末影龙而不对其造成Float.MAX_VALUE的伤害，保证了末影龙与末影龙战斗（EndDragonFight）的行为正常。public void kill() { remove(Entity.RemovalReason.KILLED); gameEvent(GameEvent.ENTITY_DIE); if (dragonFight != null) { dragonFight.updateDragon(this); dragonFight.setDragonKilled(this); } }后记末影龙的核心行为到此就基本上讲完啦~ 鉴于笔者水平有限，文章中难免有不足或错误之处。如果文章某处有问题，可以委婉地指出来，笔者会对相关内容进行检查的。末影龙有关的代码复杂，虽然Wiki上有一部分的内容，但这些内容不够具体，且有些写得不完全正确，因此写作本文也额外耗费了笔者不少精力。尽管如此，笔者希望本文能给大家带来帮助，如果本文对各位读者有一定的作用与价值，那笔者付出这些努力也是值得的。
面对AI图时，为什么我们可以“一眼AI”？——浅析AI图现有的不足之处
注：- 本文有较强时效性，请关注本文发表时间与更新时间- 本文的创作是为了指出而不是解决问题，因此不会细致分析问题出现的原因背景最近在研究AI绘画，以前使用过Stable Diffusion WebUI，现在在尝试ComfyUI。然而不论是Stable Diffusion v1.5还是较新的SDXL，所绘制的图片均存在一定的问题。举下面一张图为例（AI直出的图，没有经过后处理）：以及用于生成这张图的Workflow（注意使用的种子不是下图中的种子）：分析这张图乍看起来似乎没毛病，但是仔细观察却能看出不少问题。当然可能不是所有的问题都被归类在这里，而且部分问题在这张图里没有体现。 将出现的问题逐类编号如下：问题1——手部缺陷众所周知，AI画不好手，尽管绘制这张图采用的模型基于SDXL训练而来，降低了坏手的概率，但仍然避免不了手部出现问题。如果AI画的图中出现了手，那么手出问题的概率在95%左右。这样的问题的出现与手部姿势极其复杂有关，一时难以解决，也很难通过Photoshop等图像处理软件后处理来补救，只能期待技术的进步能解决这一问题。问题2——窗户歪斜这也是AI绘画出现的特有问题，相似的问题还有生成水印和（在书本、电脑屏幕上）生成不可读的文字等。凡是出现直线时，均有可能出现这样的问题，好在这个概率不算太高，并且相对前者更容易补救。问题3——不合逻辑仔细看裙子的网格，会发现阴影处有1根网格线，到了有光照的地方却突变为2根网格线，显然不符合逻辑。当遇到元素间的交叉处时，出现这种问题的概率更大且问题更明显。上个问题与这个问题的出现，可以反映出AI并不理解所画的内容。问题4——头发杂糅不只是画手，由于头发的复杂性，画头发也是AI面临的一个重要难题。几乎每一张图都有这个问题，只是这个问题相对比较隐蔽，没有那么明显，第一眼很难看出来，需要细看才能发现。问题5——随机噪点这种问题出现的概率或与模型有关，有些模型噪点出现得很多，有些却很少。不过通常它们在数量不大的情况下不怎么影响观感，也很容易通过Photoshop等软件去除。结语期末考试的时候做到一篇英语阅读理解，大致是讲述了元宇宙（Metaverse）看上去很有吸引力，然而亲身体验却发现效果没那么好。AI绘画或许也处于这种状态吧，表面光鲜亮丽，大有取代人类画师的可能，可实际上还存在着诸多问题，与人类画师差距依然较大。希望未来这些问题能得到解决，使AI绘画能更好地服务人类~
文章说明下文中(op)表示暂时使用的参数名[op]表示可选操作或参数op1/op2表示并列的必需操作或参数[op1]/[op2]表示并列的可选操作或参数指令格式指令名：/summoningex (op)（其中op的类型为area或summon）area提供了对召唤区域（Summoning Area）的操作格式：/summoningex area (op)（其中op的类型为define，list，query，remove，show或hide）define用于定义和命名召唤区域格式：/summoningex area define (x1), (y1), (z1), (x2), (y2), (x2) as #(name) [colored (colorop) (colorval) [in (dim)]]/[in (dim)]x1, y1, z1和x2, y2, z2定义了召唤区域的位置（以（x1, y1, z1）与（x2, y2, z2）两个方块坐标（BlockPos）为顶点）注意x1, y1, z1, x2, y2, z2为整数且x1, x2之间，y1, y2之间与z1, z2之间没有严格的大小限制（也就是说x1既可以大于x2，又可以等于或小于x2）x1与x2，y1与y2，z1与z2都相同时表示了一个但方块的召唤区域name定义了召唤区域的名称（指令中的"#"号不可省略且不能为中文）colorop（可以传入custom或preset）及colorval当color传入custom时，colorval应传入一个[0, 16777215]间的十进制整数，表示了召唤区域的RGB颜色值当color传入preset时，colorval应传入原版的一种染料颜色（DyeColor），该颜色表示了召唤区域的颜色（但这个颜色的饱和度最大值被限制在了88%）若不指定颜色则会随机为召唤区域分配一个颜色相关代码染料颜色的获取（getDyeColor(CommandContext, String)静态方法返回输入的染料颜色）：public static int getIntDyeColor(CommandContext<CommandSourceStack> context, String name) { int textColor = getDyeColor(context, name).getTextColor(); float[] hsbvals = new float[3]; Color.RGBtoHSB(textColor >> 16 & 0xFF, textColor >> 8 & 0xFF, textColor & 0xFF, hsbvals); // Restricts the saturation hsbvals[1] = Math.min(0.88f, hsbvals[1]); int color = Color.HSBtoRGB(hsbvals[0], hsbvals[1], hsbvals[2]); // Make sure the color value is positive color &= 0xffffff; return color; }颜色的随机分配private static int randomColor(RandomSource random) { // Randomly selects hue from 0 to 0.95 (step=0.05) float hue = 0.05f * random.nextInt(20); // Two types of saturation - low(0.5) and high(0.8) float saturation = random.nextBoolean() ? 0.5f : 0.8f; // Randomly selects hue from 0.5 to 1 (step=0.125) float brightness = 0.5f + 0.125f * random.nextInt(5); return Color.HSBtoRGB(hue, saturation, brightness); }dim定义了召唤区域所在的维度若不指定维度则会使用当前所在维度举例/summoningex area define 0 100 0 10 110 10 as #Example colored custom 16777215 in minecraft:overworld在主世界定义了一个名为Example，两个顶点分别为（0，100，0），（10，110，10）两个方块坐标，颜色为白色的召唤区域/summoningex area define 20 100 20 25 90 25 as #Example2在指令使用者所在维度定义了一个名为Example2，两个顶点分别为（20，100，20），（25，90，25），颜色随机的召唤区域list用于列出指定维度内所有的召唤区域格式：/summoningex area list [in (dim)]dim定义了被用来列出召唤区域的维度若不指定维度则会使用当前所在维度举例/summoningex area list in minecraft:the_nether列出了下界所有的召唤区域query用于在指定维度内查询指定名称的召唤区域格式：/summoningex area query local/global (op)（op将在下文中说明）local与global当使用local时，op为召唤区域名，用于在指令使用者所在维度内查询指定名称的的召唤区域当使用global时，op为维度+召唤区域名，用于在输入的维度内查询指定名称的的召唤区域注意使用global时不会在输入召唤区域名时提供建议（Suggestion）举例/summoningex area query local #Example在指令使用者所在维度查询了名为Example的召唤区域/summoningex area query global minecraft:overworld #Example2在主世界查询了名为Example2的召唤区域remove用于在指定维度内移除指定名称的召唤区域格式：/summoningex area remove all/global/local (op)（op将在下文中说明）all此时op为completely或in (dim)，也可以省略op当op为completely时，将会移除所有维度内所有的召唤区域当op为in (dim)时（dim为维度），将会移除指定维度内所有的召唤区域当op省略时，将会移除指令使用者所在维度内所有的召唤区域local与globallocal与global的含义与query中大致相同，此处不再阐述举例/summoningex area remove local #Example在指令使用者所在维度移除了名为Example的召唤区域/summoningex area remove all in minecraft:the_end移除了末地所有的召唤区域show以长方体边框显示所有的召唤区域（召唤区域默认隐藏）当在使用指令/summoningex area show后，如果添加了新的维度，则可能需要重新使用指令hide隐藏所有的召唤区域summon提供了在召唤区域（长方体）的底面召唤指定数量实体的操作（召唤的实体将在召唤区域的底面随机分布）格式：/summoningex summon (entity) inside #(name) [count (cnt) [nbt (nbt)]]entity指定了所召唤实体的类型（同原版/summon指令的第一个参数）name召唤区域的名称（这里只能使用指令使用者所在维度的召唤区域）cnt指定了召唤实体的数量（最大为2000）若不指定则默认召唤一个实体nbt指定了召唤的实体的NBT（对每个召唤的实体使用该NBT的副本）与原版/summon指令相同，当传入NBT时，Mob类型实体的finalizeSpawn方法将不会被调用举例/summoningex summon minecraft:pig inside #Example count 10在指令使用者所在维度中名为Example的召唤区域内召唤了10只猪/summoningex summon minecraft:iron_golem inside #Example2 count 5 nbt {Health:1}在指令使用者所在维度中名为Example2的召唤区域内召唤了5个生命值为1的铁傀儡新的实体选择器（较新的版本已移除）本Mod提供了一个新的实体选择器（IEntitySelectorType）（默认为@b，可在配置文件内修改）该实体选择器可以选中所有（或满足一定条件的）与指定召唤区域相交（指碰撞箱相交）的实体格式/@b(#(name))[op]name召唤区域的名称（这里只能使用指令使用者所在维度的召唤区域）[op]与原版实体选择器@e相同举例/kill @b(#Example)杀死指令使用者所在维度中名为Example的召唤区域（下文简记为召唤区域Example）内的所有实体/effect give @b(#Example)[type=!minecraft:player] minecraft:wither 45将45秒的凋零效果应用于召唤区域Example内所有不为玩家的实体注意事项与原版/summon指令相同，Mod的所有指令的执行权限为2后记高中学习压力较大（高一的暑假就只放30多天，这个Mod是暑假抽时间做的），并且两周回家一次，因此开学后可能不会频繁地维护Mod，但我会尽量不停更，也许也会为Mod增加一些内容233
引入在世界边境附近调试Mod实体时，偶然发现了这样的问题（图中激光渲染的位置出现异常偏移）这是怎么回事呢？常用技巧在Minecraft的Mod开发中，开发者们可能常使用以下的代码辅助世界的渲染：Vector3d projectedView = Minecraft.getInstance().gameRenderer.getMainCamera().getPosition(); matrixStack.translate(-projectedView.x, -projectedView.y, -projectedView.z);这样做的好处显而易见——可以在渲染中用绝对坐标替换相对坐标。不过使用这样的方式，有时也会导致一些问题，先从float和double的有效数字分析Java中的浮点数下表为float和double的一些属性比较属性floatdouble符号位/bit11指数位/bit811尾数位/bit2352占用内存空间4 Byte (32bit)8 Byte (64bit)由此可知float的有效数字位数为6~7位，而double则为15~16位float的局限性MatrixStack的translate方法以下是MatrixStack类中translate方法的源代码public void translate(double x, double y, double z) { MatrixStack.Entry entry = this.poseStack.getLast(); entry.pose.multiply(Matrix4f.createTranslateMatrix((float) x, (float) y, (float) z)); }可以发现，此方法内做了一个强制类型转换，将双精度的double转换为了单精度的float！世界的大小在World（SRG名，1.16.5）类中，有如下的私有静态方法private static boolean isInWorldBoundsHorizontal(BlockPos pos) { return pos.getX() >= -30000000 && pos.getZ() >= -30000000 && pos.getX() < 30000000 && pos.getZ() < 30000000; }由此可知，MC的世界是60000001x60000001格的，这会使得当坐标足够大时，用float表示坐标会使float的小数部分丢失，从而发现了问题所在问题发生的原因在未解决bug时，由于直接在translate中传入世界真实坐标，导致强制类型转换，进而导致double后面的小数部分丢失问题的解决translate时，传入激光起始位置的坐标与玩家坐标的差，使数字变小，以确保float的小数部分保留Vector3d cameraPos = Minecraft.getInstance().gameRenderer.getMainCamera().getPosition(); double x = src.x - cameraPos.x; double y = src.y - cameraPos.y; double z = src.z - cameraPos.z; matrixStack.translate(x, y, z);最后效果如下图所示激光渲染的位置的异常偏移消失了！总结float不精确，开发中无性能需求应尽量使用double（虽然也不是100%精确）Minecraft的世界坐标（x和z）的绝对值上限为30000000而不能无限增大，一定程度上与double的精度限制有关（毕竟每个世界坐标都是double的，但主要还是因为int能表示的范围有限）原版下实体的生命上限为1024，这也在一定程度上与float的精度限制有关