D28 - 「来互相伤害啊!」:粗乃玩摇杆!

先建立游戏组件,以便加入後续内容。

src\components\window-app-cat-vs-dog\game-scene.vue <template lang="pug">

.game-scene.w-600px.h-800px(:id='`game-scene-${id}`')

src\components\window-app-cat-vs-dog\game-scene.vue <script>

/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/types/type').PinCapability} PinCapability
 */

import { mapState } from 'vuex';
import Phaser from 'phaser';

export default {
  name: 'GameScene',
  data() {
    return {
      /** @type {Phaser.Game} */
      game: null,
    };
  },
  mounted() {
    this.initGame();
  },
  methods: {
    /** 初始化游戏 */
    initGame() {
      /** @type {Phaser.Types.Core.GameConfig} */
      const config = {
        type: Phaser.WEBGL,
        width: 600,
        height: 800,
        parent: `game-scene-${this.id}`,
        scene: [],
        backgroundColor: '#FFF',
        disableContextMenu: true,

        physics: {
          default: 'arcade',
          arcade: {
            // debug: true,
          },
        },
      };

      this.game = new Phaser.Game(config);
    },
  },
};

建立视窗,打开 DevTools,应该会在 Console 中看到以下讯息。

Untitled

这就表示我们成功载入 Phaser 了!

接下来让我们加入摇杆并完成设定栏位吧。

认识摇杆

先来认识新朋友,相信任何有过游戏机的玩家应该都有用过。

没用过摇杆,也有看过摇杆走路。ᕕ( ゚ ∀。)ᕗ

2021-09-17 23.11.59.jpg

D28 - 认识摇杆.png

D28 - 认识摇杆讯号 (2).png

两个可变电阻刚好相交 90 度角,如此便可以表示一个平面上的运动。

接下来让我们组一个电路,实际看看摇杆模组的讯号。

组电路之前一样先来检查摇杆功能是否正常。

测试摇杆功能

基本上概念等测试「可变电阻」与「按钮」。

电阻部分

D28 - 测试摇杆可变电阻功能.png

按钮部分

D28 - 测试摇杆按钮功能.png

电路实验

透过先前建立的「数位 I/O 视窗」与「类比输入视窗」来看看讯号吧!

首先完成电路接线。

D28 - 摇杆电路.png

接着建立视窗实测看看。

D28 - 查看摇杆讯号内容.gif

建立摇杆物件

鳕鱼:「让我们回到 D12 与 D15 一样,开始解析数位讯号与类比讯号吧!」

电子助教:「同一个段子用两次会没人要看喔 ...('◉◞⊖◟◉` )」

鳕鱼:「窝错惹,请大家不要离开 ( ´•̥̥̥ ω •̥̥̥` )」

与 D25 时建立按钮物件一样,来做一个摇杆物件吧!

建立 joy-stick.js 用於摇杆讯号转换、抽象化,方便应用。

  • 给定脚位、模式、收发器,自动处理 Port 转换、类比讯号、数位讯号监听等等逻辑。
  • 内部引用 button.js,并转发所有按钮事件。
  • 自动回报 XY 轴类比数值。

src\script\electronic-components\joy-stick.js

/**
 * @typedef {import('EventEmitter2').Listener} Listener
 * 
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 *
 * @typedef {import('@/types/type').PinInfo} PinInfo
 * @typedef {import('@/script/firmata/firmata').DigitalResponseMessage} DigitalResponseMessage
 * @typedef {import('@/script/firmata/firmata').AnalogResponseMessage} AnalogResponseMessage
 * @typedef {import('@/script/electronic-components/Button').ConstructorParams} ButtonParams
 *
 * @typedef {Object} ConstructorParams
 * @property {PortTransceiver} transceiver Port 收发器
 * @property {Object} analogPinMap 类比脚位映射表
 * @property {AxisOptions} xAxis X 轴设定
 * @property {AxisOptions} yAxis Y 轴设定
 * @property {Object} btn 按钮设定
 * @property {PinInfo} btn.pin 指定脚位
 * @property {number} btn.mode 按钮脚位模式
 * 
 * @typedef {Object} AxisOptions 轴设定
 * @property {PinInfo} pin 指定脚位
 * @property {number} [origin] 原点。摇杆无动作时,类比数值基准点。
 * @property {number} [threshold] 阀值。origin 正负 threshold 之内的数值,视为没有动作。
 * @property {boolean} [isReverse] 轴反转
 * 
 */

import EventEmitter2 from 'eventemitter2';
import { findLast, throttle } from 'lodash-es';

import { PinMode } from '@/script/utils/firmata.utils';

import Button from './button';

const axisOptionsDefault = {
  origin: 510,
  threshold: 20,
  isReverse: false,
};

/** 
 * 基本摇杆
 */
export default class extends EventEmitter2.EventEmitter2 {
  /** 目前轴类比数值 */
  axesValue = {
    x: 0,
    y: 0,
  };

  /** @type {PortTransceiver} */
  portTransceiver = null;
  analogPinMap = {};

  /** X 轴设定
   * @type {AxisOptions}
   */
  xAxis = null;
  /** Y 轴设定
   * @type {AxisOptions}
   */
  yAxis = null;

  /** 按钮物件
   * @type {Button}
   */
  btn = null;

  /** 数值回报监听器
   * @type {Listener}
   */
  listener = null;

  throttle = {
    setAxesValue: null,
  };

  /**
   * @param {ConstructorParams} params
   */
  constructor(params) {
    super();

    const {
      transceiver = null,
      analogPinMap = null,
      xAxis, yAxis, btn
    } = params;

    if (!transceiver) throw new Error(`transceiver 必填`);
    if (!analogPinMap) throw new Error(`analogPinMap 必填`);
    if (!xAxis?.pin) throw new Error(`xAxis.pin 必填`);
    if (!yAxis?.pin) throw new Error(`yAxis.pin 必填`);
    if (!btn?.pin) throw new Error(`btn.pin 必填`);

    // 储存变数
    this.portTransceiver = transceiver;
    this.analogPinMap = analogPinMap;
    this.xAxis = {
      ...axisOptionsDefault, ...xAxis
    }
    this.yAxis = {
      ...axisOptionsDefault, ...yAxis
    }

    /** 初始化按钮物件 */
    this.btn = new Button({
      ...btn,
      transceiver,
    });
    /** 将所有 btn 事件转送出去 */
    this.btn.onAny((event, value) => this.emit(event, value));

    /** 建立 throttle 功能 */
    this.throttle = {
      setAxesValue: throttle((...params) => {
        this.setAxesValue(...params);
      }, 100),
    }

    this.init();
  }

  /** 初始化
   * 进行脚位设定、启动监听器
   */
  async init() {
    const xPinNum = this.xAxis.pin.number;
    const yPinNum = this.yAxis.pin.number;

    this.portTransceiver.addCmd('setMode', {
      pin: xPinNum,
      mode: PinMode.ANALOG_INPUT,
    });
    this.portTransceiver.addCmd('setMode', {
      pin: yPinNum,
      mode: PinMode.ANALOG_INPUT,
    });

    this.listener = this.portTransceiver.on('data:analogMessage', (data) => {
      this.handleData(data);
    }, { objectify: true });
  }
  /** 销毁所有物件、监听器 */
  destroy() {
    this.btn.destroy();
    this.listener.off();
    this.removeAllListeners();
  }

  /** 处理类比讯号数值
   * @param {AnalogResponseMessage[]} data
   */
  handleData(data) {
    const { xAxis, yAxis, analogPinMap } = this;

    let x = 0;
    let y = 0;

    /** 取得 X 轴类比资料 */
    const xVal = findLast(data, ({ analogPin }) => {
      const mapNum = analogPinMap[xAxis.pin.number];
      return mapNum === analogPin
    });
    if (xVal) {
      x = this.calcAxisValue(xVal.value, xAxis)
    }

    /** 取得 Y 轴类比资料 */
    const yVal = findLast(data, ({ analogPin }) => {
      const mapNum = analogPinMap[yAxis.pin.number];
      return mapNum === analogPin
    });
    if (yVal) {
      y = this.calcAxisValue(yVal.value, yAxis)
    }

    this.throttle.setAxesValue({
      x, y,
    });
  }

  /** 储存轴向类比数值 */
  setAxesValue({ x, y }) {
    this.axesValue.x = x;
    this.axesValue.y = y;

    this.emit('data', this.axesValue);
  }
  /** 取得轴向类比数值 */
  getAxesValue() {
    return this.axesValue;
  }
  /** 取得按钮数值 */
  getBtnValue() {
    return this.btn.getValue();
  }

  /** 将类比数值转换为摇杆轴资料
   * @param {number} value
   * @param {AxisOptions} options 
   */
  calcAxisValue(value, options) {
    const { origin, threshold, isReverse } = options;

    /** 
     * 需要设定 threshold,因为实际上摇杆回到中点时,
     * 类比数值并非完全静止,可能会在正负 1 或 2 些微浮动,
     * 如果判断摇杆静止是用单一数值判断,容易误判,
     * 所以透过 threshold,以范围来判断,就可以解决误判问题。
     */
    const delta = origin - value;
    if (Math.abs(delta) < threshold) {
      return 0;
    }

    return isReverse ? delta * -1 : delta;
  }

}

完成设定栏位

了解摇杆後,现在我们知道总共需要设定 3 个脚位,让我们回到 window-app-cat-vs-dog.vue 完成设定栏位选项。

  • data()
    • 加入 xPinyPinbtnPin
  • computed
    • 加入 supportPullupPins() 列举支援上拉输入脚位清单
    • 加入 supportAnalogInputPins() 列举支援类比输入脚位清单
    • 完成 isSettingOk() 程序。
  • watch
    • 侦测 xPinyPinbtnPin 变化,并呼叫 handlePinSelect()

src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <script>

// ...

import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP, ANALOG_INPUT } = PinMode;

export default {
  name: 'WindowAppCatVsDog',
  // ...
  data() {
    return {
      xPin: null,
      yPin: null,
      btnPin: null,
    };
  },
  computed: {
    ...mapState({
      boardPins: (state) => state.board.info.pins,
    }),

    /** 支援上拉输入功能脚位 */
    supportPullupPins() {
      /** @type {PinInfo[]} */
      const boardPins = this.boardPins;

      return boardPins.filter((pin) =>
        pin.capabilities.some((capability) => INPUT_PULLUP === capability.mode)
      );
    },

    /** 支援类比输入功能脚位 */
    supportAnalogInputPins() {
      /** @type {PinInfo[]} */
      const boardPins = this.boardPins;

      return boardPins.filter((pin) =>
        pin.capabilities.some((capability) => ANALOG_INPUT === capability.mode)
      );
    },

    /** 设定栏位是否完成 */
    isSettingOk() {
      return this.xPin && this.yPin && this.btnPin;
    },
  },
	watch: {
    xPin(newVal, oldVal) {
      this.handlePinSelect(newVal, oldVal);
    },
    yPin(newVal, oldVal) {
      this.handlePinSelect(newVal, oldVal);
    },
    btnPin(newVal, oldVal) {
      this.handlePinSelect(newVal, oldVal);
    },
  },
  // ...
};

src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <template lang="pug">

base-window.window-app-cat-vs-dog(
  :pos='pos',
  header-icon='r_videogame_asset',
  body-class='c-col',
  title='猫狗大战'
)
  .h-full.overflow-hidden
    game-scene

    transition(name='fade-up')
      .setting-form(v-if='!isSettingOk')
        .form-section
          .form-item.mb-20px
            q-icon.mr-10px(name='r_gamepad', size='20px')
            .text-18px.font-700
              | 设定控制器

          .form-item
            .text-16px.w-200px
              | X 轴讯号
            base-select-pin.w-full(
              v-model='xPin',
              :pins='supportAnalogInputPins',
              color='light-green-4',
              placeholder='点击选择',
              @err='handleErr'
            )

          .form-item
            .text-16px.w-200px
              | Y 轴讯号
            base-select-pin.w-full(
              v-model='yPin',
              :pins='supportAnalogInputPins',
              color='light-green-4',
              placeholder='点击选择',
              @err='handleErr'
            )

          .form-item
            .text-16px.w-200px
              | 按钮讯号
            base-select-pin.w-full(
              v-model='btnPin',
              :pins='supportPullupPins',
              color='light-green-4',
              placeholder='点击选择',
              @err='handleErr'
            )

完成後应该会长这样。

Untitled

将着将脚位透过 props 传入 game-scene.vue 组件吧。

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

export default {
  name: 'GameScene',
  props: {
    xPin: {
      type: Object,
      default() {
        return null;
      },
    },
    yPin: {
      type: Object,
      default() {
        return null;
      },
    },
    btnPin: {
      type: Object,
      default() {
        return null;
      },
    },
  },
  // ...
};

window-app-cat-vs-dog.vue 模板中的 game-scene.vue 也要记得加入参数。

src\components\window-app-cat-vs-dog\window-app-cat-vs-dog.vue <template lang="pug">

base-window.window-app-cat-vs-dog(
  :pos='pos',
  header-icon='r_videogame_asset',
  body-class='c-col',
  title='猫狗大战'
)
  .h-full.overflow-hidden
    game-scene(:x-pin='xPin', :y-pin='yPin', :btn-pin='btnPin')
		
		// ...

接着侦测是否所有的脚位都设定完成,完成後进行摇杆初始化。

  • data() 新增 joyStick,储存摇杆物件。
  • 从 Vuex 中取得 portTransceiveranalogPinMap
  • 删除 mounted() 内的 this.initGame(),改由其他程序负责呼叫初始化。
  • methods() 新增 initController() ,用於初始化摇杆。
  • created() 使用 $watch 进行所有脚位侦测与初始化。
  • beforeDestroy() 销毁所有物件。

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

import JoyStick from '@/script/electronic-components/joy-stick';

import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP } = PinMode;

export default {
  name: 'GameScene',
  // ...
  data() {
    return {
      /** @type {Phaser.Game} */
      game: null,

      /** @type {JoyStick} */
      joyStick: null,
    };
  },
	computed: {
    ...mapState({
      /** @type {PortTransceiver} */
      portTransceiver: (state) => state.core.transceiver,
      analogPinMap: (state) => state.board.info.analogPinMap,
    }),
  },
  // ...
	created() {
    // 同时侦测所有 Pin
		this.$watch(
      () => {
        return [this.xPin, this.yPin, this.btnPin];
      },
      ([xPin, yPin, btnPin]) => {
        // 所有脚位都设定完成才初始化
        if (!xPin || !yPin || !btnPin) return;

        this.initController();
        this.initGame();
      }
    );
  },
  mounted() {
    // 清空
  },
  beforeDestroy() {
    this.game?.destroy?.();
    this.joyStick?.destroy?.();
  },
  methods: {
    // ...

    /** 初始化摇杆 */
    initController() {
      this.joyStick = new JoyStick({
        transceiver: this.portTransceiver,
        analogPinMap: this.analogPinMap,
        xAxis: {
          pin: this.xPin,
        },
        yAxis: {
          pin: this.yPin,
        },
        btn: {
          pin: this.btnPin,
          mode: INPUT_PULLUP,
        },
      });
    },
  },
};

忽然发现建立视窗时有个错误。

Untitled

这是因为忘记使用 injectprovide 进来的 id 取出来,就直接使用 id 造成的错误。

修正一下这个问题。

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

export default {
  name: 'GameScene',
  // ...
  inject: ['id'],
  // ...
};

完成控制器设定!

D28 - 完成选择控制器设定.gif

加入监听事件看看摇杆物件有没有正常作用吧。

initController() 中加入 onAny()

src\components\window-app-cat-vs-dog\game-scene.vue <script>

// ...

import JoyStick from '@/script/electronic-components/joy-stick';

import { PinMode } from '@/script/utils/firmata.utils';
const { INPUT_PULLUP } = PinMode;

export default {
  name: 'GameScene',
  // ...
  methods: {
    // ...

    /** 初始化摇杆 */
    initController() {
      this.joyStick = new JoyStick({
        // ...
      });

			this.joyStick.onAny((event, value) => {
        if (event === 'data') {
          const { x, y } = value;
          console.log(`[ joyStick on ${event} ] value : `, x, y);
          return;
        }

        console.log(`[ joyStick on ${event} ] value : `, value);
      });
    },
  },
};

D28 - 摇杆物件功能正常.gif

摇杆功能正常!一起玩摇杆吧!✧*。٩(ˊᗜˋ*)و✧*。

总结

  • 认识摇杆元件
  • 建立摇杆物件
  • 完成控制器设定
  • 成功取得摇杆资料

以上程序码已同步至 GitLab,大家可以前往下载:

GitLab - D28


<<:  【设计+切版30天实作】|Day28 - CTA区块 - 超快速切出简约CTA,让使用者注册起来!!

>>:  [ Day 27 ] 实作一个 React.js 网站 3/5

Day 16 Azure cognitive service: Face recognition- 让你的机器人认得你

Azure face service: Face recognition- 让你的机器人认得你 人脸...

[Day 19] Node http request

前言 今天来看看, node 怎麽进行一个 http request 正文 打开 http.js 这...

Day23 切版笔记- 人员介绍卡片

运用到的观念 border搭配伪元素制作出三角形区块 绝对定位&相对定位 用:hover ...

Day5- Java语言编译器:javac & 运行工具:java (下)

接续着上篇的内容,这篇要介绍情境二、三~ 情境二:引用到其他的java档 Step1.先创建资料夹及...

【Day01-资料】什麽才叫做资料?不就是资料吗还有什麽差别?

在这个演算法当道的时代 每一家网路公司在想办法尽量的搜集使用者的资讯 不论是苹果限制脸书获取使用者的...