比赛项目总结

这是2020年的第一篇文章,离上次写文章已经过了好长时间了,主要是工作真的很忙。刚刚一个版本转测完,趁周六这个时间,把之前去年觉得有意思的一件事情,想做下总结。

在2019年的9月,公司举办了一场比赛,这个比赛队伍需要为Vans创造出一个小程序/小游戏/AR/AI的互动 体验,并在腾讯模拟商店完成demo实操。聚焦于A (认知Aware) 和I (感兴趣Interest) 的阶段,帮助Vans门店探索有趣购物体验的同时 在互动过程中鼓励用户主动留下个人可识别信息。


心里活动

想记录下这次比赛的心里活动。现在回忆起来很有意思~

一开始我一般不会主动去关注这些比赛,主要是觉得工作比较忙,很花精力,周末有点时间,能在家休整下对我来说是很棒的事情。但是当他们把比赛的奖品列给我的时候,我。。。就好不犹豫的答应了(就是这么没有底线->逃)。当然还有一个原因,是参与这个比赛的其他小伙伴都很优秀,优秀的产品,优秀的设计,优秀的开发GG。

最后确认了比赛的小伙伴,2个开发,2个产品,1个设计师。

真正到开始参赛是,遇到的问题是:

  1. 方案讨论太久,留给开发时间不足。导致时间很紧张。
  2. 实际方案跟我擅长的不搭配,我以为我是做小程序,实际后来去做的是大屏幕的3D动画。
  3. 不擅长写css, 自从来了公司后,重构和写js的就是分开的,导致了我这边对css不熟练,会写会调,但是不专业。另外一个开发GG也是一样的情况。
  4. 用户调研到了出demo的时候才开始,所以当用户体验流程出现问题要修改时,时间非常紧,心情也会比较低落。

最难的应该是时间短,因为问题都是有办法解决的,难的是在有限的时间内解决自己不熟悉的问题。

中间产品们提的方案非常完整,增加了排队等机制,这些对于我们来说在这个时间内已经不可能完成了,通过大家一起的商定,决定先把主流程做出来。这里真的觉得会为开发考虑开发成本以及衡量时间的产品有多优秀。事实也证明对于比赛来说这些流程确实也不需要,把用户核心的体验环节打磨好才是最重要的。

开发小哥是个高级工程师,非常优秀,各方面都比我有经验,有时有一点的碰撞,就已经让我觉得不需此行


比赛题目分析

比赛的最重要的一点通过产品吸引用户进入门店,并主动留下个人可识别信息。产品小姐姐们迅速确认了这一点,围绕在互动有趣的体验,开始了脑暴。

最后我们确认了最后方案:用户使用平衡板,在1分钟内触地3次,将结束比赛,否则通关。我们通过手机陀螺仪感应用户的行为,如摔倒、空闲。手机陀螺仪和server间建立websocket连接。server端和大屏端也建立websocket连接,大屏收到消息后,展示用户行为,如摔倒等内容。这个方案的优点就在于平衡板,用户看到这个东西的时候,就已经很想踩上去了。实际上后来我们发现也是这样,很多路过的小伙伴经过,都想要去体验下,挑战下自己再这个平衡板上能坚持多久,当有小伙伴同行时,这个方案的优势会放大。后来我们发现,相比纯大屏的游戏来说,我们的确实更加生动,也更加吸引用户参与游戏,让用户有参与感。

在这个基础上,产品们想了很多优化:

  1. 如何才能记录下用户的偏好?帮助企业更好的拿到用户的画像?–增加战服挑选,战服由不同的搭配组成,从而在选择过程中,可知道用户偏好。
  2. 如果留资?–启动游戏需要用微信扫码小程序,过程中静默授权,即可拿到openid。
  3. 如何游戏过程中,能让用户了解企业文化?–游戏闯关与企业大事件结合,游戏结束,用户在手机上或者大屏即可查到当前分数对应的企业大事件,从而宣导企业文化。
  4. 如何更让用户有参与感?–增加用户闯关音效动画鼓励,结合排行榜等内容

开发方案:

  1. 手机陀螺仪感知平衡板行为,与server端建立通信
  2. server端负责与大屏和手机陀螺仪建立websocket链接
  3. 大屏幕上是一个3D动画,展示比赛开始,人物前进、摔倒、闯关、游戏结束等行为。
  4. 小程序端承载用户扫码开始游戏行为

最终确认方案后,我和开发GG就确认了分工,我做3D动画,他做小程序端。这对我们来说也是有一定的意义的,他之前没有接触过小程序,我之前没有接触过3D动画,趁这次比赛的机会也算对自己的认知有所增进。


3D动画

最后选定了用Three.js做 3D 动画。对于一个完全不了解的东西并且需要快速开发出来的产品,我做了下面几件事情:

  1. 先没看开发文档,去官网看了下 demo, 把所有 demo 浏览了一次,看了下用Three.js能实现哪些东西。demo里有个 roller coaster,看到这个我安心了,虽然跟最终我们需要的东西相差太大,但是基本上的过称是一致的,就是要有人坐在过山车上,旁边景物移动的过称。
  2. 去网上找了一本很简单的入门的书,大概了解了下有哪些Api, 了解什么是渲染器,什么是场景、灯光、视角等。
  3. 去 github 上找有没有相关的类似项目可以供参考。
  4. 结合具体我们的需求,看官方文档,比如如何实现图片纹理的改变。如果官网没找到示例,看有没有npm可以使用。
  5. 确定了哪些是必须用 3D 动画去实现的,哪些是 css 可以搞定的。
  6. 理清思路,大屏需要做的是建立与 server 端的链接,接受 server 端拿到的陀螺仪的改变,从而在大屏里展示不同的内容。

最后的demo如下:(以下为是最简单的动画,金币、人物摔倒、平衡板、生命值的改变等都没有录进去)。


代码简单记录

下面简单的记录下动画这块的代码,我估计过一段时间这些Three.js的Api都会忘记了。

object.js里定义的是各种各样的物体,比如斑马线,山峰等:

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
/**
* 斑马线
* @constructor
*/
var Roadline = function () {
let geomLine = new THREE.BoxGeometry(1,1,16)

let matLine = new THREE.MeshPhongMaterial({
color: Colors.white
})

let line = new THREE.Mesh(geomLine, matLine)

line.receiveShadow =true

this.line = line
}

/**
* 山峰固定不动
*/
class Mountain {
constructor() {
let map = new THREE.TextureLoader().load(require("../../ui/images/img_mountain.png"))

let material = new THREE.SpriteMaterial({
map,
// depthTest: false
})

let mountain = new THREE.Sprite(material)

mountain.scale.set(961 / 3, 140 / 3)

this.mountain = mountain
}
}

/**
* 草丛
*/
class Bush {
constructor() {
let random = getRandomInt(3) + 1

let map = new THREE.TextureLoader().load(require("../../ui/images/img_grass" + random + ".png"))
let material = new THREE.SpriteMaterial({
map,
depthTest: false,
transparent: true,
})

let bush = new THREE.Sprite(material)

bush.castShadow = true
bush.receiveShadow = true

let scaleX = 8, scaleY = 8

switch (random) {
case 1:
scaleX = 170 / 22
scaleY = 425 / 22
break;

case 2:
scaleX = 39 / 10
scaleY = 103 / 10
break;

case 3:
scaleX = 55 / 10
scaleY = 103 / 10
break;
}

bush.scale.set(scaleX, scaleY)

this.bush = bush
}
}

定义好物体后,就可以开始创建场景,灯光。其实整体的思路就是把创建好一个个的物体,把物体摆放到对应的位置,随着时间的推移,去移动这些物体的位置。这样就可以形成一个3D动画的效果了。

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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
import * as THREE from 'three'
import TextSprite from '@seregpie/three.text-sprite';

import {
Ground,
Moca,
Mountain,
Money,
Roadline,
Cloud,
Bush,
BigEvent
} from './Object.js'

class ThreeAnimate {
constructor(clothes) {
// scene 场景
// windowHeight 高度
// windowWidth 宽度
this.scene = null
this.windowHeight = window.innerHeight
this.windowWidth = window.innerWidth
this.aspectRatio = this.windowWidth / this.windowHeight

this.fieldOfView = 94
this.near = 0.1
this.far = 500

this.camera = null
this.container = null
this.renderer = null
this.player = null
this.ground = null

this.clouds = []
this.bushes = []
this.roadlines = []
this.golds = []
this.mocas = []

this.delta = 0.1
this.speed = 15
this.sound = null

// 颜色
this.Colors = {
grayBackground: 0xF9F9F9,
mainGround: 0xA9A9A9,
grey: 0xD3D3D3,
white: 0xffffff,
}

this.events = [
'1966 世界第一款定制鞋发售',
'1976 滑手不二之选',
'1977 爵士条纹',
'1978 专业级脚踝保护',
'1995 极限运动音乐节',
'1997 三冠王系列赛事',
'1998 滑板公园',
'2003 滑板队首次巡演',
'2005 街头滑板对抗赛',
'2011 🎬冲浪电影',
'2015 🎬滑板电影',
'2019 联名腾讯《智零创造营》'
]

this.bigEvent = null
this.bigEventText = null

this.curEventId = 0

this.clothes = clothes + 1
}

createScene() {
// 创建场景
this.scene = new THREE.Scene()


// 给场景添加雾化效果
this.scene.fog = new THREE.Fog(this.Colors.grayBackground, this.near, this.far - 150)

// 创建透视相机
this.camera = new THREE.PerspectiveCamera(
this.fieldOfView,
this.aspectRatio,
this.near,
this.far
)

// 设置相机的位置
this.camera.position.x = 0
this.camera.position.z = 14
this.camera.position.y = 10

// 设置渲染器, 开启反锯齿,设置背景透明
this.renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true
})

// 设置渲染器的宽度和高度
this.renderer.setSize(this.windowWidth, this.windowHeight)
this.renderer.setClearColor(this.Colors.white, 1)

// 开启阴影效果
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap

// 将渲染器添加至dom节点
this.container = document.getElementById('initdom')
this.container.appendChild(this.renderer.domElement)

// resize 后更新 renderer 等
window.addEventListener('resize', this.handleWindowResize, false)
}

/**
* 处理浏览器缩放情况
*/
handleWindowResize() {
this.windowHeight = window.innerHeight
this.windowWidth = window.innerWidth

this.aspectRatio = this.windowWidth / this.windowHeight

if (this.renderer) {
this.renderer.setSize(this.windowWidth, this.windowHeight)
}

this.camera.aspect = this.windowWidth / this.windowHeight
this.camera.updateProjectionMatrix()
}

/**
* 创建的灯光
*/
createLights() {
let ambientLight = new THREE.AmbientLight(0xffffff, 0.2)
ambientLight.position.set(20, 80, 20)

// groundColor 从地面发出的光线颜色
// Color 从天空发出的光线颜色
let hemiLight = new THREE.HemisphereLight(0xDCDCDC, 0xffffff, 1)
hemiLight.position.y = 30

this.scene.add(hemiLight)
this.scene.add(ambientLight)
}

/**
* 创建摩擦
*/
createMocas() {
let m1 = new Moca([1, 4, -4, 2, 2])
this.mocas.push(m1)
this.scene.add(m1.moca)

let m2 = new Moca([2, 3, -2, 1, 1])
this.mocas.push(m2)
this.scene.add(m2.moca)

let m3 = new Moca([3, -2, -6, 21/80, 135/80])
this.mocas.push(m3)
this.scene.add(m3.moca)
}

/**
* 创建地面
*/
createGround() {
this.ground = new Ground()

this.ground.mesh.position.y = -1.5
this.ground.mesh.position.z = -50

this.scene.add(this.ground.mesh)
}

/**
* 创建山
*/
createMountain() {
var m = new Mountain()

let mountain = m.mountain

mountain.position.z = -220

this.scene.add(mountain)
}

/**
* 部署云
*/
placeClouds() {
var nCloud = 7

for (var i = 0; i < nCloud; i++) {
var c = new Cloud(4);

c.cloud.position.z = (Math.random() * 300) - 150
c.cloud.position.x = (Math.random() * 200) - 100
c.cloud.position.y = (Math.random() * 10) + 20

this.clouds.push(c)
this.scene.add(c.cloud)
}
}

/**
* 部署草丛
*/
placeBush() {
var nBushes = 10;

for (var i = 0; i < nBushes; i++) {
var b = new Bush(4);

b.bush.position.z = (Math.random() * 300) - 200;

if (Math.floor(Math.random() * 2) + 1 == 1) {
b.bush.position.x = Math.floor(Math.random() * 80) + 1 - 160;
} else {
b.bush.position.x = Math.floor(Math.random() * 80) + 1 + 40;
}

b.bush.position.y = 0;

this.bushes.push(b)
this.scene.add(b.bush)
}
}

/**
* 部署金币
*/
placeGolds() {
var nMoney = 7

for (var i = 0; i < nMoney; i++) {
var m = new Money()

m.money.position.z = (Math.random() * 300 - 120)

this.golds.push(m)

this.scene.add(m.money)
}
}

/**
* 部署路线
*/
placeRoadLines() {
for (var i = 0; i < 6; i++) {
var l = new Roadline()
l.line.position.y = -1.49
l.line.position.x = 0;
l.line.position.z = 0
this.roadlines.push(l)
this.scene.add(l.line)
}

var previouslinepos = 0;
for (var i = 0; i < this.roadlines.length; i++) {
this.roadlines[i].line.position.z = previouslinepos - 25;
previouslinepos = this.roadlines[i].line.position.z;
}
}

/**
* 放大事件,这里需要放置图片和文字两个内容
*/
placeBigEvent() {
let random = -239

var e = new BigEvent()

e.event.position.z = random

this.bigEvent = e

this.scene.add(e.event)

let sprite = new TextSprite({
fillStyle: '#D51E20',
align: 'center',
fontFamily: 'FZLanTingHei-HN-GBK',
fontSize: 1.5,
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: 'normal',
text: this.events[this.curEventId]
})

sprite.position.z = random
sprite.position.y = 7.5
this.bigEventText = sprite
this.scene.add(sprite)
}

/**
* 重新部署全部内容
*/
replaceEverything() {
for (var i = 0; i < this.clouds.length; i++) {
if (this.clouds[i].cloud.position.x > 150) {
this.clouds[i].cloud.position.x = -100
}
if (this.clouds[i].cloud.position.z > 50) {
this.clouds[i].cloud.position.z = -200
this.clouds[i].cloud.position.x = (Math.random() * 300) - 150
}
}

for (var i = 0; i < this.roadlines.length; i++) {
if (this.roadlines[i].line.position.z > 30) {
this.roadlines[i].line.position.z = -130
}
}

for (var i = 0; i < this.bushes.length; i++) {
if (this.bushes[i].bush.position.z > 50) {
this.bushes[i].bush.position.z = -235;
if (Math.floor(Math.random() * 2) + 1 == 1) {
this.bushes[i].bush.position.x = Math.floor(Math.random() * 160) + 1 - 170;
} else {
this.bushes[i].bush.position.x = Math.floor(Math.random() * 160) + 1 + 40;
}
}
}

for (var i = 0; i < this.golds.length; i++) {
if (this.golds[i].money.position.z > 70) {
this.golds[i].money.position.z = -160
}
}

if (this.curEventId !== 11) {
// 重新摆放大事件的位置
if (this.bigEvent.event.position.z > 130) {
this.bigEvent.event.position.z = -236
}

// 重新摆放大事件的名称位置
if (this.bigEventText.position.z > 130) {
if (this.curEventId < this.events.length - 1) {
this.curEventId++
}

this.bigEventText.position.z = -236
this.bigEventText.text = this.events[this.curEventId];
}
}

}

/**
* 移动物体
*/
moveEverything() {
for (var i = 0; i < this.clouds.length; i++) {
this.clouds[i].cloud.position.z += this.delta * this.speed / 3;
}

for (var i = 0; i < this.roadlines.length; i++) {
this.roadlines[i].line.position.z += this.delta * this.speed;
}

//Move Bushes
for (var i = 0; i < this.bushes.length; i++) {
this.bushes[i].bush.position.z += this.delta * this.speed;
}

//Move BigEvents
this.bigEvent.event.position.z += this.delta * this.speed / 2;
this.bigEventText.position.z += this.delta * this.speed / 2;

if (this.bigEvent.event.position.z > -10) {
this.bigEvent.event.position.z = 500
}

if (this.bigEventText.position.z > -10) {
this.bigEventText.position.z = 500
}
}

/**
* 循环
*/
loop() {
if (this.renderer) {
this.replaceEverything()

this.moveEverything()

this.renderer.render(this.scene, this.camera)

requestAnimationFrame(this.loop.bind(this))
}
}

/**
* 创建玩家
*/
createPlayer() {
var map = new THREE.TextureLoader().load(require("../../ui/images/img_player_run" + this.clothes + ".png"))
var material = new THREE.SpriteMaterial({
map,
depthTest: false
})

this.player = new THREE.Sprite(material)

this.player.scale.set(426 / 70, 729 / 70)
this.player.material.map.needsUpdate = true

this.scene.add(this.player)
}

/**
* 玩家摔倒动画
*/
playerFallDown() {
if (!this.player) {
return
}

this.player.material.map = THREE.ImageUtils.loadTexture(require("../../ui/images/img_player_falldown" +this.clothes+ ".png"))
this.player.scale.set(748/70, 563 / 70)

setTimeout(() => {
this.playerRun()
}, 1000)
}

/**
* 玩家重新开始比赛
*/
playerRun() {
this.player.material.map = THREE.ImageUtils.loadTexture(require("../../ui/images/img_player_run" + this.clothes + ".png"))
this.player.scale.set(426/70, 729 / 70)
}

/**
* 创建音效
*/
createAudio() {
let listener = new THREE.AudioListener()

this.camera.add(listener)

this.sound = new THREE.Audio(listener)

let audioLoader = new THREE.AudioLoader()

audioLoader.load( require("../sounds/3m_luora.wav"), buffer => {
this.sound.setBuffer(buffer)
this.sound.setLoop(true)
this.sound.setVolume(0.5)
this.sound.play()
this.sound.hasPlaybackControl = true
})
}

initRace() {
this.createScene()

this.createLights()

this.createGround()
this.createMountain()
this.createPlayer()
this.createMocas()

this.placeClouds()
this.placeBush()
// this.placeGolds()
this.placeRoadLines()
this.placeBigEvent()

this.createAudio()

this.camera.lookAt(this.player.position)

this.loop()
}

destory () {
this.renderer = null
this.scene = null
}
}


export default ThreeAnimate

监听陀螺仪的:

1
2
3
4
5
window.addEventListener(
'deviceorientation',
this.handleMotionChange.bind(this),
false
);

更多其他的代码如 websocket 通信、server端处理就不记录了📝。


总结

主要是想记录这一段经历,工作之余,有机会和小伙伴们一起参加一个这样比赛,对我来说是件很棒的事情。

中间大家一起经历过(主要是我跟开发GG,哈哈)难受和很失落的时候,但是想到既然来了就要做到最好以及最后比赛的奖品,还是坚持下去了。

嘻嘻,重点当然是最后的奖品啦,ipad pro + ip11 pro max。

超级开心🌹。给小伙伴们打call。

奖品