复刻《传送门 2》炮塔:三轴动作+语音+LED 的 Arduino 实战
项目背景
这个圣诞,我决定复刻《传送门 2》里的智能炮塔。核心挑战:先在 Fusion 360 完整建模装配,再动手制造。核心硬件:Arduino Nano、MP3 解码芯片、测距传感器、舵机、LED 与 3D 打印件。
功能目标:
- 检测到人靠近 → 曲柄滑块机构展开“翅膀”
- 人持续停留 → 机枪音效 + LED 模拟开火
- 人离开 → 扫描搜索动作
- 无人时收拢休眠
- 全程播放游戏原版语音
外观尽量还原游戏模型,同时保证可打印、可组装。音频文件存 MicroSD 卡(背部隐形插槽),共 18 句台词。VL53L0X ToF 测距传感器矩形封装,组装后正面几乎不可见。
步骤 1:Fusion 360 建模
从网络草图导入三视图轮廓拉伸成型,再挖壳、切槽。电子元件作为组件嵌入模型定位,驱动参数:
- Arduino Nano USB 口组装后仍可插拔升级固件
- MicroSD 卡同理,隐藏于黑色嵌条内
- ToF 传感器正面不可见
- 尾部 2.1mm 电源座
- 打印件尽量大、少碎件、无支撑
翅膀展开采用曲柄滑块机构——纯粹想用曲柄滑块!虽增复杂度,但几何约束保证行程极限与重复精度。模型验证无误后打印原型,再回炉优化外观与装配性(assemblability)。全曲面、紧凑内部、组装后无调整空间,挑战极大,但学会了嵌套组件管理,值了!
步骤 2:3D 打印件

Portal turret v4.zip 含最新全套打印件。新增“后测试腿”:非原版设计,但位于背部,允许直插 mini-USB。
Prusa Mk2、PLA 材质、竖向打印机身无支撑。侧向大跨度桥接在 0.2mm 层高下表现良好;更大层高会在嵌条处出现缝隙。所有悬空特征采用 45° 倒角,极少需支撑。后处理极少。嵌条宽度统一、厚度一致,亦可用黑色薄板材代替(竖向打印)。滑块销孔需 1/8" 钻头追孔润滑,钉子做滑轨顺滑关键。
Lens+v3b.stl Body-RHS+v4.stl Body-LHS+w+components+v4.stl Wing+RH+bottom+v4.stl Wing+RH+top+v4.stl Wing+LH+bottom+v4.stl Wing+LH+top+v4.stl Pitch+Frame+v4.stl Leg+-+REAR+v4.stl Infill+strips+v3.stl Leg+-+REAR+test.stl Pitch+Frame+-+modified+1.stl wings+v6.zip Arduino+Base+w+Radio.stl Portal+Turret+v4.zip
步骤 3:电子元件
V4(红色炮塔)桥接更小,需配配套翅膀与俯仰框架。 V6 翅膀 可选,支持不同配色“枪身”。
原型验证后打印 V2,全 PLA,黑色+主题色(蓝)+微量透明 PLA 做中央“透镜/激光眼”。下图涵盖主要元件(除布线)。
- Arduino Nano ×1
- DFPlayer Mini MP3 模块(或 MP3-TF-16P)×1
- VL53L0X ToF 测距传感器 ×1
- SG90 微型舵机 ×3
- 5mm 红色 LED ×5、220Ω 限流电阻 ×5
- 1kΩ 电阻 ×1
- 4cm 4Ω 3W 喇叭 ×1
- 2.1mm 电源座 ×1
- 3" 成型钉 ×4(滑轨)
- M2×6 ×8、M2.5×8 ×2、M3×8 ×4、M3×12 ×4、M3×16 ×2
- 热缩管、扎带若干
螺丝建议买套装,单买极麻烦。
步骤 4:电路设计
同上清单,原理图见文末。
步骤 5:机械组装






打印件大孔预攻丝,组装前追丝利于拧入。Arduino 与 MP3 模块卡扣固定免螺丝,VL53L0X 免紧固件嵌入前壳——先试装再接线。滑块用 4 根成型钉(剪头)做导轨,必须光滑。舵机安装朝向关键:俯仰与偏航舵机装入时置“中位”。曲柄安装使展开位对应逆时针回收(正视视角),展开位为连杆与曲柄共线再转 10° 死点锁定。腿部最简:2-2.5mm 螺丝,腿盖扣住肘部,螺丝头不得高出腿板干涉旋转。所有舵机输出端用舵机自带短十字摇臂压入打印件(直接打印花键孔成功率低)。曲柄组装用长 2.5mm 螺丝,连杆不得夹紧;若翅膀滑动顺滑,甚至可省略 Crank2 短螺丝方案。喇叭被舵机安装座(两件)夹持,再锁俯仰舵机,依次串联滑块、曲柄连杆,整体装入左半身 4 颗小螺丝。主体装好后暂放 Arduino 与 MP3,开始布线。
步骤 6:布线



V5 含 nRF24L01 无线版(红色炮塔照片),引脚全变,细节后补。
内部极其紧凑,预留 3-4 英寸线长最省事。LED 直焊 220Ω 电阻、套热缩、扭线、测好后备用(用 CAT5 细线减少视觉干扰)。机械件试装定走线,再剪线备焊。舵机接头做成插拔式,便于换挡位舵机(原型阶段已救场多次)。主线焊好后最后焊 LED,小心塞入半壳,**切记:勿让线材顶住 Nano 复位键,否则死机**。合体前上传代码通电自检 LED/舵机/MP3,无误再合体。


步骤 7:Arduino 代码
已更新:清理结构、调整行为参数。
附件代码驱动视频演示行为。我将持续调校炮塔“性格”。代码采用子程序模块化,主循环清爽,便于调试不同行为。大量前置变量便于微调停泊位、行程极限等。音频用 DFMiniMP3 库(DFRobot 库曾报错),保留静态回调函数。音频文件放 SD 卡 mp3/0001.mp3 格式,文件名四位数字前缀+后缀备注。素材源自维基百科“Portal Turret sounds”页面,代码随机挑选 2-3 句同类台词避免单调。
步骤 8:机身合体
黑色嵌条是难点:尺寸微小,槽口需用细工具疏通确保低阻力再合体。线束整齐扎带大幅降低难度。建议先合两半壳,再插嵌条:先插有“搁板”挡板的一侧,再微撬另一侧压入。虽棘手但成品美观且结实。
步骤 9:翅膀组装




机身合体后着手翅膀。**关键:滑块孔必追 1/8" 钻头、清毛刺**。钉子剪头(斜口钳/台钳/锯),压入翅膀件。直、光、润是顺滑关键。棉签蘸干性石墨或其它 PLA 兼容润滑脂(个人润滑剂也极好、便宜、极滑——但得解释给伴侣/父母为什么在工作台用这个😅)。先辨认左右上下件,单件试滑,再合上下件、抹润滑脂、确认无卡顿再接连杆、锁螺丝。LED 插入枪口、线材贴翼面隐藏,测试通过可热熔胶锁死。
步骤 10:吓唬朋友!
尾部建议用弯头电源头,避免干涉后腿摆动。修长后腿(V3)已预留空间。接电(5V 供 Nano)后静默待机,检测到设定距离内目标即觉醒“屠杀”入侵者!
欢迎分享你的构建与新创意!
代码(含无线主控协议)
// Portal Turret - Option With Radio!! Chris Nowak - Feb 2019
// https://www.instructables.com/member/ChrisN219/
// https://create.arduino.cc/projecthub/Novachris/talking-portal-2-turret-gun-637bf3
// 支持 Cara Mia 歌剧、闲聊模式、手动模式,受主控台(MTC)指挥
// 单机自主运行无需 MTC,预留三炮塔协同位,调试代码保留
#include <Wire.h> #include <VL53L0X.h> #include "Arduino.h" #include <SoftwareSerial.h> #include <Servo.h> #include <DFMiniMp3.h> #include <RF24.h> #include <RF24Network.h> #include <SPI.h>
class Mp3Notify { public: static void OnError(uint8_t errorCode) { Serial.println(); Serial.print("Com Error "); Serial.println(errorCode); } static void OnPlayFinished(uint8_t globalTrack) { Serial.println(); Serial.print("Play finished for #"); Serial.println(globalTrack); } static void OnCardOnline(uint8_t code) { Serial.println(); Serial.print("Card online "); Serial.println(code); } static void OnCardInserted(uint8_t code) { Serial.println(); Serial.print("Card inserted "); Serial.println(code); } static void OnCardRemoved(uint8_t code) { Serial.println(); Serial.print("Card removed "); Serial.println(code); } };
Servo servo_pivot; Servo servo_wings; Servo servo_pitch; VL53L0X sensor;
SoftwareSerial secondarySerial(4, 2); DFMiniMp3<SoftwareSerial, Mp3Notify> mp3(secondarySerial);
RF24 radio(10, 9); RF24Network network(radio);
const uint64_t this_node = 01; const uint64_t WHT = 02; const uint64_t BLU = 03; const uint64_t MTC = 00;
unsigned long previousMillis1 = 0, previousMillis2 = 0, previousMillis3 = 0;
byte LED_LH_up = A1, LED_LH_down = A2, LED_CENTRE = A3, LED_RH_up = 7, LED_RH_down = 8;
byte PIVOT = 6, WINGS = 3, PITCH = 5;
byte parkPIVOT = 96, posPIVOT = 96, maxPIVOT = 116, minPIVOT = 76;
byte WINGclose = 165, WINGopen = 10, WINGpos = 160;
byte parkPITCH = 86, posPITCH = 86, maxPITCH = 96, minPITCH = 75;
byte pos = 90, pulse = 20, pitchCW = 1, pivotCW = 1;
byte randomWake, randomOpen, randomFalse, randomStart, randomDisengage;
int triggerDistance = 300; byte buttonStatus, busyRed = 1, restModeWhite, goState;
int x, y; byte pb1State, pb2State, pb3State, randomPic;
int payload[6];
int chatSayings[] = { 0, 204,164,25, 205,127,76, 208,162,65, 143,230,23, 130,41,225, 153,31,133, 234,49,155, 229,175,74, 231,58,226, 161,223,59, 227,68,236, 136,50,224, 34,160,78, 222,42 };
int NormalTimings[] = { 1000, 2600,800,2800,900,1700,1600,1300,2500,1400,1900, 1600,2300,800,3000,300,100,200,0,0,300, 298000,1300,2600,1300,1400,2100,1900,1600,800,1700, 1100,1000,1000,2100,1500,1300,1100,800,1200,1000, 2200,1700,1300,1400,1500,1000,2000,500,2700,9000, 1100,1200,900,2400,1200,1100,2100,2000,2500,1700, 1100,1000,1100,500,1900,0,1300,2100,1700,900, 1100,800,1100,1700,1100,1100,1500,1500,500,900, 2100 };
int DefectiveTimings[] = { 1000, 1700,900,2000,600,1100,1800,1900,3000,1500,800, 2100,800,1900,900,3200,2700,0,0,0,2000, 4400,800,3200,900,1400,2000,2100,1200,1300,1000, 1100,1400,2100,1000,1600,1000,1200 };
void setup(){ secondarySerial.begin(9600); mp3.begin(); mp3.setVolume(22); Wire.begin(); SPI.begin(); radio.begin(); radio.setPALevel(RF24_PA_LOW); radio.setDataRate(RF24_2MBPS); network.begin(70, this_node); sensor_read(); pinMode(LED_LH_up, OUTPUT); pinMode(LED_LH_down, OUTPUT); pinMode(LED_CENTRE, OUTPUT); pinMode(LED_RH_up, OUTPUT); pinMode(LED_RH_down, OUTPUT); digitalWrite(LED_CENTRE, HIGH); activate_servos(); servo_wings.write(WINGopen); servo_pivot.write(parkPIVOT); servo_pitch.write(parkPITCH); randomWake = random(1, 3); mp3.playMp3FolderTrack(1); delay(2000); servo_wings.write(WINGclose); delay(1500); digitalWrite(LED_CENTRE, LOW); turn_off_servos(); busyRed = 0; }
void loop(){ while (sensor.readRangeSingleMillimeters()>triggerDistance) { ReadNet(); WriteNet(); output_sensor(); if (payload[5] == 1) { delay(500); WriteNet(); Cara_Mia(); } else if (payload[5] == 2) { delay(500); WriteNet(); Chatty_time(); } else if (payload[5] == 3) { delay(500); WriteNet(); ManualControl(); } ReadNet(); } if (sensor.readRangeSingleMillimeters()<triggerDistance){ delay(200); if (sensor.readRangeSingleMillimeters()<triggerDistance){ busyRed = 1; ReadNet(); WriteNet(); output_sensor(); activate_servos(); activate(); if (sensor.readRangeSingleMillimeters()>triggerDistance){ falseActivate(); delay(2000); scanArea(); } else { engage(); delay(2400); for(int j=0;j<=2;j++){ if(sensor.readRangeSingleMillimeters()<triggerDistance){ for(int i=0;i<=25;i++){ fire(); } output_sensor(); } } } output_sensor(); disengage(); } else { output_sensor(); delay(350); } } }
// 子程序:sensor_read、output_sensor、increment_servos、activate、falseActivate、scanArea、engage、fire、disengage、turn_off_servos、activate_servos、Cara_Mia、Chatty_time、ManualControl、ReadNet、WriteNet
// 完整代码见附件
自定义零件与外壳
Lens+v3b.stl Body-RHS+v4.stl Body-LHS+w+components+v4.stl Wing+RH+bottom+v4.stl Wing+RH+top+v4.stl Wing+LH+bottom+v4.stl Wing+LH+top+v4.stl Pitch+Frame+v4.stl Leg+-+REAR+v4.stl Infill+strips+v3.stl Leg+-+REAR+test.stl Pitch+Frame+-+modified+1.stl Arduino+Base+w+Radio.stl
原理图

制造工艺