Merchant(商人)

EntityMerchant —— 本 mod 唯一注册的实体。放置 Market 方块 时生成,全 mod 全部市场状态都存在这个实体上。

基本信息

属性 值 来源
类 com.guigs44.farmingforengineers.entity.EntityMerchant(extends EntityCreature implements INpc) entity/EntityMerchant.java:28
注册名 farmingforengineers:merchant FarmingForEngineers.java:89
实体 ID 0 FarmingForEngineers.java:89
追踪范围 / 更新频率 64 / 3 tick FarmingForEngineers.java:89
生命值 20(applyEntityAttributes 只设了 movementSpeed,未设 maxHealth,沿用 EntityCreature 默认 20) entity/EntityMerchant.java:74
护甲 0(armor 属性未设置) —
碰撞箱 0.6 × 1.95(setSize(0.6f, 1.95f)) entity/EntityMerchant.java:58
眼高 1.62F entity/EntityMerchant.java:210
移动速度 0.5(SharedMonsterAttributes.movementSpeed 基值) entity/EntityMerchant.java:77
渲染 RenderMerchant:直接复用 ModelVillager(shadow 0.5),贴图 farmingforengineers:textures/entity/merchant.png client/render/RenderMerchant.java:20
isAIEnabled true entity/EntityMerchant.java:64
攻击方式 无(未覆写 attackEntityAsMob / attackEntityAt) —

生成

  • 不自然生成:EntityRegistry.registerModEntity(..., mod = this, 64, 3, true) 的最后一位是 sendVelocityUpdates = true,不是世界生成。LivingHandler / EntityLiving 实体生成器在 src/main/java/ 全仓库零命中。
  • 唯一来源:玩家放置市场方块时由 BlockMarket.onBlockPlacedBy 在方块正对面手动 world.spawnEntityInWorld(merchant)。
  • 生成群系 / 高度:不适用(唯一来源是方块位置)。

行为(AI 任务表)

EntityMerchant 构造函数只注册 3 个任务(entity/EntityMerchant.java:59):

优先级 任务 说明
0 EntityAISwimming 原版,避免淹死
1 EntityAIAvoidEntity(this, EntityZombie.class, 8f, 0.6, 0.6) 距僵尸 8 格内绕开
5 EntityAIMerchant(this, 0.6) 走回自己的市场方块,速度 0.6

EntityAIMerchant 的逻辑:

  • shouldExecute():已在市场(isAtMarket())则调 setToFacingAngle() 并返回 false;否则把目标点设为市场 + facing.getOpposite() 偏移,返回 true
  • continueExecuting():!entity.getNavigator().noPath()
  • startExecuting():navigator.tryMoveToXYZ(movePosX, movePosY, movePosZ, 0.6)
  • setMutexBits(1)

isAtMarket() 判据为距离平方 ≤ MARKET_ARRIVAL_DISTANCE = 1.75 × 1.75。

[!WARNING] EntityAIMerchant.java:30 的 Z 分量写的是 opposite.offsetY 而不是 offsetZ:movePosZ = entity.marketZ + opposite.offsetY。因为只取 X/Z 水平朝向,offsetY 恒为 0,这行等价于 marketZ + 0,即导航目标永远落在市场的同一 Z 平面上,不做前后偏移。属于源码既有行为,非笔误推断。

生命周期:脱离市场即消失

onEntityUpdate(entity/EntityMerchant.java:144)在服务端每 20 tick 检查一次:

  • isMarketValid() 为 false → worldObj.setEntityState(this, (byte) 12) 然后 setDead();客户端收到状态 12 时执行 disappear()(播 fire.ignite + 50 个 fireworksSpark + 1 个 largeexplode)
  • isMarketValid() 的判定:worldObj.getBlock(marketX, marketY, marketZ) == ModBlocks.market || == Blocks.bedrock。源码注释说明加载世界初期 getBlock 会短暂返回基岩,故把基岩也算作「有效」,避免每次读档商人集体消失。
  • canDespawn() 返回 false,商人永不自然消失——只会因为市场方块被拆而消失。

出生动画变体(SpawnAnimationType,3 值)

由 BlockMarket.onBlockPlacedBy 依环境挑选,每个变体的行为差异如下:

枚举值 触发条件 位置 服务端行为 客户端表现
FALLING 市场方块处 canBlockSeeTheSky merchantY + 172 写入 NBT SpawnAnimation = 1 播放 farmingforengineers:falling 音效;从 172 格高自由落体;spawnDone 仍为 false
DIGGING 不见天,且市场方块正下方 !isAirBlock(x, y-1, z) merchantY + 0.5 或 merchantY 下一 tick 发状态 13 并置 spawnDone = true 收到 13 → diggingAnimation = 60;每 tick 递减并每 2 tick 播一次挖掘音 Block.soundTypeGravel.getBreakSound()(音量 1.0,音调 random() + 0.5);每 tick 尝试生成方块碎屑粒子(已注释掉,见下方「死代码」)
MAGIC 以上都不成立 merchantY + 0.5 或 merchantY 无额外状态 立即播 fire.ignite(ModSounds.LOC_POOF)并爆 50 个 fireworksSpark + 1 个 largeexplode

FALLING 变体有一处专门的免伤处理(entity/EntityMerchant.java:200):

若 !spawnDone && damageSrc == DamageSource.fall,播放 getHurtSound()(mob.villager.hit,音量 1.0 音调 2.0),把 spawnDone 置 true 并 return ——从天上摔下来的第一次坠落伤害被完全吞掉,不会真的摔死。

[!NOTE] MAGIC 与 FALLING 在 onEntityUpdate 里都不会自动置 spawnDone = true(只有 DIGGING 走状态 13 那条路)。FALLING 靠吞掉第一次坠落伤害的副作用顺带置位。

名称

NAMES 是 4 个字符串,onInitialSpawn 随机取一个写入 CustomNameTag,并 setAlwaysRenderNameTag(true):

名称
Swap-O-Matic
Emerald Muncher
Back Alley Dealer
Weathered Salesperson

另有 0.1% 概率(Math.random() < 0.001)改名为 Pam 或 Blay(各 50%)。

NBT 字段

writeEntityToNBT / readEntityFromNBT 写 4 个自定义键(entity/EntityMerchant.java:96):

键 类型 含义
MarketPosX int 市场方块 X
MarketPosY int 市场方块 Y
MarketPosZ int 市场方块 Z
SpawnDone boolean 出生动画是否已完成(影响免伤与 DIGGING 后续)
SpawnAnimation byte SpawnAnimationType.ordinal()(0 MAGIC / 1 FALLING / 2 DIGGING)

[!WARNING] 读档时存在两处源码既有缺陷,读出来的市场坐标与朝向都是错的:

  1. readEntityFromNBT 判 if (compound.hasKey("MarketPosX")) 才重建市场,但调用的是 setMarket(marketX, marketY, marketZ, ...),传的是尚未赋值的字段本身(此刻全是 0),而不是从 compound 取值。
  2. 紧接着读 ForgeDirection.getOrientation(compound.getByte("Facing")),但 Facing 这个键从未被写入(源码里 compound.setByte("Facing", ...) 被注释掉了,见 entity/EntityMerchant.java:103)。getByte 对不存在的键返回 0 → ForgeDirection.DOWN。

净效果:读档后商人的市场坐标归 (0,0,0)、朝向朝下。isMarketValid() 会读到原版世界原点(通常是石头而非基岩)→ 判为无效 → 20 tick 后商人消失。玩家需要重新放一个市场方块。spawnDone 与 SpawnAnimation 两条不受影响,读档正常。

未写入的还有 facing(注释掉的第 6 个键)与 diggingAnimation(纯客户端状态,客户端自己从状态 13 重建)。

掉落

无掉落。未覆写 dropFewItems / dropRareDrop / dropXp(grep 三个模式全仓库 0 命中),也没有 onDeath 里的 entityDropItem——杀死商人只有经验球。

音效

三个声音全部复用原版,无一个是 mod 自有实体音效:

用途 声音
闲置 getLivingSound() mob.villager.idle
受伤 getHurtSound() mob.villager.hit
死亡 getDeathSound() mob.villager.death

交互

交互方 行为
玩家右键 interact() → 仅当 isMarketValid() 为 true 时 player.openGui(MOD_ID, GuiHandler.MARKET, world, marketX, marketY, marketZ);否则 return super.interact(player)(对普通村民 AI 实体等同无操作)
拴绳 未覆写 canBeLeashedTo(覆写被注释掉)→ 走 EntityCreature 默认,可被拴
活塞 未做处理,随活塞推动(推动后仍会按 20 tick 校验并回走)

死代码(源码内被注释 / 空转)

  • onEntityUpdate 里 DIGGING 阶段的 4 次/ tick 粒子循环:循环体只做 int stateId = 0;,所有 spawnParticle 调用均被注释 → 实际不产生任何粒子
  • EntityAIMerchant 的 movePosY 赋值后 tryMoveToXYZ 会用到,但目标 Y 恒等于市场 Y
  • getLivingSound 等三方法在 INpc 接口下不被调用路径使用,仅防御性实现

相关条目