add audio volume
This commit is contained in:
parent
e8b565508f
commit
2a55252942
@ -18,6 +18,8 @@ const FontName = enum {
|
|||||||
const EnumArray = std.EnumArray(FontName, Gfx.Font.Id);
|
const EnumArray = std.EnumArray(FontName, Gfx.Font.Id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
arena: std.heap.ArenaAllocator,
|
||||||
|
|
||||||
font_id: FontName.EnumArray,
|
font_id: FontName.EnumArray,
|
||||||
wood01: Audio.Data.Id,
|
wood01: Audio.Data.Id,
|
||||||
map: tiled.Tilemap,
|
map: tiled.Tilemap,
|
||||||
@ -26,8 +28,12 @@ tileset_texture: Gfx.TextureId,
|
|||||||
players_texture: Gfx.TextureId,
|
players_texture: Gfx.TextureId,
|
||||||
tile_size: Engine.Vec2,
|
tile_size: Engine.Vec2,
|
||||||
player_size: Engine.Vec2,
|
player_size: Engine.Vec2,
|
||||||
|
move_sound: []Audio.Data.Id,
|
||||||
|
|
||||||
pub fn init(gpa: std.mem.Allocator) !Assets {
|
pub fn init(gpa: std.mem.Allocator) !Assets {
|
||||||
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
||||||
|
errdefer arena.deinit();
|
||||||
|
|
||||||
const font_id_array: FontName.EnumArray = .init(.{
|
const font_id_array: FontName.EnumArray = .init(.{
|
||||||
.regular = try Gfx.addFont("regular", @embedFile("assets/roboto-font/Roboto-Regular.ttf")),
|
.regular = try Gfx.addFont("regular", @embedFile("assets/roboto-font/Roboto-Regular.ttf")),
|
||||||
.bold = try Gfx.addFont("bold", @embedFile("assets/roboto-font/Roboto-Bold.ttf")),
|
.bold = try Gfx.addFont("bold", @embedFile("assets/roboto-font/Roboto-Bold.ttf")),
|
||||||
@ -82,7 +88,18 @@ pub fn init(gpa: std.mem.Allocator) !Assets {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const move_c = try Audio.load(.{
|
||||||
|
.data = @embedFile("assets/kenney_desert-shooter-pack_1.0/Sounds/move-c.ogg"),
|
||||||
|
.format = .vorbis,
|
||||||
|
});
|
||||||
|
const move_d = try Audio.load(.{
|
||||||
|
.data = @embedFile("assets/kenney_desert-shooter-pack_1.0/Sounds/move-d.ogg"),
|
||||||
|
.format = .vorbis,
|
||||||
|
});
|
||||||
|
const move_sound = try arena.allocator().dupe(Audio.Data.Id, &.{ move_c, move_d });
|
||||||
|
|
||||||
return Assets{
|
return Assets{
|
||||||
|
.arena = arena,
|
||||||
.font_id = font_id_array,
|
.font_id = font_id_array,
|
||||||
.wood01 = wood01,
|
.wood01 = wood01,
|
||||||
.map = map,
|
.map = map,
|
||||||
@ -90,11 +107,13 @@ pub fn init(gpa: std.mem.Allocator) !Assets {
|
|||||||
.tileset_texture = tileset_texture,
|
.tileset_texture = tileset_texture,
|
||||||
.tile_size = .init(16, 16),
|
.tile_size = .init(16, 16),
|
||||||
.players_texture = players_texture,
|
.players_texture = players_texture,
|
||||||
.player_size = .init(24, 24)
|
.player_size = .init(24, 24),
|
||||||
|
.move_sound = move_sound
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Assets, gpa: std.mem.Allocator) void {
|
pub fn deinit(self: *Assets, gpa: std.mem.Allocator) void {
|
||||||
self.map.deinit();
|
self.map.deinit();
|
||||||
self.tilesets.deinit(gpa);
|
self.tilesets.deinit(gpa);
|
||||||
|
self.arena.deinit();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,5 +60,15 @@ pub const Data = union(enum) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn getSampleRate(self: Data) u32 {
|
||||||
|
return switch (self) {
|
||||||
|
.raw => |opts| opts.sample_rate,
|
||||||
|
.vorbis => |opts| blk: {
|
||||||
|
const info = opts.stb_vorbis.getInfo();
|
||||||
|
break :blk info.sample_rate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub const Id = enum (u16) { _ };
|
pub const Id = enum (u16) { _ };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,13 +13,17 @@ const Mixer = @This();
|
|||||||
|
|
||||||
pub const Instance = struct {
|
pub const Instance = struct {
|
||||||
data_id: AudioData.Id,
|
data_id: AudioData.Id,
|
||||||
|
volume: f32 = 0,
|
||||||
cursor: u32 = 0,
|
cursor: u32 = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Command = union(enum) {
|
pub const Command = union(enum) {
|
||||||
play: struct {
|
pub const Play = struct {
|
||||||
data_id: AudioData.Id,
|
id: AudioData.Id,
|
||||||
},
|
volume: f32 = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
play: Play,
|
||||||
|
|
||||||
pub const RingBuffer = struct {
|
pub const RingBuffer = struct {
|
||||||
// TODO: This ring buffer will work in a single producer single consumer configuration
|
// TODO: This ring buffer will work in a single producer single consumer configuration
|
||||||
@ -62,11 +66,13 @@ pub const Command = union(enum) {
|
|||||||
|
|
||||||
instances: std.ArrayList(Instance),
|
instances: std.ArrayList(Instance),
|
||||||
commands: Command.RingBuffer,
|
commands: Command.RingBuffer,
|
||||||
|
working_buffer: []f32,
|
||||||
|
|
||||||
pub fn init(
|
pub fn init(
|
||||||
gpa: Allocator,
|
gpa: Allocator,
|
||||||
max_instances: u32,
|
max_instances: u32,
|
||||||
max_commands: u32
|
max_commands: u32,
|
||||||
|
working_buffer_size: u32
|
||||||
) !Mixer {
|
) !Mixer {
|
||||||
var instances = try std.ArrayList(Instance).initCapacity(gpa, max_instances);
|
var instances = try std.ArrayList(Instance).initCapacity(gpa, max_instances);
|
||||||
errdefer instances.deinit(gpa);
|
errdefer instances.deinit(gpa);
|
||||||
@ -74,7 +80,11 @@ pub fn init(
|
|||||||
const commands = try gpa.alloc(Command, max_commands);
|
const commands = try gpa.alloc(Command, max_commands);
|
||||||
errdefer gpa.free(commands);
|
errdefer gpa.free(commands);
|
||||||
|
|
||||||
|
const working_buffer = try gpa.alloc(f32, working_buffer_size);
|
||||||
|
errdefer gpa.free(working_buffer);
|
||||||
|
|
||||||
return Mixer{
|
return Mixer{
|
||||||
|
.working_buffer = working_buffer,
|
||||||
.instances = instances,
|
.instances = instances,
|
||||||
.commands = .{
|
.commands = .{
|
||||||
.items = commands
|
.items = commands
|
||||||
@ -85,6 +95,7 @@ pub fn init(
|
|||||||
pub fn deinit(self: *Mixer, gpa: Allocator) void {
|
pub fn deinit(self: *Mixer, gpa: Allocator) void {
|
||||||
self.instances.deinit(gpa);
|
self.instances.deinit(gpa);
|
||||||
gpa.free(self.commands.items);
|
gpa.free(self.commands.items);
|
||||||
|
gpa.free(self.working_buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn queue(self: *Mixer, command: Command) void {
|
pub fn queue(self: *Mixer, command: Command) void {
|
||||||
@ -95,8 +106,15 @@ pub fn stream(self: *Mixer, store: Store, buffer: []f32, num_frames: u32, num_ch
|
|||||||
while (self.commands.pop()) |command| {
|
while (self.commands.pop()) |command| {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
.play => |opts| {
|
.play => |opts| {
|
||||||
|
const volume = @max(opts.volume, 0);
|
||||||
|
if (volume == 0) {
|
||||||
|
log.warn("Attempt to play audio with 0 volume", .{});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
self.instances.appendBounded(.{
|
self.instances.appendBounded(.{
|
||||||
.data_id = opts.data_id
|
.data_id = opts.id,
|
||||||
|
.volume = volume,
|
||||||
}) catch log.warn("Maximum number of audio instances reached!", .{});
|
}) catch log.warn("Maximum number of audio instances reached!", .{});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -106,11 +124,14 @@ pub fn stream(self: *Mixer, store: Store, buffer: []f32, num_frames: u32, num_ch
|
|||||||
const sample_rate: u32 = @intCast(saudio.sampleRate());
|
const sample_rate: u32 = @intCast(saudio.sampleRate());
|
||||||
|
|
||||||
@memset(buffer, 0);
|
@memset(buffer, 0);
|
||||||
|
assert(self.working_buffer.len >= num_frames);
|
||||||
|
|
||||||
// var written: u32 = 0;
|
|
||||||
for (self.instances.items) |*instance| {
|
for (self.instances.items) |*instance| {
|
||||||
const audio_data = store.get(instance.data_id);
|
const audio_data = store.get(instance.data_id);
|
||||||
const samples = audio_data.streamChannel(buffer[0..num_frames], instance.cursor, 0, sample_rate);
|
const samples = audio_data.streamChannel(self.working_buffer[0..num_frames], instance.cursor, 0, sample_rate);
|
||||||
|
for (0.., samples) |i, sample| {
|
||||||
|
buffer[i] += sample * instance.volume;
|
||||||
|
}
|
||||||
instance.cursor += @intCast(samples.len);
|
instance.cursor += @intCast(samples.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ pub const Mixer = @import("./mixer.zig");
|
|||||||
|
|
||||||
pub const Command = Mixer.Command;
|
pub const Command = Mixer.Command;
|
||||||
|
|
||||||
|
const Nanoseconds = @import("../root.zig").Nanoseconds;
|
||||||
const sokol = @import("sokol");
|
const sokol = @import("sokol");
|
||||||
const saudio = sokol.audio;
|
const saudio = sokol.audio;
|
||||||
|
|
||||||
@ -38,7 +39,11 @@ pub fn init(opts: Options) !void {
|
|||||||
.max_vorbis_alloc_buffer_size = opts.max_vorbis_alloc_buffer_size,
|
.max_vorbis_alloc_buffer_size = opts.max_vorbis_alloc_buffer_size,
|
||||||
});
|
});
|
||||||
|
|
||||||
mixer = try Mixer.init(gpa, opts.max_instances, opts.max_instances);
|
mixer = try Mixer.init(gpa,
|
||||||
|
opts.max_instances,
|
||||||
|
opts.max_instances,
|
||||||
|
opts.buffer_frames
|
||||||
|
);
|
||||||
|
|
||||||
saudio.setup(.{
|
saudio.setup(.{
|
||||||
.logger = opts.logger,
|
.logger = opts.logger,
|
||||||
@ -64,6 +69,23 @@ pub fn load(opts: Store.LoadOptions) !Data.Id {
|
|||||||
return try store.load(opts);
|
return try store.load(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Info = struct {
|
||||||
|
sample_count: u32,
|
||||||
|
sample_rate: u32,
|
||||||
|
|
||||||
|
pub fn getDuration(self: Info) Nanoseconds {
|
||||||
|
return @as(Nanoseconds, self.sample_count) * std.time.ns_per_s / self.sample_rate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn getInfo(id: Data.Id) Info {
|
||||||
|
const data = store.get(id);
|
||||||
|
return Info{
|
||||||
|
.sample_count = data.getSampleCount(),
|
||||||
|
.sample_rate = data.getSampleRate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
fn sokolStreamCallback(buffer: [*c]f32, num_frames: i32, num_channels: i32) callconv(.c) void {
|
fn sokolStreamCallback(buffer: [*c]f32, num_frames: i32, num_channels: i32) callconv(.c) void {
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -163,10 +163,6 @@ pub fn getKeyState(self: Frame, key_code: KeyCode) KeyState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const PlayAudioOptions = struct {
|
|
||||||
id: AudioData.Id,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn pushAudioCommand(self: *Frame, command: AudioCommand) void {
|
fn pushAudioCommand(self: *Frame, command: AudioCommand) void {
|
||||||
const arena = self.arena.allocator();
|
const arena = self.arena.allocator();
|
||||||
|
|
||||||
@ -191,11 +187,9 @@ pub fn prependGraphicsCommand(self: *Frame, command: GraphicsCommand) void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn playAudio(self: *Frame, options: PlayAudioOptions) !void {
|
pub fn playAudio(self: *Frame, options: AudioCommand.Play) void {
|
||||||
self.pushAudioCommand(.{
|
self.pushAudioCommand(.{
|
||||||
.play = .{
|
.play = options,
|
||||||
.data_id = options.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,28 @@ pub const bytes_per_kb = 1000;
|
|||||||
pub const bytes_per_mb = bytes_per_kb * 1000;
|
pub const bytes_per_mb = bytes_per_kb * 1000;
|
||||||
pub const bytes_per_gb = bytes_per_mb * 1000;
|
pub const bytes_per_gb = bytes_per_mb * 1000;
|
||||||
|
|
||||||
|
pub const Range = struct {
|
||||||
|
from: f32,
|
||||||
|
to: f32,
|
||||||
|
|
||||||
|
pub const zero = init(0, 0);
|
||||||
|
|
||||||
|
pub fn init(from: f32, to: f32) Range {
|
||||||
|
return Range{
|
||||||
|
.from = from,
|
||||||
|
.to = to
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn getSize(self: Range) f32 {
|
||||||
|
return @abs(self.from - self.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn random(self: Range, rng: std.Random) f32 {
|
||||||
|
return self.from + rng.float(f32) * (self.to - self.from);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
pub const Vec2 = extern struct {
|
pub const Vec2 = extern struct {
|
||||||
x: f32,
|
x: f32,
|
||||||
y: f32,
|
y: f32,
|
||||||
|
|||||||
@ -130,8 +130,10 @@ fn sokolInit(self: *Engine) !void {
|
|||||||
.logger = .{ .func = sokolLogCallback },
|
.logger = .{ .func = sokolLogCallback },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const seed: u64 = @bitCast(std.time.milliTimestamp());
|
||||||
|
|
||||||
self.assets = try Assets.init(self.allocator);
|
self.assets = try Assets.init(self.allocator);
|
||||||
self.game = try Game.init(self.allocator, &self.assets);
|
self.game = try Game.init(self.allocator, seed, &self.assets);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sokolCleanup(self: *Engine) void {
|
fn sokolCleanup(self: *Engine) void {
|
||||||
|
|||||||
71
src/game.zig
71
src/game.zig
@ -5,14 +5,19 @@ const assert = std.debug.assert;
|
|||||||
const Assets = @import("./assets.zig");
|
const Assets = @import("./assets.zig");
|
||||||
|
|
||||||
const Engine = @import("./engine/root.zig");
|
const Engine = @import("./engine/root.zig");
|
||||||
|
const Nanoseconds = Engine.Nanoseconds;
|
||||||
const imgui = Engine.imgui;
|
const imgui = Engine.imgui;
|
||||||
const Vec2 = Engine.Vec2;
|
const Vec2 = Engine.Vec2;
|
||||||
const Rect = Engine.Math.Rect;
|
const Rect = Engine.Math.Rect;
|
||||||
const rgb = Engine.Math.rgb;
|
const rgb = Engine.Math.rgb;
|
||||||
|
const Range = Engine.Math.Range;
|
||||||
const TextureId = Engine.Graphics.TextureId;
|
const TextureId = Engine.Graphics.TextureId;
|
||||||
|
const AudioId = Engine.Audio.Data.Id;
|
||||||
|
|
||||||
const Game = @This();
|
const Game = @This();
|
||||||
|
|
||||||
|
const RNGState = std.Random.DefaultPrng;
|
||||||
|
|
||||||
const Animation = struct {
|
const Animation = struct {
|
||||||
texture: TextureId,
|
texture: TextureId,
|
||||||
frames: []Frame,
|
frames: []Frame,
|
||||||
@ -46,17 +51,58 @@ const Animation = struct {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AudioBundle = struct {
|
||||||
|
cooldown_until: ?Engine.Nanoseconds,
|
||||||
|
|
||||||
|
const empty = AudioBundle{
|
||||||
|
.cooldown_until = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlayOptions = struct {
|
||||||
|
sounds: []const AudioId,
|
||||||
|
volume: Range = .init(1, 1),
|
||||||
|
fixed_delay: Range = .zero,
|
||||||
|
rng: std.Random
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn play(self: *AudioBundle, frame: *Engine.Frame, opts: PlayOptions) void {
|
||||||
|
if (opts.sounds.len == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self.cooldown_until) |cooldown_until| {
|
||||||
|
if (cooldown_until > frame.time_ns) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sound_index = opts.rng.uintLessThan(usize, opts.sounds.len);
|
||||||
|
const sound = opts.sounds[sound_index];
|
||||||
|
|
||||||
|
frame.playAudio(.{
|
||||||
|
.id = sound,
|
||||||
|
.volume = opts.volume.random(opts.rng)
|
||||||
|
});
|
||||||
|
|
||||||
|
const sound_info = Engine.Audio.getInfo(sound);
|
||||||
|
var duration = sound_info.getDuration();
|
||||||
|
duration += @intFromFloat(opts.fixed_delay.random(opts.rng) * std.time.ns_per_s);
|
||||||
|
self.cooldown_until = frame.time_ns + duration;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
arena: std.heap.ArenaAllocator,
|
arena: std.heap.ArenaAllocator,
|
||||||
gpa: Allocator,
|
gpa: Allocator,
|
||||||
|
rng: RNGState,
|
||||||
assets: *Assets,
|
assets: *Assets,
|
||||||
|
|
||||||
player: Vec2,
|
player: Vec2,
|
||||||
player_anim_state: Animation.State = .default,
|
player_anim_state: Animation.State = .default,
|
||||||
last_faced_left: bool = false,
|
last_faced_left: bool = false,
|
||||||
|
player_walk_sound: AudioBundle = .empty,
|
||||||
|
|
||||||
player_anim: Animation,
|
player_anim: Animation,
|
||||||
|
|
||||||
pub fn init(gpa: Allocator, assets: *Assets) !Game {
|
pub fn init(gpa: Allocator, seed: u64, assets: *Assets) !Game {
|
||||||
var arena = std.heap.ArenaAllocator.init(gpa);
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
||||||
errdefer arena.deinit();
|
errdefer arena.deinit();
|
||||||
|
|
||||||
@ -72,11 +118,7 @@ pub fn init(gpa: Allocator, assets: *Assets) !Game {
|
|||||||
},
|
},
|
||||||
.{
|
.{
|
||||||
.uv = getUVFromTilemap(tilemap_size, tile_size, 1, 0),
|
.uv = getUVFromTilemap(tilemap_size, tile_size, 1, 0),
|
||||||
.duration = 0.1,
|
.duration = 0.2,
|
||||||
},
|
|
||||||
.{
|
|
||||||
.uv = getUVFromTilemap(tilemap_size, tile_size, 2, 0),
|
|
||||||
.duration = 0.15,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@ -86,7 +128,8 @@ pub fn init(gpa: Allocator, assets: *Assets) !Game {
|
|||||||
.gpa = gpa,
|
.gpa = gpa,
|
||||||
.assets = assets,
|
.assets = assets,
|
||||||
.player = findSpawnpoint(assets) orelse .init(0, 0),
|
.player = findSpawnpoint(assets) orelse .init(0, 0),
|
||||||
.player_anim = player_anim
|
.player_anim = player_anim,
|
||||||
|
.rng = RNGState.init(seed)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,7 +204,7 @@ fn drawTilemap(self: *Game, frame: *Engine.Frame) void {
|
|||||||
.color = rgb(255, 255, 255),
|
.color = rgb(255, 255, 255),
|
||||||
.texture = .{
|
.texture = .{
|
||||||
.id = self.assets.tileset_texture,
|
.id = self.assets.tileset_texture,
|
||||||
.uv = getUVFromTilemapByID(tilemap_size, tile_size,tile.id)
|
.uv = getUVFromTilemapByID(tilemap_size, tile_size, tile.id)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -202,16 +245,16 @@ pub fn tick(self: *Game, frame: *Engine.Frame) !void {
|
|||||||
|
|
||||||
if (dir.x != 0 or dir.y != 0) {
|
if (dir.x != 0 or dir.y != 0) {
|
||||||
self.player_anim_state.update(self.player_anim, dt);
|
self.player_anim_state.update(self.player_anim, dt);
|
||||||
|
self.player_walk_sound.play(frame, .{
|
||||||
|
.sounds = self.assets.move_sound,
|
||||||
|
.fixed_delay = .init(0.1, 0.15),
|
||||||
|
.volume = .init(0.025, 0.03),
|
||||||
|
.rng = self.rng.random()
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
self.player_anim_state.frame_index = 0;
|
self.player_anim_state.frame_index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (dir.x != 0 or dir.y != 0) {
|
|
||||||
// try frame.playAudio(.{
|
|
||||||
// .id = self.assets.wood01
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
self.player = self.player.add(dir.multiplyScalar(50 * dt));
|
self.player = self.player.add(dir.multiplyScalar(50 * dt));
|
||||||
|
|
||||||
const regular_font = self.assets.font_id.get(.regular);
|
const regular_font = self.assets.font_id.get(.regular);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user