first commit
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* FireButton.js
|
||||
* Virtual fire button component positioned at the bottom-right of the screen.
|
||||
* Overlaid as a separate layer above the map when they intersect.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
class FireButton {
|
||||
constructor() {
|
||||
this.radius = 35;
|
||||
// Anchored to screen bottom-right, shifted slightly towards upper-left
|
||||
const padding = this.radius + 40;
|
||||
this.cx = SCREEN_WIDTH - padding - 15; // right edge + extra leftward offset
|
||||
this.cy = SCREEN_HEIGHT - padding - 30; // bottom edge + extra upward offset
|
||||
|
||||
this._pressed = false;
|
||||
this._touchId = null;
|
||||
this._fireCallback = null;
|
||||
|
||||
// Visual feedback
|
||||
this._pressScale = 1;
|
||||
|
||||
// Touch area
|
||||
this._touchAreaRadius = this.radius * 1.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the callback for when fire is triggered.
|
||||
* @param {Function} cb
|
||||
*/
|
||||
onFire(cb) {
|
||||
this._fireCallback = cb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch events.
|
||||
* @param {string} eventType
|
||||
* @param {Touch} touch
|
||||
* @returns {boolean} Whether this button consumed the touch.
|
||||
*/
|
||||
handleTouch(eventType, touch) {
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
if (eventType === 'touchstart') {
|
||||
const dist = Math.sqrt((tx - this.cx) ** 2 + (ty - this.cy) ** 2);
|
||||
if (dist <= this._touchAreaRadius) {
|
||||
this._pressed = true;
|
||||
this._touchId = touch.identifier;
|
||||
this._pressScale = 0.85;
|
||||
|
||||
// Fire immediately on press
|
||||
if (this._fireCallback) {
|
||||
this._fireCallback();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (eventType === 'touchend') {
|
||||
if (this._pressed && touch.identifier === this._touchId) {
|
||||
this._pressed = false;
|
||||
this._touchId = null;
|
||||
this._pressScale = 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the fire button.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.5;
|
||||
|
||||
const r = this.radius * this._pressScale;
|
||||
|
||||
// Outer ring
|
||||
ctx.strokeStyle = '#FF4444';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.cx, this.cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Inner fill
|
||||
ctx.fillStyle = this._pressed ? '#FF6666' : '#CC3333';
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.cx, this.cy, r * 0.75, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Fire icon (crosshair)
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.strokeStyle = '#FFFFFF';
|
||||
ctx.lineWidth = 2;
|
||||
const crossSize = r * 0.35;
|
||||
|
||||
// Horizontal line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.cx - crossSize, this.cy);
|
||||
ctx.lineTo(this.cx + crossSize, this.cy);
|
||||
ctx.stroke();
|
||||
|
||||
// Vertical line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.cx, this.cy - crossSize);
|
||||
ctx.lineTo(this.cx, this.cy + crossSize);
|
||||
ctx.stroke();
|
||||
|
||||
// Center dot
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.cx, this.cy, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Whether the button is currently pressed. */
|
||||
get pressed() {
|
||||
return this._pressed;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FireButton;
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* TutorialOverlay.js
|
||||
* New player tutorial overlay shown on first play.
|
||||
* Displays 2-3 step instructions for controls.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
MAP_OFFSET_X,
|
||||
MAP_WIDTH,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
class TutorialOverlay {
|
||||
constructor() {
|
||||
this._active = false;
|
||||
this._step = 0;
|
||||
this._totalSteps = 3;
|
||||
|
||||
this._steps = [
|
||||
{
|
||||
title: '移动坦克',
|
||||
desc: '拖动左下角的摇杆\n控制坦克上下左右移动',
|
||||
highlight: 'joystick',
|
||||
},
|
||||
{
|
||||
title: '发射子弹',
|
||||
desc: '点击右下角的按钮\n向前方发射子弹',
|
||||
highlight: 'fire',
|
||||
},
|
||||
{
|
||||
title: '保护基地',
|
||||
desc: '消灭所有敌人\n不要让基地被摧毁!',
|
||||
highlight: 'base',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the tutorial.
|
||||
*/
|
||||
show() {
|
||||
this._active = true;
|
||||
this._step = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the tutorial.
|
||||
*/
|
||||
hide() {
|
||||
this._active = false;
|
||||
}
|
||||
|
||||
/** Whether the tutorial is active. */
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch to advance steps.
|
||||
* @returns {boolean} Whether the tutorial consumed the touch.
|
||||
*/
|
||||
handleTouch() {
|
||||
if (!this._active) return false;
|
||||
|
||||
this._step++;
|
||||
if (this._step >= this._totalSteps) {
|
||||
this._active = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the tutorial overlay.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
if (!this._active) return;
|
||||
|
||||
const step = this._steps[this._step];
|
||||
|
||||
// Semi-transparent overlay
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
|
||||
const cx = SCREEN_WIDTH / 2;
|
||||
const cy = SCREEN_HEIGHT / 2;
|
||||
|
||||
// Step indicator
|
||||
ctx.fillStyle = '#AAAAAA';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(`${this._step + 1} / ${this._totalSteps}`, cx, cy - 80);
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.fillText(step.title, cx, cy - 40);
|
||||
|
||||
// Description (multi-line)
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '16px Arial';
|
||||
const lines = step.desc.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
ctx.fillText(lines[i], cx, cy + 10 + i * 24);
|
||||
}
|
||||
|
||||
// Highlight area indicator
|
||||
this._drawHighlight(ctx, step.highlight);
|
||||
|
||||
// Tap to continue
|
||||
ctx.fillStyle = '#888888';
|
||||
ctx.font = '13px Arial';
|
||||
ctx.fillText('点击屏幕继续', cx, SCREEN_HEIGHT - 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a highlight circle around the relevant UI element.
|
||||
* @private
|
||||
*/
|
||||
_drawHighlight(ctx, type) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#FFD700';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([8, 4]);
|
||||
|
||||
switch (type) {
|
||||
case 'joystick':
|
||||
ctx.beginPath();
|
||||
ctx.arc(Math.floor(MAP_OFFSET_X / 2), SCREEN_HEIGHT - 100, 65, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
case 'fire': {
|
||||
const rightAreaStart = MAP_OFFSET_X + MAP_WIDTH;
|
||||
ctx.beginPath();
|
||||
ctx.arc(Math.floor(rightAreaStart + (SCREEN_WIDTH - rightAreaStart) / 2), SCREEN_HEIGHT - 100, 50, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'base': {
|
||||
// Arrow pointing to base area
|
||||
const baseCx = SCREEN_WIDTH / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseCx, SCREEN_HEIGHT * 0.7);
|
||||
ctx.lineTo(baseCx, SCREEN_HEIGHT * 0.8);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseCx - 8, SCREEN_HEIGHT * 0.8);
|
||||
ctx.lineTo(baseCx + 8, SCREEN_HEIGHT * 0.8);
|
||||
ctx.lineTo(baseCx, SCREEN_HEIGHT * 0.8 + 10);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TutorialOverlay;
|
||||
Reference in New Issue
Block a user