电视盒子/机顶盒游戏开发踩坑记录:按键处理与适配技巧

去年做了一款 TV 端游戏,跟手机游戏差别还挺大的。遥控器、手柄操作,焦点导航,都是些新东西。这篇记录一下 TV 盒子开发的核心要点和踩过的坑。

TV 游戏 vs 手机游戏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌─────────────────────────────────────────────────────────────┐
│ TV 游戏 vs 手机游戏对比 │
├─────────────────────────────────────────────────────────────┤

│ 特性 TV 游戏 手机游戏
│ ─────────────────────────────────────────────────

│ 输入方式 ┌──────────────┐ ┌──────────────┐
│ │ 遥控器 │ │ 触屏 │
│ │ 手柄 │ │ 手势 │
│ │ 键盘 │ │ 重力感应 │
│ └──────────────┘ └──────────────┘

│ 操作特点 • 方向键导航 • 直接点击
│ • 按键数量少 • 多点触控
│ • 需要焦点管理 • 无需焦点管理

│ 屏幕特性 • 大屏幕(40-70寸) • 小屏幕(4-7寸)
│ • 远距离观看 • 近距离观看
│ • 高分辨率 • 分辨率多样

│ UI 设计 • 大字体大按钮 • 精细界面
│ • 清晰的焦点指示 • 无需焦点指示
│ • 简洁的层级 • 复杂的层级

常见 TV 设备类型

设备类型 典型产品 输入设备 系统版本
Android TV Mi Box, Nvidia Shield 遥控器/手柄 Android 8+
智能电视 小米电视, TCL 遥控器 自研/Android
IPTV 机顶盒 华为, 中兴 专用遥控器 Android 4.4+
OTT 盒子 Fire TV, Apple TV 遥控器/手柄 Fire OS/tvOS

按键事件处理

Android 按键映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 常见 TV 按键键值
public class TVKeyCode {
// 方向键
public static final int KEYCODE_DPAD_UP = 19;
public static final int KEYCODE_DPAD_DOWN = 20;
public static final int KEYCODE_DPAD_LEFT = 21;
public static final int KEYCODE_DPAD_RIGHT = 22;
public static final int KEYCODE_DPAD_CENTER = 23; // 确定/OK键

// 常用功能键
public static final int KEYCODE_BACK = 4; // 返回
public static final int KEYCODE_HOME = 3; // Home
public static final int KEYCODE_MENU = 82; // 菜单
public static final int KEYCODE_VOLUME_UP = 24; // 音量+
public static final int KEYCODE_VOLUME_DOWN = 25; // 音量-

// 数字键
public static final int KEYCODE_0 = 7;
public static final int KEYCODE_1 = 8;
// ... KEYCODE_9 = 16

// 彩色按键(部分遥控器)
public static final int KEYCODE_PROG_RED = 183; // 红
public static final int KEYCODE_PROG_GREEN = 184; // 绿
public static final int KEYCODE_PROG_YELLOW = 185; // 黄
public static final int KEYCODE_PROG_BLUE = 186; // 蓝
}

Cocos2d-x 按键事件处理

Java 层按键分发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Cocos2dxGLSurfaceView.java
public class Cocos2dxGLSurfaceView extends GLSurfaceView {

@Override
public boolean onKeyDown(final int pKeyCode, final KeyEvent pKeyEvent) {
// 将按键事件分发到 C++ 层
this.queueEvent(new Runnable() {
@Override
public void run() {
Cocos2dxGLSurfaceView.this.mCocos2dxRenderer.handleKeyDown(pKeyCode);
}
});

// 按键冒泡控制:
// 除了返回按键,其他按键最好都要冒泡给系统处理
if (pKeyCode != KeyEvent.KEYCODE_BACK) {
return super.onKeyDown(pKeyCode, pKeyEvent);
}
return true; // 返回键消费,不冒泡
}

@Override
public boolean onKeyUp(final int pKeyCode, final KeyEvent pKeyEvent) {
this.queueEvent(new Runnable() {
@Override
public void run() {
Cocos2dxGLSurfaceView.this.mCocos2dxRenderer.handleKeyUp(pKeyCode);
}
});

if (pKeyCode != KeyEvent.KEYCODE_BACK) {
return super.onKeyUp(pKeyCode, pKeyEvent);
}
return true;
}
}

关键注意事项:

  1. 必须冒泡:除返回键外,其他按键最好都冒泡给系统处理
  2. 返回键特殊处理:返回键冒泡会导致游戏直接切换到后台
  3. 部分盒子限制:有些机顶盒只能监听按下(onKeyDown)或抬起(onKeyUp)中的一个

C++ 层按键处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// TVInputManager.h
#ifndef __TV_INPUT_MANAGER_H__
#define __TV_INPUT_MANAGER_H__

#include "cocos2d.h"

USING_NS_CC;

// TV 按键码枚举
enum class TVKey {
UP = 19,
DOWN = 20,
LEFT = 21,
RIGHT = 22,
OK = 23,
BACK = 4,
HOME = 3,
MENU = 82
};

// 按键事件回调接口
class TVInputDelegate {
public:
virtual void onTVKeyPressed(TVKey key) = 0;
virtual void onTVKeyReleased(TVKey key) = 0;
};

class TVInputManager {
public:
static TVInputManager* getInstance();

void init();
void setDelegate(TVInputDelegate* delegate);

// 按键事件入口(从 Java 层调用)
void onKeyDown(int keyCode);
void onKeyUp(int keyCode);

// 启用/禁用输入
void setEnabled(bool enabled);

private:
TVInputManager();
~TVInputManager();

bool _enabled;
TVInputDelegate* _delegate;
std::map<int, bool> _keyStates;
};

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// TVInputManager.cpp
#include "TVInputManager.h"

static TVInputManager* s_instance = nullptr;

TVInputManager* TVInputManager::getInstance() {
if (!s_instance) {
s_instance = new TVInputManager();
}
return s_instance;
}

TVInputManager::TVInputManager()
: _enabled(true)
, _delegate(nullptr) {
}

void TVInputManager::init() {
// 创建按键监听器
auto keyListener = EventListenerKeyboard::create();

keyListener->onKeyPressed = [this](EventKeyboard::KeyCode keyCode, Event* event) {
if (!_enabled) return;

int androidKeyCode = static_cast<int>(keyCode);
this->onKeyDown(androidKeyCode);
};

keyListener->onKeyReleased = [this](EventKeyboard::KeyCode keyCode, Event* event) {
if (!_enabled) return;

int androidKeyCode = static_cast<int>(keyCode);
this->onKeyUp(androidKeyCode);
};

Director::getInstance()->getEventDispatcher()->
addEventListenerWithFixedPriority(keyListener, 1);
}

void TVInputManager::onKeyDown(int keyCode) {
// 防止按键重复触发
if (_keyStates[keyCode]) return;
_keyStates[keyCode] = true;

if (_delegate) {
_delegate->onTVKeyPressed(static_cast<TVKey>(keyCode));
}
}

void TVInputManager::onKeyUp(int keyCode) {
_keyStates[keyCode] = false;

if (_delegate) {
_delegate->onTVKeyReleased(static_cast<TVKey>(keyCode));
}
}

void TVInputManager::setEnabled(bool enabled) {
_enabled = enabled;
}

UI 焦点导航系统

焦点管理架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────┐
│ TVFocusManager │
│ (焦点管理器) │
├─────────────────────────────────────────┤

│ ┌─────────────────────────────────┐
│ │ Current Focus Node │
│ │ (当前获得焦点的控件) │
│ └──────────────┬──────────────────┘
│ │
│ ┌───────────┼───────────┐
│ │ │ │
│ ▼ ▼ ▼
│ ┌──────┐ ┌──────┐ ┌──────┐
│ │ Up │ │ Down │ │ OK │
│ │邻居 │ │邻居 │ │响应 │
│ └──────┘ └──────┘ └──────┘

焦点导航实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// TVFocusManager.h
#ifndef __TV_FOCUS_MANAGER_H__
#define __TV_FOCUS_MANAGER_H__

#include "cocos2d.h"

USING_NS_CC;

// 可聚焦接口
class IFocusable {
public:
virtual void setFocused(bool focused) = 0;
virtual bool isFocused() const = 0;
virtual Rect getFocusBounds() const = 0;

// 邻居节点
virtual IFocusable* getUpNeighbor() const = 0;
virtual IFocusable* getDownNeighbor() const = 0;
virtual IFocusable* getLeftNeighbor() const = 0;
virtual IFocusable* getRightNeighbor() const = 0;

// 选中回调
virtual void onSelect() = 0;
virtual void onBack() = 0;
};

class TVFocusManager : public TVInputDelegate {
public:
static TVFocusManager* getInstance();

void init();

// 注册/注销可聚焦控件
void registerFocusable(IFocusable* node);
void unregisterFocusable(IFocusable* node);

// 设置当前焦点
void setFocus(IFocusable* node);
IFocusable* getCurrentFocus() const;

// 方向导航
void moveFocusUp();
void moveFocusDown();
void moveFocusLeft();
void moveFocusRight();

// TVInputDelegate 实现
void onTVKeyPressed(TVKey key) override;
void onTVKeyReleased(TVKey key) override;

private:
TVFocusManager();

IFocusable* _currentFocus;
std::vector<IFocusable*> _focusables;
};

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// TVFocusManager.cpp
#include "TVFocusManager.h"
#include "TVInputManager.h"

static TVFocusManager* s_instance = nullptr;

TVFocusManager* TVFocusManager::getInstance() {
if (!s_instance) {
s_instance = new TVFocusManager();
}
return s_instance;
}

TVFocusManager::TVFocusManager()
: _currentFocus(nullptr) {
}

void TVFocusManager::init() {
// 注册为输入管理器的委托
TVInputManager::getInstance()->setDelegate(this);
TVInputManager::getInstance()->init();
}

void TVFocusManager::onTVKeyPressed(TVKey key) {
switch (key) {
case TVKey::UP:
moveFocusUp();
break;
case TVKey::DOWN:
moveFocusDown();
break;
case TVKey::LEFT:
moveFocusLeft();
break;
case TVKey::RIGHT:
moveFocusRight();
break;
case TVKey::OK:
if (_currentFocus) {
_currentFocus->onSelect();
}
break;
case TVKey::BACK:
if (_currentFocus) {
_currentFocus->onBack();
}
break;
default:
break;
}
}

void TVFocusManager::moveFocusUp() {
if (!_currentFocus) return;

IFocusable* neighbor = _currentFocus->getUpNeighbor();
if (neighbor) {
setFocus(neighbor);
}
}

void TVFocusManager::moveFocusDown() {
if (!_currentFocus) return;

IFocusable* neighbor = _currentFocus->getDownNeighbor();
if (neighbor) {
setFocus(neighbor);
}
}

void TVFocusManager::moveFocusLeft() {
if (!_currentFocus) return;

IFocusable* neighbor = _currentFocus->getLeftNeighbor();
if (neighbor) {
setFocus(neighbor);
}
}

void TVFocusManager::moveFocusRight() {
if (!_currentFocus) return;

IFocusable* neighbor = _currentFocus->getRightNeighbor();
if (neighbor) {
setFocus(neighbor);
}
}

void TVFocusManager::setFocus(IFocusable* node) {
if (_currentFocus) {
_currentFocus->setFocused(false);
}

_currentFocus = node;

if (_currentFocus) {
_currentFocus->setFocused(true);
}
}

可聚焦按钮实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// TVButton.h
#ifndef __TV_BUTTON_H__
#define __TV_BUTTON_H__

#include "cocos2d.h"
#include "TVFocusManager.h"

class TVButton : public cocos2d::MenuItemSprite, public IFocusable {
public:
static TVButton* create(const std::string& normalImage,
const std::string& selectedImage,
const std::string& callback);

bool init(const std::string& normalImage,
const std::string& selectedImage);

// IFocusable 实现
void setFocused(bool focused) override;
bool isFocused() const override;
cocos2d::Rect getFocusBounds() const override;

void setUpNeighbor(IFocusable* neighbor) { _upNeighbor = neighbor; }
void setDownNeighbor(IFocusable* neighbor) { _downNeighbor = neighbor; }
void setLeftNeighbor(IFocusable* neighbor) { _leftNeighbor = neighbor; }
void setRightNeighbor(IFocusable* neighbor) { _rightNeighbor = neighbor; }

IFocusable* getUpNeighbor() const override { return _upNeighbor; }
IFocusable* getDownNeighbor() const override { return _downNeighbor; }
IFocusable* getLeftNeighbor() const override { return _leftNeighbor; }
IFocusable* getRightNeighbor() const override { return _rightNeighbor; }

void onSelect() override;
void onBack() override;

private:
bool _focused;
IFocusable* _upNeighbor;
IFocusable* _downNeighbor;
IFocusable* _leftNeighbor;
IFocusable* _rightNeighbor;

cocos2d::Node* _focusIndicator; // 焦点指示器(发光边框等)
};

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// TVButton.cpp
#include "TVButton.h"

USING_NS_CC;

TVButton* TVButton::create(const std::string& normalImage,
const std::string& selectedImage,
const std::string& callback) {
TVButton* ret = new TVButton();
if (ret && ret->init(normalImage, selectedImage)) {
ret->autorelease();
return ret;
}
CC_SAFE_DELETE(ret);
return nullptr;
}

bool TVButton::init(const std::string& normalImage,
const std::string& selectedImage) {
_focused = false;
_upNeighbor = nullptr;
_downNeighbor = nullptr;
_leftNeighbor = nullptr;
_rightNeighbor = nullptr;

// 创建焦点指示器(发光效果)
_focusIndicator = Sprite::create("focus_border.png");
_focusIndicator->setVisible(false);
addChild(_focusIndicator, -1);

// 注册到焦点管理器
TVFocusManager::getInstance()->registerFocusable(this);

return true;
}

void TVButton::setFocused(bool focused) {
_focused = focused;

// 显示/隐藏焦点指示器
_focusIndicator->setVisible(focused);

// 缩放效果
if (focused) {
runAction(ScaleTo::create(0.1f, 1.1f));
_focusIndicator->runAction(RepeatForever::create(
Sequence::create(
FadeTo::create(0.5f, 200),
FadeTo::create(0.5f, 255),
nullptr
)
));
} else {
runAction(ScaleTo::create(0.1f, 1.0f));
_focusIndicator->stopAllActions();
_focusIndicator->setOpacity(255);
}
}

bool TVButton::isFocused() const {
return _focused;
}

Rect TVButton::getFocusBounds() const {
return getBoundingBox();
}

void TVButton::onSelect() {
// 触发点击回调
activate();

// 点击动画反馈
runAction(Sequence::create(
ScaleTo::create(0.05f, 0.95f),
ScaleTo::create(0.05f, 1.1f),
nullptr
));
}

void TVButton::onBack() {
// 返回键处理,默认返回上一页
Director::getInstance()->popScene();
}

踩坑记录

坑 1:Home 键无法监听

现象: 按 Home 键无法在游戏内捕获,直接返回系统桌面

原因: Home 键是系统级按键,普通应用无法拦截(安全机制)

解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 无法直接监听 Home 键,但可以通过生命周期感知
public class MainActivity extends Cocos2dxActivity {

@Override
protected void onPause() {
super.onPause();
// 游戏进入后台,暂停游戏
nativeGamePause();
}

@Override
protected void onResume() {
super.onResume();
// 游戏回到前台,恢复游戏
nativeGameResume();
}

private native void nativeGamePause();
private native void nativeGameResume();
}

坑 2:部分盒子只能监听按下或抬起

现象: 某些机顶盒只触发 onKeyDown 或 onKeyUp 之一

解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 在按键管理器中统一处理
void TVInputManager::onKeyDown(int keyCode) {
// 如果已经按下,不再重复触发
if (_keyStates[keyCode]) return;
_keyStates[keyCode] = true;

if (_delegate) {
_delegate->onTVKeyPressed(static_cast<TVKey>(keyCode));
}
}

void TVInputManager::onKeyUp(int keyCode) {
// 如果未记录为按下状态,模拟一个按下事件(兼容性处理)
if (!_keyStates[keyCode]) {
if (_delegate) {
_delegate->onTVKeyPressed(static_cast<TVKey>(keyCode));
}
}
_keyStates[keyCode] = false;

if (_delegate) {
_delegate->onTVKeyReleased(static_cast<TVKey>(keyCode));
}
}

坑 3:返回键冒泡导致应用退出

现象: 按返回键时游戏直接退到后台,没有触发游戏内的返回处理

解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 关键:返回键消费事件,不冒泡给系统
@Override
public boolean onKeyDown(final int pKeyCode, final KeyEvent pKeyEvent) {
this.queueEvent(new Runnable() {
@Override
public void run() {
Cocos2dxGLSurfaceView.this.mCocos2dxRenderer.handleKeyDown(pKeyCode);
}
});

// 返回键返回 true,消费事件
if (pKeyCode == KeyEvent.KEYCODE_BACK) {
return true; // 消费事件,不冒泡
}

// 其他按键冒泡给系统处理
return super.onKeyDown(pKeyCode, pKeyEvent);
}

坑 4:焦点丢失或无法导航

现象: 按方向键焦点没有按预期移动

常见原因和解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. 确保邻居节点正确设置
TVButton* btnStart = TVButton::create("start.png", "start_sel.png");
TVButton* btnSetting = TVButton::create("setting.png", "setting_sel.png");
TVButton* btnExit = TVButton::create("exit.png", "exit_sel.png");

// 设置垂直导航关系
btnStart->setDownNeighbor(btnSetting);
btnSetting->setUpNeighbor(btnStart);
btnSetting->setDownNeighbor(btnExit);
btnExit->setUpNeighbor(btnSetting);

// 2. 默认设置第一个焦点
TVFocusManager::getInstance()->setFocus(btnStart);

// 3. 在场景切换时重新设置焦点
void MainMenuScene::onEnter() {
Layer::onEnter();
// 恢复焦点
TVFocusManager::getInstance()->setFocus(_defaultButton);
}

坑 5:不同厂商遥控器按键差异

现象: 不同品牌盒子的遥控器按键值不一致

解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 按键映射配置
class KeyCodeMapper {
public:
static int getStandardKeyCode(int rawKeyCode, const std::string& deviceModel) {
// 小米盒子映射
static const std::map<int, int> xiaomiMap = {
{23, 23}, // OK
{4, 4}, // BACK
{122, 19}, // UP (小米的特殊映射)
{123, 20}, // DOWN
{124, 21}, // LEFT
{125, 22}, // RIGHT
};

// 华为盒子映射
static const std::map<int, int> huaweiMap = {
{66, 23}, // OK (华为使用 66)
{4, 4}, // BACK
{19, 19}, // UP
{20, 20}, // DOWN
{21, 21}, // LEFT
{22, 22}, // RIGHT
};

if (deviceModel.find("Xiaomi") != std::string::npos) {
auto it = xiaomiMap.find(rawKeyCode);
return (it != xiaomiMap.end()) ? it->second : rawKeyCode;
}

if (deviceModel.find("Huawei") != std::string::npos) {
auto it = huaweiMap.find(rawKeyCode);
return (it != huaweiMap.end()) ? it->second : rawKeyCode;
}

return rawKeyCode;
}
};

UI 设计最佳实践

设计原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────┐
│ TV 游戏 UI 设计原则 │
├─────────────────────────────────────────────────────────────┤

│ 1. 大字体大按钮
│ ├── 字体大小:最小 24sp(推荐 32sp+)
│ ├── 按钮尺寸:最小 120x60dp(推荐 160x80dp+)
│ └── 间距:元素间至少 20dp

│ 2. 清晰的焦点指示
│ ├── 焦点状态:放大 1.1 倍
│ ├── 边框高亮:发光效果或颜色变化
│ └── 阴影效果:增加层次感

│ 3. 简洁的层级结构
│ ├── 菜单层级:不超过 3 层
│ ├── 每屏选项:5-7 个最佳
│ └── 返回路径:始终可返回上一级

│ 4. 安全的屏幕边距
│ ├── 电视存在过扫描区域
│ ├── 安全边距:屏幕边缘 5% 区域不放重要内容
│ └── 重要按钮放在屏幕中央区域

焦点指示器设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 创建发光边框效果
void createFocusEffect(Node* target) {
// 1. 缩放效果
auto scaleUp = ScaleTo::create(0.1f, 1.1f);
auto scaleDown = ScaleTo::create(0.1f, 1.0f);

// 2. 发光边框
auto glow = LayerColor::create(Color4B(255, 200, 0, 100));
glow->setContentSize(target->getContentSize() + Size(20, 20));
glow->setPosition(-10, -10);

// 3. 呼吸动画
glow->runAction(RepeatForever::create(
Sequence::create(
FadeTo::create(0.5f, 150),
FadeTo::create(0.5f, 255),
nullptr
)
));

target->addChild(glow, -1);
}

写在最后

TV 盒子/机顶盒游戏开发的关键要点:

  1. 按键处理

    • 除返回键外,其他按键建议冒泡
    • 兼容部分盒子只能监听按下或抬起的情况
    • Home 键无法监听,通过生命周期处理
  2. 焦点导航

    • 实现焦点管理器处理方向键导航
    • 每个可交互元素需要明确的邻居关系
    • 提供清晰的焦点视觉反馈
  3. UI 设计

    • 大字体大按钮,远距离可识别
    • 简洁层级,避免过深的菜单
    • 考虑电视过扫描的安全边距
  4. 兼容性

    • 不同厂商按键映射可能存在差异
    • 需要在主流设备上进行充分测试
    • 提供按键映射配置能力

有问题欢迎留言交流。