mirror of
https://github.com/flynx/Slang.git
synced 2025-10-29 02:30:08 +00:00
512 lines
14 KiB
JavaScript
Executable File
512 lines
14 KiB
JavaScript
Executable File
/**********************************************************************
|
|
*
|
|
* Simple Snake
|
|
*
|
|
* This code is designed to illustrate the non-intuitive approach to an
|
|
* implementation, building a snake game as a cellular automaton rather
|
|
* than the more obvious, set of entities (OOP) or a number of sets
|
|
* of procedures and data structures, directly emulating the "tactile"
|
|
* perception of the game, i.e. independent field, snakes, walls, apples
|
|
* and their interactions.
|
|
*
|
|
* In this approach there are no entities, no snakes, no apples, no
|
|
* walls, just a set of cells in a field and cell behaviours per game
|
|
* step:
|
|
* - empty cells, apples and walls just sit there
|
|
* - "snake" cells:
|
|
* - decrement age
|
|
* - if age is 0 clear cell
|
|
* - if cell has direction (i.e. snake head)
|
|
* - if target cell is red (apple) increment age
|
|
* - color new cell in direction:
|
|
* - set age on to current age + 1
|
|
* - set direction to current
|
|
* - clear direction
|
|
*
|
|
* NOTE: that in the above description some details are omitted for
|
|
* clarity...
|
|
*
|
|
*
|
|
* This code is structured in a scalable and introspective way:
|
|
* - Snake object is reusable as a prototype enabling multiple games
|
|
* to run at the same time
|
|
* - Snake implements an open external control scheme, i.e. it does not
|
|
* impose a specific way to implementing the way to control the game
|
|
* - Simple (but not trivial) code and code structure
|
|
* - Introspective: no hidden/masked state or functionality
|
|
* - No external dependencies
|
|
*
|
|
*
|
|
* Goals:
|
|
* - Show that the "intuitive" is not the only approach
|
|
* - Show that the "intuitive" is not allways the simplest
|
|
* - Show one approach to a scalable yet simple architecture
|
|
*
|
|
*
|
|
*
|
|
**********************************************************************/
|
|
|
|
function makeEvent(handler_attr){
|
|
return function(func){
|
|
if(func === null){
|
|
delete this[handler_attr]
|
|
|
|
} else if(func instanceof Function){
|
|
var handlers = this[handler_attr] = this[handler_attr] || []
|
|
handlers.push(func)
|
|
|
|
} else {
|
|
var that = this
|
|
var args = [].slice.call(arguments)
|
|
this[handler_attr]
|
|
&& this[handler_attr]
|
|
.forEach(function(handler){ handler.apply(that, args) })
|
|
}
|
|
return this
|
|
}
|
|
}
|
|
|
|
var Snake = {
|
|
config: {
|
|
field_size: 32,
|
|
interval: 150,
|
|
},
|
|
|
|
_field: null,
|
|
_cells: null,
|
|
players: null,
|
|
field_size: null,
|
|
|
|
get random_point(){
|
|
var cells = this._cells
|
|
var l = cells.length
|
|
var w = this.field_size.width
|
|
|
|
do {
|
|
var i = Math.floor(Math.random() * l)
|
|
} while(cells[i].classList.length > 0)
|
|
|
|
return {
|
|
x: i%w,
|
|
y: Math.floor(i/w),
|
|
}
|
|
},
|
|
get random_direction(){
|
|
return ('nesw')[Math.floor(Math.random() * 4)] },
|
|
|
|
// utils...
|
|
call: function(func){
|
|
return func.apply(this, [].slice.call(arguments, 1)) },
|
|
apply: function(func, args){
|
|
return func.apply(this, args) },
|
|
normalize_point: function(point){
|
|
point = point || {}
|
|
var w = this.field_size.width
|
|
var x = point.x % w
|
|
x = x < 0 ? (x + w) : x
|
|
var h = this.field_size.height
|
|
var y = point.y % h
|
|
y = y < 0 ? (y + h) : y
|
|
return { x: x, y: y }
|
|
},
|
|
_make_field: function(w){
|
|
var l = []
|
|
l.length = w || this.config.field_size
|
|
l.fill('<td/>')
|
|
this._field.innerHTML =
|
|
`<table class="field" cellspacing="0">\n${
|
|
l.map(function(){
|
|
return ` <tr> ${ l.join('') } </tr>`
|
|
}).join('\n')
|
|
}\n</table>`
|
|
},
|
|
_tick: function(){
|
|
var that = this
|
|
var l = this._cells.length
|
|
var w = this.field_size.width
|
|
var h = this.field_size.height
|
|
var tick = this.__tick = (this.__tick + 1 || 0)
|
|
var directions = 'neswn'
|
|
|
|
this._cells.forEach(function(cell, i){
|
|
var color = cell.style.backgroundColor
|
|
|
|
// skip cells we touched on this tick...
|
|
if(cell.tick == tick){
|
|
return
|
|
}
|
|
|
|
// snake...
|
|
if(cell.age != null){
|
|
// handle cell age...
|
|
if(cell.age == 0){
|
|
delete cell.age
|
|
cell.classList.remove('snake')
|
|
cell.style.backgroundColor = ''
|
|
|
|
} else {
|
|
cell.age -= 1
|
|
}
|
|
|
|
// snake head -> move...
|
|
var direction = cell.direction
|
|
if(directions.indexOf(direction) >= 0){
|
|
// turn...
|
|
if(that.players[color] != ''){
|
|
var turn = that.players[color] || ''
|
|
var j = turn == 'left' ? directions.indexOf(direction) - 1
|
|
: directions.indexOf(direction) + 1
|
|
j = j < 0 ? 3 : j
|
|
direction = directions[j]
|
|
that.players[color] = ''
|
|
}
|
|
|
|
// get next cell index...
|
|
var next =
|
|
direction == 'n' ?
|
|
(i < w ? l - w + i : i - w)
|
|
: direction == 's' ?
|
|
(i > (l-w-1) ? i - (l-w) : i + w)
|
|
: direction == 'e' ?
|
|
((i+1)%w == 0 ? i - (w-1) : i + 1)
|
|
: (i%w == 0 ? i + (w-1) : i - 1)
|
|
next = that._cells[next]
|
|
|
|
var age = cell.age
|
|
var move = false
|
|
|
|
// special case: other snake's head -> kill both...
|
|
if(next.direction){
|
|
var other = next.style.backgroundColor
|
|
next.classList.remove('snake')
|
|
next.style.backgroundColor = ''
|
|
// NOTE: we are not deleteing .direction here as
|
|
// we can have upto 4 snakes colliding...
|
|
next.direction = ''
|
|
that.snakeKilled(other, next.age+1)
|
|
that.snakeKilled(color, age+2)
|
|
delete next.age
|
|
|
|
// apple -> increment age...
|
|
} else if(next.classList.contains('apple')){
|
|
age += 1
|
|
move = true
|
|
next.classList.remove('apple')
|
|
that.appleEaten(color, age+2)
|
|
|
|
// empty -> just move...
|
|
} else if(next.classList.length == 0){
|
|
move = true
|
|
|
|
// other -> kill...
|
|
} else {
|
|
that.snakeKilled(color, age+2)
|
|
}
|
|
|
|
// do the move...
|
|
if(move){
|
|
next.tick = tick
|
|
next.style.backgroundColor = color
|
|
next.classList.add('snake')
|
|
next.age = age + 1
|
|
next.direction = direction
|
|
}
|
|
|
|
delete cell.direction
|
|
}
|
|
}
|
|
cell.tick = tick
|
|
})
|
|
this.tick(tick)
|
|
},
|
|
|
|
// constructors...
|
|
snake: function(color, age, point, direction){
|
|
point = this.normalize_point(point || this.random_point)
|
|
|
|
var head = this._cells[point.x + point.y * this.field_size.width]
|
|
head.style.backgroundColor = color
|
|
head.classList.add('snake')
|
|
head.direction = direction || this.random_direction
|
|
head.age = (age || 5) - 1
|
|
this.players[color] = ''
|
|
|
|
return this
|
|
.snakeBorn(color)
|
|
},
|
|
apple: function(point){
|
|
point = this.normalize_point(point || this.random_point)
|
|
var c = this._cells[point.x + point.y * this.field_size.width]
|
|
c.classList.add('apple')
|
|
c.style.backgroundColor = ''
|
|
return this
|
|
},
|
|
wall: function(point, direction, length){
|
|
direction = direction || this.random_direction
|
|
point = this.normalize_point(point || this.random_point)
|
|
var x = point.x
|
|
var y = point.y
|
|
length = length || 1
|
|
|
|
while(length > 0){
|
|
var c = this._cells[x + y * this.field_size.width]
|
|
c.classList.add('wall')
|
|
c.style.backgroundColor = ''
|
|
|
|
x += direction == 'e' ? 1
|
|
: direction == 'w' ? -1
|
|
: 0
|
|
x = x < 0 ? this.field_size.width + x
|
|
: x % this.field_size.width
|
|
y += direction == 'n' ? -1
|
|
: direction == 's' ? 1
|
|
: 0
|
|
y = y < 0 ? this.field_size.height + y
|
|
: y % this.field_size.height
|
|
length -= 1
|
|
}
|
|
|
|
return this
|
|
},
|
|
level: function(level){
|
|
var that = this
|
|
level.forEach(function(wall){
|
|
that.wall.apply(that, wall) })
|
|
return this
|
|
},
|
|
|
|
// events...
|
|
snakeKilled: makeEvent('__killHandlers'),
|
|
snakeBorn: makeEvent('__birthHandlers'),
|
|
appleEaten: makeEvent('__appleEatenHandlers'),
|
|
tick: makeEvent('__tickHandlers'),
|
|
gameStarted: makeEvent('__startHandlers'),
|
|
gameStopped: makeEvent('__stopHandlers'),
|
|
|
|
// actions...
|
|
setup: function(field, size, interval){
|
|
this.config.field_size = size || this.config.field_size
|
|
this.config.interval = interval || this.config.interval
|
|
field = field || this._field
|
|
field = this._field = typeof(field) == typeof('str') ? document.querySelector(field)
|
|
: field
|
|
this._make_field()
|
|
this._cells = [].slice.call(field.querySelectorAll('td'))
|
|
this.field_size = {
|
|
width: field.querySelector('tr').querySelectorAll('td').length,
|
|
height: field.querySelectorAll('tr').length,
|
|
}
|
|
this.players = {}
|
|
return this
|
|
.appleEaten(null)
|
|
.snakeKilled(null)
|
|
},
|
|
start: function(t){
|
|
this.__timer = this.__timer
|
|
|| setInterval(this._tick.bind(this), t || this.config.interval || 200)
|
|
// reset player control actions...
|
|
var that = this
|
|
Object.keys(this.players)
|
|
.forEach(function(k){ that.players[k] = '' })
|
|
return this
|
|
.tick()
|
|
.gameStarted()
|
|
},
|
|
stop: function(){
|
|
clearInterval(this.__timer)
|
|
delete this.__timer
|
|
delete this.__tick
|
|
return this
|
|
.gameStopped()
|
|
},
|
|
pause: function(){
|
|
return this.__timer ? this.stop() : this.start() },
|
|
left: function(color){
|
|
this.players[color || Object.keys(this.players)[0]] = 'left'
|
|
return this
|
|
},
|
|
right: function(color){
|
|
this.players[color || Object.keys(this.players)[0]] = 'right'
|
|
return this
|
|
},
|
|
}
|
|
|
|
|
|
|
|
/*********************************************************************/
|
|
|
|
var __CACHE_UPDATE_CHECK = 5*60*1000
|
|
var __HANDLER_SET = false
|
|
var __DEBOUNCE_TIMEOUT = 100
|
|
var __DEBOUNCE = false
|
|
|
|
var KEY_CONFIG = {
|
|
' ': ['pause'],
|
|
n: setup,
|
|
ArrowLeft: ['left'],
|
|
ArrowRight: ['right'],
|
|
// IE compatibility...
|
|
Left: ['left'],
|
|
Right: ['right'],
|
|
}
|
|
function makeKeyboardHandler(snake){
|
|
return function(event){
|
|
clearHints()
|
|
var action = KEY_CONFIG[event.key]
|
|
action
|
|
&& (action instanceof Function ?
|
|
action.call(snake)
|
|
: action[0] in snake ?
|
|
snake[action[0]].apply(snake, action.slice(1))
|
|
: null) }}
|
|
function makeTapHandler(snake){
|
|
return function(event){
|
|
// prevent clicks and touches from triggering the same action
|
|
// twice -- only handle the first one within timeout...
|
|
// NOTE: this should not affect events of the same type...
|
|
if(__DEBOUNCE && event.type != __DEBOUNCE){ return }
|
|
__DEBOUNCE = event.type
|
|
setTimeout(function(){ __DEBOUNCE = false }, __DEBOUNCE_TIMEOUT)
|
|
|
|
clearHints()
|
|
// top of screen (1/8)...
|
|
;(event.clientY || event.changedTouches[0].pageY) <= (document.body.clientHeight / 8) ?
|
|
setup()
|
|
// bottom of screen 1/8...
|
|
: (event.clientY || event.changedTouches[0].pageY) >= (document.body.clientHeight / 8)*8 ?
|
|
Snake.pause()
|
|
// left/right of screen...
|
|
: (event.clientX || event.changedTouches[0].pageX) <= (document.body.clientWidth / 2) ?
|
|
Snake.left()
|
|
: Snake.right() }}
|
|
function clearHints(){
|
|
document.body.classList.contains('hints')
|
|
&& document.body.classList.remove('hints') }
|
|
function digitizeBackground(snake, walls){
|
|
snake._cells.forEach(function(c){
|
|
var v = Math.floor(Math.random() * 6)
|
|
// bg cell...
|
|
c.classList.length == 0 ?
|
|
(c.style.backgroundColor =
|
|
`rgb(${255 - v}, ${255 - v}, ${255 - v})`)
|
|
// wall...
|
|
: walls && c.classList.contains('wall') ?
|
|
(c.style.backgroundColor =
|
|
`rgb(${220 - v*2}, ${220 - v*2}, ${220 - v*2})`)
|
|
// skip the rest...
|
|
: null })
|
|
return snake
|
|
}
|
|
|
|
|
|
//---------------------------------------------------------------------
|
|
|
|
function setup(snake, timer, size){
|
|
snake = snake || Snake
|
|
|
|
// levels...
|
|
var A = Math.round((size || snake.config.field_size)/8)
|
|
var RANDOM3_LEVEL = [
|
|
[null, null, A*6],
|
|
[null, null, A*6],
|
|
[null, null, A*6],
|
|
]
|
|
var HALVES_LEVEL = [
|
|
[null, null, A*8],
|
|
]
|
|
var QUARTERS_LEVEL = [
|
|
[null, 's', A*8],
|
|
[null, 'e', A*8],
|
|
]
|
|
|
|
function showScore(color, age){
|
|
score = snake.__top_score =
|
|
(!snake.__top_score || snake.__top_score.score < age) ?
|
|
{
|
|
color: color || '',
|
|
score: age || 0,
|
|
}
|
|
: snake.__top_score
|
|
snake._field.setAttribute('score', score.score)
|
|
snake._field.setAttribute('snake', score.color)
|
|
snake._field.setAttribute('state', (
|
|
score.score == age && score.color == color) ? '(current)' : '')
|
|
}
|
|
|
|
// setup event handlers (only once)...
|
|
if(!__HANDLER_SET){
|
|
// control handlers...
|
|
document.addEventListener('keydown', makeKeyboardHandler(snake))
|
|
document.addEventListener('touchstart', makeTapHandler(snake))
|
|
//document.addEventListener('mousedown', makeTapHandler(snake))
|
|
|
|
// cache updater...
|
|
var appCache = window.applicationCache
|
|
if(appCache
|
|
&& appCache.status != appCache.UNCACHED){
|
|
appCache.addEventListener('updateready', function(){
|
|
if(appCache.status == appCache.UPDATEREADY){
|
|
console.log('CACHE: new version available...')
|
|
appCache.swapCache()
|
|
|
|
confirm('New version ready, reload?')
|
|
&& location.reload()
|
|
}
|
|
})
|
|
setInterval(function(){ appCache.update() }, __CACHE_UPDATE_CHECK)
|
|
}
|
|
|
|
__HANDLER_SET = true
|
|
}
|
|
|
|
// setup the game...
|
|
return snake
|
|
// prepare the field/game...
|
|
.setup('.simplesnake', size, timer)
|
|
.call(digitizeBackground, snake)
|
|
.call(function(){
|
|
this.__snake_apples = []
|
|
return this
|
|
})
|
|
|
|
// load level...
|
|
.level(RANDOM3_LEVEL)
|
|
//.level(HALVES_LEVEL)
|
|
//.level(QUARTERS_LEVEL)
|
|
|
|
// game events / meta game rules...
|
|
// reconstruct eaten apples...
|
|
.appleEaten(function(color, age){
|
|
this.apple()
|
|
showScore(color, age)
|
|
})
|
|
// one apple per snake...
|
|
.snakeBorn(function(color){
|
|
this.__snake_apples.indexOf(color) < 0
|
|
&& this.apple()
|
|
&& this.__snake_apples.push(color) })
|
|
// reconstruct snakes and pause game...
|
|
// XXX for multiplayer reconstruct the snake on timeout and do
|
|
// not pause...
|
|
.snakeKilled(function(color, age){
|
|
this
|
|
.pause()
|
|
.snake(color, 3)
|
|
showScore(color, 3)
|
|
})
|
|
// indicate game state...
|
|
.gameStarted(function(){
|
|
this._field.classList.remove('paused') })
|
|
.gameStopped(function(){
|
|
this._field.classList.add('paused') })
|
|
|
|
// game eleemnts...
|
|
.apple()
|
|
.snake('blue', 3)
|
|
}
|
|
|
|
|
|
|
|
/**********************************************************************
|
|
* vim:set ts=4 sw=4 : */
|