Skip to content

Commit d191082

Browse files
mattgodboltclaude
andcommitted
Add mouse as analogue joystick support
Implements analogue joystick emulation using the mouse position, addressing issue #501. The mouse position on the BBC display is mapped to ADC channels 0 (X-axis) and 1 (Y-axis), with left mouse button acting as fire button 1. Key features: - Mouse position tracked globally, even when not over the BBC display - Accessible via ADVAL(0) for X and ADVAL(1) for Y in BBC BASIC - Fire button triggered by left mouse click when over the display - Can be enabled via config dialog or URL parameter (?mouseJoystickEnabled) - Works alongside existing gamepad support Implementation details: - Created MouseJoystickSource class implementing AnalogueSource interface - Simplified ADC source management with updateAdcSources() helper function - Added proper cleanup and disposal of event listeners - Full test coverage for the new functionality Closes #501 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 5249d9c commit d191082

File tree

6 files changed

+459
-27
lines changed

6 files changed

+459
-27
lines changed

index.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,30 @@ <h5 class="modal-title" id="configurationModalLabel">Emulation Configuration</h5
888888
</div>
889889
<div id="micPermissionStatus" class="mt-2 small text-muted"></div>
890890
</div>
891+
892+
<div id="mouseJoystickSettings" style="margin-top: 15px">
893+
<div class="row align-items-center">
894+
<div class="col-sm-6">
895+
<label class="col-form-label">Mouse joystick:</label>
896+
<div class="form-check">
897+
<input
898+
class="form-check-input"
899+
type="checkbox"
900+
id="mouseJoystickEnabled"
901+
name="mouseJoystickEnabled"
902+
/>
903+
<label class="form-check-label" for="mouseJoystickEnabled">
904+
Enable mouse as analogue joystick
905+
</label>
906+
</div>
907+
</div>
908+
<div class="col-sm-6">
909+
<div class="small text-muted mt-2">
910+
Maps mouse position on BBC display to channels 0 (X) and 1 (Y)
911+
</div>
912+
</div>
913+
</div>
914+
</div>
891915
</div>
892916
<div class="modal-footer">
893917
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>

src/config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export class Config {
1515
this.setTeletext(this.model.hasTeletextAdaptor);
1616
this.setMusic5000(this.model.hasMusic5000);
1717
this.setEconet(this.model.hasEconet);
18+
// Note: mouseJoystickEnabled state will be set from main.js
1819
});
1920

2021
$configuration.addEventListener("hide.bs.modal", () => onClose(this.changed));
@@ -52,6 +53,10 @@ export class Config {
5253
this.changed.microphoneChannel = channel;
5354
this.setMicrophoneChannel(channel);
5455
});
56+
57+
$("#mouseJoystickEnabled").on("click", () => {
58+
this.changed.mouseJoystickEnabled = $("#mouseJoystickEnabled").prop("checked");
59+
});
5560
}
5661

5762
setMicrophoneChannel(channel) {
@@ -62,6 +67,10 @@ export class Config {
6267
}
6368
}
6469

70+
setMouseJoystickEnabled(enabled) {
71+
$("#mouseJoystickEnabled").prop("checked", !!enabled);
72+
}
73+
6574
setModel(modelName) {
6675
this.model = findModel(modelName);
6776
$(".bbc-model").text(this.model.name);

src/main.js

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { toHfe } from "./disc-hfe.js";
2626
import { Keyboard } from "./keyboard.js";
2727
import { GamepadSource } from "./gamepad-source.js";
2828
import { MicrophoneInput } from "./microphone-input.js";
29+
import { MouseJoystickSource } from "./mouse-joystick-source.js";
2930
import {
3031
buildUrlFromParams,
3132
guessModelFromHostname,
@@ -93,6 +94,7 @@ const paramTypes = {
9394
logFdcCommands: ParamTypes.BOOL,
9495
logFdcStateChanges: ParamTypes.BOOL,
9596
coProcessor: ParamTypes.BOOL,
97+
mouseJoystickEnabled: ParamTypes.BOOL,
9698

9799
// Numeric parameters
98100
speed: ParamTypes.INT,
@@ -241,13 +243,15 @@ const config = new Config(function (changed) {
241243
emulationConfig.keyLayout = changed.keyLayout;
242244
keyboard.setKeyLayout(changed.keyLayout);
243245
}
244-
// Restore gamepad as source for the old channels
245-
for (let oldChannel = 0; oldChannel < 4; ++oldChannel)
246-
processor.adconverter.setChannelSource(oldChannel, gamepadSource);
247-
if (changed.microphoneChannel !== undefined) {
248-
const channel = changed.microphoneChannel;
249-
console.log(`Moving microphone to channel ${channel}`);
250-
setupMicrophone(channel).then(() => {});
246+
// Handle ADC source changes
247+
if (changed.mouseJoystickEnabled !== undefined || changed.microphoneChannel !== undefined) {
248+
// Update sources based on new settings
249+
updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
250+
251+
// Handle microphone initialization if needed
252+
if (changed.microphoneChannel !== undefined) {
253+
setupMicrophone().then(() => {});
254+
}
251255
}
252256
updateUrl();
253257
});
@@ -262,6 +266,7 @@ config.setEconet(parsedQuery.hasEconet);
262266
config.setMusic5000(parsedQuery.hasMusic5000);
263267
config.setTeletext(parsedQuery.hasTeletextAdaptor);
264268
config.setMicrophoneChannel(parsedQuery.microphoneChannel);
269+
config.setMouseJoystickEnabled(parsedQuery.mouseJoystickEnabled);
265270

266271
model = config.model;
267272

@@ -507,19 +512,41 @@ processor = new Cpu6502(
507512
econet,
508513
);
509514

510-
// Set up gamepad as the default source for all channels
515+
// Create input sources
511516
const gamepadSource = new GamepadSource(emulationConfig.getGamepads);
512-
processor.adconverter.setChannelSource(0, gamepadSource);
513-
processor.adconverter.setChannelSource(1, gamepadSource);
514-
processor.adconverter.setChannelSource(2, gamepadSource);
515-
processor.adconverter.setChannelSource(3, gamepadSource);
516517

517518
// Create MicrophoneInput but don't enable by default
518519
const microphoneInput = new MicrophoneInput();
519520
microphoneInput.setErrorCallback((message) => {
520521
showError("accessing microphone", message);
521522
});
522523

524+
// Create MouseJoystickSource but don't enable by default
525+
const screenCanvas = document.getElementById("screen");
526+
const mouseJoystickSource = new MouseJoystickSource(screenCanvas);
527+
528+
// Helper to manage ADC source configuration
529+
function updateAdcSources(mouseJoystickEnabled, microphoneChannel) {
530+
// Default all channels to gamepad
531+
for (let ch = 0; ch < 4; ch++) {
532+
processor.adconverter.setChannelSource(ch, gamepadSource);
533+
}
534+
535+
// Apply mouse joystick if enabled (takes priority on channels 0 & 1)
536+
if (mouseJoystickEnabled) {
537+
processor.adconverter.setChannelSource(0, mouseJoystickSource);
538+
processor.adconverter.setChannelSource(1, mouseJoystickSource);
539+
mouseJoystickSource.setVia(processor.sysvia);
540+
} else {
541+
mouseJoystickSource.setVia(null);
542+
}
543+
544+
// Apply microphone if configured (can override any channel)
545+
if (microphoneChannel !== undefined) {
546+
processor.adconverter.setChannelSource(microphoneChannel, microphoneInput);
547+
}
548+
}
549+
523550
async function ensureMicrophoneRunning() {
524551
if (microphoneInput.audioContext && microphoneInput.audioContext.state !== "running") {
525552
try {
@@ -533,19 +560,14 @@ async function ensureMicrophoneRunning() {
533560
return true;
534561
}
535562

536-
async function setupMicrophone(channel) {
563+
async function setupMicrophone() {
537564
const $micPermissionStatus = $("#micPermissionStatus");
538565
$micPermissionStatus.text("Requesting microphone access...");
539566

540567
// Try to initialise the microphone
541568
const success = await microphoneInput.initialise();
542569
if (success) {
543-
console.log("Microphone: Successfully initialised from URL parameters");
544-
// Set microphone as source for its channel
545-
console.log(`Setting microphone as source for channel ${channel}`);
546-
processor.adconverter.setChannelSource(channel, microphoneInput);
547570
$micPermissionStatus.text("Microphone connected successfully");
548-
549571
await ensureMicrophoneRunning();
550572

551573
// Try starting audio context from user gesture
@@ -554,7 +576,6 @@ async function setupMicrophone(channel) {
554576
};
555577
document.addEventListener("click", tryAgain);
556578
} else {
557-
console.error("Microphone: Failed to initialise from URL parameters:", microphoneInput.getErrorMessage());
558579
$micPermissionStatus.text(`Error: ${microphoneInput.getErrorMessage() || "Unknown error"}`);
559580
config.setMicrophoneChannel(undefined);
560581
// Update URL to remove the parameter
@@ -564,16 +585,16 @@ async function setupMicrophone(channel) {
564585
}
565586

566587
if (parsedQuery.microphoneChannel !== undefined) {
567-
console.log("Microphone: Initialising from URL parameters");
568-
569588
// We need to use setTimeout to make sure this runs after the page has loaded
570589
// This is needed because some browsers require user interaction for audio context
571590
setTimeout(async () => {
572-
console.log("Microphone: Delayed initialisation starting");
573-
await setupMicrophone(parsedQuery.microphoneChannel);
591+
await setupMicrophone();
574592
}, 1000);
575593
}
576594

595+
// Apply ADC source settings from URL parameters
596+
updateAdcSources(parsedQuery.mouseJoystickEnabled, parsedQuery.microphoneChannel);
597+
577598
// Initialise keyboard now that processor exists
578599
keyboard = new Keyboard({
579600
processor,

src/mouse-joystick-source.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { AnalogueSource } from "./analogue-source.js";
2+
3+
/**
4+
* Mouse-based joystick implementation of AnalogueSource
5+
* Maps mouse position relative to BBC display center to ADC channels
6+
*/
7+
export class MouseJoystickSource extends AnalogueSource {
8+
/**
9+
* Create a new MouseJoystickSource
10+
* @param {HTMLCanvasElement} canvas - The BBC display canvas element
11+
*/
12+
constructor(canvas) {
13+
super();
14+
this.canvas = canvas;
15+
this.mouseX = 0.5; // Normalized position (0-1)
16+
this.mouseY = 0.5; // Normalized position (0-1)
17+
this.isActive = false;
18+
this.via = null; // Will be set later
19+
20+
// Bind event handlers
21+
this.handleMouseMove = this.handleMouseMove.bind(this);
22+
this.handleMouseEnter = this.handleMouseEnter.bind(this);
23+
this.handleMouseLeave = this.handleMouseLeave.bind(this);
24+
this.handleMouseDown = this.handleMouseDown.bind(this);
25+
this.handleMouseUp = this.handleMouseUp.bind(this);
26+
this.handleGlobalMouseMove = this.handleGlobalMouseMove.bind(this);
27+
28+
// Attach event listeners
29+
this.canvas.addEventListener("mousemove", this.handleMouseMove);
30+
this.canvas.addEventListener("mouseenter", this.handleMouseEnter);
31+
this.canvas.addEventListener("mouseleave", this.handleMouseLeave);
32+
this.canvas.addEventListener("mousedown", this.handleMouseDown);
33+
this.canvas.addEventListener("mouseup", this.handleMouseUp);
34+
35+
// Also listen to global mouse moves to track position even when not over canvas
36+
document.addEventListener("mousemove", this.handleGlobalMouseMove);
37+
}
38+
39+
/**
40+
* Handle mouse movement over the canvas
41+
* @param {MouseEvent} event - The mouse event
42+
*/
43+
handleMouseMove(event) {
44+
if (!this.isActive) return;
45+
46+
const rect = this.canvas.getBoundingClientRect();
47+
const x = event.clientX - rect.left;
48+
const y = event.clientY - rect.top;
49+
50+
// Normalize to 0-1 range
51+
this.mouseX = x / rect.width;
52+
this.mouseY = y / rect.height;
53+
54+
// Clamp values
55+
this.mouseX = Math.max(0, Math.min(1, this.mouseX));
56+
this.mouseY = Math.max(0, Math.min(1, this.mouseY));
57+
}
58+
59+
/**
60+
* Handle mouse entering the canvas
61+
*/
62+
handleMouseEnter() {
63+
this.isActive = true;
64+
}
65+
66+
/**
67+
* Handle mouse leaving the canvas
68+
*/
69+
handleMouseLeave() {
70+
this.isActive = false;
71+
// Don't center when mouse leaves - keep last position
72+
}
73+
74+
/**
75+
* Handle global mouse movement (even when not over canvas)
76+
* @param {MouseEvent} event - The mouse event
77+
*/
78+
handleGlobalMouseMove(event) {
79+
const rect = this.canvas.getBoundingClientRect();
80+
const x = event.clientX - rect.left;
81+
const y = event.clientY - rect.top;
82+
83+
// Normalize to 0-1 range
84+
this.mouseX = x / rect.width;
85+
this.mouseY = y / rect.height;
86+
87+
// Clamp values to 0-1 range
88+
this.mouseX = Math.max(0, Math.min(1, this.mouseX));
89+
this.mouseY = Math.max(0, Math.min(1, this.mouseY));
90+
}
91+
92+
/**
93+
* Handle mouse button press
94+
* @param {MouseEvent} event - The mouse event
95+
*/
96+
handleMouseDown(event) {
97+
if (!this.isActive || !this.via) return;
98+
99+
// Only handle left mouse button (button 0)
100+
if (event.button === 0) {
101+
// Set fire button 1 pressed (PB4)
102+
this.via.setJoystickButton(0, true);
103+
event.preventDefault();
104+
}
105+
}
106+
107+
/**
108+
* Handle mouse button release
109+
* @param {MouseEvent} event - The mouse event
110+
*/
111+
handleMouseUp(event) {
112+
if (!this.via) return;
113+
114+
// Only handle left mouse button (button 0)
115+
if (event.button === 0) {
116+
// Release fire button 1 (PB4)
117+
this.via.setJoystickButton(0, false);
118+
event.preventDefault();
119+
}
120+
}
121+
122+
/**
123+
* Set the VIA reference for button handling
124+
* @param {object} via - The system VIA
125+
*/
126+
setVia(via) {
127+
this.via = via;
128+
}
129+
130+
/**
131+
* Get analog value from mouse position for the specified channel
132+
* @param {number} channel - The ADC channel (0-3)
133+
* @returns {number} A value between 0 and 0xffff
134+
*/
135+
getValue(channel) {
136+
let value;
137+
138+
// Use mouse position when enabled (always active when assigned to a channel)
139+
{
140+
switch (channel) {
141+
case 0:
142+
// X axis for joystick 1
143+
// Convert from [0,1] to [0,0xffff]
144+
value = Math.floor(this.mouseX * 0xffff);
145+
break;
146+
case 1:
147+
// Y axis for joystick 1
148+
// Convert from [0,1] to [0,0xffff]
149+
value = Math.floor(this.mouseY * 0xffff);
150+
break;
151+
case 2:
152+
// X axis for joystick 2 (not used for mouse)
153+
value = 0x8000;
154+
break;
155+
case 3:
156+
// Y axis for joystick 2 (not used for mouse)
157+
value = 0x8000;
158+
break;
159+
default:
160+
value = 0x8000;
161+
break;
162+
}
163+
}
164+
165+
return value;
166+
}
167+
168+
/**
169+
* Clean up event listeners when source is no longer needed
170+
*/
171+
dispose() {
172+
this.canvas.removeEventListener("mousemove", this.handleMouseMove);
173+
this.canvas.removeEventListener("mouseenter", this.handleMouseEnter);
174+
this.canvas.removeEventListener("mouseleave", this.handleMouseLeave);
175+
this.canvas.removeEventListener("mousedown", this.handleMouseDown);
176+
this.canvas.removeEventListener("mouseup", this.handleMouseUp);
177+
document.removeEventListener("mousemove", this.handleGlobalMouseMove);
178+
}
179+
}

0 commit comments

Comments
 (0)