/**
* A Simple 1:N Collision Detection
*
* @class
* @extends collie.Component
* @param {Object} htOption
* @param {Number} [htOption.frequency=3] Check Frequency, 1이면 매 프레임 마다, 10이면 10프레임 마다 한 번씩 체크한다
* @param {Number} [htOption.cacheSize=80] 캐시 타일 크기 단위는 px
* @param {Boolean} [htOption.useDebug=false] 충돌체크 영역을 화면에 표시
* @param {String} [htOption.debugColor=yellow] 충돌체크 영역의 색, useDebug를 활성화할 때 사용한다
* @param {String} [htOption.debugListenerColor=yellow] 리스너의 충돌체크 영역의 색, useDebug를 활성화할 때 사용한다
* @param {Number} [htOption.debugOpacity=0.5] 충돌체크 영역의 투명도, useDebug를 활성화할 때 사용한다
* @requires collie.addon.js
* @example
* var sensor = new collie.Sensor({
* frequency : 10
* });
*
* // Add target objects
* sensor.add(oDisplayObjectTarget, "anyText");
* sensor.add(oDisplayObjectTarget, "anyText");
* sensor.add(oDisplayObjectTarget, "anyText");
* sensor.add(oDisplayObjectTarget, "otherText");
* sensor.add(oDisplayObjectTarget, "otherText");
* sensor.add(oDisplayObjectTarget, "otherText");
*
* // Add target object that has a circle shape
* sensor.add(oDisplayObjectTarget, "otherText", 15); // radius
*
* // Add target object that has a margin of width and a margin of height
* sensor.add(oDisplayObjectTarget, "otherText", 10, 20); // a margin of width, a margin of height
*
* // Add a listener object for detecting target objects set a category as "anyCategory"
* sensor.addListener(oDisplayObjectListener, "anyText", function (a, b) {
* // begin collision
* }, function (a, b) {
* // end collision
* });
*
* // Add a listener object that has a circle shape
* sensor.addListener(oDisplayObjectListener, "anyText", function (a, b) {
* // begin collision
* }, function (a, b) {
* // end collision
* }, 15); // radius
*
* // start sensing
* sensor.start();
*
* // stop sensing
* sensor.stop();
*/
collie.Sensor = collie.Class(/** @lends collie.Sensor.prototype */{
/**
* If you want to make sensitive higher a case of pass through like a bullet, you should decrease this value. (px) It will affect the performance
* @type {Number}
*/
RAY_SENSING_DISTANCE : 10, // px
$init : function (htOption) {
this.option({
frequency : 3,
cacheSize : 80,
useDebug : false,
debugListenerColor : "red",
debugColor : "yellow",
debugOpacity : 0.5
});
if (typeof htOption !== "undefined") {
this.option(htOption);
}
this._nCurrentFrame = null;
this._nAccumulateFrame = 0;
this._aListeners = [];
this._htListenerCollision = {};
this._htObject = {};
this._htCacheMap = {};
this._htCustomAreaCircle = {};
this._htCustomAreaBox = {};
this._fUpdate = this._onProcess.bind(this);
},
/**
* 충돌 체크를 일괄적으로 진행
*
* @private
* @param {Number} nFrame 진행된 프레임 수
*/
update : function (nFrame) {
if (this._nCurrentFrame === null || this._nCurrentFrame > nFrame) {
this._nCurrentFrame = nFrame;
}
this._nAccumulateFrame += (nFrame - this._nCurrentFrame);
this._nCurrentFrame = nFrame;
// 빈도 수가 채워지면 그 때 확인 함
if (this._nAccumulateFrame >= this._htOption.frequency) {
this._nAccumulateFrame = 0;
this._makeCacheMap();
this._htListenerCollisionBefore = this._htListenerCollision;
this._htListenerCollision = {};
for (var i = 0, l = this._aListeners.length; i < l; i++) {
var listener = this._aListeners[i];
var htBoundary = this._getBoundary(listener.displayObject);
this._makeCustomArea(listener.displayObject, htBoundary);
this.detect(listener.displayObject, listener.category, htBoundary, listener.beginCallback, listener.endCallback);
}
}
},
_getBoundary : function (oDisplayObject) {
var nBeforeLeft = null;
var nBeforeRight = null;
var nBeforeTop = null;
var nBeforeBottom = null;
var points;
// 이전 움직임이 있다면 저장
if (oDisplayObject._htBoundary !== null) {
nBeforeLeft = oDisplayObject._htBoundary.left;
nBeforeTop = oDisplayObject._htBoundary.top;
nBeforeBottom = oDisplayObject._htBoundary.bottom;
nBeforeRight = oDisplayObject._htBoundary.right;
points = oDisplayObject._htBoundary.points;
}
var htBoundary = oDisplayObject.getBoundary(true, true);
// 의미 있게 움직였다면 영역 확장 함
if (nBeforeLeft !== null && (
Math.abs(nBeforeLeft - htBoundary.left) >= this.RAY_SENSING_DISTANCE ||
Math.abs(nBeforeTop - htBoundary.top) >= this.RAY_SENSING_DISTANCE
)) {
points.push(htBoundary.points[0]);
points.push(htBoundary.points[1]);
points.push(htBoundary.points[2]);
points.push(htBoundary.points[3]);
htBoundary.expanded = {
left : Math.min(nBeforeLeft, htBoundary.left),
right : Math.max(nBeforeRight, htBoundary.right),
top : Math.min(nBeforeTop, htBoundary.top),
bottom : Math.max(nBeforeBottom, htBoundary.bottom),
points : points,
isExpanded : true
};
} else {
htBoundary.expanded = htBoundary;
htBoundary.expanded.isExpanded = false;
}
return htBoundary;
},
/**
* @param {collie.DisplayObject} oDisplayObject
* @param {Object} htBoundary
* @private
*/
_makeCustomArea : function (oDisplayObject, htBoundary) {
var nId = oDisplayObject.getId();
if (this._htCustomAreaBox[nId] || this._htCustomAreaCircle[nId]) {
htBoundary.isTransform = true;
htBoundary.centerX = htBoundary.points[0][0] + (htBoundary.points[2][0] - htBoundary.points[0][0]) / 2;
htBoundary.centerY = htBoundary.points[0][1] + (htBoundary.points[2][1] - htBoundary.points[0][1]) / 2;
// 상자면 미리 구해 놓기
if (this._htCustomAreaBox[nId]) {
var minX = Number.MAX_VALUE;
var minY = Number.MAX_VALUE;
var maxX = -Number.MAX_VALUE;
var maxY = -Number.MAX_VALUE;
for (var i = 0, l = htBoundary.points.length; i < l; i++) {
var point = htBoundary.points[i];
var theta = Math.atan2(htBoundary.centerY - point[1], htBoundary.centerX - point[0]);
point[0] += this._htCustomAreaBox[nId] * Math.cos(theta);
point[1] += this._htCustomAreaBox[nId] * Math.sin(theta);
minX = Math.min(minX, point[0]);
maxX = Math.max(maxX, point[0]);
minY = Math.min(minY, point[1]);
maxY = Math.max(maxY, point[1]);
}
htBoundary.left = minX;
htBoundary.right = maxX;
htBoundary.top = minY;
htBoundary.bottom = maxY;
}
}
},
/**
* @private
*/
_makeCacheMap : function () {
var displayObject;
var startX;
var startY;
var endX;
var endY;
for (var key in this._htObject) {
this._htCacheMap[key] = {};
for (var i = 0, l = this._htObject[key].length; i < l; i++) {
displayObject = this._htObject[key][i];
var htBoundary = this._getBoundary(displayObject);
startX = Math.floor(htBoundary.expanded.left / this._htOption.cacheSize);
endX = Math.floor(htBoundary.expanded.right / this._htOption.cacheSize);
startY = Math.floor(htBoundary.expanded.top / this._htOption.cacheSize);
endY = Math.floor(htBoundary.expanded.bottom / this._htOption.cacheSize);
this._makeCustomArea(displayObject, htBoundary);
// 타일에 객체 담아 둠
for (var row = startY; row <= endY; row++) {
this._htCacheMap[key][row] = this._htCacheMap[key][row] || {};
for (var col = startX; col <= endX; col++) {
this._htCacheMap[key][row][col] = this._htCacheMap[key][row][col] || [];
this._htCacheMap[key][row][col].push(displayObject);
}
}
}
}
},
/**
* 충돌 갑지 체크
*
* @param {collie.DisplayObject} oDisplayObject 등록된 객체들과 충돌 감지할 객체
* @param {String} sCategory 여러 개의 카테고리 입력 가능, 구분은 콤마(,)
* @param {Object} htBoundaryListener Listener의 Boundary는 미리 구해 놓는다
* @param {Function} fBeginCallback 충돌이 일어났을 때 실행될 함수
* @param {Function} fEndCallback 충돌이 끝났을 때 실행될 함수
*/
detect : function (oDisplayObject, sCategory, htBoundaryListener, fBeginCallback, fEndCallback) {
if (sCategory.indexOf(",") !== -1) {
var aCategories = sCategory.split(",");
for (var i = 0, l = aCategories.length; i < l; i++) {
this.detect(oDisplayObject, aCategories[i], htBoundaryListener, fBeginCallback, fEndCallback);
}
} else {
var startX = Math.floor(htBoundaryListener.expanded.left / this._htOption.cacheSize);
var endX = Math.floor(htBoundaryListener.expanded.right / this._htOption.cacheSize);
var startY = Math.floor(htBoundaryListener.expanded.top / this._htOption.cacheSize);
var endY = Math.floor(htBoundaryListener.expanded.bottom / this._htOption.cacheSize);
var idA = oDisplayObject.getId();
if (this._htCacheMap[sCategory]) {
for (var row = startY; row <= endY; row++) {
for (var col = startX; col <= endX; col++) {
if (this._htCacheMap[sCategory][row] && this._htCacheMap[sCategory][row][col]) {
for (var i = 0, l = this._htCacheMap[sCategory][row][col].length; i < l; i++) {
var target = this._htCacheMap[sCategory][row][col][i];
var idB = target.getId();
// 이미 충돌 했다면 지나감
if (this._htListenerCollision[idA] && this._htListenerCollision[idA][idB]) {
continue;
}
var bIsHit = this._hitTest(oDisplayObject, target, htBoundaryListener, target._htBoundary);
var bIsInCollision = (this._htListenerCollisionBefore[idA] && this._htListenerCollisionBefore[idA][idB]);
// 만났을 떄
if (bIsHit) {
this._htListenerCollision[idA] = this._htListenerCollision[idA] || {};
this._htListenerCollision[idA][idB] = true;
if (!bIsInCollision) {
fBeginCallback(oDisplayObject, target);
}
}
}
}
}
}
// 충돌이 일어난 것중에 다시 만나지 않은 것이 있다면 end 호출
for (var idB in this._htListenerCollisionBefore[idA]) {
if (!this._htListenerCollision[idA] || !this._htListenerCollision[idA][idB]) {
fEndCallback(oDisplayObject, collie.util.getDisplayObjectById(idB));
}
}
}
}
},
/**
* 충돌 테스트
* @private
* @param {collie.DisplayObject} oA
* @param {collie.DisplayObject} oB
* @param {Object} htA collie.DisplayObject#getBoundary
* @param {Object} htB collie.DisplayObject#getBoundary
* @return {Boolean} 겹치면 true
*/
_hitTest : function (oA, oB, htA, htB) {
var idA = oA.getId();
var idB = oB.getId();
// 둘 중에 하나라도 벗어나는게 있다면 겹치는게 아님
if (
(htA.expanded.left > htB.expanded.right || htB.expanded.left > htA.expanded.right) ||
(htA.expanded.top > htB.expanded.bottom || htB.expanded.top > htA.expanded.bottom)
) {
return false;
} else if (htA.isTransform || htB.isTransform || htA.expanded.isExpanded || htB.expanded.isExpanded) {
// 빠르게 움직일 땐 사각형 모델을 적용함
if (htA.expanded.isExpanded || htB.expanded.isExpanded || (!this._htCustomAreaCircle[idA] && !this._htCustomAreaCircle[idB])) {
if (
(
htA.expanded.left <= htB.expanded.left &&
htA.expanded.top <= htB.expanded.top &&
htA.expanded.right >= htB.expanded.right &&
htA.expanded.bottom >= htB.expanded.bottom
) || (
htA.expanded.left > htB.expanded.left &&
htA.expanded.top > htB.expanded.top &&
htA.expanded.right < htB.expanded.right &&
htA.expanded.bottom < htB.expanded.bottom
)
) {
return true;
}
// 교차하는 선이 있으면 true
//TODO O(N^2)보다 더 빠른 알고리즘이 있었으면 좋겠음
for (var i = 0, l = htA.expanded.points.length; i < l; i++) {
var a1 = htA.expanded.points[i];
var a2 = htA.expanded.points[(i === l - 1) ? 0 : i + 1];
for (var j = 0, jl = htB.expanded.points.length; j < jl; j++) {
var b1 = htB.expanded.points[j];
var b2 = htB.expanded.points[(j === jl - 1) ? 0 : j + 1];
if (this._isIntersectLine(a1, a2, b1, b2)) {
return true;
}
}
}
} else if (this._htCustomAreaCircle[idA] && this._htCustomAreaCircle[idB]) {
return collie.util.getDistance(htA.centerX, htA.centerY, htB.centerX, htB.centerY) <= this._htCustomAreaCircle[idA] + this._htCustomAreaCircle[idB];
} else {
var box;
var circle;
var radius;
if (this._htCustomAreaCircle[idB]) {
box = htA;
circle = htB;
radius = this._htCustomAreaCircle[idB];
} else {
box = htB;
circle = htA;
radius = this._htCustomAreaCircle[idA];
}
for (var i = 0, l = box.points.length; i < l; i++) {
var a1 = htA.points[i];
var a2 = htA.points[(i === l - 1) ? 0 : i + 1];
var theta = Math.atan((circle.centerY - a1[1]) / (circle.centerX - a1[0]));
theta -= Math.atan((a2[1] - a1[1]) / (a2[0] - a1[0]));
if (Math.sin(theta) * collie.util.getDistance(circle.centerX, circle.centerY, a1[0], a1[1]) <= radius) {
return true;
}
}
return false;
}
// 교차하는 선이 없으면 false
return false;
} else {
return true;
}
},
/**
* @private
* @param {Array} a1
* @param {Array} a2
* @param {Array} b1
* @param {Array} b2
* @return {Boolean} 겹치면 true
*/
_isIntersectLine : function (a1, a2, b1, b2) {
var denom = (b2[1] - b1[1]) * (a2[0] - a1[0]) - (b2[0] - b1[0]) * (a2[1] - a1[1]);
if (denom === 0) {
return false;
}
var _t = (b2[0] - b1[0]) * (a1[1] - b1[1]) - (b2[1] - b1[1]) * (a1[0] - b1[0]);
var _s = (a2[0] - a1[0]) * (a1[1] - b1[1]) - (a2[1] - a1[1]) * (a1[0] - b1[0]);
var t = _t / denom;
var s = _s / denom;
// var x = a1[0] + t * (a2[0] - a1[0]);
// var y = a1[1] + t * (a2[1] - a1[1]);
if ((t < 0 || t > 1 || s < 0 || s > 1) || (_t === 0 && _s === 0)) {
return false;
}
return true;
},
/**
* 충돌감지 보고를 받을 객체를 등록
*
* @param {collie.DisplayObject} oDisplayObject 등록된 객체들과 충돌 감지할 객체
* @param {String} sCategory 여러 개의 카테고리 입력 가능, 구분은 콤마(,)
* @param {Function} fBeginCallback 충돌이 일어났을 때 실행될 함수
* @param {collie.DisplayObject} fBeginCallback.listener 리스너 객체
* @param {collie.DisplayObject} fBeginCallback.trigger 충돌이 일어난 객체
* @param {Function} fEndCallback 충돌이 끝났을 때 실행될 함수
* @param {collie.DisplayObject} fEndCallback.listener 리스너 객체
* @param {collie.DisplayObject} fEndCallback.trigger 충돌이 일어난 객체
* @param {Number} vWidth 이 값만 있으면 radius, 원형으로 탐지하고,
* @param {Number} nHeight vWidth와 같이 이 값도 있으면 중심으로 부터의 사각형으로 탐지한다.
*/
addListener : function (oDisplayObject, sCategory, fBeginCallback, fEndCallback, vWidth, nHeight) {
this._aListeners.push({
category : sCategory,
displayObject : oDisplayObject,
beginCallback : fBeginCallback,
endCallback : fEndCallback
});
this._addCustomArea(oDisplayObject, vWidth, nHeight);
// 디버깅 모드면 영역을 표시
if (this._htOption.useDebug) {
this._drawArea(oDisplayObject, true, vWidth, nHeight);
}
},
/**
* 충돌 감지에 객체 등록
*
* @param {collie.DisplayObject} oDisplayObject
* @param {String} sCategory 여러 개의 카테고리 입력 가능, 구분은 콤마(,)
* @param {Number} vWidth 이 값만 있으면 radius, 원형으로 탐지하고,
* @param {Number} nHeight vWidth와 같이 이 값도 있으면 중심으로 부터의 사각형으로 탐지한다.
*/
add : function (oDisplayObject, sCategory, vWidth, nHeight) {
if (sCategory.indexOf(",") !== -1) {
var aCategories = sCategory.split(",");
for (var i = 0, l = aCategories.length; i < l; i++) {
this.add(oDisplayObject, aCategories[i], vWidth, nHeight);
}
} else {
this._htObject[sCategory] = this._htObject[sCategory] || [];
this._htObject[sCategory].push(oDisplayObject);
this._addCustomArea(oDisplayObject, vWidth, nHeight);
// 디버깅 모드면 영역을 표시
if (this._htOption.useDebug) {
this._drawArea(oDisplayObject, false, vWidth, nHeight);
}
}
},
/**
* 충돌 감지에서 제거
*
* @param {collie.DisplayObject} oDisplayObject
* @param {String} sCategory 여러 개의 카테고리 입력 가능, 구분은 콤마(,), 값이 없을 시에는 모든 카테고리에서 제거
*/
remove : function (oDisplayObject, sCategory) {
if (!sCategory) {
for (var i in this._htObject) {
this.remove(oDisplayObject, i);
}
} else if (sCategory.indexOf(",") !== -1) {
var aCategories = sCategory.split(",");
for (var i = 0, l = aCategories.length; i < l; i++) {
this.remove(oDisplayObject, aCategories[i]);
}
} else if (this._htObject[sCategory]) {
for (var i = 0, l = this._htObject[sCategory].length; i < l; i++) {
if (this._htObject[sCategory][i] === oDisplayObject) {
if (this._htCustomAreaCircle[oDisplayObject.getId()]) {
delete this._htCustomAreaCircle[oDisplayObject];
}
if (this._htCustomAreaBox[oDisplayObject.getId()]) {
delete this._htCustomAreaBox[oDisplayObject];
}
this._htObject[sCategory].splice(i, 1);
break;
}
}
}
},
/**
* @private
* @param {collie.DisplayObject} oDisplayObject 대상 객체
* @param {Number} vWidth 이 값만 있으면 radius, 원형으로 탐지하고,
* @param {Number} nHeight vWidth와 같이 이 값도 있으면 중심으로 부터의 사각형으로 탐지한다.
*/
_addCustomArea : function (oDisplayObject, vWidth, nHeight) {
// 사각형
if (typeof nHeight !== "undefined") {
this._htCustomAreaBox[oDisplayObject.getId()] = Math.sqrt(Math.pow(vWidth, 2) + Math.pow(nHeight, 2)); // 대각선 길이
} else if (typeof vWidth !== "undefined") { // 원형
this._htCustomAreaCircle[oDisplayObject.getId()] = vWidth; // 반지름 길이
}
},
/**
* 감지 시작
*/
start : function () {
collie.Renderer.attach("process", this._fUpdate);
},
/**
* 감지 끝
*/
stop : function () {
collie.Renderer.detach("process", this._fUpdate);
this._nCurrentFrame = null;
this._nAccumulateFrame = 0;
},
/**
* collie.Renderer의 process 이벤트 리스너
* @private
*/
_onProcess : function (e) {
this.update(e.frame);
},
/**
* 충돌 영역을 그림
*
* @private
* @param {Boolean} bListener 리스너 여부
*/
_drawArea : function (oDisplayObject, bListener, vWidth, nHeight) {
var sColor = bListener ? this._htOption.debugListenerColor : this._htOption.debugColor;
var id = oDisplayObject.getId();
if (this._htCustomAreaCircle[id]) {
var circle = new collie.Circle({
radius : this._htCustomAreaCircle[id],
fillColor : sColor,
opacity : this._htOption.debugOpacity
}).addTo(oDisplayObject);
circle.center(oDisplayObject._htOption.width / 2, oDisplayObject._htOption.height / 2);
} else if (this._htCustomAreaBox[id]) {
new collie.DisplayObject({
x : vWidth,
y : nHeight,
width : oDisplayObject._htOption.width - vWidth * 2,
height : oDisplayObject._htOption.height - nHeight * 2,
backgroundColor : sColor,
opacity : this._htOption.debugOpacity
}).addTo(oDisplayObject);
} else {
new collie.DisplayObject({
width : oDisplayObject._htOption.width,
height : oDisplayObject._htOption.height,
backgroundColor : sColor,
opacity : this._htOption.debugOpacity
}).addTo(oDisplayObject);
}
}
}, collie.Component);