/** * Joystick.js * Virtual joystick component for touch-based directional control. * Positioned at the bottom-left of the screen; overlaid as a separate layer * above the map when they intersect. */ const { DIRECTION, SCREEN_WIDTH, SCREEN_HEIGHT, } = require('../base/GameGlobal'); class Joystick { constructor() { // Position and size — anchored to screen bottom-left, shifted slightly // towards the upper-right for comfortable thumb reach this.radius = 50; this.innerRadius = 20; const padding = this.radius + 30; this.cx = padding + 15; // left edge + rightward offset this.cy = SCREEN_HEIGHT - padding - 30; // bottom edge + upward offset // State this._active = false; this._touchId = null; this._touchX = 0; this._touchY = 0; this._direction = -1; // -1 = no direction this._dx = 0; this._dy = 0; // Touch area (larger than visual for easier use) this._touchAreaRadius = this.radius * 2; } /** * Handle touch events. * @param {string} eventType * @param {Touch} touch - Single touch object. * @returns {boolean} Whether this joystick consumed the touch. */ handleTouch(eventType, touch) { const tx = touch.clientX; const ty = touch.clientY; if (eventType === 'touchstart') { // Check if touch is within joystick area const dist = Math.sqrt((tx - this.cx) ** 2 + (ty - this.cy) ** 2); if (dist <= this._touchAreaRadius) { this._active = true; this._touchId = touch.identifier; this._updateDirection(tx, ty); return true; } return false; } if (eventType === 'touchmove') { if (this._active && touch.identifier === this._touchId) { this._updateDirection(tx, ty); return true; } return false; } if (eventType === 'touchend') { if (this._active && touch.identifier === this._touchId) { this._active = false; this._touchId = null; this._direction = -1; this._dx = 0; this._dy = 0; return true; } return false; } return false; } /** * Calculate direction from touch position. * @private */ _updateDirection(tx, ty) { this._touchX = tx; this._touchY = ty; const dx = tx - this.cx; const dy = ty - this.cy; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 10) { this._direction = -1; this._dx = 0; this._dy = 0; return; } // Clamp to radius for visual const clampDist = Math.min(dist, this.radius); this._dx = (dx / dist) * clampDist; this._dy = (dy / dist) * clampDist; // Determine 4-direction based on angle const angle = Math.atan2(dy, dx); // Right: -45° to 45°, Down: 45° to 135°, Left: 135° to -135°, Up: -135° to -45° if (angle >= -Math.PI / 4 && angle < Math.PI / 4) { this._direction = DIRECTION.RIGHT; } else if (angle >= Math.PI / 4 && angle < Math.PI * 3 / 4) { this._direction = DIRECTION.DOWN; } else if (angle >= -Math.PI * 3 / 4 && angle < -Math.PI / 4) { this._direction = DIRECTION.UP; } else { this._direction = DIRECTION.LEFT; } } /** * Render the joystick. * @param {CanvasRenderingContext2D} ctx */ render(ctx) { ctx.save(); ctx.globalAlpha = 0.3; // Outer circle ctx.fillStyle = '#333333'; ctx.strokeStyle = '#666666'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(this.cx, this.cy, this.radius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Direction indicators ctx.globalAlpha = 0.3; ctx.fillStyle = '#FFFFFF'; const arrowSize = 8; // Up arrow this._drawArrow(ctx, this.cx, this.cy - this.radius * 0.6, DIRECTION.UP, arrowSize); // Down arrow this._drawArrow(ctx, this.cx, this.cy + this.radius * 0.6, DIRECTION.DOWN, arrowSize); // Left arrow this._drawArrow(ctx, this.cx - this.radius * 0.6, this.cy, DIRECTION.LEFT, arrowSize); // Right arrow this._drawArrow(ctx, this.cx + this.radius * 0.6, this.cy, DIRECTION.RIGHT, arrowSize); // Inner knob ctx.globalAlpha = 0.6; const knobX = this._active ? this.cx + this._dx : this.cx; const knobY = this._active ? this.cy + this._dy : this.cy; ctx.fillStyle = this._active ? '#FFD700' : '#888888'; ctx.beginPath(); ctx.arc(knobX, knobY, this.innerRadius, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } /** * Draw a small directional arrow. * @private */ _drawArrow(ctx, x, y, dir, size) { ctx.beginPath(); switch (dir) { case DIRECTION.UP: ctx.moveTo(x, y - size); ctx.lineTo(x - size, y + size); ctx.lineTo(x + size, y + size); break; case DIRECTION.DOWN: ctx.moveTo(x, y + size); ctx.lineTo(x - size, y - size); ctx.lineTo(x + size, y - size); break; case DIRECTION.LEFT: ctx.moveTo(x - size, y); ctx.lineTo(x + size, y - size); ctx.lineTo(x + size, y + size); break; case DIRECTION.RIGHT: ctx.moveTo(x + size, y); ctx.lineTo(x - size, y - size); ctx.lineTo(x - size, y + size); break; } ctx.closePath(); ctx.fill(); } /** Current direction (-1 if idle). */ get direction() { return this._direction; } /** Whether the joystick is being touched. */ get active() { return this._active; } } module.exports = Joystick;