{
  "case_id": "B2",
  "case_name": "规则自变的台球",
  "prompt": "设计一款\"规则会自己改变\"的台球游戏，要求：\n1. 3D台球基础玩法\n2. 每进一球，触发随机规则变化：\n   - 重力反转（球往上飞）\n   - 台面倾斜（球自动滚动）\n   - 球的大小随机变化\n   - 出现隐形障碍物\n3. 屏幕提示当前规则\n4. 使用Three.js，单文件HTML",
  "result": {
    "success": true,
    "content": "这是一个基于 **Three.js** (渲染) 和 **Cannon-es** (物理引擎) 的单文件 HTML 3D 台球游戏。\n\n### 游戏特色：\n1.  **混沌物理**：每进一球，物理规则重置。\n2.  **操作方式**：\n    *   **按住鼠标左键** 并在屏幕上拖动来蓄力（会出现瞄准线）。\n    *   **松开鼠标** 击球（白球）。\n    *   **旋转视角**：按住鼠标右键拖动。\n3.  **包含规则**：\n    *   **重力反转**：球会飞向天花板（天花板有隐形碰撞体）。\n    *   **台面倾斜**：重力方向改变，球会自动滚向一边。\n    *   **尺寸错乱**：所有球的大小随机缩放（物理碰撞体也会变）。\n    *   **隐形迷宫**：台面上随机生成看不见的障碍物。\n\n### 代码文件\n你可以直接将以下代码保存为 `.html` 文件并在浏览器中打开。\n\n```html\n<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <title>混沌台球：规则随你变</title>\n    <style>\n        body { margin: 0; overflow: hidden; background-color: #1a1a1a; font-family: 'Arial', sans-serif; }\n        #ui-container {\n            position: absolute;\n            top: 20px;\n            width: 100%;\n            text-align: center;\n            pointer-events: none;\n            z-index: 10;\n        }\n        #current-rule {\n            font-size: 32px;\n            font-weight: bold;\n            color: #ffcc00;\n            text-shadow: 2px 2px 4px #000;\n            background: rgba(0, 0, 0, 0.5);\n            padding: 10px 20px;\n            border-radius: 10px;\n            display: inline-block;\n            transition: transform 0.3s ease;\n        }\n        #score-board {\n            margin-top: 10px;\n            color: white;\n            font-size: 18px;\n        }\n        #instruction {\n            position: absolute;\n            bottom: 20px;\n            width: 100%;\n            text-align: center;\n            color: rgba(255,255,255,0.6);\n            pointer-events: none;\n        }\n    </style>\n    <!-- 引入 Three.js 和 Cannon-es -->\n    <script type=\"importmap\">\n        {\n            \"imports\": {\n                \"three\": \"https://unpkg.com/three@0.160.0/build/three.module.js\",\n                \"three/addons/\": \"https://unpkg.com/three@0.160.0/examples/jsm/\",\n                \"cannon-es\": \"https://unpkg.com/cannon-es@0.20.0/dist/cannon-es.js\"\n            }\n        }\n    </script>\n</head>\n<body>\n\n<div id=\"ui-container\">\n    <div id=\"current-rule\">当前规则：标准物理</div>\n    <div id=\"score-board\">已进球: <span id=\"score\">0</span></div>\n</div>\n<div id=\"instruction\">左键拖拽击球 | 右键旋转视角</div>\n\n<script type=\"module\">\n    import * as THREE from 'three';\n    import { OrbitControls } from 'three/addons/controls/OrbitControls.js';\n    import * as CANNON from 'cannon-es';\n\n    // --- 全局变量 ---\n    let scene, camera, renderer, controls;\n    let world;\n    let balls = []; // 存储 { mesh, body, isCue }\n    let cueBall;\n    let obstacles = []; // 隐形障碍物\n    let score = 0;\n    \n    // 游戏状态\n    let isDragging = false;\n    let dragStart = new THREE.Vector2();\n    let dragCurrent = new THREE.Vector2();\n    const raycaster = new THREE.Raycaster();\n    const mouse = new THREE.Vector2();\n    \n    // 瞄准线\n    let aimLine;\n\n    // 台桌参数\n    const TABLE_WIDTH = 15;\n    const TABLE_LENGTH = 25;\n    const WALL_HEIGHT = 1;\n    const BALL_RADIUS = 0.5;\n\n    // 规则系统\n    const RULES = [\n        { name: \"重力反转\", type: \"gravity_invert\" },\n        { name: \"台面倾斜 (向左)\", type: \"tilt_left\" },\n        { name: \"台面倾斜 (向右)\", type: \"tilt_right\" },\n        { name: \"尺寸错乱\", type: \"random_size\" },\n        { name: \"隐形障碍\", type: \"invisible_walls\" },\n        { name: \"标准物理\", type: \"normal\" } // 作为缓冲\n    ];\n\n    init();\n    animate();\n\n    function init() {\n        // 1. Three.js 场景设置\n        scene = new THREE.Scene();\n        scene.background = new THREE.Color(0x222222);\n\n        camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);\n        camera.position.set(0, 20, 30);\n\n        renderer = new THREE.WebGLRenderer({ antialias: true });\n        renderer.shadowMap.enabled = true;\n        renderer.setSize(window.innerWidth, window.innerHeight);\n        document.body.appendChild(renderer.domElement);\n\n        controls = new OrbitControls(camera, renderer.domElement);\n        controls.enableDamping = true;\n        controls.maxPolarAngle = Math.PI / 2 - 0.1; // 防止钻到桌子底下\n\n        // 灯光\n        const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);\n        scene.add(ambientLight);\n        const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);\n        dirLight.position.set(10, 20, 10);\n        dirLight.castShadow = true;\n        dirLight.shadow.camera.top = 20;\n        dirLight.shadow.camera.bottom = -20;\n        dirLight.shadow.camera.left = -20;\n        dirLight.shadow.camera.right = 20;\n        scene.add(dirLight);\n\n        // 2. Cannon.js 物理世界\n        world = new CANNON.World();\n        world.gravity.set(0, -9.82, 0);\n        \n        // 材质\n        const concreteMaterial = new CANNON.Material('concrete');\n        const plasticMaterial = new CANNON.Material('plastic');\n        const concretePlasticContactMaterial = new CANNON.ContactMaterial(concreteMaterial, plasticMaterial, {\n            friction: 0.1,\n            restitution: 0.7\n        });\n        world.addContactMaterial(concretePlasticContactMaterial);\n\n        // 3. 创建台球桌\n        createTable(concreteMaterial);\n\n        // 4. 创建球\n        createBalls(plasticMaterial);\n\n        // 5. 辅助元素\n        createAimLine();\n\n        // 6. 事件监听\n        window.addEventListener('resize', onWindowResize);\n        window.addEventListener('pointerdown', onPointerDown);\n        window.addEventListener('pointermove', onPointerMove);\n        window.addEventListener('pointerup', onPointerUp);\n    }\n\n    function createTable(material) {\n        // 地板 (视觉)\n        const geometry = new THREE.PlaneGeometry(TABLE_WIDTH, TABLE_LENGTH);\n        const mat = new THREE.MeshStandardMaterial({ color: 0x006600, roughness: 0.8 });\n        const mesh = new THREE.Mesh(geometry, mat);\n        mesh.rotation.x = -Math.PI / 2;\n        mesh.receiveShadow = true;\n        scene.add(mesh);\n\n        // 地板 (物理)\n        const groundShape = new CANNON.Box(new CANNON.Vec3(TABLE_WIDTH/2, TABLE_LENGTH/2, 0.1));\n        const groundBody = new CANNON.Body({ mass: 0, material: material });\n        groundBody.addShape(groundShape);\n        groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);\n        world.addBody(groundBody);\n\n        // 天花板 (物理 - 防止重力反转时球飞走)\n        const ceilingShape = new CANNON.Box(new CANNON.Vec3(TABLE_WIDTH, TABLE_LENGTH, 0.1));\n        const ceilingBody = new CANNON.Body({ mass: 0 });\n        ceilingBody.addShape(ceilingShape, new CANNON.Vec3(0, 10, 0)); // 10个单位高\n        ceilingBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);\n        world.addBody(ceilingBody);\n\n        // 墙壁\n        createWall(TABLE_WIDTH/2 + 0.5, 0, 0.5, TABLE_LENGTH, material); // 右\n        createWall(-TABLE_WIDTH/2 - 0.5, 0, 0.5, TABLE_LENGTH, material); // 左\n        createWall(0, TABLE_LENGTH/2 + 0.5, TABLE_WIDTH + 2, 0.5, material); // 上\n        createWall(0, -TABLE_LENGTH/2 - 0.5, TABLE_WIDTH + 2, 0.5, material); // 下\n    }\n\n    function createWall(x, z, w, l, material) {\n        // 视觉\n        const geo = new THREE.BoxGeometry(w, WALL_HEIGHT, l);\n        const mat = new THREE.MeshStandardMaterial({ color: 0x5c3a21 });\n        const mesh = new THREE.Mesh(geo, mat);\n        mesh.position.set(x, WALL_HEIGHT/2, z);\n        mesh.castShadow = true;\n        scene.add(mesh);\n\n        // 物理\n        const shape = new CANNON.Box(new CANNON.Vec3(w/2, WALL_HEIGHT/2, l/2));\n        const body = new CANNON.Body({ mass: 0, material: material });\n        body.addShape(shape);\n        body.position.set(x, WALL_HEIGHT/2, z);\n        world.addBody(body);\n    }\n\n    function createBalls(material) {\n        // 白球\n        cueBall = createBall(0, -TABLE_LENGTH/3, 0xffffff, material, true);\n\n        // 彩球 (金字塔排列)\n        let startZ = TABLE_LENGTH/4;\n        let rows = 4;\n        for(let i=0; i<rows; i++) {\n            for(let j=0; j<=i; j++) {\n                let x = (j - i/2) * (BALL_RADIUS * 2.1);\n                let z = startZ - i * (BALL_RADIUS * 1.8);\n                // 随机颜色\n                let color = Math.random() * 0xffffff;\n                createBall(x, z, color, material, false);\n            }\n        }\n    }\n\n    function createBall(x, z, color, material, isCue) {\n        // 视觉\n        const geometry = new THREE.SphereGeometry(BALL_RADIUS, 32, 32);\n        const mat = new THREE.MeshStandardMaterial({ color: color, roughness: 0.4, metalness: 0.3 });\n        const mesh = new THREE.Mesh(geometry, mat);\n        mesh.castShadow = true;\n        scene.add(mesh);\n\n        // 物理\n        const shape = new CANNON.Sphere(BALL_RADIUS);\n        const body = new CANNON.Body({ mass: 1, material: material });\n        body.addShape(shape);\n        body.position.set(x, BALL_RADIUS, z);\n        body.linearDamping = 0.5; // 模拟滚动阻力\n        body.angularDamping = 0.5;\n        world.addBody(body);\n\n        const ballObj = { mesh, body, isCue, originalRadius: BALL_RADIUS };\n        balls.push(ballObj);\n        return ballObj;\n    }\n\n    function createAimLine() {\n        const material = new THREE.LineBasicMaterial({ color: 0xffffff });\n        const points = [new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0)];\n        const geometry = new THREE.BufferGeometry().setFromPoints(points);\n        aimLine = new THREE.Line(geometry, material);\n        aimLine.visible = false;\n        scene.add(aimLine);\n    }\n\n    // --- 核心逻辑：规则改变 ---\n\n    function triggerRandomRule() {\n        // 重置之前可能存在的临时状态\n        resetPhysicsState();\n\n        // 随机选一个规则\n        const rule = RULES[Math.floor(Math.random() * RULES.length)];\n        \n        // UI 提示\n        const ui = document.getElementById('current-rule');\n        ui.innerText = \"当前规则：\" + rule.name;\n        ui.style.transform = \"scale(1.2)\";\n        setTimeout(() => ui.style.transform = \"scale(1)\", 300);\n\n        // 应用规则\n        switch(rule.type) {\n            case 'gravity_invert':\n                world.gravity.set(0, 9.82, 0); // 重力向上\n                break;\n            case 'tilt_left':\n                world.gravity.set(-5, -9.82, 0);\n                wakeUpAllBalls();\n                break;\n            case 'tilt_right':\n                world.gravity.set(5, -9.82, 0);\n                wakeUpAllBalls();\n                break;\n            case 'random_size':\n                balls.forEach(b => {\n                    const scale = 0.5 + Math.random() * 1.5; // 0.5x 到 2.0x\n                    b.mesh.scale.set(scale, scale, scale);\n                    // 更新物理半径 (Cannon 需要移除旧形状添加新形状)\n                    b.body.removeShape(b.body.shapes[0]);\n                    b.body.addShape(new CANNON.Sphere(b.originalRadius * scale));\n                    b.body.wakeUp();\n                });\n                break;\n            case 'invisible_walls':\n                createInvisibleObstacles();\n                break;\n            case 'normal':\n            default:\n                // 默认重力在 resetPhysicsState 中已设置\n                break;\n        }\n    }\n\n    function resetPhysicsState() {\n        world.gravity.set(0, -9.82, 0); // 恢复重力\n        \n        // 移除隐形障碍物\n        obstacles.forEach(obs => {\n            world.removeBody(obs);\n        });\n        obstacles = [];\n\n        // 恢复球的大小\n        balls.forEach(b => {\n            b.mesh.scale.set(1, 1, 1);\n            if (b.body.shapes[0].radius !== b.originalRadius) {\n                b.body.removeShape(b.body.shapes[0]);\n                b.body.addShape(new CANNON.Sphere(b.originalRadius));\n            }\n            b.body.wakeUp();\n        });\n    }\n\n    function createInvisibleObstacles() {\n        for(let i=0; i<5; i++) {\n            const size = 1 + Math.random();\n            const shape = new CANNON.Box(new CANNON.Vec3(size, 1, size));\n            const body = new CANNON.Body({ mass: 0 }); // 静态\n            body.addShape(shape);\n            \n            // 随机位置 (避免太靠近中心)\n            const x = (Math.random() - 0.5) * (TABLE_WIDTH - 4);\n            const z = (Math.random() - 0.5) * (TABLE_LENGTH - 4);\n            \n            body.position.set(x, 1, z);\n            world.addBody(body);\n            obstacles.push(body);\n        }\n    }\n\n    function wakeUpAllBalls() {\n        balls.forEach(b => b.body.wakeUp());\n    }\n\n    // --- 进球检测 ---\n    function checkPockets() {\n        // 简化版：检测球是否靠近桌子的四个角或两条长边的中点\n        // 或者更简单：如果球掉出边界（y < -2）或者飞太高 (y > 12)\n        \n        // 这里我们用坐标范围检测进球（假设角落有洞）\n        // 实际上，我们检查球是否超出了桌子的内壁范围稍微多一点点\n        \n        const limitX = TABLE_WIDTH/2 - BALL_RADIUS;\n        const limitZ = TABLE_LENGTH/2 - BALL_RADIUS;\n\n        for (let i = balls.length - 1; i >= 0; i--) {\n            const b = balls[i];\n            const pos = b.body.position;\n\n            // 简单的进球逻辑：如果球的中心点出了桌面范围，算进球 (简化处理)\n            // 为了体验更好，我们定义如果球掉下去了 (y < 0) 就算进球\n            // 或者如果是重力反转模式，球飞太高碰到天花板就算\"进球\"吗？不，那样太容易了。\n            // 我们只检测是否掉出台面 (Y轴)\n\n            if (pos.y < -2) {\n                handleBallPot(b, i);\n            }\n        }\n    }\n\n    function handleBallPot(ballObj, index) {\n        // 移除视觉和物理对象\n        scene.remove(ballObj.mesh);\n        world.removeBody(ballObj.body);\n        balls.splice(index, 1);\n\n        if (ballObj.isCue) {\n            // 白球进洞：惩罚 -> 重置白球\n            setTimeout(() => respawnCueBall(), 1000);\n        } else {\n            // 彩球进洞：加分 + 触发规则\n            score++;\n            document.getElementById('score').innerText = score;\n            triggerRandomRule();\n        }\n    }\n\n    function respawnCueBall() {\n        // 简单的重生逻辑\n        const mat = cueBall.mesh.material; // 复用材质\n        // 重新创建\n        cueBall = createBall(0, -TABLE_LENGTH/3, 0xffffff, null, true);\n        // 注意：createBall 内部会 push 到 balls 数组，物理材质需要重新获取\n        // 这里稍微 hack 一下，直接用默认材质，反正只是 demo\n    }\n\n    // --- 交互控制 ---\n\n    function onPointerDown(event) {\n        if (event.button !== 0) return; // 只响应左键\n\n        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;\n        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;\n\n        raycaster.setFromCamera(mouse, camera);\n        \n        // 检测是否点击了白球\n        if (cueBall) {\n            const intersects = raycaster.intersectObject(cueBall.mesh);\n            if (intersects.length > 0) {\n                isDragging = true;\n                controls.enabled = false; // 禁用视角旋转\n                dragStart.set(event.clientX, event.clientY);\n                \n                // 显示瞄准线起点\n                const pos = cueBall.mesh.position;\n                const positions = aimLine.geometry.attributes.position.array;\n                positions[0] = pos.x;\n                positions[1] = pos.y;\n                positions[2] = pos.z;\n                positions[3] = pos.x;\n                positions[4] = pos.y;\n                positions[5] = pos.z;\n                aimLine.geometry.attributes.position.needsUpdate = true;\n                aimLine.visible = true;\n            }\n        }\n    }\n\n    function onPointerMove(event) {\n        if (!isDragging) return;\n\n        dragCurrent.set(event.clientX, event.clientY);\n        \n        // 计算拖拽向量 (屏幕空间 -> 世界方向)\n        // 简化：根据鼠标Y轴移动决定力度，根据X轴移动微调角度？\n        // 更好的方式：射线检测地板平面，计算白球到鼠标点的向量\n        \n        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;\n        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;\n        raycaster.setFromCamera(mouse, camera);\n        \n        // 创建一个虚拟的地板平面用于射线检测\n        const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -cueBall.mesh.position.y);\n        const target = new THREE.Vector3();\n        raycaster.ray.intersectPlane(plane, target);\n\n        if (target) {\n            // 限制力度长度\n            const direction = new THREE.Vector3().subVectors(cueBall.mesh.position, target);\n            // 这里我们反向，鼠标往后拉，球往前打\n            \n            // 更新瞄准线\n            const pos = cueBall.mesh.position;\n            const positions = aimLine.geometry.attributes.position.array;\n            positions[0] = pos.x;\n            positions[1] = pos.y;\n            positions[2] = pos.z;\n            \n            // 线条指向击球方向\n            positions[3] = pos.x + direction.x;\n            positions[4] = pos.y + direction.y; // 保持水平\n            positions[5] = pos.z + direction.z;\n            aimLine.geometry.attributes.position.needsUpdate = true;\n        }\n    }\n\n    function onPointerUp(event) {\n        if (!isDragging) return;\n        isDragging = false;\n        controls.enabled = true;\n        aimLine.visible = false;\n\n        // 计算最终力度并击球\n        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;\n        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;\n        raycaster.setFromCamera(mouse, camera);\n        const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -cueBall.mesh.position.y);\n        const target = new THREE.Vector3();\n        raycaster.ray.intersectPlane(plane, target);\n\n        if (target && cueBall) {\n            // 向量：球位置 - 鼠标位置 = 击球方向\n            const forceVector = new THREE.Vector3().subVectors(cueBall.mesh.position, target);\n            \n            // 限制最大力度\n            const maxForce = 50;\n            const multiplier = 5; \n            if (forceVector.length() > 10) forceVector.setLength(10);\n            \n            // 只有 y 轴不施加力 (除非重力反转模式下想打飞球，但这里保持水平击打体验更好)\n            forceVector.y = 0; \n\n            // CannonJS 施加脉冲\n            cueBall.body.wakeUp();\n            cueBall.body.applyImpulse(\n                new CANNON.Vec3(forceVector.x * multiplier, 0, forceVector.z * multiplier),\n                cueBall.body.position\n            );\n        }\n    }\n\n    function onWindowResize() {\n        camera.aspect = window.innerWidth / window.innerHeight;\n        camera.updateProjectionMatrix();\n        renderer.setSize(window.innerWidth, window.innerHeight);\n    }\n\n    // --- 游戏循环 ---\n    function animate() {\n        requestAnimationFrame(animate);\n\n        // 更新物理\n        world.step(1 / 60);\n\n        // 同步物理体和网格\n        balls.forEach(b => {\n            b.mesh.position.copy(b.body.position);\n            b.mesh.quaternion.copy(b.body.quaternion);\n        });\n        \n        // 瞄准线跟随白球（如果没有在拖拽）\n        if (!isDragging && cueBall) {\n             // pass\n        }\n\n        checkPockets();\n        controls.update();\n        renderer.render(scene, camera);\n    }\n\n</script>\n</body>\n</html>\n```\n\n### 玩法说明：\n\n1.  **初始化**：游戏开始时是标准的物理规则。\n2.  **击球**：\n    *   将鼠标移动到白球上，**按住鼠标左键**。\n    *   像拉弹弓一样向后拖动鼠标（会出现一条白线指示击球方向和力度）。\n    *   **松开左键** 发射白球。\n3.  **触发混乱**：\n    *   只要有**彩色球**掉出台面（因为没有做复杂的球袋模型，只要球掉落到台面下方即视为进球），屏幕上方的规则提示就会改变。\n    *   **Gravity Invert (重力反转)**：球会全部飘起来，贴在隐形的天花板上。你需要在这个状态下尝试击球（虽然很难控制）。\n    *   **Tilt (倾斜)**：台面会向左或向右倾斜，球会自动滚动，你需要预判偏移量。\n    *   **Random Size (尺寸错乱)**：有的球变大，有的变小，物理碰撞体积也会随之改变，导致击球线路完全不可预测。\n    *   **Invisible Walls (隐形障碍)**：台面上会出现几个看不见的立方体，球碰到空气会反弹。\n\n### 技术实现细节：\n\n*   **单文件结构**：所有 CSS、HTML 和 JS (ES Modules) 都在一个文件中，通过 CDN 动态加载 Three.js 和 Cannon-es。\n*   **物理同步**：每一帧 `world.step` 后，将 Cannon body 的位置和旋转复制给 Three.js 的 Mesh。\n*   **状态管理**：`triggerRandomRule` 函数负责清理上一轮的混乱状态（如删除隐形墙、重置重力），然后应用新的随机规则。\n*   **简化碰撞**：为了保证单文件代码简洁，没有使用复杂的 CSG 挖洞，而是通过检测球的 Y 坐标是否低于阈值来判定\"进球\"。",
    "model": "gemini-3-pro-preview",
    "usage": {
      "prompt_tokens": 122,
      "completion_tokens": 8217,
      "total_tokens": 8339
    },
    "timestamp": "2025-11-19 13:26:51"
  },
  "needs_screenshot": true,
  "needs_manual_test": true
}