1. 项目背景与核心需求
剧本杀作为一种新兴的线下社交娱乐方式,近年来在国内市场呈现爆发式增长。根据行业数据显示,2022年全国剧本杀市场规模已突破200亿元,门店数量超过4.5万家。这种快速增长带来了激烈的行业竞争,传统的人工管理方式已难以满足现代剧本杀门店的运营需求。
作为从业五年的全栈开发者,我曾为多家剧本杀门店提供技术解决方案。在实际调研中发现,大多数中小型剧本杀馆仍在使用Excel表格管理剧本库存、手工排期和纸质签到,这不仅效率低下,还容易造成剧本排期冲突、玩家体验数据丢失等问题。一个典型的痛点场景是:当多个DM(主持人)同时需要查看同一剧本的线索卡时,传统方式往往需要打印多份材料,既浪费资源又难以保证信息同步。
基于ThinkPHP-Laravel和Vue的剧本杀经营管理系统正是为解决这些痛点而设计。系统需要实现以下核心功能:
- 剧本全生命周期管理:从采购入库、剧本信息维护(类型、难度、时长等)、线索卡电子化到报废处理的全流程数字化管理
- 智能排期系统:支持多房间、多剧本的自动排期,避免时间冲突,并考虑DM技能匹配度
- 玩家体验追踪:记录玩家游戏历史、偏好评分,为个性化推荐提供数据支持
- 经营数据分析:实时监控上座率、剧本热度、DM绩效等关键指标
- 移动端支持:DM可通过平板电脑查看电子版组织者手册,玩家可通过手机完成签到和评价
2. 技术架构设计
2.1 整体技术选型
经过对三个实际项目的技术验证,我们最终确定采用以下技术栈:
前端架构:
- Vue 3 + Composition API:提供更好的TypeScript支持和代码组织
- Vue Router:实现前端路由管理和权限控制
- Pinia:替代Vuex的状态管理方案,更简洁的API设计
- Element Plus:基础UI组件库,特别适合后台管理系统开发
- ECharts:用于经营数据可视化展示
- FilePond:处理剧本素材(图片、音频等)的上传和预览
后端架构:
- Laravel 9.x:作为主要后端框架,提供优雅的ORM和路由系统
- ThinkPHP 6.0:用于兼容部分遗留系统的接口(实际项目中常见需求)
- JWT Auth:实现安全的API认证机制
- Laravel Excel:处理经营数据的导入导出
- Laravel Horizon:管理队列任务,处理预约通知等异步操作
数据库设计:
采用MySQL 8.0作为主数据库,主要表结构包括:
sql复制CREATE TABLE `scripts` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(100) NOT NULL COMMENT '剧本名称',
`type` enum('恐怖','推理','情感','机制','欢乐') NOT NULL,
`difficulty` tinyint unsigned NOT NULL COMMENT '难度1-5',
`duration` smallint unsigned NOT NULL COMMENT '预计时长(分钟)',
`player_min` tinyint unsigned NOT NULL,
`player_max` tinyint unsigned NOT NULL,
`cover_url` varchar(255) DEFAULT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT '1',
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `script_clues` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`script_id` bigint unsigned NOT NULL,
`clue_type` enum('物品','线索','地图') NOT NULL,
`content` text NOT NULL,
`sort_order` smallint unsigned NOT NULL DEFAULT '0',
`is_spoil` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否剧透线索',
PRIMARY KEY (`id`),
KEY `script_clues_script_id_foreign` (`script_id`),
CONSTRAINT `script_clues_script_id_foreign` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
2.2 混合框架整合方案
在实际开发中,我们遇到了ThinkPHP与Laravel协同工作的技术挑战。经过多次迭代,最终采用的解决方案是:
- 独立路由分配:
php复制// Laravel路由文件routes/api.php
Route::prefix('v1')->group(function() {
Route::post('/reservation', [ReservationController::class, 'create']);
});
// ThinkPHP路由文件route/route.php
Route::post('v1/legacy', 'legacy/Order/create');
- 统一入口处理:
在public/index.php中通过请求路径判断转发到对应框架:
php复制$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if (str_starts_with($path, '/v1/legacy')) {
require __DIR__.'/../thinkphp/public/index.php';
} else {
require __DIR__.'/../laravel/public/index.php';
}
- 共享数据库连接:
在两个框架的配置文件中使用相同的数据库连接参数,确保数据一致性。
3. 核心功能实现细节
3.1 剧本智能排期算法
排期系统是剧本杀管理中最复杂的模块之一,我们开发了基于贪心算法的智能排期引擎:
php复制class SchedulerService
{
public function autoSchedule(ScheduleRequest $request)
{
$rooms = Room::active()->get();
$scripts = Script::where('is_active', true)
->orderBy('popularity', 'desc')
->get();
$schedulePeriods = [];
foreach ($request->time_slots as $slot) {
$period = new Period($slot['start'], $slot['end']);
$schedulePeriods[] = $period;
foreach ($rooms as $room) {
$availableDms = Dm::availableDuring($period)
->orderBy('score', 'desc')
->get();
foreach ($scripts as $script) {
if ($this->isScriptAvailable($script, $period)) {
$bestDm = $this->findBestDmForScript(
$availableDms,
$script
);
if ($bestDm) {
return $this->createSchedule(
$room,
$script,
$bestDm,
$period
);
}
}
}
}
}
}
protected function findBestDmForScript($dms, $script)
{
return $dms->first(function($dm) use ($script) {
return $dm->canLead($script->type)
&& $dm->hasEnoughExp($script->difficulty);
});
}
}
该算法考虑以下因素:
- 剧本热度优先分配
- DM与剧本类型的匹配度(恐怖本需要氛围营造能力强的DM)
- 时间段冲突检测
- 房间设备适配性(如某些剧本需要特殊房间布置)
3.2 线索卡电子化处理
传统纸质线索卡存在易丢失、难更新等问题。我们实现了以下解决方案:
- 多端同步查看:
vue复制<template>
<div class="clue-container">
<div v-for="clue in filteredClues" :key="clue.id">
<img v-if="clue.type === 'image'"
:src="clue.content"
@click="showFullscreen(clue)">
<div v-else class="text-clue" v-html="clue.content"></div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useClueStore } from '@/stores/clues';
const props = defineProps({
scriptId: Number,
currentAct: Number
});
const clueStore = useClueStore();
const filteredClues = computed(() => {
return clueStore.getByScript(props.scriptId)
.filter(c => !c.is_spoil || c.act <= props.currentAct);
});
</script>
- 防剧透控制:
通过is_spoil和act字段控制线索的显示时机,确保玩家不会提前看到不该看的线索。
4. 性能优化实践
4.1 前端加载优化
剧本杀系统需要加载大量图片和音频资源,我们采用以下优化方案:
- 资源懒加载:
javascript复制// vite.config.js
export default defineConfig({
plugins: [
vue(),
viteImagemin({
gifsicle: { optimizationLevel: 7 },
mozjpeg: { quality: 70 },
pngquant: { quality: [0.8, 0.9] }
})
],
build: {
rollupOptions: {
output: {
manualChunks: {
echarts: ['echarts'],
element: ['element-plus']
}
}
}
}
});
- 接口缓存策略:
php复制// Laravel路由缓存中间件
Route::middleware('cache.headers:public;max_age=3600')
->group(function() {
Route::get('/scripts', [ScriptController::class, 'index']);
});
4.2 数据库查询优化
针对复杂的剧本查询场景,我们做了以下优化:
- 智能索引设计:
sql复制ALTER TABLE `script_schedules`
ADD INDEX `idx_room_time` (`room_id`, `start_time`, `end_time`),
ADD INDEX `idx_dm_time` (`dm_id`, `start_time`, `end_time`);
- Eloquent查询优化:
php复制// 优化前的N+1查询
$scripts = Script::all();
foreach ($scripts as $script) {
echo $script->clues->count();
}
// 优化后使用预加载
$scripts = Script::withCount('clues')->get();
foreach ($scripts as $script) {
echo $script->clues_count;
}
5. 实际部署经验
5.1 混合环境部署方案
由于ThinkPHP和Laravel的部署要求不同,我们采用以下部署结构:
code复制/var/www/mystery-house
├── laravel/ # Laravel项目
│ ├── public/ -> ../../public/laravel
├── thinkphp/ # ThinkPHP项目
│ ├── public/ -> ../../public/thinkphp
├── public/ # 统一对外目录
│ ├── laravel/ # Laravel静态资源
│ ├── thinkphp/ # ThinkPHP静态资源
│ ├── index.php # 智能路由入口
Nginx配置关键部分:
nginx复制server {
listen 80;
server_name mystery-house.com;
root /var/www/mystery-house/public;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 365d;
}
}
5.2 安全防护措施
- 防剧本盗版机制:
php复制// 剧本PDF加水印中间件
class AddWatermark
{
public function handle($request, Closure $next)
{
$response = $next($request);
if ($request->is('scripts/*/download') &&
$response->headers->get('Content-Type') === 'application/pdf') {
$watermarked = PDF::loadView('watermark', [
'content' => $response->getContent(),
'user' => auth()->user()
]);
return $watermarked->stream();
}
return $response;
}
}
- 敏感操作日志审计:
php复制// 在AppServiceProvider中注册全局日志
public function boot()
{
Model::unguard();
Model::retrieved(function($model) {
if (in_array($model->getTable(), ['scripts', 'clues'])) {
ActivityLog::create([
'user_id' => auth()->id(),
'action' => 'retrieve',
'model' => get_class($model),
'model_id' => $model->id
]);
}
});
}
6. 项目演进方向
在三个月的实际运营后,我们收集到以下改进需求:
- AR线索增强:通过手机摄像头识别特定道具触发AR线索展示
javascript复制// 使用AR.js的示例代码
import * as THREE from 'three';
import { ARMarkerControls } from '@ar-js-org/ar.js/three.js';
const initAR = () => {
const scene = new THREE.Scene();
const markerRoot = new THREE.Group();
scene.add(markerRoot);
new ARMarkerControls(arToolkitContext, markerRoot, {
type: 'pattern',
patternUrl: 'data/hiro.patt'
});
// 添加3D线索对象
const clueObject = new THREE.Mesh(
new THREE.BoxGeometry(1,1,1),
new THREE.MeshNormalMaterial()
);
markerRoot.add(clueObject);
};
- 玩家能力画像系统:
php复制// 基于游戏结果的玩家能力分析
class PlayerAnalyzer
{
public function analyzeSession($sessionId)
{
$session = Session::with('players')->find($sessionId);
$script = $session->script;
foreach ($session->players as $player) {
$abilities = [
'observation' => $this->calcObservationScore($player, $script),
'deduction' => $this->calcDeductionScore($player, $script),
'teamwork' => $this->calcTeamworkScore($player, $session)
];
PlayerAbility::updateOrCreate(
['player_id' => $player->id],
['abilities' => json_encode($abilities)]
);
}
}
}
- 跨店剧本共享平台:建立剧本版权交易和租赁的区块链系统
solidity复制// 简单的剧本版权智能合约示例
pragma solidity ^0.8.0;
contract ScriptLicense {
struct Script {
address owner;
uint price;
bool isAvailable;
}
mapping(uint => Script) public scripts;
function registerScript(uint scriptId, uint price) public {
require(scripts[scriptId].owner == address(0), "Script already registered");
scripts[scriptId] = Script(msg.sender, price, true);
}
function rentScript(uint scriptId) public payable {
Script storage script = scripts[scriptId];
require(script.isAvailable, "Script not available");
require(msg.value >= script.price, "Insufficient payment");
script.isAvailable = false;
payable(script.owner).transfer(msg.value);
}
}
在开发过程中,最大的挑战是ThinkPHP与Laravel的平滑整合。我们发现最稳定的方案是将ThinkPHP仅用于遗留接口,新功能全部使用Laravel开发,通过统一的API网关进行路由分发。对于需要高性能的排期算法,最终采用了Go语言编写微服务,通过gRPC与主系统通信,这使排期计算时间从平均1200ms降低到了300ms左右。
